nexus-shared 3.2.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2359 -0
- package/README.md +1911 -1123
- package/dist/Client.Index.d.ts +27 -0
- package/dist/Client.Index.js +27 -0
- package/dist/Components/Chats/ChatComposer.d.ts +7 -1
- package/dist/Components/Chats/ChatComposer.js +9 -6
- package/dist/Components/Chats/ChatParts.d.ts +15 -2
- package/dist/Components/Chats/ChatParts.js +15 -4
- package/dist/Components/Chats/ChatPopup.d.ts +46 -0
- package/dist/Components/Chats/ChatPopup.js +34 -0
- package/dist/Components/Chats/ChatThread.d.ts +9 -1
- package/dist/Components/Chats/ChatThread.js +54 -6
- package/dist/Components/Dashboards/DashboardBoard.d.ts +62 -0
- package/dist/Components/Dashboards/DashboardBoard.js +594 -0
- package/dist/Components/Dashboards/DashboardWidgets.d.ts +155 -0
- package/dist/Components/Dashboards/DashboardWidgets.js +159 -0
- package/dist/Components/Dashboards/WidgetFrame.d.ts +51 -0
- package/dist/Components/Dashboards/WidgetFrame.js +45 -0
- package/dist/Components/Documents/Button.d.ts +6 -1
- package/dist/Components/Documents/Button.js +2 -2
- package/dist/Components/Documents/DocumentFrame.d.ts +50 -0
- package/dist/Components/Documents/DocumentFrame.js +83 -0
- package/dist/Components/Documents/Menu.d.ts +4 -0
- package/dist/Components/Documents/Menu.js +40 -24
- package/dist/Components/Documents/PrintDocument.d.ts +95 -0
- package/dist/Components/Documents/PrintDocument.js +62 -0
- package/dist/Components/Documents/TabButtons.d.ts +6 -0
- package/dist/Components/Documents/TabButtons.js +5 -4
- package/dist/Components/Documents/Tabs.d.ts +59 -0
- package/dist/Components/Documents/Tabs.js +70 -0
- package/dist/Components/Domains/DomainCard.d.ts +98 -0
- package/dist/Components/Domains/DomainCard.js +182 -0
- package/dist/Components/Forms/ApiForm.js +24 -3
- package/dist/Components/Forms/Crud.d.ts +2 -2
- package/dist/Components/Forms/Crud.js +628 -247
- package/dist/Components/Forms/CrudActions.d.ts +137 -0
- package/dist/Components/Forms/CrudActions.js +214 -0
- package/dist/Components/Forms/CrudHistory.d.ts +6 -1
- package/dist/Components/Forms/CrudHistory.js +14 -6
- package/dist/Components/Forms/CrudNarrowPicker.d.ts +31 -0
- package/dist/Components/Forms/CrudNarrowPicker.js +180 -0
- package/dist/Components/Forms/CrudParts.d.ts +37 -2
- package/dist/Components/Forms/CrudParts.js +123 -7
- package/dist/Components/Forms/CrudRowForms.d.ts +26 -0
- package/dist/Components/Forms/CrudRowForms.js +174 -0
- package/dist/Components/Forms/CrudScopes.d.ts +28 -0
- package/dist/Components/Forms/CrudScopes.js +76 -0
- package/dist/Components/Forms/CrudTree.d.ts +90 -0
- package/dist/Components/Forms/CrudTree.js +363 -0
- package/dist/Components/Forms/CrudWorkspace.d.ts +73 -0
- package/dist/Components/Forms/CrudWorkspace.js +177 -0
- package/dist/Components/Forms/ExcelImport.js +3 -3
- package/dist/Components/Forms/Form.d.ts +6 -5
- package/dist/Components/Forms/Form.js +134 -21
- package/dist/Components/Forms/FormLeaveGuard.d.ts +44 -0
- package/dist/Components/Forms/FormLeaveGuard.js +65 -0
- package/dist/Components/Forms/FormQuestion.d.ts +6 -0
- package/dist/Components/Forms/FormQuestion.js +28 -0
- package/dist/Components/Forms/RecordFlows.d.ts +50 -0
- package/dist/Components/Forms/RecordFlows.js +282 -0
- package/dist/Components/Forms/SubmitForm.d.ts +37 -2
- package/dist/Components/Forms/SubmitForm.js +182 -10
- package/dist/Components/Inputs/CheckBox.js +1 -1
- package/dist/Components/Inputs/ChoiceField.d.ts +21 -1
- package/dist/Components/Inputs/ChoiceField.js +28 -2
- package/dist/Components/Inputs/ComputedInput.d.ts +51 -0
- package/dist/Components/Inputs/ComputedInput.js +179 -0
- package/dist/Components/Inputs/Dropdown.d.ts +13 -0
- package/dist/Components/Inputs/Dropdown.js +8 -6
- package/dist/Components/Inputs/DropdownParts.d.ts +3 -1
- package/dist/Components/Inputs/DropdownParts.js +16 -4
- package/dist/Components/Inputs/Field.d.ts +7 -1
- package/dist/Components/Inputs/Field.js +2 -2
- package/dist/Components/Inputs/GroupForm.d.ts +6 -6
- package/dist/Components/Inputs/GroupForm.js +12 -12
- package/dist/Components/Inputs/InputRenderer.d.ts +13 -6
- package/dist/Components/Inputs/InputRenderer.js +37 -32
- package/dist/Components/Inputs/MarkdownEditor.d.ts +6 -0
- package/dist/Components/Inputs/MarkdownEditor.js +32 -6
- package/dist/Components/Inputs/PictureInput.d.ts +41 -0
- package/dist/Components/Inputs/PictureInput.js +75 -0
- package/dist/Components/Inputs/RadioGroup.d.ts +16 -3
- package/dist/Components/Inputs/RadioGroup.js +8 -3
- package/dist/Components/Inputs/RowsInput.d.ts +15 -2
- package/dist/Components/Inputs/RowsInput.js +96 -8
- package/dist/Components/Inputs/TabularForm.d.ts +5 -5
- package/dist/Components/Inputs/TabularForm.js +45 -32
- package/dist/Components/Inputs/TextArea.d.ts +6 -0
- package/dist/Components/Inputs/TextArea.js +11 -2
- package/dist/Components/Inputs/TextBoxTypes.d.ts +6 -0
- package/dist/Components/Inputs/TextBoxTypes.js +17 -0
- package/dist/Components/Inputs/TreeView.d.ts +76 -2
- package/dist/Components/Inputs/TreeView.js +227 -16
- package/dist/Components/Layouts/MessageBox.d.ts +15 -2
- package/dist/Components/Layouts/MessageBox.js +56 -7
- package/dist/Components/Layouts/Popup.d.ts +34 -7
- package/dist/Components/Layouts/Popup.js +143 -13
- package/dist/Components/Layouts/PopupDock.d.ts +3 -1
- package/dist/Components/Layouts/PopupDock.js +2 -1
- package/dist/Components/Layouts/PopupHost.js +5 -4
- package/dist/Components/Messages/MailClient.d.ts +50 -0
- package/dist/Components/Messages/MailClient.js +410 -0
- package/dist/Components/Messages/MailColumns.d.ts +21 -0
- package/dist/Components/Messages/MailColumns.js +92 -0
- package/dist/Components/Messages/MailCompose.d.ts +46 -0
- package/dist/Components/Messages/MailCompose.js +154 -0
- package/dist/Components/Messages/MailReader.d.ts +43 -0
- package/dist/Components/Messages/MailReader.js +207 -0
- package/dist/Components/Messages/MailThread.d.ts +71 -0
- package/dist/Components/Messages/MailThread.js +185 -0
- package/dist/Components/Messages/MessageBell.d.ts +41 -0
- package/dist/Components/Messages/MessageBell.js +76 -0
- package/dist/Components/Viewers/DataTable.js +153 -49
- package/dist/Components/Viewers/DataTableColumns.d.ts +2 -2
- package/dist/Components/Viewers/DataTableColumns.js +2 -1
- package/dist/Components/Viewers/DataTableFilters.d.ts +1 -1
- package/dist/Components/Viewers/DataTableFilters.js +17 -2
- package/dist/Components/Viewers/DataTableParts.d.ts +23 -5
- package/dist/Components/Viewers/DataTableParts.js +26 -14
- package/dist/Components/Viewers/OwnerFiles.d.ts +227 -0
- package/dist/Components/Viewers/OwnerFiles.js +700 -0
- package/dist/Components/Viewers/QrCodeImage.d.ts +22 -0
- package/dist/Components/Viewers/QrCodeImage.js +15 -0
- package/dist/Components/Viewers/RecordCards.d.ts +16 -0
- package/dist/Components/Viewers/RecordCards.js +509 -0
- package/dist/Helpers/ApiModules.js +4 -0
- package/dist/Helpers/AuditEntries.d.ts +50 -0
- package/dist/Helpers/AuditEntries.js +96 -0
- package/dist/Helpers/BusyHooks.d.ts +6 -0
- package/dist/Helpers/BusyHooks.js +39 -0
- package/dist/Helpers/ChatBackend.d.ts +8 -1
- package/dist/Helpers/ChatBackend.js +18 -0
- package/dist/Helpers/ChatHelpers.d.ts +2 -0
- package/dist/Helpers/ChatHelpers.js +4 -1
- package/dist/Helpers/CrudBackend.d.ts +20 -2
- package/dist/Helpers/CrudBackend.js +46 -2
- package/dist/Helpers/CrudColumns.d.ts +91 -6
- package/dist/Helpers/CrudColumns.js +148 -7
- package/dist/Helpers/CrudDetailsTabs.d.ts +58 -0
- package/dist/Helpers/CrudDetailsTabs.js +126 -0
- package/dist/Helpers/CrudHelpers.d.ts +116 -2
- package/dist/Helpers/CrudHelpers.js +256 -0
- package/dist/Helpers/CrudNarrowPick.d.ts +83 -0
- package/dist/Helpers/CrudNarrowPick.js +157 -0
- package/dist/Helpers/CrudPickerPlan.d.ts +58 -0
- package/dist/Helpers/CrudPickerPlan.js +46 -0
- package/dist/Helpers/CrudRowForms.d.ts +32 -0
- package/dist/Helpers/CrudRowForms.js +76 -0
- package/dist/Helpers/CrudScopes.d.ts +32 -0
- package/dist/Helpers/CrudScopes.js +79 -0
- package/dist/Helpers/DashboardHelpers.d.ts +115 -0
- package/dist/Helpers/DashboardHelpers.js +305 -0
- package/dist/Helpers/DomainCards.d.ts +140 -0
- package/dist/Helpers/DomainCards.js +370 -0
- package/dist/Helpers/DropdownHelpers.d.ts +3 -1
- package/dist/Helpers/DropdownHelpers.js +6 -2
- package/dist/Helpers/ExcelHelpers.d.ts +1 -1
- package/dist/Helpers/ExcelHelpers.js +12 -11
- package/dist/Helpers/FileAdapter.d.ts +158 -0
- package/dist/Helpers/FileAdapter.js +173 -0
- package/dist/Helpers/FilePrimaries.d.ts +48 -0
- package/dist/Helpers/FilePrimaries.js +138 -0
- package/dist/Helpers/FormConfirm.d.ts +32 -0
- package/dist/Helpers/FormConfirm.js +40 -0
- package/dist/Helpers/FormStore.d.ts +100 -5
- package/dist/Helpers/FormStore.js +368 -25
- package/dist/Helpers/InputConditions.d.ts +56 -0
- package/dist/Helpers/InputConditions.js +192 -0
- package/dist/Helpers/InputMemory.d.ts +106 -0
- package/dist/Helpers/InputMemory.js +395 -0
- package/dist/Helpers/InputNarrowing.d.ts +50 -0
- package/dist/Helpers/InputNarrowing.js +148 -0
- package/dist/Helpers/InputParamsHelpers.d.ts +44 -1
- package/dist/Helpers/InputParamsHelpers.js +157 -74
- package/dist/Helpers/MailClientHelpers.d.ts +96 -0
- package/dist/Helpers/MailClientHelpers.js +211 -0
- package/dist/Helpers/MessageBackend.d.ts +152 -0
- package/dist/Helpers/MessageBackend.js +329 -0
- package/dist/Helpers/MessageHelpers.d.ts +160 -0
- package/dist/Helpers/MessageHelpers.js +573 -0
- package/dist/Helpers/MessageUnread.d.ts +95 -0
- package/dist/Helpers/MessageUnread.js +245 -0
- package/dist/Helpers/NexusDriveAdapter.d.ts +71 -0
- package/dist/Helpers/NexusDriveAdapter.js +236 -0
- package/dist/Helpers/NumberHelpers.d.ts +156 -0
- package/dist/Helpers/NumberHelpers.js +242 -0
- package/dist/Helpers/PagedPicker.d.ts +42 -0
- package/dist/Helpers/PagedPicker.js +85 -0
- package/dist/Helpers/Permissions.js +2 -1
- package/dist/Helpers/QrCode.d.ts +26 -0
- package/dist/Helpers/QrCode.js +463 -0
- package/dist/Helpers/RecordCards.d.ts +205 -0
- package/dist/Helpers/RecordCards.js +378 -0
- package/dist/Helpers/RequestCache.d.ts +14 -2
- package/dist/Helpers/RequestCache.js +22 -3
- package/dist/Helpers/RowsStore.d.ts +61 -2
- package/dist/Helpers/RowsStore.js +203 -16
- package/dist/Helpers/ServerPreview.d.ts +69 -0
- package/dist/Helpers/ServerPreview.js +99 -0
- package/dist/Helpers/TableColumns.js +4 -3
- package/dist/Helpers/TableExport.d.ts +7 -1
- package/dist/Helpers/TableExport.js +9 -3
- package/dist/Helpers/TableFilters.d.ts +1 -1
- package/dist/Helpers/TableFilters.js +24 -23
- package/dist/Helpers/TableHelpers.d.ts +38 -4
- package/dist/Helpers/TableHelpers.js +78 -10
- package/dist/Helpers/TableJoins.d.ts +13 -0
- package/dist/Helpers/TableJoins.js +34 -0
- package/dist/Helpers/TableStore.d.ts +28 -4
- package/dist/Helpers/TableStore.js +73 -11
- package/dist/Helpers/ToolbarFit.d.ts +23 -0
- package/dist/Helpers/ToolbarFit.js +30 -0
- package/dist/Helpers/TreeStore.d.ts +156 -6
- package/dist/Helpers/TreeStore.js +547 -18
- package/dist/Interfaces/ApiInterfaces.d.ts +10 -1
- package/dist/Interfaces/ApiInterfaces.js +10 -1
- package/dist/Interfaces/ChatInterfaces.d.ts +22 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +539 -6
- package/dist/Interfaces/DashboardInterfaces.d.ts +123 -0
- package/dist/Interfaces/DashboardInterfaces.js +1 -0
- package/dist/Interfaces/DomainInterfaces.d.ts +145 -0
- package/dist/Interfaces/DomainInterfaces.js +1 -0
- package/dist/Interfaces/FileInterfaces.d.ts +183 -0
- package/dist/Interfaces/FileInterfaces.js +6 -0
- package/dist/Interfaces/FormInterfaces.d.ts +460 -32
- package/dist/Interfaces/FormInterfaces.js +79 -1
- package/dist/Interfaces/InputInterfaces.d.ts +22 -3
- package/dist/Interfaces/MailInterfaces.d.ts +192 -0
- package/dist/Interfaces/MailInterfaces.js +1 -0
- package/dist/Interfaces/RecordInterfaces.d.ts +367 -0
- package/dist/Interfaces/RecordInterfaces.js +1 -0
- package/dist/Interfaces/TableInterfaces.d.ts +102 -3
- package/dist/Services/ExcelApi.js +5 -0
- package/dist/Services/FileApi.d.ts +13 -0
- package/dist/Services/FileApi.js +29 -0
- package/dist/Services/ImageEditorLoader.d.ts +14 -0
- package/dist/Services/ImageEditorLoader.js +51 -0
- package/dist/Services/JoinSources.d.ts +79 -6
- package/dist/Services/JoinSources.js +132 -21
- package/dist/Services/MessageApi.d.ts +18 -0
- package/dist/Services/MessageApi.js +64 -0
- package/dist/Services/ModuleImport.d.ts +7 -0
- package/dist/Services/ModuleImport.js +22 -0
- package/dist/Shared.Index.d.ts +28 -0
- package/dist/Shared.Index.js +28 -0
- package/package.json +13 -2
- package/src/Styles/Nexus.Avatar.css +6 -5
- package/src/Styles/Nexus.Button.css +39 -4
- package/src/Styles/Nexus.Chat.css +114 -22
- package/src/Styles/Nexus.Choice.css +16 -0
- package/src/Styles/Nexus.Crud.css +486 -21
- package/src/Styles/Nexus.Dashboard.css +763 -0
- package/src/Styles/Nexus.Domain.css +384 -0
- package/src/Styles/Nexus.Dropdown.css +8 -0
- package/src/Styles/Nexus.Editor.css +20 -0
- package/src/Styles/Nexus.Files.css +437 -0
- package/src/Styles/Nexus.Form.css +5 -2
- package/src/Styles/Nexus.Index.css +7 -0
- package/src/Styles/Nexus.Input.css +73 -0
- package/src/Styles/Nexus.Mail.css +1062 -0
- package/src/Styles/Nexus.Menu.css +10 -1
- package/src/Styles/Nexus.Popup.css +135 -1
- package/src/Styles/Nexus.Print.css +288 -0
- package/src/Styles/Nexus.QrCode.css +12 -0
- package/src/Styles/Nexus.Record.css +430 -0
- package/src/Styles/Nexus.Tab.Buttons.css +87 -22
- package/src/Styles/Nexus.Table.css +1458 -1387
- package/src/Styles/Nexus.Theme.Picker.css +1 -0
- package/src/Styles/Nexus.Themes.css +12 -6
- package/src/Styles/Nexus.Tokens.css +8 -3
- package/src/Styles/Nexus.Tree.css +41 -1
|
@@ -1,27 +1,40 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx,
|
|
3
|
-
import { iconArrowBackUp, iconEye, iconFlag, iconFlagOff, iconHistory, iconInfoCircle, iconList, iconLoader2, iconLock, iconNotes, iconPencil, iconPin, iconPinnedOff,
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { iconArrowBackUp, iconEye, iconFlag, iconFlagOff, iconHistory, iconInfoCircle, iconList, iconLoader2, iconLock, iconNotes, iconPencil, iconPin, iconPinnedOff, iconStar, iconStarOff, iconTrash, iconTrashX, InlineIcon, } from "nexus-icons";
|
|
4
4
|
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
5
5
|
import { abortError } from "../../Helpers/ApiFormHelpers.js";
|
|
6
|
+
import { useSlowBusy } from "../../Helpers/BusyHooks.js";
|
|
6
7
|
import { isAbortError, unwrapApiResult } from "../../Helpers/ApiResponses.js";
|
|
7
8
|
import { getCrudBackend } from "../../Helpers/CrudBackend.js";
|
|
8
|
-
import { actionAllowed, availableModes, CRUD_CACHE_LIFETIME, CRUD_MARK_BITS, CRUD_MARKS, CRUD_MODES, CRUD_ROW_ACTIONS, CrudCache, crudCacheMode, crudListEndpoint, crudMassActions, crudMassEndpoint, crudMessageAction, crudRowActions, crudRowEndpoint, dropMarkedRows, hasMark, leavesList, markDirections, markedRecords, markLimit, massActionAllowed, patchCachedList, patchMarkedRecords, pinRoom, withMark, } from "../../Helpers/CrudHelpers.js";
|
|
9
|
+
import { actionAllowed, availableModes, CRUD_CACHE_LIFETIME, CRUD_MARK_BITS, CRUD_MARKS, CRUD_MODES, CRUD_ROW_ACTIONS, CrudCache, crudCacheMode, crudInNarrowing, crudJoinedNames, crudJoinsMissing, crudNarrowing, crudParentCounts, crudSavedCount, crudSavedRecord, crudTreeChanges, crudListEndpoint, crudMassActions, crudMassEndpoint, crudMessageAction, crudRowActions, crudRowEndpoint, dropMarkedRows, hasMark, leavesList, markDirections, markedRecords, markLimit, massActionAllowed, patchCachedList, patchMarkedRecords, pinRoom, withMark, } from "../../Helpers/CrudHelpers.js";
|
|
9
10
|
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
10
11
|
import { FormStore } from "../../Helpers/FormStore.js";
|
|
11
12
|
import { buildMessage, pluralize } from "../../Helpers/MessageBuilder.js";
|
|
12
13
|
import { getPermissions, permissionsVersion, subscribePermissions } from "../../Helpers/Permissions.js";
|
|
13
14
|
import { filterRequest } from "../../Helpers/TableFilters.js";
|
|
14
|
-
import { columnValue, formatCellText, readPath } from "../../Helpers/TableHelpers.js";
|
|
15
|
+
import { columnValue, formatCellText, namingColumn, readPath } from "../../Helpers/TableHelpers.js";
|
|
16
|
+
import { narrowPickWords } from "../../Helpers/CrudNarrowPick.js";
|
|
17
|
+
import { useCrudNarrowPicker } from "./CrudNarrowPicker.js";
|
|
18
|
+
import { CrudScopeControl, useCrudScopes } from "./CrudScopes.js";
|
|
19
|
+
import { openCrudRowForm } from "./CrudRowForms.js";
|
|
20
|
+
import { crudRowFormOffered, crudRowFormWrites } from "../../Helpers/CrudRowForms.js";
|
|
21
|
+
import { CRUD_DETAILS_TAB, crudDetailsWithoutProfile } from "../../Helpers/CrudDetailsTabs.js";
|
|
22
|
+
import { crudScopeProps, crudScopeStateKey } from "../../Helpers/CrudScopes.js";
|
|
23
|
+
import { EInputTypes } from "../../Interfaces/FormInterfaces.js";
|
|
15
24
|
import { api as browserApi } from "../../Services/BrowserApi.js";
|
|
16
25
|
import { expireLookups } from "../../Services/JoinSources.js";
|
|
26
|
+
import { expireTreePages, writeTreeChanges } from "../../Helpers/TreeStore.js";
|
|
17
27
|
import { Button } from "../Documents/Button.js";
|
|
18
28
|
import { TabButtons } from "../Documents/TabButtons.js";
|
|
19
29
|
import { openPopup } from "../Layouts/PopupHost.js";
|
|
20
30
|
import { toast } from "../Layouts/Toaster.js";
|
|
21
31
|
import { DataTable, useTable } from "../Viewers/DataTable.js";
|
|
22
32
|
import { ApiForm } from "./ApiForm.js";
|
|
33
|
+
import { CRUD_ACTION_WORDS, CRUD_ASKED, crudActionEntry, CrudFormBody, crudSubject, openCrudForm, sendCrudRowAction } from "./CrudActions.js";
|
|
23
34
|
import { openAuditHistory } from "./CrudHistory.js";
|
|
24
35
|
import { CrudDetails } from "./CrudParts.js";
|
|
36
|
+
import { confirmLeaving, CrudWorkspace } from "./CrudWorkspace.js";
|
|
37
|
+
import { FormLeaveGuard } from "./FormLeaveGuard.js";
|
|
25
38
|
import { confirmDiscard, FormButtons } from "./SubmitForm.js";
|
|
26
39
|
/*
|
|
27
40
|
* Crud: a module's records in one component. It takes a controller's endpoints, the table's columns, and the form's
|
|
@@ -31,6 +44,11 @@ import { confirmDiscard, FormButtons } from "./SubmitForm.js";
|
|
|
31
44
|
* answer instead of reloading.
|
|
32
45
|
*/
|
|
33
46
|
const ALL_ACTIONS = ["create", "view", "edit", "flag", "pin", "favorite", "history", "trash", "recover", "delete", "trashFromDelete", "description"];
|
|
47
|
+
/**
|
|
48
|
+
* The actions that change a record, which a locked one (`rowLock`) lists switched off. Pin and Favourite are left out on
|
|
49
|
+
* purpose: they are the user's own marks and change nothing on the record, as View and History change nothing.
|
|
50
|
+
*/
|
|
51
|
+
const CHANGES_RECORD = new Set(["edit", "flag", "trash", "recover", "delete", "trashFromDelete", "description"]);
|
|
34
52
|
/** How each mark shows: its section above the list, the actions that put it on and take it off, and what its limit says. */
|
|
35
53
|
const MARK_INFO = {
|
|
36
54
|
pin: {
|
|
@@ -63,43 +81,14 @@ const MODE_INFO = {
|
|
|
63
81
|
trash: { label: "Trash", icon: iconTrash, empty: () => "The trash is empty." },
|
|
64
82
|
deleted: { label: "Deleted", icon: iconTrashX, empty: plural => `No deleted ${plural}.` },
|
|
65
83
|
};
|
|
66
|
-
/** Actions that move a record between the lists are asked about first; flags and pins are undone by a click. */
|
|
67
|
-
const ASKED = new Set(["trash", "recover", "delete", "trashFromDelete"]);
|
|
68
84
|
/** Actions that take a record out of the active lists, so it leaves the sections of its marks too. */
|
|
69
85
|
const LEFT_ACTIVE = new Set(["trash", "delete", "trashFromDelete"]);
|
|
70
86
|
const capitalize = (text) => (text ? text[0].toUpperCase() + text.slice(1) : text);
|
|
71
87
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
72
|
-
function isInputList(inputs) {
|
|
73
|
-
return Array.isArray(inputs);
|
|
74
|
-
}
|
|
75
|
-
/** The subject the endpoints were given: `apiController(module, "Guest", "guest")` → "guest". */
|
|
76
|
-
function endpointSubject(api) {
|
|
77
|
-
const endpoints = [api.create, api.update, api.details, ...Object.values(api.lists ?? {}), ...Object.values(api.pages ?? {})];
|
|
78
|
-
return endpoints.find(endpoint => endpoint?.subject)?.subject;
|
|
79
|
-
}
|
|
80
|
-
/**
|
|
81
|
-
* The body of an Add, Edit or details popup. Without an `aside` it is the form (or the details) and nothing else, so
|
|
82
|
-
* a popup that wants no panel carries no wrapper at all. With one, the two become columns that scroll on their own,
|
|
83
|
-
* and the form is first in the DOM — which is also the order they stack in when there is no room for two. A details
|
|
84
|
-
* popup is marked, because there the panel is what the popup is for and takes the larger share.
|
|
85
|
-
*/
|
|
86
|
-
function CrudFormBody({ aside, details, children }) {
|
|
87
|
-
if (!aside)
|
|
88
|
-
return children;
|
|
89
|
-
return (_jsxs("div", { className: cx("nx-crud-aside", details && "nx-crud-aside--details"), children: [_jsx("div", { className: "nx-crud-aside__main", children: children }), _jsx("aside", { className: "nx-crud-aside__side", children: aside })] }));
|
|
90
|
-
}
|
|
91
88
|
/** Whether the details endpoint's record says anything the list's row did not. Plain JSON, compared field by field. */
|
|
92
89
|
function addsToRow(row, full) {
|
|
93
90
|
return Object.entries(full).some(([name, value]) => JSON.stringify(value) !== JSON.stringify(row[name]));
|
|
94
91
|
}
|
|
95
|
-
/**
|
|
96
|
-
* A details record that lands sooner than this says nothing: the demo answers in about 0.3s and most backends sooner,
|
|
97
|
-
* and a word shown for a frame or two is the flicker this avoids. Longer than the popup's 160ms fade, so the word
|
|
98
|
-
* never lands on a half-faded popup.
|
|
99
|
-
*/
|
|
100
|
-
const DETAILS_STATUS_DELAY = 400;
|
|
101
|
-
/** Once "Loading…" shows, it stays at least this long, so a record that lands just after the delay does not blink it. */
|
|
102
|
-
const DETAILS_STATUS_HOLD = 300;
|
|
103
92
|
/** One details popup's load state, shared by the details (which load) and the footer (which says so), as a form popup shares its FormStore with FormButtons. */
|
|
104
93
|
function detailsLoad(initial) {
|
|
105
94
|
let state = initial;
|
|
@@ -123,32 +112,13 @@ function detailsLoad(initial) {
|
|
|
123
112
|
}
|
|
124
113
|
/**
|
|
125
114
|
* A details popup's footer: a form's bar, its status line on the left and Edit and Close on the right. The line is
|
|
126
|
-
* always there, so what it says never moves anything: "Loading…" only for a slow record
|
|
127
|
-
* one that failed.
|
|
115
|
+
* always there, so what it says never moves anything: "Loading…" only for a slow record (`useSlowBusy`: the demo
|
|
116
|
+
* answers in about 0.3s), "Not all fields loaded." for one that failed.
|
|
128
117
|
*/
|
|
129
118
|
function DetailsFooter({ load, children }) {
|
|
130
119
|
const state = useSyncExternalStore(load.subscribe, load.get, load.get);
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
useEffect(() => {
|
|
134
|
-
if (state === "loading") {
|
|
135
|
-
if (said === "loading")
|
|
136
|
-
return;
|
|
137
|
-
const timer = window.setTimeout(() => {
|
|
138
|
-
since.current = performance.now();
|
|
139
|
-
setSaid("loading");
|
|
140
|
-
}, DETAILS_STATUS_DELAY);
|
|
141
|
-
return () => window.clearTimeout(timer);
|
|
142
|
-
}
|
|
143
|
-
const next = state === "failed" ? "failed" : null;
|
|
144
|
-
const left = said === "loading" ? since.current + DETAILS_STATUS_HOLD - performance.now() : 0;
|
|
145
|
-
if (left <= 0) {
|
|
146
|
-
setSaid(next);
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
const timer = window.setTimeout(() => setSaid(next), left);
|
|
150
|
-
return () => window.clearTimeout(timer);
|
|
151
|
-
}, [state, said]);
|
|
120
|
+
const slow = useSlowBusy(state === "loading");
|
|
121
|
+
const said = slow ? "loading" : state === "failed" ? "failed" : null;
|
|
152
122
|
return (_jsxs("div", { className: "nx-form-buttons nx-form-buttons--end nx-crud-details__foot", children: [_jsxs("p", { className: "nx-form-status nx-form-status--muted", "aria-live": "polite", children: [said === "loading" && (_jsxs(_Fragment, { children: [_jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin" }), _jsx("span", { children: "Loading\u2026" })] })), said === "failed" && (_jsxs(_Fragment, { children: [_jsx(InlineIcon, { icon: iconInfoCircle }), _jsx("span", { children: "Not all fields loaded." })] }))] }), _jsx("div", { className: "nx-form-buttons__actions", children: children })] }));
|
|
153
123
|
}
|
|
154
124
|
/**
|
|
@@ -165,21 +135,37 @@ function DetailsFooter({ load, children }) {
|
|
|
165
135
|
* paging="server"
|
|
166
136
|
* />
|
|
167
137
|
*/
|
|
168
|
-
export function Crud(
|
|
138
|
+
export function Crud(given) {
|
|
139
|
+
// A scope shows the same kind of record: its words, its popups and its saved settings' name are the CRUD's own.
|
|
140
|
+
const subject = given.subject ?? crudSubject(given.api) ?? "record";
|
|
141
|
+
const plural = given.subjectPlural ?? pluralize(subject);
|
|
142
|
+
// Where a narrowed list's record is picked, in the words its empty list, its Add and its Add form's fixed field use.
|
|
143
|
+
const pickWords = narrowPickWords(given.narrowPicker?.label, subject, plural);
|
|
144
|
+
// A list narrowed by a box of its own (`narrowPicker`) is one narrowed by the record picked there. While nothing is
|
|
145
|
+
// picked the box asks for a pick, and says why in the list's words.
|
|
146
|
+
const narrowPicker = useCrudNarrowPicker(given.narrowPicker, {
|
|
147
|
+
empty: given.narrowPicker?.emptyMessage ?? pickWords.empty,
|
|
148
|
+
add: given.narrowPicker?.addMessage ?? pickWords.add,
|
|
149
|
+
});
|
|
150
|
+
// Permissions: every list and action asks, again whenever the user's rights change.
|
|
151
|
+
const permissionsStamp = useSyncExternalStore(subscribePermissions, permissionsVersion, permissionsVersion);
|
|
152
|
+
const permissions = getPermissions();
|
|
153
|
+
const ready = permissions.ready;
|
|
154
|
+
const wantedModes = given.modes ?? CRUD_MODES;
|
|
155
|
+
const can = useCallback((endpoint) => permissions.can(endpoint), [permissions, permissionsStamp]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
156
|
+
// A list widened to another scope (`scopes`) is the CRUD on that scope's endpoints, with what differs on it; one is
|
|
157
|
+
// offered only to a person who may call one of its lists.
|
|
158
|
+
const scoping = useCrudScopes(given.scopes, scope => ready && availableModes(scope.api, given.paging ?? "client", wantedModes, can).length > 0);
|
|
159
|
+
const givenRef = useRef(given);
|
|
160
|
+
givenRef.current = given;
|
|
161
|
+
const scoped = crudScopeProps(given, scoping.scope);
|
|
162
|
+
const props = narrowPicker.narrow ? { ...scoped, narrow: narrowPicker.narrow } : scoped;
|
|
169
163
|
const { api, columns, rowKey = "id", paging = "client", table: tableOptions, id, className, style, ref } = props;
|
|
170
164
|
const latest = useRef(props);
|
|
171
165
|
latest.current = props;
|
|
172
|
-
const subject = props.subject ?? endpointSubject(api) ?? "record";
|
|
173
|
-
const plural = props.subjectPlural ?? pluralize(subject);
|
|
174
166
|
const name = props.name ?? subject;
|
|
175
167
|
const backend = props.backend ?? getCrudBackend();
|
|
176
|
-
// Permissions: every list and action asks, again whenever the user's rights change.
|
|
177
|
-
const permissionsStamp = useSyncExternalStore(subscribePermissions, permissionsVersion, permissionsVersion);
|
|
178
|
-
const permissions = getPermissions();
|
|
179
|
-
const ready = permissions.ready;
|
|
180
|
-
const wantedModes = props.modes ?? CRUD_MODES;
|
|
181
168
|
const wantedActions = props.actions ?? ALL_ACTIONS;
|
|
182
|
-
const can = useCallback((endpoint) => permissions.can(endpoint), [permissions, permissionsStamp]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
183
169
|
const modes = useMemo(() => (ready ? availableModes(api, paging, wantedModes, can) : []), [ready, api, paging, wantedModes, can]);
|
|
184
170
|
const allowed = useMemo(() => {
|
|
185
171
|
const map = {};
|
|
@@ -200,18 +186,65 @@ export function Crud(props) {
|
|
|
200
186
|
const mode = chosenMode && modes.includes(chosenMode) ? chosenMode : modes[0];
|
|
201
187
|
const modeRef = useRef(mode);
|
|
202
188
|
modeRef.current = mode;
|
|
189
|
+
// A list narrowed to the records of one picked in another (`narrow`): the picked record's value and name, and the
|
|
190
|
+
// part of the list's name it adds, so a list narrowed to one record is never filed or written as another's.
|
|
191
|
+
const narrowing = crudNarrowing(props.narrow);
|
|
192
|
+
const narrowingRef = useRef(narrowing);
|
|
193
|
+
narrowingRef.current = narrowing;
|
|
194
|
+
const scope = narrowing ? `@${narrowing.key}=${narrowing.value ?? ""}` : "";
|
|
195
|
+
const scopeRef = useRef(scope);
|
|
196
|
+
scopeRef.current = scope;
|
|
197
|
+
/** Whether a record belongs in the list's narrowing: always, without one. */
|
|
198
|
+
const inScope = (row) => crudInNarrowing(row, narrowingRef.current);
|
|
199
|
+
// The scope shown (`scopes`): "" for the CRUD's own list.
|
|
200
|
+
const scopeId = scoping.scope?.id ?? "";
|
|
201
|
+
// No list yet while the scope kept in this browser is read, or while the narrowing box settles as the page opens.
|
|
202
|
+
const scopeWaiting = scoping.waiting || narrowPicker.waiting;
|
|
203
|
+
const chooseScope = useRef(scoping.choose);
|
|
204
|
+
chooseScope.current = scoping.choose;
|
|
205
|
+
const scopeIdRef = useRef(scopeId);
|
|
206
|
+
scopeIdRef.current = scopeId;
|
|
207
|
+
/** The list on screen, as an action asked from it is told apart: its scope, its mode and its narrowing. */
|
|
208
|
+
const listNow = () => `${scopeIdRef.current}#${modeRef.current ?? ""}${scopeRef.current}`;
|
|
203
209
|
const table = useTable();
|
|
204
210
|
const lifetime = props.cache === false ? 0 : (props.cache?.lifetime ?? CRUD_CACHE_LIFETIME);
|
|
205
|
-
|
|
211
|
+
// The lists kept, one cache per scope: each scope's lists are its endpoints' answers.
|
|
212
|
+
const [caches] = useState(() => new Map());
|
|
213
|
+
const cacheOf = (scope) => {
|
|
214
|
+
let kept = caches.get(scope);
|
|
215
|
+
if (!kept)
|
|
216
|
+
caches.set(scope, (kept = new CrudCache(lifetime)));
|
|
217
|
+
return kept;
|
|
218
|
+
};
|
|
219
|
+
const cache = cacheOf(scopeId);
|
|
220
|
+
const clearCaches = useCallback(() => {
|
|
221
|
+
for (const kept of caches.values())
|
|
222
|
+
kept.clear();
|
|
223
|
+
}, [caches]);
|
|
206
224
|
// The cache lives while the page is open, as the old CRUD's did.
|
|
207
|
-
useEffect(() => () =>
|
|
225
|
+
useEffect(() => () => clearCaches(), [clearCaches]);
|
|
208
226
|
const rawKey = (row) => readPath(row, rowKey);
|
|
227
|
+
/** Why a record may not be changed here (`rowLock`), or null when it may. */
|
|
228
|
+
const lockOf = (row) => latest.current.rowLock?.(row) || null;
|
|
229
|
+
/**
|
|
230
|
+
* Why one action may not run on a record, or null when it may: the whole record's lock (`rowLock`) for every action that
|
|
231
|
+
* changes it, else that action's own (`rowActionLock`), such as a head office that may be edited but never trashed.
|
|
232
|
+
*/
|
|
233
|
+
const actionLockOf = (row, action) => {
|
|
234
|
+
if (!CHANGES_RECORD.has(action))
|
|
235
|
+
return null;
|
|
236
|
+
const whole = lockOf(row);
|
|
237
|
+
if (whole)
|
|
238
|
+
return whole;
|
|
239
|
+
const own = latest.current.rowActionLock?.(row)?.[action];
|
|
240
|
+
return own || null;
|
|
241
|
+
};
|
|
209
242
|
const labelOf = useCallback((row) => {
|
|
210
243
|
const custom = latest.current.rowLabel;
|
|
211
244
|
if (custom)
|
|
212
245
|
return custom(row);
|
|
213
|
-
const first = latest.current.columns
|
|
214
|
-
const text = first ? formatCellText(first, columnValue(first, row)) : "";
|
|
246
|
+
const first = namingColumn(latest.current.columns);
|
|
247
|
+
const text = first ? formatCellText(first, columnValue(first, row), { currency: tableOptions?.currency }, row) : "";
|
|
215
248
|
return text || `${capitalize(subject)} ${table.keyOf(row)}`;
|
|
216
249
|
}, [subject, table]);
|
|
217
250
|
/*
|
|
@@ -287,18 +320,28 @@ export function Crud(props) {
|
|
|
287
320
|
}
|
|
288
321
|
/* Loading */
|
|
289
322
|
const load = useCallback((query, signal) => {
|
|
290
|
-
|
|
323
|
+
// No list before the permissions say which, nor before the scope kept in this browser is read.
|
|
324
|
+
if (!mode || scopeWaiting)
|
|
291
325
|
return new Promise(() => undefined);
|
|
326
|
+
// A narrowed list with nothing picked shows nothing, and asks for nothing.
|
|
327
|
+
const narrowed = narrowingRef.current;
|
|
328
|
+
if (narrowed && narrowed.value === null)
|
|
329
|
+
return paging === "server" ? { rows: [], total: 0, matches: 0 } : [];
|
|
330
|
+
// A list that pages in the browser keeps every record of the list, whoever it names, and shows the picked one's:
|
|
331
|
+
// picking another shows its records from the same list, with no request. A server's pages are its own, and filtered.
|
|
332
|
+
const shown = (result) => (narrowed && Array.isArray(result) ? result.filter(inScope) : result);
|
|
292
333
|
const endpoint = crudListEndpoint(api, mode, paging);
|
|
293
334
|
const sortColumn = query.sort ? latest.current.columns.find(column => column.key === query.sort.key) : undefined;
|
|
294
335
|
const filters = query.filters ? JSON.stringify(query.filters) : "";
|
|
295
|
-
const key = paging === "server" ? `${mode}|${query.search}|${query.sort ? `${query.sort.key}:${query.sort.direction}` : ""}|${query.page}|${query.pageSize}|${filters}` : mode;
|
|
336
|
+
const key = paging === "server" ? `${mode}|${query.search}|${query.sort ? `${query.sort.key}:${query.sort.direction}` : ""}|${query.page}|${query.pageSize}|${filters}|${scope}` : mode;
|
|
296
337
|
const kept = cache.get(key);
|
|
297
338
|
if (kept)
|
|
298
|
-
return kept;
|
|
339
|
+
return shown(kept);
|
|
299
340
|
const options = { signal, toast: false, subject: plural };
|
|
300
|
-
if (paging === "server")
|
|
301
|
-
|
|
341
|
+
if (paging === "server") {
|
|
342
|
+
const named = filterRequest(query.filters, latest.current.table?.filterInputs ?? [], latest.current.columns);
|
|
343
|
+
options.body = backend.pageRequest(query, sortColumn?.sortKey ?? sortColumn?.key, narrowed ? { ...named, [narrowed.key]: narrowed.raw } : named);
|
|
344
|
+
}
|
|
302
345
|
return browserApi.send(endpoint, options).then(answer => {
|
|
303
346
|
if (answer.aborted)
|
|
304
347
|
throw abortError();
|
|
@@ -306,9 +349,12 @@ export function Crud(props) {
|
|
|
306
349
|
throw new Error(answer.message ?? `Could not load the ${plural}.`);
|
|
307
350
|
const result = paging === "server" ? backend.readPage(answer.result) : backend.readList(answer.result);
|
|
308
351
|
cache.set(key, result);
|
|
309
|
-
return result;
|
|
352
|
+
return shown(result);
|
|
310
353
|
});
|
|
311
|
-
},
|
|
354
|
+
},
|
|
355
|
+
// `scope` is what tells one narrowing's loader from another's; the rest of the narrowing is read through its ref.
|
|
356
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
357
|
+
[mode, api, paging, cache, backend, plural, scope, scopeWaiting]);
|
|
312
358
|
/** Which list a record belongs in, as this backend reads the record. */
|
|
313
359
|
const member = (row, of) => backend.listMembership(row, of);
|
|
314
360
|
/**
|
|
@@ -326,24 +372,54 @@ export function Crud(props) {
|
|
|
326
372
|
cache.map((key, value) => patchCachedList(value, crudCacheMode(key), changes, row => table.keyOf(row), member));
|
|
327
373
|
else
|
|
328
374
|
cache.clear();
|
|
375
|
+
// Another scope's lists (`scopes`) are the server's to say: whether a record belongs in them is not in the record (its
|
|
376
|
+
// branch, say), so they are dropped, and each loads when it is shown again.
|
|
377
|
+
for (const [kept, other] of caches)
|
|
378
|
+
if (kept !== scopeIdRef.current)
|
|
379
|
+
other.clear();
|
|
329
380
|
// The list on screen is the table's own, every row of it, so it is written back whole rather than patched - when
|
|
330
381
|
// the change was written into it. When it was not (see `holds`), the table is read again from the lists just
|
|
331
382
|
// patched, or loaded when it has none, and a load still out for it is stopped: it may have been asked before the
|
|
332
|
-
// change, and would land without it.
|
|
383
|
+
// change, and would land without it. A narrowed table holds the picked record's rows of a list kept whole, which
|
|
384
|
+
// the patch above has already written.
|
|
333
385
|
if (!written)
|
|
334
386
|
void table.reload({ quiet: true });
|
|
335
|
-
else if (paging === "client" && shown)
|
|
387
|
+
else if (paging === "client" && shown && !narrowingRef.current)
|
|
336
388
|
cache.update(shown, table.getRows());
|
|
337
389
|
}
|
|
338
390
|
/**
|
|
339
|
-
* Whether the table holds the list an action was asked from, every row of it, so the answer can be
|
|
340
|
-
* Not when the user moved to another list while a slow answer was out -
|
|
341
|
-
*
|
|
342
|
-
* file one list under another's name.
|
|
391
|
+
* Whether the table holds the list an action was asked from (`listNow()` then), every row of it, so the answer can be
|
|
392
|
+
* written into it. Not when the user moved to another list while a slow answer was out - another mode, or another
|
|
393
|
+
* record picked for a narrowed list - since the table holds that list then, and not while a list is loading: a switch
|
|
394
|
+
* keeps the old list's rows until the new ones land, and writing those back would file one list under another's name.
|
|
343
395
|
*/
|
|
344
396
|
function holds(from) {
|
|
345
|
-
return (from === undefined || from ===
|
|
397
|
+
return (from === undefined || from === listNow()) && table.getState().status === "ready";
|
|
346
398
|
}
|
|
399
|
+
/**
|
|
400
|
+
* Whether an answer came for another scope than the one shown now: the person switched while it was out. Which lists
|
|
401
|
+
* hold the record is then not known here, so every list kept is dropped and the one on screen read again, quietly;
|
|
402
|
+
* nothing is written into the table. `asked` is the scope shown when the action was asked for.
|
|
403
|
+
*/
|
|
404
|
+
function answeredElsewhere(asked) {
|
|
405
|
+
if (asked === scopeIdRef.current)
|
|
406
|
+
return false;
|
|
407
|
+
clearCaches();
|
|
408
|
+
reloadMarks();
|
|
409
|
+
void table.reload({ quiet: true });
|
|
410
|
+
// Pickers, joins and trees elsewhere show the same records whatever the scope: what they looked up is old now (the
|
|
411
|
+
// scope's endpoints the change went through, and the list's on screen), and a tree's kept pages are dropped, as no
|
|
412
|
+
// tree change can be worked out from a list this CRUD no longer holds.
|
|
413
|
+
const askedApi = asked ? givenRef.current.scopes?.options.find(option => option.id === asked)?.api : givenRef.current.api;
|
|
414
|
+
if (askedApi)
|
|
415
|
+
expireLookups(askedApi);
|
|
416
|
+
expireLookups(latest.current.api);
|
|
417
|
+
for (const tree of latest.current.trees ?? [])
|
|
418
|
+
expireTreePages(tree.cacheKey);
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
/** Whether a record belongs in the list on screen: its band's list, and the narrowing's record. */
|
|
422
|
+
const shownMember = (row) => inScope(row) && member(row, modeRef.current ?? "normal") !== false;
|
|
347
423
|
function afterChange(action, rows, changes, written) {
|
|
348
424
|
syncCaches(changes, written);
|
|
349
425
|
// What other tables and pickers looked up from this controller may now be wrong - a name, a status - so it is marked
|
|
@@ -351,14 +427,72 @@ export function Crud(props) {
|
|
|
351
427
|
if (action !== "pin" && action !== "favorite")
|
|
352
428
|
expireLookups(api);
|
|
353
429
|
const shown = modeRef.current;
|
|
354
|
-
if (shown)
|
|
355
|
-
|
|
430
|
+
if (!shown)
|
|
431
|
+
return;
|
|
432
|
+
const before = new Map(changes.map(change => [change.key, change.before]));
|
|
433
|
+
const change = { action, rows, mode: shown, before: rows.map(row => before.get(table.keyOf(row))) };
|
|
434
|
+
// The parents' own rows this table holds count their children as the tree's parents do: each row once, however many
|
|
435
|
+
// placements of the same records the page gives, and counted from the rows as they were before any of them.
|
|
436
|
+
let held = null;
|
|
437
|
+
const counted = new Map();
|
|
438
|
+
for (const tree of latest.current.trees ?? []) {
|
|
439
|
+
// A tree elsewhere showing these records keeps its pages: the change is written into them, never read again.
|
|
440
|
+
const treeChanges = crudTreeChanges(change, tree, backend);
|
|
441
|
+
writeTreeChanges(tree.cacheKey, treeChanges, tree.rules);
|
|
442
|
+
if (treeChanges.length === 0 || !tree.rules.hasChildrenKey)
|
|
443
|
+
continue;
|
|
444
|
+
held ??= heldRows();
|
|
445
|
+
for (const parent of crudParentCounts(treeChanges, held, tree)) {
|
|
446
|
+
const key = table.keyOf(parent);
|
|
447
|
+
if (!counted.has(key))
|
|
448
|
+
counted.set(key, parent);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (counted.size > 0)
|
|
452
|
+
patchRows([...counted.values()]);
|
|
453
|
+
latest.current.onChange?.(change);
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Records that changed with another one's change - a parent's count - written wherever they are held: the table, the
|
|
457
|
+
* lists kept, the marked sections. None of them joins or leaves a list, and nothing is loaded.
|
|
458
|
+
*/
|
|
459
|
+
function patchRows(rows) {
|
|
460
|
+
const write = holds(undefined);
|
|
461
|
+
const changes = rows.map(row => {
|
|
462
|
+
const key = table.keyOf(row);
|
|
463
|
+
const held = table.getRow(key);
|
|
464
|
+
if (held && write)
|
|
465
|
+
table.upsert(row, { flash: false });
|
|
466
|
+
return { key, row, before: held ?? row };
|
|
467
|
+
});
|
|
468
|
+
cache.map((key, value) => patchCachedList(value, crudCacheMode(key), changes, row => table.keyOf(row), member));
|
|
469
|
+
if (write && paging === "client" && modeRef.current)
|
|
470
|
+
cache.update(modeRef.current, table.getRows());
|
|
471
|
+
patchMarked([...rows]);
|
|
472
|
+
}
|
|
473
|
+
/** Every record the table and the lists kept hold, once each (the table's own last): where a parent's row may be. */
|
|
474
|
+
function heldRows() {
|
|
475
|
+
const found = new Map();
|
|
476
|
+
const add = (rows) => {
|
|
477
|
+
for (const row of rows)
|
|
478
|
+
found.set(table.keyOf(row), row);
|
|
479
|
+
};
|
|
480
|
+
cache.map((_key, value) => {
|
|
481
|
+
add(Array.isArray(value) ? value : value.rows);
|
|
482
|
+
return value;
|
|
483
|
+
});
|
|
484
|
+
add(table.getRows());
|
|
485
|
+
return [...found.values()];
|
|
356
486
|
}
|
|
357
487
|
function applyChange(action, rows, options = {}) {
|
|
358
|
-
const { result, mass = false, markOn, values, from } = options;
|
|
488
|
+
const { result, mass = false, markOn, values, from, asked = scopeIdRef.current } = options;
|
|
359
489
|
const shown = modeRef.current;
|
|
360
490
|
if (!shown)
|
|
361
491
|
return;
|
|
492
|
+
if (answeredElsewhere(asked)) {
|
|
493
|
+
latest.current.onChange?.({ action, rows, mode: shown });
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
362
496
|
// Every table write below is for the list the action was asked from; when the table holds another, none is made.
|
|
363
497
|
const write = holds(from);
|
|
364
498
|
const answered = rows.length > 1 ? backend.answeredKeys(result, rowKey) : null;
|
|
@@ -439,7 +573,9 @@ export function Crud(props) {
|
|
|
439
573
|
* mark goes on top of its section, one that lost it leaves), and the record itself in the table and every list kept.
|
|
440
574
|
* A mark never moves a record between lists: it stays where it is, on its page.
|
|
441
575
|
*/
|
|
442
|
-
function applyMarks(mark, rows, stored) {
|
|
576
|
+
function applyMarks(mark, rows, stored, asked = scopeIdRef.current) {
|
|
577
|
+
if (answeredElsewhere(asked))
|
|
578
|
+
return;
|
|
443
579
|
const write = holds(undefined);
|
|
444
580
|
const updated = [];
|
|
445
581
|
const moved = [];
|
|
@@ -462,10 +598,13 @@ export function Crud(props) {
|
|
|
462
598
|
}
|
|
463
599
|
/**
|
|
464
600
|
* Records a page's own action changed, written exactly where the built-in ones write theirs. The handle's `apply`.
|
|
601
|
+
* `held` gives, by key, a record as it was where the table does not hold it (a details popup's record from the pinned
|
|
602
|
+
* band, or from a page since turned): a record known to have been in the list on screen and still in it is then
|
|
603
|
+
* written in place in every page kept, and none is dropped or read again for it.
|
|
465
604
|
*/
|
|
466
|
-
function applyRows(rows, gone = false) {
|
|
605
|
+
function applyRows(rows, gone = false, held, asked = scopeIdRef.current) {
|
|
467
606
|
const shown = modeRef.current;
|
|
468
|
-
if (!shown || rows.length === 0)
|
|
607
|
+
if (!shown || rows.length === 0 || answeredElsewhere(asked))
|
|
469
608
|
return;
|
|
470
609
|
const moved = [];
|
|
471
610
|
const updated = [];
|
|
@@ -481,13 +620,15 @@ export function Crud(props) {
|
|
|
481
620
|
const key = table.keyOf(next);
|
|
482
621
|
if (!key)
|
|
483
622
|
continue;
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
623
|
+
const inTable = table.getRow(key);
|
|
624
|
+
const before = inTable ?? held?.get(key);
|
|
625
|
+
// The record decides whether it is still in the list on screen - its band, and the record a narrowed list shows the
|
|
626
|
+
// records of; a backend that cannot say keeps it there.
|
|
627
|
+
if (gone || !inScope(next) || member(next, shown) === false)
|
|
487
628
|
leaving.push(key);
|
|
488
629
|
else if (!before && member(next, shown))
|
|
489
630
|
write = false;
|
|
490
|
-
else if (
|
|
631
|
+
else if (inTable && write)
|
|
491
632
|
table.upsert(next, { flash: false });
|
|
492
633
|
if (before && backend.marksOf(next) !== backend.marksOf(before))
|
|
493
634
|
stored.set(key, backend.marksOf(next));
|
|
@@ -504,17 +645,13 @@ export function Crud(props) {
|
|
|
504
645
|
}
|
|
505
646
|
/* Row actions */
|
|
506
647
|
async function runRowAction(action, row) {
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
return;
|
|
510
|
-
const key = rawKey(row);
|
|
511
|
-
const from = modeRef.current;
|
|
512
|
-
const markOn = action === "flag" ? !backend.isFlagged(row) : undefined;
|
|
648
|
+
const from = listNow();
|
|
649
|
+
const asked = scopeIdRef.current;
|
|
513
650
|
// A flag carries the way it goes: never a toggle the server works out.
|
|
514
|
-
const
|
|
515
|
-
const answer =
|
|
651
|
+
const markOn = action === "flag" ? !backend.isFlagged(row) : undefined;
|
|
652
|
+
const answer = await sendCrudRowAction(api, action, row, { rowKey, label: labelOf(row), backend, markOn });
|
|
516
653
|
if (answer?.ok)
|
|
517
|
-
applyChange(action, [row], { result: answer.result, markOn, from });
|
|
654
|
+
applyChange(action, [row], { result: answer.result, markOn, from, asked });
|
|
518
655
|
}
|
|
519
656
|
/**
|
|
520
657
|
* Puts a mark on one record, or takes it off: the way the record's marks say, never a toggle the server works out.
|
|
@@ -534,10 +671,11 @@ export function Crud(props) {
|
|
|
534
671
|
const wanted = withMark(before, mark, on);
|
|
535
672
|
const whole = Boolean(api.bookmark);
|
|
536
673
|
const id = rawKey(row);
|
|
674
|
+
const asked = scopeIdRef.current;
|
|
537
675
|
const answer = await browserApi.send(endpoint, { params: { id, [rowKey]: id }, subject: labelOf(row), action: crudMessageAction(mark, on), ...(whole ? backend.marksRequest(wanted) : {}) });
|
|
538
676
|
if (!answer.ok)
|
|
539
677
|
return;
|
|
540
|
-
applyMarks(mark, [row], new Map([[table.keyOf(row), (whole ? backend.answeredMarks(answer.result) : null) ?? wanted]]));
|
|
678
|
+
applyMarks(mark, [row], new Map([[table.keyOf(row), (whole ? backend.answeredMarks(answer.result) : null) ?? wanted]]), asked);
|
|
541
679
|
}
|
|
542
680
|
/**
|
|
543
681
|
* Puts a mark on the selected records, or takes it off. Only the records the change is for are sent - the rest are
|
|
@@ -562,11 +700,12 @@ export function Crud(props) {
|
|
|
562
700
|
const keys = sent.map(rawKey);
|
|
563
701
|
const request = api.mass?.bookmark ? backend.massMarksRequest(keys, mark, on) : { body: backend.massRequest(mark, keys) };
|
|
564
702
|
const action = crudMessageAction(mark, on);
|
|
703
|
+
const asked = scopeIdRef.current;
|
|
565
704
|
const answer = await browserApi.send(endpoint, { ...request, subject, plural: props.subjectPlural, count: sent.length, action, toast: { success: false } });
|
|
566
705
|
if (!answer.ok)
|
|
567
706
|
return;
|
|
568
707
|
const done = changedOf(sent, answer.result);
|
|
569
|
-
applyMarks(mark, done, new Map(done.map(row => [table.keyOf(row), withMark(marksOf(row), mark, on)])));
|
|
708
|
+
applyMarks(mark, done, new Map(done.map(row => [table.keyOf(row), withMark(marksOf(row), mark, on)])), asked);
|
|
570
709
|
table.clearSelection();
|
|
571
710
|
sayMassDone(action, sent.length, done.length);
|
|
572
711
|
if (sent.length < wanted.length)
|
|
@@ -599,12 +738,13 @@ export function Crud(props) {
|
|
|
599
738
|
if (!sent.length)
|
|
600
739
|
return;
|
|
601
740
|
const action = crudMessageAction("flag", on);
|
|
602
|
-
const from =
|
|
741
|
+
const from = listNow();
|
|
742
|
+
const asked = scopeIdRef.current;
|
|
603
743
|
const answer = await browserApi.send(endpoint, { body: backend.massRequest("flag", sent.map(rawKey)), ...backend.flagRequest(on), subject, plural: props.subjectPlural, count: sent.length, action, toast: { success: false } });
|
|
604
744
|
if (!answer.ok)
|
|
605
745
|
return;
|
|
606
746
|
const done = changedOf(sent, answer.result);
|
|
607
|
-
applyChange("flag", done, { result: answer.result, mass: true, markOn: on, from });
|
|
747
|
+
applyChange("flag", done, { result: answer.result, mass: true, markOn: on, from, asked });
|
|
608
748
|
table.clearSelection();
|
|
609
749
|
sayMassDone(action, sent.length, done.length);
|
|
610
750
|
}
|
|
@@ -617,88 +757,94 @@ export function Crud(props) {
|
|
|
617
757
|
if (!endpoint)
|
|
618
758
|
return;
|
|
619
759
|
const options = { body: backend.massRequest(action, rows.map(rawKey)), subject, plural: props.subjectPlural, count: rows.length, action: crudMessageAction(action) };
|
|
620
|
-
const from =
|
|
621
|
-
const
|
|
760
|
+
const from = listNow();
|
|
761
|
+
const asked = scopeIdRef.current;
|
|
762
|
+
const answer = CRUD_ASKED.has(action) ? await browserApi.confirm(endpoint, options) : await browserApi.send(endpoint, options);
|
|
622
763
|
if (!answer?.ok)
|
|
623
764
|
return;
|
|
624
|
-
applyChange(action, rows, { result: answer.result, mass: true, from });
|
|
765
|
+
applyChange(action, rows, { result: answer.result, mass: true, from, asked });
|
|
625
766
|
table.clearSelection();
|
|
626
767
|
}
|
|
627
768
|
/* Popups */
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
769
|
+
/**
|
|
770
|
+
* Why nothing can be added to a narrowed list now: no record picked, or one that may take none (`narrow.addLock`, by
|
|
771
|
+
* default the backend's rule: a record a picker would not offer takes no new records). `null` while Add adds, and
|
|
772
|
+
* always without a narrowing.
|
|
773
|
+
*/
|
|
774
|
+
function narrowedAddLock() {
|
|
775
|
+
const narrow = latest.current.narrow;
|
|
776
|
+
const narrowed = narrowingRef.current;
|
|
777
|
+
if (!narrow || !narrowed)
|
|
778
|
+
return null;
|
|
779
|
+
if (!narrow.picked || narrowed.value === null)
|
|
780
|
+
return narrow.addMessage ?? pickWords.add;
|
|
781
|
+
if (narrow.addLock)
|
|
782
|
+
return narrow.addLock(narrow.picked) || null;
|
|
783
|
+
return backend.isPickable(narrow.picked) ? null : `${narrowed.label} cannot take a new ${subject} now.`;
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Opens Add or Edit. `opened`: the scope the form belongs to and its props - a details popup's, captured when it opened,
|
|
787
|
+
* so an Edit pressed in it after a switch is that scope's form on that scope's endpoints; default the scope shown.
|
|
788
|
+
* `after`: called once the save is written (a workspace section's Edit reads the record again).
|
|
789
|
+
*/
|
|
790
|
+
async function openForm(action, row, values, opened, after) {
|
|
791
|
+
// A locked record is shown, not edited: its details say what it is, and the page's columns can say why.
|
|
792
|
+
if (action === "edit" && row && actionLockOf(row, "edit")) {
|
|
793
|
+
openDetails(row);
|
|
644
794
|
return;
|
|
645
795
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
const
|
|
650
|
-
const
|
|
651
|
-
|
|
652
|
-
const
|
|
653
|
-
const
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
796
|
+
// A narrowed list adds for the record picked in the other one, and only while it may take one: its value fixed in
|
|
797
|
+
// the form, the form one of its own per picked record ("For Ram Thapa" in its title bar), and its box named from the
|
|
798
|
+
// picked record with no lookup.
|
|
799
|
+
const picked = latest.current.narrow?.picked;
|
|
800
|
+
const narrowed = narrowingRef.current;
|
|
801
|
+
// The scope the form was opened on: a save that lands after a switch is that scope's, on its endpoints and inputs.
|
|
802
|
+
const asked = opened ? opened.asked : scopeIdRef.current;
|
|
803
|
+
const scoped = opened ? opened.props : latest.current;
|
|
804
|
+
let place = { values, label: row ? labelOf(row) : undefined };
|
|
805
|
+
if (action === "create" && narrowed) {
|
|
806
|
+
if (narrowedAddLock() !== null || !picked)
|
|
807
|
+
return;
|
|
808
|
+
place = {
|
|
809
|
+
values: { ...values, [narrowed.key]: narrowed.raw },
|
|
810
|
+
locked: { [narrowed.key]: (latest.current.narrow?.fixedMessage ?? pickWords.fixed)(narrowed.label) },
|
|
811
|
+
known: [picked],
|
|
812
|
+
label: `For ${narrowed.label}`,
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
await openCrudForm({
|
|
816
|
+
api: scoped.api,
|
|
817
|
+
action,
|
|
818
|
+
row,
|
|
819
|
+
...place,
|
|
820
|
+
inputs: scoped.inputs,
|
|
821
|
+
form: scoped.form,
|
|
822
|
+
subject,
|
|
823
|
+
// Each scope keeps its own Add (and its draft): one from another scope never comes back here. A record keeps one
|
|
824
|
+
// Edit wherever it is opened from.
|
|
825
|
+
name: action === "create" && asked ? `${name}@${asked}` : name,
|
|
826
|
+
rowKey,
|
|
827
|
+
key: row ? table.keyOf(row) : "",
|
|
828
|
+
onSaved: (result, sent, joined) => {
|
|
829
|
+
run.current.onSaved(action, row, result, sent, joined, asked);
|
|
830
|
+
after?.();
|
|
673
831
|
},
|
|
674
|
-
content: popup => (_jsx(CrudFormBody, { aside: typeof options.aside === "function" ? options.aside({ form, action, row }) : options.aside, children: _jsx(ApiForm, { form: form, inputs: inputs, buttons: false, defaultValue: (action === "create" ? { ...newValues, ...values } : (row ?? undefined)), loadValue: loadValue, api: endpoint, transform: options.transform ? formValues => options.transform(formValues, { action, row }) : undefined, validate: options.validate, onFieldChange: options.onFieldChange, design: options.design, draftKey: options.draftKey ? `${options.draftKey}:${name}:${action === "create" ? "new" : key}` : undefined, "aria-label": action === "create" ? `Add ${subject}` : `Edit ${label}`, onSuccess: (result, saved) => {
|
|
675
|
-
onSaved(action, row, result, saved);
|
|
676
|
-
popup.close();
|
|
677
|
-
} }) })),
|
|
678
|
-
footer: popup => _jsx(FormButtons, { form: form, submitLabel: action === "create" ? `Add ${subject}` : "Save", onCancel: () => popup.close() }),
|
|
679
832
|
});
|
|
680
|
-
if (action === "create") {
|
|
681
|
-
const entry = { handle, form };
|
|
682
|
-
addPopup.current = entry;
|
|
683
|
-
void handle.closed.then(() => {
|
|
684
|
-
if (addPopup.current === entry)
|
|
685
|
-
addPopup.current = null;
|
|
686
|
-
});
|
|
687
|
-
}
|
|
688
833
|
}
|
|
689
834
|
/**
|
|
690
|
-
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
835
|
+
* A saved record read back on its own - only where what the page holds cannot finish it: a backend whose saved record
|
|
836
|
+
* still does not say which list it belongs in (`savedRow` wrote no status; the Nexus backend's always does), or a name
|
|
837
|
+
* the database joins that no picked or held record gives (`crudJoinsMissing`: a `dbJoin` column without `from`). The row
|
|
838
|
+
* goes in at once with what the form sent, and the rest lands a moment later.
|
|
693
839
|
*/
|
|
694
|
-
async function fillSaved(key, record) {
|
|
840
|
+
async function fillSaved(key, record, asked = scopeIdRef.current) {
|
|
695
841
|
const endpoint = api.details;
|
|
696
842
|
if (!endpoint)
|
|
697
843
|
return;
|
|
698
844
|
const id = readPath(record, rowKey);
|
|
699
845
|
const answer = await browserApi.get(endpoint, { params: { id, [rowKey]: id }, toast: false });
|
|
700
846
|
const full = unwrapApiResult(answer);
|
|
701
|
-
if (!isRecord(full))
|
|
847
|
+
if (!isRecord(full) || answeredElsewhere(asked))
|
|
702
848
|
return;
|
|
703
849
|
const before = table.getRow(key);
|
|
704
850
|
const next = { ...(before ?? record), ...full };
|
|
@@ -706,40 +852,70 @@ export function Crud(props) {
|
|
|
706
852
|
if (write && before)
|
|
707
853
|
table.upsert(next, { flash: false });
|
|
708
854
|
syncCaches([{ key, row: next, before }], write);
|
|
855
|
+
patchMarked([next]);
|
|
856
|
+
for (const tree of latest.current.trees ?? [])
|
|
857
|
+
writeTreeChanges(tree.cacheKey, crudTreeChanges({ action: "edit", rows: [next], mode: modeRef.current ?? "normal", before: [record] }, tree, backend), tree.rules);
|
|
709
858
|
}
|
|
710
|
-
|
|
859
|
+
/**
|
|
860
|
+
* A saved record finished with no call: the fields only the server keeps as the backend writes them (`savedRow`), the
|
|
861
|
+
* names the database joins from the records the form picked or the page holds (`crudJoinedNames`), and the count of its
|
|
862
|
+
* children each tree placement keeps (`crudSavedCount`: none for a new one, the held row's for an edited one). `held` is
|
|
863
|
+
* the record as the page holds it now (an edit). A narrowed list's picked record names the field it narrows by.
|
|
864
|
+
*/
|
|
865
|
+
function finished(action, record, held, joined) {
|
|
866
|
+
let next = backend.savedRow(action, record, held);
|
|
867
|
+
const picked = latest.current.narrow?.picked;
|
|
868
|
+
next = crudJoinedNames(next, held, latest.current.columns, (from, key) => joined(from, key, picked ? [picked] : []));
|
|
869
|
+
for (const tree of latest.current.trees ?? [])
|
|
870
|
+
next = crudSavedCount(next, held, tree);
|
|
871
|
+
return next;
|
|
872
|
+
}
|
|
873
|
+
function onSaved(action, row, result, sent, joined, asked = scopeIdRef.current) {
|
|
711
874
|
// The record as the server now has it: what was sent, over the row it came from, with whatever the answer added.
|
|
712
|
-
// A backend that answers the record it wrote leaves nothing to load at all.
|
|
713
875
|
const answered = backend.answeredRow(result);
|
|
714
|
-
|
|
876
|
+
let record = crudSavedRecord(row, sent, answered);
|
|
877
|
+
// A new record whose answer is only its key still gets it, so its row goes in.
|
|
878
|
+
if (action === "create" && !answered && !readPath(record, rowKey)) {
|
|
879
|
+
const given = backend.answeredKey(result);
|
|
880
|
+
if (given !== null && !rowKey.includes("."))
|
|
881
|
+
record[rowKey] = given;
|
|
882
|
+
}
|
|
715
883
|
const shown = modeRef.current;
|
|
884
|
+
if (answeredElsewhere(asked)) {
|
|
885
|
+
latest.current.onChange?.({ action, rows: [record], mode: shown ?? "normal" });
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
716
888
|
if (action === "edit") {
|
|
889
|
+
// The record as the page holds it now - flagged or counted since the form opened, perhaps - or, off the page shown
|
|
890
|
+
// (a pinned record, another page), as the form opened it.
|
|
717
891
|
const key = table.keyOf(record);
|
|
718
|
-
const
|
|
892
|
+
const held = (key ? table.getRow(key) : undefined) ?? row ?? undefined;
|
|
893
|
+
record = finished(action, record, held, joined);
|
|
719
894
|
const write = holds(undefined);
|
|
720
|
-
|
|
895
|
+
// Into the table when it belongs in the list on screen; out of it when an edit took it elsewhere - to another
|
|
896
|
+
// record of a narrowed list's, say. Another list's record, saved while this one shows, never goes in.
|
|
897
|
+
if (write && shownMember(record))
|
|
721
898
|
table.upsert(record);
|
|
899
|
+
else if (write && key && table.getRow(key))
|
|
900
|
+
table.remove([key]);
|
|
722
901
|
patchMarked([record]);
|
|
723
|
-
afterChange("edit", [record], key ? [{ key, row: record, before }] : [], write);
|
|
902
|
+
afterChange("edit", [record], key ? [{ key, row: record, before: held }] : [], write);
|
|
903
|
+
if (key && api.details && crudJoinsMissing(record, held, latest.current.columns))
|
|
904
|
+
void fillSaved(key, record, asked);
|
|
724
905
|
return;
|
|
725
906
|
}
|
|
726
|
-
|
|
727
|
-
//
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
if (given !== null && !rowKey.includes("."))
|
|
731
|
-
record[rowKey] = given;
|
|
732
|
-
}
|
|
907
|
+
record = finished(action, record, undefined, joined);
|
|
908
|
+
// A new record belongs to the list of every record - in a narrowed list, of the record it names, which may no longer
|
|
909
|
+
// be the one picked when its form was opened; without even a key the lists are dropped and the one on screen loads
|
|
910
|
+
// again (`syncCaches`).
|
|
733
911
|
const key = table.keyOf(record);
|
|
734
|
-
// Without a key the lists are dropped and the one on screen loads again (`syncCaches`).
|
|
735
912
|
const write = Boolean(key) && holds(undefined);
|
|
736
|
-
if (write && shown === "normal")
|
|
913
|
+
if (write && shown === "normal" && inScope(record))
|
|
737
914
|
table.upsert(record);
|
|
738
915
|
afterChange("create", [record], key ? [{ key, row: record, before: undefined }] : [], write);
|
|
739
|
-
//
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
void fillSaved(key, record);
|
|
916
|
+
// Read back only what the page cannot finish: a list unknown (no list can place a record without it), or a name.
|
|
917
|
+
if (key && api.details && (member(record, "normal") === undefined || crudJoinsMissing(record, undefined, latest.current.columns)))
|
|
918
|
+
void fillSaved(key, record, asked);
|
|
743
919
|
}
|
|
744
920
|
/** A record's history, in a popup of its own: one per record, brought back if it is open. */
|
|
745
921
|
function openHistory(row) {
|
|
@@ -752,57 +928,190 @@ export function Crud(props) {
|
|
|
752
928
|
if (details === false)
|
|
753
929
|
return;
|
|
754
930
|
const key = table.keyOf(row);
|
|
755
|
-
const canEdit = allowed.edit && modeRef.current !== undefined && CRUD_ROW_ACTIONS[modeRef.current].includes("edit");
|
|
931
|
+
const canEdit = allowed.edit && modeRef.current !== undefined && CRUD_ROW_ACTIONS[modeRef.current].includes("edit") && !actionLockOf(row, "edit");
|
|
756
932
|
const endpoint = api.details;
|
|
757
933
|
const status = detailsLoad(endpoint ? "loading" : "done");
|
|
934
|
+
// A record's workspace: a profile and tabs, the details one tab among the page's own (`details.tabs`, `profile`).
|
|
935
|
+
const workspace = Boolean(details?.tabs || details?.profile);
|
|
758
936
|
// The panel's record: the row at once, so it has something to show while the full one loads, and that one after.
|
|
759
|
-
const shown = typeof details?.aside === "function" ? new FormStore() : null;
|
|
937
|
+
const shown = typeof details?.aside === "function" || workspace ? new FormStore() : null;
|
|
938
|
+
// The forms a workspace's tabs draw: closing it with changes in one asks first, as a CRUD form's popup does.
|
|
939
|
+
const guard = new FormLeaveGuard();
|
|
940
|
+
const leave = () => confirmLeaving(guard);
|
|
760
941
|
shown?.load(row, { draft: false });
|
|
761
942
|
const aside = typeof details?.aside === "function" ? details.aside({ form: shown, row }) : details?.aside;
|
|
762
|
-
|
|
943
|
+
const idOf = () => rawKey(row);
|
|
944
|
+
// The scope the details were opened on, and its props: a refresh that lands after a switch is that scope's, and so is
|
|
945
|
+
// an Edit pressed in them.
|
|
946
|
+
const asked = scopeIdRef.current;
|
|
947
|
+
const opened = { asked, props: latest.current };
|
|
948
|
+
// The record from the details endpoint, over the row; a panel's record is loaded only when it adds something, since a
|
|
949
|
+
// panel that renders from the record would otherwise ask again for exactly what it is already showing.
|
|
950
|
+
const readDetails = (signal) => {
|
|
951
|
+
const loading = browserApi
|
|
952
|
+
.get(endpoint, { params: { id: idOf(), [rowKey]: idOf() }, signal, toast: false })
|
|
953
|
+
.then(unwrapApiResult)
|
|
954
|
+
.then(full => {
|
|
955
|
+
if (shown && isRecord(full) && addsToRow(row, full))
|
|
956
|
+
shown.load({ ...row, ...full }, { draft: false });
|
|
957
|
+
return full;
|
|
958
|
+
});
|
|
959
|
+
// The footer follows the very promise the details do, with the same test for a popup closed meanwhile.
|
|
960
|
+
void loading.then(() => status.set("done"), error => {
|
|
961
|
+
if (!signal.aborted && !isAbortError(error))
|
|
962
|
+
status.set("failed");
|
|
963
|
+
});
|
|
964
|
+
return loading;
|
|
965
|
+
};
|
|
966
|
+
// A section's own Edit in a workspace's Details tab (`details.sections`): the Crud's Edit form or one of its row forms,
|
|
967
|
+
// offered by the rules the row's menu uses, the record read again once saved. Nothing where the form is not offered.
|
|
968
|
+
const sectionEdit = (reread) => (group) => {
|
|
969
|
+
const section = details?.sections?.find(one => one.group === group);
|
|
970
|
+
if (!section?.edit)
|
|
971
|
+
return null;
|
|
972
|
+
const record = table.getRow(key) ?? shown.getValue();
|
|
973
|
+
const mode = modeRef.current;
|
|
974
|
+
if (mode === undefined || section.hidden?.(record))
|
|
975
|
+
return null;
|
|
976
|
+
const again = () => void reread();
|
|
977
|
+
let open = null;
|
|
978
|
+
if (section.edit === "edit") {
|
|
979
|
+
if (allowed.edit && CRUD_ROW_ACTIONS[mode].includes("edit") && !actionLockOf(record, "edit"))
|
|
980
|
+
open = () => void openForm("edit", table.getRow(key) ?? shown.getValue(), undefined, opened, again);
|
|
981
|
+
}
|
|
982
|
+
else {
|
|
983
|
+
const form = latest.current.rowForms?.find(one => one.id === section.edit);
|
|
984
|
+
if (form && crudRowFormOffered(form, record, mode, can) && !form.lock?.(record))
|
|
985
|
+
open = () => openRowForm(form, table.getRow(key) ?? shown.getValue(), again);
|
|
986
|
+
}
|
|
987
|
+
if (!open)
|
|
988
|
+
return null;
|
|
989
|
+
const word = section.label ?? "Edit";
|
|
990
|
+
return (_jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconPencil }), "aria-label": `${word} ${group}`, "data-action": "section-edit", onClick: open, children: word }));
|
|
991
|
+
};
|
|
992
|
+
// A workspace's Details tab leaves out the fields its profile shows (`profile.fields`).
|
|
993
|
+
const fields = (record, load, without, reread) => (_jsx(CrudFormBody, { aside: aside, details: true, below: (details?.asidePlace ?? (workspace ? "below" : "beside")) === "below", children: _jsx(CrudDetails, { row: record, columns: crudDetailsWithoutProfile(details?.columns ?? latest.current.columns, without), render: details?.render, currency: tableOptions?.currency, load: load, headingEnd: reread && details?.sections?.length ? sectionEdit(reread) : undefined }) }));
|
|
994
|
+
// The workspace reads the record once for the whole popup - the profile, every tab and the fields share it - and
|
|
995
|
+
// again on each `refresh()`. Closing the popup stops the first read, never a refresh's: a tab saved something on
|
|
996
|
+
// the server, and the lists must hear of it even when the popup closed before the record came back.
|
|
997
|
+
const reading = new AbortController();
|
|
998
|
+
let pending;
|
|
999
|
+
const startReading = () => (pending ??= readDetails(reading.signal));
|
|
1000
|
+
const refresh = async () => {
|
|
1001
|
+
if (!endpoint)
|
|
1002
|
+
return table.getRow(key) ?? shown.getValue();
|
|
1003
|
+
const answer = await browserApi.get(endpoint, { params: { id: idOf(), [rowKey]: idOf() } });
|
|
1004
|
+
const full = answer.ok ? answer.result : null;
|
|
1005
|
+
if (!isRecord(full))
|
|
1006
|
+
return null;
|
|
1007
|
+
// The record as the page holds it when the answer lands: the table's row, or, off the page shown (from the pinned
|
|
1008
|
+
// band, a page turned since), the popup's.
|
|
1009
|
+
const held = table.getRow(key) ?? shown.getValue();
|
|
1010
|
+
// Into the table, the marks and every list kept, as an edit's answer is; and into every tree that shows the record.
|
|
1011
|
+
const next = { ...held, ...full };
|
|
1012
|
+
shown.load({ ...shown.getValue(), ...full }, { draft: false });
|
|
1013
|
+
run.current.applyRows([next], false, new Map([[key, held]]), asked);
|
|
1014
|
+
for (const tree of latest.current.trees ?? [])
|
|
1015
|
+
writeTreeChanges(tree.cacheKey, crudTreeChanges({ action: "edit", rows: [next], mode: modeRef.current ?? "normal", before: [held] }, tree, backend), tree.rules);
|
|
1016
|
+
return next;
|
|
1017
|
+
};
|
|
1018
|
+
// The popup's footer: the load's line, the page's actions, View history, Edit and Close. In a workspace it is the
|
|
1019
|
+
// Details tab's alone (`onTab`): another tab has its own footer (`CrudTabFooter`) and the title bar's X still closes.
|
|
1020
|
+
const footer = (popup) => (_jsxs(DetailsFooter, { load: status, children: [details?.actions
|
|
1021
|
+
?.filter(action => (!action.endpoint || (ready && can(action.endpoint))) && !action.hidden?.(row))
|
|
1022
|
+
.map(action => (_jsx(Button, { icon: action.icon, "data-action": action.id, onClick: () => action.onSelect(table.getRow(key) ?? row, popup), children: action.label }, action.id))), allowed.history && (_jsx(Button, { icon: _jsx(InlineIcon, { icon: iconHistory }), "data-action": "history", onClick: () => openHistory(table.getRow(key) ?? row), children: "View history" })), canEdit && (_jsx(Button, { icon: _jsx(InlineIcon, { icon: iconPencil }), onClick: async () => {
|
|
1023
|
+
if (!(await leave()))
|
|
1024
|
+
return;
|
|
1025
|
+
popup.close();
|
|
1026
|
+
void openForm("edit", table.getRow(key) ?? row, undefined, opened);
|
|
1027
|
+
}, children: "Edit" })), _jsx(Button, { variant: "primary", onClick: async () => {
|
|
1028
|
+
if (await leave())
|
|
1029
|
+
popup.close();
|
|
1030
|
+
}, children: "Close" })] }));
|
|
1031
|
+
let footerShown = true;
|
|
1032
|
+
const onTab = (tab, popup) => {
|
|
1033
|
+
const shows = tab === CRUD_DETAILS_TAB;
|
|
1034
|
+
if (shows === footerShown)
|
|
1035
|
+
return;
|
|
1036
|
+
footerShown = shows;
|
|
1037
|
+
popup.update({ footer: shows ? footer : undefined });
|
|
1038
|
+
};
|
|
1039
|
+
const handle = openPopup({
|
|
763
1040
|
key: `${name}-view-${key}`,
|
|
764
1041
|
reopen: "restore",
|
|
765
1042
|
title: `${capitalize(subject)} ${details?.label ?? "details"}`,
|
|
766
1043
|
description: labelOf(row),
|
|
767
1044
|
icon: _jsx(InlineIcon, { icon: iconEye }),
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
//
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
1045
|
+
// A workspace is wide: a column of its own, and beside it a tab that holds a form or a list of its own.
|
|
1046
|
+
size: details?.size ?? (details?.tabs ? "full" : "md"),
|
|
1047
|
+
// The workspace meets the popup's edges: its column runs from the title bar to the footer.
|
|
1048
|
+
flush: workspace,
|
|
1049
|
+
// Focus goes to the chosen tab in a workspace. Elsewhere it goes where it goes without a panel, the footer, rather
|
|
1050
|
+
// than to the first button the panel happens to draw: a panel that renders at once (a Copy button) would otherwise
|
|
1051
|
+
// take it, and one that loads would not.
|
|
1052
|
+
initialFocus: workspace ? '.nx-crud-workspace [role="tab"][aria-selected="true"], .nx-popup__foot button' : aside ? ".nx-popup__foot button" : undefined,
|
|
1053
|
+
beforeClose: workspace ? leave : undefined,
|
|
1054
|
+
content: workspace
|
|
1055
|
+
? popup => (_jsx(CrudWorkspace, { options: details, form: shown, current: () => table.getRow(key) ?? shown.getValue(), popup: popup, subject: capitalize(subject), load: endpoint ? startReading : undefined, read: status, refresh: refresh, guard: guard, columns: details?.columns ?? latest.current.columns, currency: tableOptions?.currency, details: (without, reread) => fields(shown.getValue(), endpoint && status.get() !== "done" ? () => startReading() : undefined, without, reread), onTab: tab => onTab(tab, popup) }))
|
|
1056
|
+
: fields(row, endpoint ? readDetails : undefined),
|
|
1057
|
+
footer,
|
|
1058
|
+
});
|
|
1059
|
+
void handle.closed.then(() => reading.abort());
|
|
1060
|
+
}
|
|
1061
|
+
/** A record as the CRUD holds it now: the table's row, else one of the lists kept, else a marked section's. */
|
|
1062
|
+
function heldRecord(key) {
|
|
1063
|
+
const sections = marked ? [...marked.rows.pin, ...marked.rows.favorite] : [];
|
|
1064
|
+
return table.getRow(key) ?? heldRows().find(row => table.keyOf(row) === key) ?? sections.find(row => table.keyOf(row) === key);
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* Opens one of the page's own row forms (`rowForms`) for a record; its save writes the fields its answer names.
|
|
1068
|
+
* `after`: called once the save is written (a workspace section's Edit reads the record again).
|
|
1069
|
+
*/
|
|
1070
|
+
function openRowForm(form, row, after) {
|
|
1071
|
+
const key = table.keyOf(row);
|
|
1072
|
+
// The scope the form was opened on: a save that lands after a switch is that scope's.
|
|
1073
|
+
const asked = scopeIdRef.current;
|
|
1074
|
+
openCrudRowForm({
|
|
1075
|
+
form,
|
|
1076
|
+
row,
|
|
1077
|
+
key,
|
|
1078
|
+
id: form.target ? form.target(row) : rawKey(row),
|
|
1079
|
+
rowKey,
|
|
1080
|
+
name,
|
|
1081
|
+
label: labelOf(row),
|
|
1082
|
+
onSaved: (result, values) => {
|
|
1083
|
+
// A save there changed the record: what pickers and joins elsewhere looked up from this controller (a run's state
|
|
1084
|
+
// in another page's box) is asked again where it is next shown, as after any change the CRUD makes.
|
|
1085
|
+
expireLookups(api);
|
|
1086
|
+
const patch = crudRowFormWrites(form.writes, result, row, values);
|
|
1087
|
+
const now = patch ? run.current.writeRow(key, patch, asked) : run.current.heldRecord(key);
|
|
1088
|
+
form.onSaved?.(result, now ?? row, values);
|
|
1089
|
+
after?.();
|
|
1090
|
+
},
|
|
798
1091
|
});
|
|
799
1092
|
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Fields a row form's answer changed, merged over the record as the CRUD holds it when the answer lands - never over
|
|
1095
|
+
* the record as the form was opened on, which an Edit since may have changed - and written wherever it is held, as the
|
|
1096
|
+
* handle's `apply` writes. A record held nowhere (another page, a list not kept) has nothing to write into.
|
|
1097
|
+
*/
|
|
1098
|
+
function writeRow(key, patch, asked) {
|
|
1099
|
+
if (answeredElsewhere(asked))
|
|
1100
|
+
return undefined;
|
|
1101
|
+
const held = heldRecord(key);
|
|
1102
|
+
if (!held)
|
|
1103
|
+
return undefined;
|
|
1104
|
+
const next = { ...held, ...patch };
|
|
1105
|
+
applyRows([next], false, new Map([[key, held]]));
|
|
1106
|
+
return next;
|
|
1107
|
+
}
|
|
800
1108
|
function openDescription(rows) {
|
|
801
1109
|
const endpoint = api.mass?.description;
|
|
802
1110
|
if (!endpoint)
|
|
803
1111
|
return;
|
|
804
1112
|
const form = new FormStore();
|
|
805
1113
|
const count = rows.length;
|
|
1114
|
+
const asked = scopeIdRef.current;
|
|
806
1115
|
openPopup({
|
|
807
1116
|
key: `${name}-description`,
|
|
808
1117
|
title: "Description",
|
|
@@ -811,8 +1120,8 @@ export function Crud(props) {
|
|
|
811
1120
|
size: "sm",
|
|
812
1121
|
closeOnBackdrop: false,
|
|
813
1122
|
beforeClose: () => confirmDiscard(form),
|
|
814
|
-
content: popup => (_jsx(ApiForm, { form: form, buttons: false, inputs: [{ type:
|
|
815
|
-
applyChange("description", rows, { result, mass: true, values });
|
|
1123
|
+
content: popup => (_jsx(ApiForm, { form: form, buttons: false, inputs: [{ type: EInputTypes.TextArea, name: "description", label: "Description", maxLength: 500, rows: 4, showCount: true }], api: endpoint, transform: values => backend.massRequest("description", rows.map(rawKey), values), "aria-label": `Description of ${count} ${count === 1 ? subject : plural}`, onSuccess: (result, values) => {
|
|
1124
|
+
applyChange("description", rows, { result, mass: true, values, asked });
|
|
816
1125
|
table.clearSelection();
|
|
817
1126
|
popup.close();
|
|
818
1127
|
} })),
|
|
@@ -820,8 +1129,8 @@ export function Crud(props) {
|
|
|
820
1129
|
});
|
|
821
1130
|
}
|
|
822
1131
|
// Row actions and the handle call these through a ref, so the table's callbacks stay the same between renders.
|
|
823
|
-
const run = useRef({ runRowAction, runMassAction, switchMark, switchMarkMany, switchFlagMany, openForm, openDetails, openHistory, applyRows });
|
|
824
|
-
run.current = { runRowAction, runMassAction, switchMark, switchMarkMany, switchFlagMany, openForm, openDetails, openHistory, applyRows };
|
|
1132
|
+
const run = useRef({ runRowAction, runMassAction, switchMark, switchMarkMany, switchFlagMany, openForm, openDetails, openHistory, applyRows, onSaved, openRowForm, writeRow, heldRecord });
|
|
1133
|
+
run.current = { runRowAction, runMassAction, switchMark, switchMarkMany, switchFlagMany, openForm, openDetails, openHistory, applyRows, onSaved, openRowForm, writeRow, heldRecord };
|
|
825
1134
|
/* The table's callbacks */
|
|
826
1135
|
const rowActions = useCallback((row) => {
|
|
827
1136
|
if (!mode)
|
|
@@ -838,15 +1147,15 @@ export function Crud(props) {
|
|
|
838
1147
|
};
|
|
839
1148
|
const info = {
|
|
840
1149
|
view: { label: `View ${(latest.current.details || undefined)?.label ?? "details"}`, icon: _jsx(InlineIcon, { icon: iconEye }) },
|
|
841
|
-
edit: {
|
|
1150
|
+
edit: { ...crudActionEntry("edit"), inline: true },
|
|
842
1151
|
flag: flagged ? { label: "Unflag", icon: _jsx(InlineIcon, { icon: iconFlagOff }) } : { label: "Flag", icon: _jsx(InlineIcon, { icon: iconFlag }) },
|
|
843
1152
|
pin: markEntry("pin"),
|
|
844
1153
|
favorite: markEntry("favorite"),
|
|
845
|
-
history:
|
|
846
|
-
trash:
|
|
847
|
-
recover: {
|
|
848
|
-
delete:
|
|
849
|
-
trashFromDelete: {
|
|
1154
|
+
history: crudActionEntry("history"),
|
|
1155
|
+
trash: crudActionEntry("trash"),
|
|
1156
|
+
recover: { ...crudActionEntry("recover"), inline: true },
|
|
1157
|
+
delete: crudActionEntry("delete"),
|
|
1158
|
+
trashFromDelete: { ...crudActionEntry("trashFromDelete"), inline: true },
|
|
850
1159
|
};
|
|
851
1160
|
for (const action of crudRowActions(mode, candidate => allowed[candidate], trashless)) {
|
|
852
1161
|
if (action === "view" && latest.current.details === false)
|
|
@@ -854,6 +1163,14 @@ export function Crud(props) {
|
|
|
854
1163
|
// A record the backend will not flag either way (a pending one) is offered neither.
|
|
855
1164
|
if (action === "flag" && !backend.canFlag(row))
|
|
856
1165
|
continue;
|
|
1166
|
+
// Listed rather than left out, so the menu says why the record cannot change instead of looking short; in the
|
|
1167
|
+
// menu, not on the row, since a switched-off icon button could not say it.
|
|
1168
|
+
// One action's own lock (`rowActionLock`) is listed the same way, beside the actions that still run.
|
|
1169
|
+
const locked = actionLockOf(row, action);
|
|
1170
|
+
if (locked) {
|
|
1171
|
+
list.push({ ...info[action], id: action, inline: false, disabled: true, description: locked, onSelect: () => undefined });
|
|
1172
|
+
continue;
|
|
1173
|
+
}
|
|
857
1174
|
const onSelect = action === "view"
|
|
858
1175
|
? () => run.current.openDetails(row)
|
|
859
1176
|
: action === "edit"
|
|
@@ -866,22 +1183,40 @@ export function Crud(props) {
|
|
|
866
1183
|
const entry = info[action];
|
|
867
1184
|
list.push({ ...entry, id: action, inline: entry.inline && (primary ? action === "edit" : true), onSelect });
|
|
868
1185
|
}
|
|
1186
|
+
// The page's own forms on a record (`rowForms`), where offered; one locked for it is listed switched off, saying why.
|
|
1187
|
+
for (const form of latest.current.rowForms ?? []) {
|
|
1188
|
+
if (!crudRowFormOffered(form, row, mode, can))
|
|
1189
|
+
continue;
|
|
1190
|
+
const locked = form.lock?.(row) || null;
|
|
1191
|
+
list.push({
|
|
1192
|
+
id: form.id,
|
|
1193
|
+
label: form.label,
|
|
1194
|
+
icon: form.icon,
|
|
1195
|
+
description: locked ?? form.description,
|
|
1196
|
+
inline: Boolean(form.inline) && !locked,
|
|
1197
|
+
disabled: Boolean(locked),
|
|
1198
|
+
onSelect: locked ? () => undefined : () => run.current.openRowForm(form, row),
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
869
1201
|
const extra = latest.current.rowActions?.(row, mode) ?? [];
|
|
870
1202
|
return [...list, ...extra];
|
|
871
|
-
}, [mode, allowed, backend, trashless, marksOf, marksWait]);
|
|
1203
|
+
}, [mode, allowed, backend, trashless, marksOf, marksWait, can]);
|
|
872
1204
|
const massActionsFor = useCallback((rows) => {
|
|
873
1205
|
if (!mode)
|
|
874
1206
|
return [];
|
|
875
1207
|
const list = [];
|
|
876
1208
|
const marks = rows.map(marksOf);
|
|
1209
|
+
// A locked record in the selection switches off every action that would change it (`rowLock`), and one whose own
|
|
1210
|
+
// lock refuses an action (`rowActionLock`) switches that action off.
|
|
1211
|
+
const holdsLocked = (action) => rows.some(row => Boolean(actionLockOf(row, action)));
|
|
877
1212
|
for (const action of crudMassActions(mode, candidate => massAllowed[candidate], massTrashless)) {
|
|
878
1213
|
if (action === "flag") {
|
|
879
1214
|
// The same for the flag: Flag while one of them can be flagged, Unflag while one of them is.
|
|
880
1215
|
const flaggable = rows.filter(row => backend.canFlag(row));
|
|
881
1216
|
if (flaggable.some(row => !backend.isFlagged(row)))
|
|
882
|
-
list.push({ id: "flag", label: "Flag", icon: _jsx(InlineIcon, { icon: iconFlag }), onSelect: selected => run.current.switchFlagMany(selected, true) });
|
|
1217
|
+
list.push({ id: "flag", label: "Flag", icon: _jsx(InlineIcon, { icon: iconFlag }), disabled: holdsLocked("flag"), onSelect: selected => run.current.switchFlagMany(selected, true) });
|
|
883
1218
|
if (flaggable.some(row => backend.isFlagged(row)))
|
|
884
|
-
list.push({ id: "unflag", label: "Unflag", icon: _jsx(InlineIcon, { icon: iconFlagOff }), onSelect: selected => run.current.switchFlagMany(selected, false) });
|
|
1219
|
+
list.push({ id: "unflag", label: "Unflag", icon: _jsx(InlineIcon, { icon: iconFlagOff }), disabled: holdsLocked("flag"), onSelect: selected => run.current.switchFlagMany(selected, false) });
|
|
885
1220
|
continue;
|
|
886
1221
|
}
|
|
887
1222
|
if (action === "pin" || action === "favorite") {
|
|
@@ -903,7 +1238,7 @@ export function Crud(props) {
|
|
|
903
1238
|
trashFromDelete: { label: "Move back to trash", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }) },
|
|
904
1239
|
description: { label: "Description", icon: _jsx(InlineIcon, { icon: iconNotes }) },
|
|
905
1240
|
};
|
|
906
|
-
list.push({ ...info[action], id: action, onSelect });
|
|
1241
|
+
list.push({ ...info[action], id: action, disabled: holdsLocked(action), onSelect });
|
|
907
1242
|
}
|
|
908
1243
|
const extra = latest.current.massActions ? latest.current.massActions(rows, mode) : [];
|
|
909
1244
|
return [...list, ...extra];
|
|
@@ -920,8 +1255,12 @@ export function Crud(props) {
|
|
|
920
1255
|
return modeRef.current ?? "normal";
|
|
921
1256
|
},
|
|
922
1257
|
setMode: next => setChosenMode(next),
|
|
1258
|
+
get scope() {
|
|
1259
|
+
return scopeIdRef.current || null;
|
|
1260
|
+
},
|
|
1261
|
+
setScope: next => chooseScope.current(next),
|
|
923
1262
|
reload: () => {
|
|
924
|
-
|
|
1263
|
+
clearCaches();
|
|
925
1264
|
reloadMarks();
|
|
926
1265
|
return table.reload({ quiet: true });
|
|
927
1266
|
},
|
|
@@ -929,8 +1268,15 @@ export function Crud(props) {
|
|
|
929
1268
|
create: values => void run.current.openForm("create", null, values),
|
|
930
1269
|
edit: row => void run.current.openForm("edit", row),
|
|
931
1270
|
view: row => run.current.openDetails(row),
|
|
932
|
-
|
|
933
|
-
|
|
1271
|
+
openRowForm: (formId, row) => {
|
|
1272
|
+
const form = latest.current.rowForms?.find(entry => entry.id === formId);
|
|
1273
|
+
if (form)
|
|
1274
|
+
run.current.openRowForm(form, row);
|
|
1275
|
+
},
|
|
1276
|
+
}), [table, clearCaches, reloadMarks]);
|
|
1277
|
+
const ownLabel = props["aria-label"] ?? (props["aria-labelledby"] ? undefined : capitalize(plural));
|
|
1278
|
+
// A narrowed table is named with the record it shows the records of: "Places: Ram Thapa".
|
|
1279
|
+
const tableLabel = ownLabel && narrowing && narrowing.value !== null ? `${ownLabel}: ${narrowing.label}` : ownLabel;
|
|
934
1280
|
const configured = CRUD_MODES.some(value => wantedModes.includes(value) && crudListEndpoint(api, value, paging));
|
|
935
1281
|
if (!configured) {
|
|
936
1282
|
return (_jsx("div", { id: id, className: cx("nx-crud", "nx-crud--denied", className), style: style, role: "alert", children: _jsxs("p", { className: "nx-crud__denied-title", children: ["Nothing to show: no ", paging === "server" ? "`pages`" : "`lists`", " endpoint for the ", plural, "."] }) }));
|
|
@@ -938,8 +1284,32 @@ export function Crud(props) {
|
|
|
938
1284
|
if (ready && !mode) {
|
|
939
1285
|
return (_jsxs("div", { id: id, className: cx("nx-crud", "nx-crud--denied", className), style: style, role: "status", children: [_jsx(InlineIcon, { icon: iconLock, className: "nx-crud__denied-icon" }), _jsxs("p", { className: "nx-crud__denied-title", children: ["You do not have permission to see ", plural, "."] }), _jsx("p", { className: "nx-crud__denied-detail", children: "Ask an administrator for access." })] }));
|
|
940
1286
|
}
|
|
1287
|
+
// The switch (or tabs) between the CRUD's own list and its scopes, while one is offered. It is drawn before the kept
|
|
1288
|
+
// choice is read too (on the CRUD's own list), so the bar does not move when it is.
|
|
1289
|
+
const scopeControl = scoping.offered.length > 0 ? _jsx(CrudScopeControl, { scopes: given.scopes, state: scoping, plural: plural }) : null;
|
|
941
1290
|
const modeOptions = modes.map(value => ({ value, label: MODE_INFO[value].label, icon: _jsx(InlineIcon, { icon: MODE_INFO[value].icon }) }));
|
|
942
|
-
|
|
1291
|
+
// One fixed label on every CRUD's list, whatever its subject (the owner, 2026-10-10); the form it opens says "Add <subject>".
|
|
1292
|
+
// A narrowed list's Add says for whom on its tooltip, and while it cannot add, why, on a press.
|
|
1293
|
+
const addLock = narrowedAddLock();
|
|
1294
|
+
const add = allowed.create
|
|
1295
|
+
? {
|
|
1296
|
+
label: CRUD_ACTION_WORDS.create.label,
|
|
1297
|
+
onClick: () => void run.current.openForm("create", null),
|
|
1298
|
+
disabled: addLock !== null,
|
|
1299
|
+
description: addLock ?? (narrowing ? `Adds a new ${subject} for ${narrowing.label}.` : undefined),
|
|
1300
|
+
// Nothing picked in the CRUD's own box: a press sends the cursor there, rather than a note under Add.
|
|
1301
|
+
onDisabledPress: () => narrowPicker.askForAdd(),
|
|
1302
|
+
}
|
|
1303
|
+
: undefined;
|
|
1304
|
+
const nonePicked = narrowing !== null && narrowing.value === null;
|
|
1305
|
+
/** What the list says when it has no rows: a narrowed one with nothing picked asks for a pick, and names whose list is empty. */
|
|
1306
|
+
const emptyMessage = (shown) => {
|
|
1307
|
+
if (nonePicked)
|
|
1308
|
+
return props.narrow?.emptyMessage ?? pickWords.empty;
|
|
1309
|
+
if (narrowing && shown === "normal")
|
|
1310
|
+
return `${narrowing.label} has no ${plural} yet.`;
|
|
1311
|
+
return MODE_INFO[shown].empty(plural);
|
|
1312
|
+
};
|
|
943
1313
|
// In the main list, select again by mark and by flag, the way the mass actions act on them - marks once they are known.
|
|
944
1314
|
const markSelects = (mark, label, otherwise) => massAllowed[mark] && !marksWait
|
|
945
1315
|
? [
|
|
@@ -966,22 +1336,33 @@ export function Crud(props) {
|
|
|
966
1336
|
key: MARK_INFO[mark].section,
|
|
967
1337
|
label: `${MARK_INFO[mark].title} ${plural}`,
|
|
968
1338
|
icon: MARK_INFO[mark].icon,
|
|
969
|
-
|
|
1339
|
+
// A narrowed list's sections hold the picked record's marked records alone, as its list does.
|
|
1340
|
+
rows: (marked?.rows[mark] ?? []).filter(inScope),
|
|
970
1341
|
loading: marksState === "loading",
|
|
971
1342
|
max: markLimit(mark),
|
|
972
1343
|
}))
|
|
973
1344
|
: undefined;
|
|
974
|
-
return (_jsx("div", { id: id, className: cx("nx-crud", className), style: style, "data-mode": mode, children: _jsx(DataTable, { table: table, columns: columns, load: load, loadKey: mode
|
|
975
|
-
|
|
1345
|
+
return (_jsx("div", { id: id, className: cx("nx-crud", className), style: style, "data-mode": mode, children: _jsx(DataTable, { table: table, caption: narrowing && (_jsxs("p", { className: "nx-crud__scope", children: [capitalize(plural), " of:", " ", nonePicked ? (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", children: "\u2014" }), _jsx("span", { className: "nx-visually-hidden", children: "no row picked" })] })) : (_jsx("strong", { className: "nx-crud__scope-name", children: narrowing.label }))] })), columns: columns, load: load, loadKey: mode && !scopeWaiting ? `${scopeId}#${mode}${scope}` : "waiting", paging: paging, rowKey: rowKey, rowLabel: labelOf, "aria-label": tableLabel, "aria-labelledby": props["aria-labelledby"], toolbar: modes.length > 1 && mode ? (_jsx(TabButtons, { size: "sm", labels: "auto", options: modeOptions, value: mode, onChange: setChosenMode, "aria-label": `${capitalize(plural)}: lists` })) : undefined, actions: scopeControl || narrowPicker.box ? (_jsxs(_Fragment, { children: [scopeControl, narrowPicker.box, props.toolbar] })) : (props.toolbar), add: add, selectable: selectable, massActions: massActionsFor, selectBy: selectBy, rowActions: rowActions, sections: sections, rowMarks: marksShown ? rowMarks : undefined, onRowOpen: onRowOpen, openOnClick: tableOptions?.openOnClick, rowStrong: tableOptions?.rowStrong, onPick: props.onPick, onReload: () => {
|
|
1346
|
+
clearCaches();
|
|
976
1347
|
reloadMarks();
|
|
977
|
-
}, emptyMessage: tableOptions?.emptyMessage ?? (mode ?
|
|
1348
|
+
}, emptyMessage: tableOptions?.emptyMessage ?? (mode ? emptyMessage(mode) : undefined),
|
|
1349
|
+
// A list that asks for a pick in the CRUD's own box points at it: its button takes the cursor there.
|
|
1350
|
+
emptyAction: nonePicked ? narrowPicker.pickButton : mode === "normal" ? undefined : null, searchPlaceholder: tableOptions?.searchPlaceholder ?? `Search ${plural}…`, stateKey: crudScopeStateKey(given.table?.stateKey ?? `crud:${name}`, scoping.scope), currency: tableOptions?.currency, pager: tableOptions?.pager, pageSize: tableOptions?.pageSize, pageSizes: tableOptions?.pageSizes, defaultSort: tableOptions?.defaultSort, numbered: tableOptions?.numbered, maxHeight: tableOptions?.maxHeight, fill: tableOptions?.fill, size: tableOptions?.size, maxSelection: tableOptions?.maxSelection, columnsMenu: tableOptions?.columnsMenu, searchDelay: tableOptions?.searchDelay, filterInputs: tableOptions?.filterInputs, reorderable: tableOptions?.reorderable, resizable: tableOptions?.resizable, pinnable: tableOptions?.pinnable, moreActions: tableOptions?.moreActions, exportable: tableOptions?.exportable, printable: tableOptions?.printable, exportName: tableOptions?.exportName, onImport: tableOptions?.onImport, excel: tableOptions?.excel && {
|
|
978
1351
|
...tableOptions.excel,
|
|
979
|
-
// The export writes the list on screen, as the backend names that list
|
|
980
|
-
|
|
1352
|
+
// The export writes the list on screen, as the backend names that list - a narrowed one by its record, as its
|
|
1353
|
+
// pages are asked for; the page's own filters win a clash.
|
|
1354
|
+
filters: () => {
|
|
1355
|
+
const narrowed = narrowingRef.current;
|
|
1356
|
+
const own = narrowed && narrowed.value !== null ? { [narrowed.key]: narrowed.raw } : {};
|
|
1357
|
+
return { ...backend.exportFilters(modeRef.current ?? "normal"), ...own, ...tableOptions.excel?.filters?.() };
|
|
1358
|
+
},
|
|
981
1359
|
// A spreadsheet wrote records straight into the database: every list this CRUD kept is out of date.
|
|
982
1360
|
onImported: () => {
|
|
983
|
-
|
|
1361
|
+
clearCaches();
|
|
984
1362
|
reloadMarks();
|
|
1363
|
+
// Records nobody here has seen: a tree elsewhere cannot have them written in, so its pages go.
|
|
1364
|
+
for (const tree of latest.current.trees ?? [])
|
|
1365
|
+
expireTreePages(tree.cacheKey);
|
|
985
1366
|
tableOptions.excel?.onImported?.();
|
|
986
1367
|
},
|
|
987
1368
|
} }) }));
|