nexus-shared 3.1.0 → 4.0.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 +2268 -446
- package/LICENSE +21 -21
- package/README.md +1705 -1084
- package/dist/Client.Index.d.ts +17 -0
- package/dist/Client.Index.js +17 -0
- 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/Tabs.d.ts +59 -0
- package/dist/Components/Documents/Tabs.js +70 -0
- package/dist/Components/Forms/ApiForm.js +19 -2
- package/dist/Components/Forms/Crud.d.ts +2 -2
- package/dist/Components/Forms/Crud.js +871 -377
- package/dist/Components/Forms/CrudActions.d.ts +136 -0
- package/dist/Components/Forms/CrudActions.js +184 -0
- package/dist/Components/Forms/CrudHistory.d.ts +66 -0
- package/dist/Components/Forms/CrudHistory.js +140 -0
- package/dist/Components/Forms/CrudNarrowPicker.d.ts +31 -0
- package/dist/Components/Forms/CrudNarrowPicker.js +180 -0
- package/dist/Components/Forms/CrudParts.d.ts +30 -2
- package/dist/Components/Forms/CrudParts.js +56 -5
- package/dist/Components/Forms/CrudRowForms.d.ts +26 -0
- package/dist/Components/Forms/CrudRowForms.js +114 -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 +63 -0
- package/dist/Components/Forms/CrudWorkspace.js +169 -0
- package/dist/Components/Forms/ExcelImport.js +3 -3
- package/dist/Components/Forms/Form.d.ts +4 -4
- package/dist/Components/Forms/Form.js +125 -16
- package/dist/Components/Forms/FormLeaveGuard.d.ts +44 -0
- package/dist/Components/Forms/FormLeaveGuard.js +65 -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 +195 -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/CodeBox.d.ts +69 -0
- package/dist/Components/Inputs/CodeBox.js +316 -0
- package/dist/Components/Inputs/DatePicker.js +4 -1
- package/dist/Components/Inputs/DateRangePicker.js +4 -1
- package/dist/Components/Inputs/DateTimePicker.js +4 -1
- package/dist/Components/Inputs/Dropdown.d.ts +13 -0
- package/dist/Components/Inputs/Dropdown.js +8 -5
- 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 +10 -11
- package/dist/Components/Inputs/InputRenderer.d.ts +9 -6
- package/dist/Components/Inputs/InputRenderer.js +39 -31
- package/dist/Components/Inputs/NumberBox.js +4 -2
- package/dist/Components/Inputs/PictureInput.d.ts +41 -0
- package/dist/Components/Inputs/PictureInput.js +70 -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 +1 -1
- package/dist/Components/Inputs/RowsInput.js +18 -3
- package/dist/Components/Inputs/TabularForm.d.ts +5 -5
- package/dist/Components/Inputs/TabularForm.js +29 -29
- package/dist/Components/Inputs/TextArea.d.ts +6 -0
- package/dist/Components/Inputs/TextArea.js +14 -2
- package/dist/Components/Inputs/TextBox.js +4 -1
- package/dist/Components/Inputs/TextBoxTypes.d.ts +6 -0
- package/dist/Components/Inputs/TextBoxTypes.js +17 -0
- package/dist/Components/Inputs/TimePicker.js +4 -1
- package/dist/Components/Inputs/TreeView.d.ts +107 -6
- package/dist/Components/Inputs/TreeView.js +513 -30
- package/dist/Components/Layouts/MessageBox.d.ts +15 -2
- package/dist/Components/Layouts/MessageBox.js +56 -7
- package/dist/Components/Layouts/Popup.d.ts +13 -6
- package/dist/Components/Layouts/Popup.js +37 -9
- 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/Layouts/ThemePicker.d.ts +3 -2
- package/dist/Components/Layouts/ThemePicker.js +16 -4
- package/dist/Components/Viewers/DataTable.js +205 -61
- package/dist/Components/Viewers/DataTableFilters.d.ts +1 -1
- package/dist/Components/Viewers/DataTableFilters.js +17 -2
- package/dist/Components/Viewers/DataTableParts.d.ts +46 -12
- package/dist/Components/Viewers/DataTableParts.js +62 -21
- package/dist/Components/Viewers/OwnerFiles.d.ts +175 -0
- package/dist/Components/Viewers/OwnerFiles.js +589 -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.d.ts +3 -34
- package/dist/Helpers/ApiModules.js +3 -3
- package/dist/Helpers/ApiRoutes.d.ts +68 -26
- package/dist/Helpers/ApiRoutes.js +31 -27
- package/dist/Helpers/ArrayHelpers.d.ts +0 -0
- package/dist/Helpers/ArrayHelpers.js +2 -0
- package/dist/Helpers/BusyHooks.d.ts +6 -0
- package/dist/Helpers/BusyHooks.js +39 -0
- package/dist/Helpers/CodeHelpers.d.ts +97 -0
- package/dist/Helpers/CodeHelpers.js +332 -0
- package/dist/Helpers/CrudBackend.d.ts +114 -6
- package/dist/Helpers/CrudBackend.js +214 -11
- package/dist/Helpers/CrudColumns.d.ts +98 -0
- package/dist/Helpers/CrudColumns.js +160 -0
- package/dist/Helpers/CrudDetailsTabs.d.ts +58 -0
- package/dist/Helpers/CrudDetailsTabs.js +126 -0
- package/dist/Helpers/CrudHelpers.d.ts +130 -6
- package/dist/Helpers/CrudHelpers.js +304 -12
- package/dist/Helpers/CrudNarrowPick.d.ts +83 -0
- package/dist/Helpers/CrudNarrowPick.js +157 -0
- package/dist/Helpers/CrudRowForms.d.ts +15 -0
- package/dist/Helpers/CrudRowForms.js +43 -0
- package/dist/Helpers/CrudScopes.d.ts +32 -0
- package/dist/Helpers/CrudScopes.js +79 -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 +78 -0
- package/dist/Helpers/FileAdapter.js +102 -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 +90 -5
- package/dist/Helpers/FormStore.js +318 -22
- package/dist/Helpers/InputConditions.d.ts +50 -0
- package/dist/Helpers/InputConditions.js +168 -0
- package/dist/Helpers/InputMemory.d.ts +106 -0
- package/dist/Helpers/InputMemory.js +385 -0
- package/dist/Helpers/InputNarrowing.d.ts +18 -0
- package/dist/Helpers/InputNarrowing.js +67 -0
- package/dist/Helpers/InputParamsHelpers.d.ts +44 -1
- package/dist/Helpers/InputParamsHelpers.js +162 -71
- package/dist/Helpers/MessageBuilder.js +2 -0
- package/dist/Helpers/NexusDriveAdapter.d.ts +55 -0
- package/dist/Helpers/NexusDriveAdapter.js +207 -0
- package/dist/Helpers/NexusHelpers.d.ts +0 -0
- package/dist/Helpers/NexusHelpers.js +1 -0
- package/dist/Helpers/NumberHelpers.d.ts +95 -0
- package/dist/Helpers/NumberHelpers.js +126 -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 +47 -0
- package/dist/Helpers/RequestCache.js +132 -0
- package/dist/Helpers/RowsStore.d.ts +37 -2
- package/dist/Helpers/RowsStore.js +128 -10
- 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 +21 -15
- package/dist/Helpers/TableFilters.d.ts +1 -1
- package/dist/Helpers/TableFilters.js +24 -23
- package/dist/Helpers/TableHelpers.d.ts +24 -3
- package/dist/Helpers/TableHelpers.js +58 -12
- package/dist/Helpers/TableJoins.d.ts +83 -0
- package/dist/Helpers/TableJoins.js +284 -0
- package/dist/Helpers/TableStore.d.ts +26 -1
- package/dist/Helpers/TableStore.js +73 -7
- package/dist/Helpers/TreeStore.d.ts +306 -11
- package/dist/Helpers/TreeStore.js +1048 -90
- package/dist/Helpers/UserPreferences.d.ts +8 -0
- package/dist/Helpers/UserPreferences.js +17 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +512 -14
- package/dist/Interfaces/FileInterfaces.d.ts +162 -0
- package/dist/Interfaces/FileInterfaces.js +4 -0
- package/dist/Interfaces/FormInterfaces.d.ts +393 -29
- package/dist/Interfaces/FormInterfaces.js +79 -1
- package/dist/Interfaces/InputInterfaces.d.ts +39 -3
- package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
- package/dist/Interfaces/PreferenceInterfaces.d.ts +8 -0
- package/dist/Interfaces/PreferenceInterfaces.js +2 -5
- package/dist/Interfaces/RecordInterfaces.d.ts +367 -0
- package/dist/Interfaces/RecordInterfaces.js +1 -0
- package/dist/Interfaces/TableInterfaces.d.ts +160 -5
- package/dist/Services/ApiProxy.d.ts +36 -1
- package/dist/Services/ApiProxy.js +182 -52
- package/dist/Services/ApiProxyRules.d.ts +203 -0
- package/dist/Services/ApiProxyRules.js +426 -0
- 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 +80 -0
- package/dist/Services/JoinSources.js +150 -0
- package/dist/Services/ModuleImport.d.ts +7 -0
- package/dist/Services/ModuleImport.js +22 -0
- package/dist/Services/ServerApi.d.ts +13 -2
- package/dist/Services/ServerApi.js +8 -3
- package/dist/Shared.Index.d.ts +20 -0
- package/dist/Shared.Index.js +20 -0
- package/package.json +92 -85
- package/src/Styles/Nexus.Avatar.css +80 -79
- package/src/Styles/Nexus.Base.css +117 -117
- package/src/Styles/Nexus.Button.css +186 -180
- package/src/Styles/Nexus.Calendar.css +273 -273
- package/src/Styles/Nexus.Chat.css +1443 -1443
- package/src/Styles/Nexus.Choice.css +359 -343
- package/src/Styles/Nexus.Crud.css +542 -168
- package/src/Styles/Nexus.DateTime.css +81 -81
- package/src/Styles/Nexus.Dropdown.css +733 -725
- package/src/Styles/Nexus.Editor.css +295 -295
- package/src/Styles/Nexus.Excel.css +389 -389
- package/src/Styles/Nexus.Files.css +410 -0
- package/src/Styles/Nexus.Form.css +282 -279
- package/src/Styles/Nexus.Index.css +41 -37
- package/src/Styles/Nexus.Input.css +238 -4
- package/src/Styles/Nexus.Markdown.css +237 -237
- package/src/Styles/Nexus.Menu.css +246 -237
- package/src/Styles/Nexus.Popup.css +800 -724
- 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.Rows.css +637 -637
- package/src/Styles/Nexus.Slider.css +276 -276
- package/src/Styles/Nexus.Tab.Buttons.css +497 -432
- package/src/Styles/Nexus.Table.css +98 -35
- package/src/Styles/Nexus.Theme.Picker.css +59 -3
- package/src/Styles/Nexus.Themes.css +200 -194
- package/src/Styles/Nexus.Time.css +144 -144
- package/src/Styles/Nexus.Toast.css +263 -263
- package/src/Styles/Nexus.Tokens.css +94 -89
- package/src/Styles/Nexus.Tree.css +530 -431
|
@@ -1,34 +1,79 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx,
|
|
3
|
-
import { iconArrowBackUp, iconEye, iconFlag, iconFlagOff, 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_MODES, CRUD_ROW_ACTIONS, CrudCache, crudCacheMode, crudListEndpoint, crudMassActions, crudMessageAction, crudRowActions, crudRowEndpoint,
|
|
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, 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
|
-
import { pluralize } from "../../Helpers/MessageBuilder.js";
|
|
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
15
|
import { columnValue, formatCellText, 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 { 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";
|
|
25
|
+
import { expireLookups } from "../../Services/JoinSources.js";
|
|
26
|
+
import { expireTreePages, writeTreeChanges } from "../../Helpers/TreeStore.js";
|
|
16
27
|
import { Button } from "../Documents/Button.js";
|
|
17
28
|
import { TabButtons } from "../Documents/TabButtons.js";
|
|
18
29
|
import { openPopup } from "../Layouts/PopupHost.js";
|
|
19
30
|
import { toast } from "../Layouts/Toaster.js";
|
|
20
31
|
import { DataTable, useTable } from "../Viewers/DataTable.js";
|
|
21
32
|
import { ApiForm } from "./ApiForm.js";
|
|
33
|
+
import { CRUD_ACTION_WORDS, CRUD_ASKED, crudActionEntry, CrudFormBody, crudSubject, openCrudForm, sendCrudRowAction } from "./CrudActions.js";
|
|
34
|
+
import { openAuditHistory } from "./CrudHistory.js";
|
|
22
35
|
import { CrudDetails } from "./CrudParts.js";
|
|
36
|
+
import { confirmLeaving, CrudWorkspace } from "./CrudWorkspace.js";
|
|
37
|
+
import { FormLeaveGuard } from "./FormLeaveGuard.js";
|
|
23
38
|
import { confirmDiscard, FormButtons } from "./SubmitForm.js";
|
|
24
39
|
/*
|
|
25
40
|
* Crud: a module's records in one component. It takes a controller's endpoints, the table's columns, and the form's
|
|
26
41
|
* inputs, and handles the rest: the lists (all, flagged, trash, deleted) the user may see, the actions they
|
|
27
|
-
* may take, the records the user pinned in
|
|
28
|
-
* popup, actions on many rows, a cache of lists while the page is open, and rows updated from each
|
|
29
|
-
* reloading.
|
|
42
|
+
* may take, the records the user pinned and their favourites in sections above the list, Add and Edit popups built on
|
|
43
|
+
* ApiForm, a details popup, actions on many rows, a cache of lists while the page is open, and rows updated from each
|
|
44
|
+
* answer instead of reloading.
|
|
30
45
|
*/
|
|
31
|
-
const ALL_ACTIONS = ["create", "view", "edit", "flag", "pin", "trash", "recover", "delete", "trashFromDelete", "description"];
|
|
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"]);
|
|
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. */
|
|
53
|
+
const MARK_INFO = {
|
|
54
|
+
pin: {
|
|
55
|
+
section: "pins",
|
|
56
|
+
title: "Pinned",
|
|
57
|
+
icon: iconPin,
|
|
58
|
+
offIcon: iconPinnedOff,
|
|
59
|
+
on: "Pin",
|
|
60
|
+
off: "Unpin",
|
|
61
|
+
full: (max, plural) => `You can pin up to ${max} ${plural}. Unpin one to pin another.`,
|
|
62
|
+
over: (sent, asked, max, plural) => `${sent} of ${asked} ${plural} pinned: you can pin up to ${max}.`,
|
|
63
|
+
},
|
|
64
|
+
favorite: {
|
|
65
|
+
section: "favorites",
|
|
66
|
+
title: "Favourite",
|
|
67
|
+
icon: iconStar,
|
|
68
|
+
offIcon: iconStarOff,
|
|
69
|
+
on: "Add to favourites",
|
|
70
|
+
off: "Remove from favourites",
|
|
71
|
+
full: (max, plural) => `You can have up to ${max} favourite ${plural}. Remove one from the favourites to add another.`,
|
|
72
|
+
over: (sent, asked, max, plural) => `${sent} of ${asked} ${plural} added to the favourites: you can have up to ${max}.`,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
/** What a set of marks draws in a row's first column, by its two known bits: nothing, the pin, the star, both. */
|
|
76
|
+
const MARK_ICONS = [0, 1, 2, 3].map(set => CRUD_MARKS.filter(mark => hasMark(set, mark)).map(mark => ({ icon: MARK_INFO[mark].icon, label: MARK_INFO[mark].title })));
|
|
32
77
|
const MODE_INFO = {
|
|
33
78
|
// "Active", not "All": the list leaves out what is in the trash and what is deleted, and "All" said otherwise.
|
|
34
79
|
normal: { label: "Active", icon: iconList, empty: plural => `No ${plural} yet.` },
|
|
@@ -36,45 +81,14 @@ const MODE_INFO = {
|
|
|
36
81
|
trash: { label: "Trash", icon: iconTrash, empty: () => "The trash is empty." },
|
|
37
82
|
deleted: { label: "Deleted", icon: iconTrashX, empty: plural => `No deleted ${plural}.` },
|
|
38
83
|
};
|
|
39
|
-
/** Actions that
|
|
40
|
-
const ASKED = new Set(["trash", "recover", "delete", "trashFromDelete"]);
|
|
41
|
-
/** Actions that take a record out of the active lists, so it is no longer among the pinned records either. */
|
|
84
|
+
/** Actions that take a record out of the active lists, so it leaves the sections of its marks too. */
|
|
42
85
|
const LEFT_ACTIVE = new Set(["trash", "delete", "trashFromDelete"]);
|
|
43
|
-
/** The words of an action that flags some records and unflags others (a mass toggle of a mixed selection). */
|
|
44
|
-
const TOGGLED_FLAG = { verb: "change the flags of {object}", done: "updated", doing: "Changing the flags of {object}", object: "the records" };
|
|
45
86
|
const capitalize = (text) => (text ? text[0].toUpperCase() + text.slice(1) : text);
|
|
46
87
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
47
|
-
function isInputList(inputs) {
|
|
48
|
-
return Array.isArray(inputs);
|
|
49
|
-
}
|
|
50
|
-
/** The subject the endpoints were given: `apiController(module, "Guest", "guest")` → "guest". */
|
|
51
|
-
function endpointSubject(api) {
|
|
52
|
-
const endpoints = [api.create, api.update, api.details, ...Object.values(api.lists ?? {}), ...Object.values(api.pages ?? {})];
|
|
53
|
-
return endpoints.find(endpoint => endpoint?.subject)?.subject;
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* The body of an Add, Edit or details popup. Without an `aside` it is the form (or the details) and nothing else, so
|
|
57
|
-
* a popup that wants no panel carries no wrapper at all. With one, the two become columns that scroll on their own,
|
|
58
|
-
* 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
|
|
59
|
-
* popup is marked, because there the panel is what the popup is for and takes the larger share.
|
|
60
|
-
*/
|
|
61
|
-
function CrudFormBody({ aside, details, children }) {
|
|
62
|
-
if (!aside)
|
|
63
|
-
return children;
|
|
64
|
-
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 })] }));
|
|
65
|
-
}
|
|
66
88
|
/** Whether the details endpoint's record says anything the list's row did not. Plain JSON, compared field by field. */
|
|
67
89
|
function addsToRow(row, full) {
|
|
68
90
|
return Object.entries(full).some(([name, value]) => JSON.stringify(value) !== JSON.stringify(row[name]));
|
|
69
91
|
}
|
|
70
|
-
/**
|
|
71
|
-
* A details record that lands sooner than this says nothing: the demo answers in about 0.3s and most backends sooner,
|
|
72
|
-
* and a word shown for a frame or two is the flicker this avoids. Longer than the popup's 160ms fade, so the word
|
|
73
|
-
* never lands on a half-faded popup.
|
|
74
|
-
*/
|
|
75
|
-
const DETAILS_STATUS_DELAY = 400;
|
|
76
|
-
/** Once "Loading…" shows, it stays at least this long, so a record that lands just after the delay does not blink it. */
|
|
77
|
-
const DETAILS_STATUS_HOLD = 300;
|
|
78
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. */
|
|
79
93
|
function detailsLoad(initial) {
|
|
80
94
|
let state = initial;
|
|
@@ -98,32 +112,13 @@ function detailsLoad(initial) {
|
|
|
98
112
|
}
|
|
99
113
|
/**
|
|
100
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
|
|
101
|
-
* always there, so what it says never moves anything: "Loading…" only for a slow record
|
|
102
|
-
* 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.
|
|
103
117
|
*/
|
|
104
118
|
function DetailsFooter({ load, children }) {
|
|
105
119
|
const state = useSyncExternalStore(load.subscribe, load.get, load.get);
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
useEffect(() => {
|
|
109
|
-
if (state === "loading") {
|
|
110
|
-
if (said === "loading")
|
|
111
|
-
return;
|
|
112
|
-
const timer = window.setTimeout(() => {
|
|
113
|
-
since.current = performance.now();
|
|
114
|
-
setSaid("loading");
|
|
115
|
-
}, DETAILS_STATUS_DELAY);
|
|
116
|
-
return () => window.clearTimeout(timer);
|
|
117
|
-
}
|
|
118
|
-
const next = state === "failed" ? "failed" : null;
|
|
119
|
-
const left = said === "loading" ? since.current + DETAILS_STATUS_HOLD - performance.now() : 0;
|
|
120
|
-
if (left <= 0) {
|
|
121
|
-
setSaid(next);
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
const timer = window.setTimeout(() => setSaid(next), left);
|
|
125
|
-
return () => window.clearTimeout(timer);
|
|
126
|
-
}, [state, said]);
|
|
120
|
+
const slow = useSlowBusy(state === "loading");
|
|
121
|
+
const said = slow ? "loading" : state === "failed" ? "failed" : null;
|
|
127
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 })] }));
|
|
128
123
|
}
|
|
129
124
|
/**
|
|
@@ -140,21 +135,37 @@ function DetailsFooter({ load, children }) {
|
|
|
140
135
|
* paging="server"
|
|
141
136
|
* />
|
|
142
137
|
*/
|
|
143
|
-
export function Crud(
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
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
|
+
});
|
|
151
150
|
// Permissions: every list and action asks, again whenever the user's rights change.
|
|
152
151
|
const permissionsStamp = useSyncExternalStore(subscribePermissions, permissionsVersion, permissionsVersion);
|
|
153
152
|
const permissions = getPermissions();
|
|
154
153
|
const ready = permissions.ready;
|
|
155
|
-
const wantedModes =
|
|
156
|
-
const wantedActions = props.actions ?? ALL_ACTIONS;
|
|
154
|
+
const wantedModes = given.modes ?? CRUD_MODES;
|
|
157
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;
|
|
163
|
+
const { api, columns, rowKey = "id", paging = "client", table: tableOptions, id, className, style, ref } = props;
|
|
164
|
+
const latest = useRef(props);
|
|
165
|
+
latest.current = props;
|
|
166
|
+
const name = props.name ?? subject;
|
|
167
|
+
const backend = props.backend ?? getCrudBackend();
|
|
168
|
+
const wantedActions = props.actions ?? ALL_ACTIONS;
|
|
158
169
|
const modes = useMemo(() => (ready ? availableModes(api, paging, wantedModes, can) : []), [ready, api, paging, wantedModes, can]);
|
|
159
170
|
const allowed = useMemo(() => {
|
|
160
171
|
const map = {};
|
|
@@ -164,7 +175,7 @@ export function Crud(props) {
|
|
|
164
175
|
}, [ready, api, wantedActions, can]);
|
|
165
176
|
const massAllowed = useMemo(() => {
|
|
166
177
|
const map = {};
|
|
167
|
-
for (const action of ["flag", "pin", "trash", "recover", "delete", "trashFromDelete", "description"])
|
|
178
|
+
for (const action of ["flag", "pin", "favorite", "trash", "recover", "delete", "trashFromDelete", "description"])
|
|
168
179
|
map[action] = ready && massActionAllowed(action, api, wantedActions, can);
|
|
169
180
|
return map;
|
|
170
181
|
}, [ready, api, wantedActions, can]);
|
|
@@ -175,101 +186,162 @@ export function Crud(props) {
|
|
|
175
186
|
const mode = chosenMode && modes.includes(chosenMode) ? chosenMode : modes[0];
|
|
176
187
|
const modeRef = useRef(mode);
|
|
177
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}`;
|
|
178
209
|
const table = useTable();
|
|
179
210
|
const lifetime = props.cache === false ? 0 : (props.cache?.lifetime ?? CRUD_CACHE_LIFETIME);
|
|
180
|
-
|
|
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]);
|
|
181
224
|
// The cache lives while the page is open, as the old CRUD's did.
|
|
182
|
-
useEffect(() => () =>
|
|
225
|
+
useEffect(() => () => clearCaches(), [clearCaches]);
|
|
183
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
|
+
};
|
|
184
242
|
const labelOf = useCallback((row) => {
|
|
185
243
|
const custom = latest.current.rowLabel;
|
|
186
244
|
if (custom)
|
|
187
245
|
return custom(row);
|
|
188
246
|
const first = latest.current.columns[0];
|
|
189
|
-
const text = first ? formatCellText(first, columnValue(first, row)) : "";
|
|
247
|
+
const text = first ? formatCellText(first, columnValue(first, row), { currency: tableOptions?.currency }, row) : "";
|
|
190
248
|
return text || `${capitalize(subject)} ${table.keyOf(row)}`;
|
|
191
249
|
}, [subject, table]);
|
|
192
|
-
/*
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
const
|
|
250
|
+
/*
|
|
251
|
+
* This user's marks: the records they pinned and their favourites, read in one call (`bookmarks`), shown in a section
|
|
252
|
+
* each above the active list, with a limit each that every CRUD shares
|
|
253
|
+
*/
|
|
254
|
+
// A backend that keeps no favourites lists its pins alone (`pins`), and every record it answers is pinned.
|
|
255
|
+
const fromBookmarks = Boolean(api.bookmarks);
|
|
256
|
+
const marksEndpoint = api.bookmarks ?? api.pins;
|
|
257
|
+
const shownMarks = CRUD_MARKS.filter(mark => wantedActions.includes(mark) && (mark === "pin" || fromBookmarks));
|
|
258
|
+
const marksOn = ready && Boolean(marksEndpoint) && shownMarks.length > 0 && can(marksEndpoint);
|
|
259
|
+
const [marked, setMarked] = useState(null);
|
|
260
|
+
const [marksState, setMarksState] = useState("none");
|
|
261
|
+
const [marksStamp, setMarksStamp] = useState(0);
|
|
199
262
|
useEffect(() => {
|
|
200
|
-
if (!
|
|
201
|
-
|
|
263
|
+
if (!marksOn || !marksEndpoint) {
|
|
264
|
+
setMarked(null);
|
|
265
|
+
setMarksState("none");
|
|
202
266
|
return;
|
|
203
267
|
}
|
|
204
268
|
const controller = new AbortController();
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
if (controller.signal.aborted)
|
|
269
|
+
setMarksState("loading");
|
|
270
|
+
void browserApi.send(marksEndpoint, { signal: controller.signal, toast: false, subject: plural }).then(answer => {
|
|
271
|
+
if (answer.aborted || controller.signal.aborted)
|
|
208
272
|
return;
|
|
209
|
-
if (
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
void browserApi.send(pinsEndpoint, { signal: controller.signal, toast: false, subject: plural }).then(answer => {
|
|
214
|
-
if (answer.aborted)
|
|
273
|
+
if (!answer.ok) {
|
|
274
|
+
// The sections are shortcuts to records the list holds as well, so they are left out quietly; the actions
|
|
275
|
+
// that need the marks say why they wait.
|
|
276
|
+
setMarksState("failed");
|
|
215
277
|
return;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
() =>
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const reloadPins = useCallback(() => setPinsStamp(stamp => stamp + 1), []);
|
|
224
|
-
/** The section after a pin or an unpin: records just pinned go on top, unpinned ones go. */
|
|
225
|
-
function applyPins(rows) {
|
|
226
|
-
setPinned(current => {
|
|
227
|
-
if (!current)
|
|
228
|
-
return current;
|
|
229
|
-
const changed = new Map(rows.map(row => [table.keyOf(row), row]));
|
|
230
|
-
const kept = current.map(row => changed.get(table.keyOf(row)) ?? row).filter(row => backend.isPinned(row));
|
|
231
|
-
const keys = new Set(kept.map(row => table.keyOf(row)));
|
|
232
|
-
return [...rows.filter(row => backend.isPinned(row) && !keys.has(table.keyOf(row))), ...kept];
|
|
278
|
+
}
|
|
279
|
+
const rows = backend.readList(answer.result);
|
|
280
|
+
setMarked(markedRecords(rows, row => (fromBookmarks ? backend.marksOf(row) : CRUD_MARK_BITS.pin), row => table.keyOf(row)));
|
|
281
|
+
setMarksState("ready");
|
|
282
|
+
}, () => {
|
|
283
|
+
if (!controller.signal.aborted)
|
|
284
|
+
setMarksState("failed");
|
|
233
285
|
});
|
|
286
|
+
return () => controller.abort();
|
|
287
|
+
}, [marksOn, marksEndpoint, fromBookmarks, backend, plural, table, marksStamp]);
|
|
288
|
+
const reloadMarks = useCallback(() => setMarksStamp(stamp => stamp + 1), []);
|
|
289
|
+
/**
|
|
290
|
+
* A record's marks: from the listing when there is one - a list's own rows need not carry them, and the Nexus lists
|
|
291
|
+
* do not - else from the record itself.
|
|
292
|
+
*/
|
|
293
|
+
const marksOf = useCallback((row) => marked?.marks.get(table.keyOf(row)) ?? (marked && fromBookmarks ? 0 : backend.marksOf(row)), [marked, fromBookmarks, backend, table]);
|
|
294
|
+
/**
|
|
295
|
+
* Why a mark cannot be put on or taken off yet. A switch stores the record's **whole** set of marks, so one sent
|
|
296
|
+
* before the set is known could take off a mark the page never read.
|
|
297
|
+
*/
|
|
298
|
+
const marksWait = fromBookmarks && marksOn && marksState !== "ready" ? (marksState === "failed" ? "Your pins and favourites could not be read. Reload the list to try again." : "Reading your pins and favourites…") : undefined;
|
|
299
|
+
/**
|
|
300
|
+
* The marks in each row's first column, where its number would be: on the live lists, once they are known, and
|
|
301
|
+
* only the marks this CRUD offers.
|
|
302
|
+
*/
|
|
303
|
+
const markMask = CRUD_MARKS.filter(mark => (marksOn && shownMarks.includes(mark)) || allowed[mark]).reduce((mask, mark) => mask | CRUD_MARK_BITS[mark], 0);
|
|
304
|
+
const rowMarks = useCallback((row) => MARK_ICONS[marksOf(row) & markMask], [marksOf, markMask]);
|
|
305
|
+
const marksShown = (mode === "normal" || mode === "flag") && markMask !== 0 && !marksWait;
|
|
306
|
+
/** How many more records may take a mark: lowering a limit never takes a mark off, it only waits to be met. */
|
|
307
|
+
const markRoom = (mark) => pinRoom(marked ? marked.rows[mark].length : undefined, markLimit(mark));
|
|
308
|
+
/** Copies of marked records, after a change that was not a mark: a flag, a description, a save. */
|
|
309
|
+
function patchMarked(rows) {
|
|
310
|
+
setMarked(current => current && patchMarkedRecords(current, rows, new Map(), row => table.keyOf(row), new Set()));
|
|
234
311
|
}
|
|
235
|
-
/**
|
|
236
|
-
function
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
return current;
|
|
240
|
-
const changed = new Map(rows.map(row => [table.keyOf(row), row]));
|
|
241
|
-
if (!current.some(row => changed.has(table.keyOf(row))))
|
|
242
|
-
return current;
|
|
243
|
-
// The record is still pinned: only its other values changed, and a list may not send the pin at all.
|
|
244
|
-
return current.map(row => {
|
|
245
|
-
const next = changed.get(table.keyOf(row));
|
|
246
|
-
return next ? backend.withPin(next, true) : row;
|
|
247
|
-
});
|
|
248
|
-
});
|
|
312
|
+
/** Records that left the active lists leave every section; their marks stay, for when they come back. */
|
|
313
|
+
function dropMarked(keys) {
|
|
314
|
+
if (keys.length)
|
|
315
|
+
setMarked(current => current && dropMarkedRows(current, keys, row => table.keyOf(row)));
|
|
249
316
|
}
|
|
250
|
-
/** Records
|
|
251
|
-
function
|
|
252
|
-
|
|
253
|
-
return;
|
|
254
|
-
const gone = new Set(keys);
|
|
255
|
-
setPinned(current => (current && current.some(row => gone.has(table.keyOf(row))) ? current.filter(row => !gone.has(table.keyOf(row))) : current));
|
|
317
|
+
/** Records back in the active list take their place in the sections of the marks they kept. */
|
|
318
|
+
function restoreMarked(rows) {
|
|
319
|
+
setMarked(current => current && patchMarkedRecords(current, rows, new Map(), row => table.keyOf(row), new Set(rows.map(row => table.keyOf(row)))));
|
|
256
320
|
}
|
|
257
|
-
/** Nothing can be pinned while the limit is reached: lowering the limit never unpins, it only waits to be met. */
|
|
258
|
-
const pinsFull = () => `You can pin up to ${maxPins} ${plural}. Unpin one to pin another.`;
|
|
259
321
|
/* Loading */
|
|
260
322
|
const load = useCallback((query, signal) => {
|
|
261
|
-
|
|
323
|
+
// No list before the permissions say which, nor before the scope kept in this browser is read.
|
|
324
|
+
if (!mode || scopeWaiting)
|
|
262
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);
|
|
263
333
|
const endpoint = crudListEndpoint(api, mode, paging);
|
|
264
334
|
const sortColumn = query.sort ? latest.current.columns.find(column => column.key === query.sort.key) : undefined;
|
|
265
335
|
const filters = query.filters ? JSON.stringify(query.filters) : "";
|
|
266
|
-
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;
|
|
267
337
|
const kept = cache.get(key);
|
|
268
338
|
if (kept)
|
|
269
|
-
return kept;
|
|
339
|
+
return shown(kept);
|
|
270
340
|
const options = { signal, toast: false, subject: plural };
|
|
271
|
-
if (paging === "server")
|
|
272
|
-
|
|
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
|
+
}
|
|
273
345
|
return browserApi.send(endpoint, options).then(answer => {
|
|
274
346
|
if (answer.aborted)
|
|
275
347
|
throw abortError();
|
|
@@ -277,9 +349,12 @@ export function Crud(props) {
|
|
|
277
349
|
throw new Error(answer.message ?? `Could not load the ${plural}.`);
|
|
278
350
|
const result = paging === "server" ? backend.readPage(answer.result) : backend.readList(answer.result);
|
|
279
351
|
cache.set(key, result);
|
|
280
|
-
return result;
|
|
352
|
+
return shown(result);
|
|
281
353
|
});
|
|
282
|
-
},
|
|
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]);
|
|
283
358
|
/** Which list a record belongs in, as this backend reads the record. */
|
|
284
359
|
const member = (row, of) => backend.listMembership(row, of);
|
|
285
360
|
/**
|
|
@@ -297,35 +372,127 @@ export function Crud(props) {
|
|
|
297
372
|
cache.map((key, value) => patchCachedList(value, crudCacheMode(key), changes, row => table.keyOf(row), member));
|
|
298
373
|
else
|
|
299
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();
|
|
300
380
|
// The list on screen is the table's own, every row of it, so it is written back whole rather than patched - when
|
|
301
381
|
// the change was written into it. When it was not (see `holds`), the table is read again from the lists just
|
|
302
382
|
// patched, or loaded when it has none, and a load still out for it is stopped: it may have been asked before the
|
|
303
|
-
// 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.
|
|
304
385
|
if (!written)
|
|
305
386
|
void table.reload({ quiet: true });
|
|
306
|
-
else if (paging === "client" && shown)
|
|
387
|
+
else if (paging === "client" && shown && !narrowingRef.current)
|
|
307
388
|
cache.update(shown, table.getRows());
|
|
308
389
|
}
|
|
309
390
|
/**
|
|
310
|
-
* Whether the table holds the list an action was asked from, every row of it, so the answer can be
|
|
311
|
-
* Not when the user moved to another list while a slow answer was out -
|
|
312
|
-
*
|
|
313
|
-
* 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.
|
|
314
395
|
*/
|
|
315
396
|
function holds(from) {
|
|
316
|
-
return (from === undefined || from ===
|
|
397
|
+
return (from === undefined || from === listNow()) && table.getState().status === "ready";
|
|
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;
|
|
317
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;
|
|
318
423
|
function afterChange(action, rows, changes, written) {
|
|
319
424
|
syncCaches(changes, written);
|
|
425
|
+
// What other tables and pickers looked up from this controller may now be wrong - a name, a status - so it is marked
|
|
426
|
+
// old, and is asked again where it is next shown. A pin or a favourite is this user's own and changes neither.
|
|
427
|
+
if (action !== "pin" && action !== "favorite")
|
|
428
|
+
expireLookups(api);
|
|
320
429
|
const shown = modeRef.current;
|
|
321
|
-
if (shown)
|
|
322
|
-
|
|
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()];
|
|
323
486
|
}
|
|
324
487
|
function applyChange(action, rows, options = {}) {
|
|
325
|
-
const { result, mass = false, markOn, values, from } = options;
|
|
488
|
+
const { result, mass = false, markOn, values, from, asked = scopeIdRef.current } = options;
|
|
326
489
|
const shown = modeRef.current;
|
|
327
490
|
if (!shown)
|
|
328
491
|
return;
|
|
492
|
+
if (answeredElsewhere(asked)) {
|
|
493
|
+
latest.current.onChange?.({ action, rows, mode: shown });
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
329
496
|
// Every table write below is for the list the action was asked from; when the table holds another, none is made.
|
|
330
497
|
const write = holds(from);
|
|
331
498
|
const answered = rows.length > 1 ? backend.answeredKeys(result, rowKey) : null;
|
|
@@ -358,29 +525,7 @@ export function Crud(props) {
|
|
|
358
525
|
moved.push({ key, row: next, before: current });
|
|
359
526
|
}
|
|
360
527
|
table.remove(leaving);
|
|
361
|
-
|
|
362
|
-
afterChange(action, updated, moved, write);
|
|
363
|
-
return;
|
|
364
|
-
}
|
|
365
|
-
if (action === "pin") {
|
|
366
|
-
const updated = [];
|
|
367
|
-
for (const row of done) {
|
|
368
|
-
const key = table.keyOf(row);
|
|
369
|
-
const current = table.getRow(key) ?? row;
|
|
370
|
-
const on = markOn ?? !backend.isPinned(current);
|
|
371
|
-
let next = backend.withPin(current, on);
|
|
372
|
-
const change = changes.get(key);
|
|
373
|
-
if (change)
|
|
374
|
-
next = { ...next, ...change };
|
|
375
|
-
if (one)
|
|
376
|
-
next = { ...next, ...one };
|
|
377
|
-
// A pin changes the section above the list, never the list itself: the record stays where it is, on its page.
|
|
378
|
-
if (write && table.getRow(key))
|
|
379
|
-
table.upsert(next, { flash: false });
|
|
380
|
-
updated.push(next);
|
|
381
|
-
moved.push({ key, row: next, before: current });
|
|
382
|
-
}
|
|
383
|
-
applyPins(updated);
|
|
528
|
+
patchMarked(updated);
|
|
384
529
|
afterChange(action, updated, moved, write);
|
|
385
530
|
return;
|
|
386
531
|
}
|
|
@@ -395,7 +540,7 @@ export function Crud(props) {
|
|
|
395
540
|
updated.push(next);
|
|
396
541
|
moved.push({ key, row: next, before: current });
|
|
397
542
|
}
|
|
398
|
-
|
|
543
|
+
patchMarked(updated);
|
|
399
544
|
afterChange(action, updated, moved, write);
|
|
400
545
|
return;
|
|
401
546
|
}
|
|
@@ -418,20 +563,54 @@ export function Crud(props) {
|
|
|
418
563
|
if (write && leavesList(action, shown))
|
|
419
564
|
table.remove(keys);
|
|
420
565
|
if (LEFT_ACTIVE.has(action))
|
|
421
|
-
|
|
566
|
+
dropMarked(keys);
|
|
567
|
+
else if (action === "recover")
|
|
568
|
+
restoreMarked(updated);
|
|
422
569
|
afterChange(action, updated, moved, write);
|
|
423
570
|
}
|
|
571
|
+
/**
|
|
572
|
+
* Marks a write stored, written in with no request: every record's set of marks, the sections (a record that took a
|
|
573
|
+
* mark goes on top of its section, one that lost it leaves), and the record itself in the table and every list kept.
|
|
574
|
+
* A mark never moves a record between lists: it stays where it is, on its page.
|
|
575
|
+
*/
|
|
576
|
+
function applyMarks(mark, rows, stored, asked = scopeIdRef.current) {
|
|
577
|
+
if (answeredElsewhere(asked))
|
|
578
|
+
return;
|
|
579
|
+
const write = holds(undefined);
|
|
580
|
+
const updated = [];
|
|
581
|
+
const moved = [];
|
|
582
|
+
for (const row of rows) {
|
|
583
|
+
const key = table.keyOf(row);
|
|
584
|
+
const marks = stored.get(key);
|
|
585
|
+
if (marks === undefined)
|
|
586
|
+
continue;
|
|
587
|
+
const current = table.getRow(key) ?? row;
|
|
588
|
+
const next = backend.withMarks(current, marks);
|
|
589
|
+
if (write && table.getRow(key))
|
|
590
|
+
table.upsert(next, { flash: false });
|
|
591
|
+
updated.push(next);
|
|
592
|
+
moved.push({ key, row: next, before: current });
|
|
593
|
+
}
|
|
594
|
+
if (!updated.length)
|
|
595
|
+
return;
|
|
596
|
+
setMarked(current => current && patchMarkedRecords(current, updated, stored, row => table.keyOf(row)));
|
|
597
|
+
afterChange(mark, updated, moved, write);
|
|
598
|
+
}
|
|
424
599
|
/**
|
|
425
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.
|
|
426
604
|
*/
|
|
427
|
-
function applyRows(rows, gone = false) {
|
|
605
|
+
function applyRows(rows, gone = false, held, asked = scopeIdRef.current) {
|
|
428
606
|
const shown = modeRef.current;
|
|
429
|
-
if (!shown || rows.length === 0)
|
|
607
|
+
if (!shown || rows.length === 0 || answeredElsewhere(asked))
|
|
430
608
|
return;
|
|
431
609
|
const moved = [];
|
|
432
610
|
const updated = [];
|
|
433
611
|
const leaving = [];
|
|
434
|
-
|
|
612
|
+
// Records whose marks the page's own action changed, as the records it gave say them.
|
|
613
|
+
const stored = new Map();
|
|
435
614
|
// The page asked from whichever list it did; the record's band decides, so only a table still loading is not
|
|
436
615
|
// written. A record that joined the list on screen - asked from another list, answered after a switch - is not in
|
|
437
616
|
// the table to be updated, so the table is read again from the patched lists instead.
|
|
@@ -441,162 +620,227 @@ export function Crud(props) {
|
|
|
441
620
|
const key = table.keyOf(next);
|
|
442
621
|
if (!key)
|
|
443
622
|
continue;
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
|
|
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)
|
|
447
628
|
leaving.push(key);
|
|
448
629
|
else if (!before && member(next, shown))
|
|
449
630
|
write = false;
|
|
450
|
-
else if (
|
|
631
|
+
else if (inTable && write)
|
|
451
632
|
table.upsert(next, { flash: false });
|
|
452
|
-
if (before && backend.
|
|
453
|
-
|
|
633
|
+
if (before && backend.marksOf(next) !== backend.marksOf(before))
|
|
634
|
+
stored.set(key, backend.marksOf(next));
|
|
454
635
|
updated.push(next);
|
|
455
636
|
moved.push({ key, row: next, before, gone });
|
|
456
637
|
}
|
|
457
638
|
if (write)
|
|
458
639
|
table.remove(leaving);
|
|
459
640
|
if (gone)
|
|
460
|
-
|
|
461
|
-
else if (pinned)
|
|
462
|
-
applyPins(updated);
|
|
641
|
+
dropMarked(leaving);
|
|
463
642
|
else
|
|
464
|
-
|
|
643
|
+
setMarked(current => current && patchMarkedRecords(current, updated, stored, row => table.keyOf(row)));
|
|
465
644
|
syncCaches(moved, write);
|
|
466
645
|
}
|
|
467
646
|
/* Row actions */
|
|
468
647
|
async function runRowAction(action, row) {
|
|
469
|
-
const
|
|
470
|
-
|
|
648
|
+
const from = listNow();
|
|
649
|
+
const asked = scopeIdRef.current;
|
|
650
|
+
// A flag carries the way it goes: never a toggle the server works out.
|
|
651
|
+
const markOn = action === "flag" ? !backend.isFlagged(row) : undefined;
|
|
652
|
+
const answer = await sendCrudRowAction(api, action, row, { rowKey, label: labelOf(row), backend, markOn });
|
|
653
|
+
if (answer?.ok)
|
|
654
|
+
applyChange(action, [row], { result: answer.result, markOn, from, asked });
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
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.
|
|
658
|
+
* Through `bookmark` it sends the record's whole set of marks with this one changed; a backend with only `pin`
|
|
659
|
+
* toggles its pin.
|
|
660
|
+
*/
|
|
661
|
+
async function switchMark(mark, row) {
|
|
662
|
+
const endpoint = crudRowEndpoint(api, mark);
|
|
663
|
+
if (!endpoint || marksWait)
|
|
471
664
|
return;
|
|
472
|
-
const
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
toast.warning(pinsFull());
|
|
665
|
+
const before = marksOf(row);
|
|
666
|
+
const on = !hasMark(before, mark);
|
|
667
|
+
if (on && markRoom(mark) < 1) {
|
|
668
|
+
toast.warning(MARK_INFO[mark].full(markLimit(mark), plural));
|
|
477
669
|
return;
|
|
478
670
|
}
|
|
479
|
-
const
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
671
|
+
const wanted = withMark(before, mark, on);
|
|
672
|
+
const whole = Boolean(api.bookmark);
|
|
673
|
+
const id = rawKey(row);
|
|
674
|
+
const asked = scopeIdRef.current;
|
|
675
|
+
const answer = await browserApi.send(endpoint, { params: { id, [rowKey]: id }, subject: labelOf(row), action: crudMessageAction(mark, on), ...(whole ? backend.marksRequest(wanted) : {}) });
|
|
676
|
+
if (!answer.ok)
|
|
677
|
+
return;
|
|
678
|
+
applyMarks(mark, [row], new Map([[table.keyOf(row), (whole ? backend.answeredMarks(answer.result) : null) ?? wanted]]), asked);
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Puts a mark on the selected records, or takes it off. Only the records the change is for are sent - the rest are
|
|
682
|
+
* already as asked - and, putting it on, only as many as the limit leaves room for, in the order they were picked.
|
|
683
|
+
*/
|
|
684
|
+
async function switchMarkMany(mark, rows, on) {
|
|
685
|
+
const endpoint = crudMassEndpoint(api, mark);
|
|
686
|
+
if (!endpoint || marksWait)
|
|
687
|
+
return;
|
|
688
|
+
const wanted = rows.filter(row => hasMark(marksOf(row), mark) !== on);
|
|
689
|
+
let sent = wanted;
|
|
690
|
+
if (on) {
|
|
691
|
+
const room = markRoom(mark);
|
|
692
|
+
if (room < 1) {
|
|
693
|
+
toast.warning(MARK_INFO[mark].full(markLimit(mark), plural));
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
sent = wanted.slice(0, room);
|
|
697
|
+
}
|
|
698
|
+
if (!sent.length)
|
|
699
|
+
return;
|
|
700
|
+
const keys = sent.map(rawKey);
|
|
701
|
+
const request = api.mass?.bookmark ? backend.massMarksRequest(keys, mark, on) : { body: backend.massRequest(mark, keys) };
|
|
702
|
+
const action = crudMessageAction(mark, on);
|
|
703
|
+
const asked = scopeIdRef.current;
|
|
704
|
+
const answer = await browserApi.send(endpoint, { ...request, subject, plural: props.subjectPlural, count: sent.length, action, toast: { success: false } });
|
|
705
|
+
if (!answer.ok)
|
|
706
|
+
return;
|
|
707
|
+
const done = changedOf(sent, answer.result);
|
|
708
|
+
applyMarks(mark, done, new Map(done.map(row => [table.keyOf(row), withMark(marksOf(row), mark, on)])), asked);
|
|
709
|
+
table.clearSelection();
|
|
710
|
+
sayMassDone(action, sent.length, done.length);
|
|
711
|
+
if (sent.length < wanted.length)
|
|
712
|
+
toast.warning(MARK_INFO[mark].over(sent.length, wanted.length, markLimit(mark), plural));
|
|
713
|
+
}
|
|
714
|
+
/** The records a mass answer says it changed: the ones it lists, or every one sent when it lists none. */
|
|
715
|
+
function changedOf(sent, result) {
|
|
716
|
+
const changed = backend.answeredKeys(result, rowKey);
|
|
717
|
+
return changed ? sent.filter(row => changed.has(table.keyOf(row))) : sent;
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* How a directed switch on a selection went, said plainly: one already as asked is skipped, not refused, so "3
|
|
721
|
+
* regions pinned" when two of them already were is the one thing not to say.
|
|
722
|
+
*/
|
|
723
|
+
function sayMassDone(action, sent, changed) {
|
|
724
|
+
const done = buildMessage("success", { action, subject, plural: props.subjectPlural, count: changed });
|
|
725
|
+
if (changed === sent)
|
|
726
|
+
toast.success(done);
|
|
727
|
+
else if (changed > 0)
|
|
728
|
+
toast.success(`${done.replace(/\.$/, "")}; the other ${sent - changed} already were.`);
|
|
729
|
+
else
|
|
730
|
+
toast.info(`Nothing changed: every one of the ${sent} already was.`);
|
|
731
|
+
}
|
|
732
|
+
/** Flags the selected records, or unflags them: only the ones the change is for, and the call says which way. */
|
|
733
|
+
async function switchFlagMany(rows, on) {
|
|
734
|
+
const endpoint = crudMassEndpoint(api, "flag");
|
|
735
|
+
if (!endpoint)
|
|
736
|
+
return;
|
|
737
|
+
const sent = rows.filter(row => backend.canFlag(row) && backend.isFlagged(row) !== on);
|
|
738
|
+
if (!sent.length)
|
|
739
|
+
return;
|
|
740
|
+
const action = crudMessageAction("flag", on);
|
|
741
|
+
const from = listNow();
|
|
742
|
+
const asked = scopeIdRef.current;
|
|
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 } });
|
|
744
|
+
if (!answer.ok)
|
|
745
|
+
return;
|
|
746
|
+
const done = changedOf(sent, answer.result);
|
|
747
|
+
applyChange("flag", done, { result: answer.result, mass: true, markOn: on, from, asked });
|
|
748
|
+
table.clearSelection();
|
|
749
|
+
sayMassDone(action, sent.length, done.length);
|
|
483
750
|
}
|
|
484
751
|
async function runMassAction(action, rows) {
|
|
485
752
|
if (action === "description") {
|
|
486
753
|
openDescription(rows);
|
|
487
754
|
return;
|
|
488
755
|
}
|
|
489
|
-
const endpoint = api
|
|
756
|
+
const endpoint = crudMassEndpoint(api, action);
|
|
490
757
|
if (!endpoint)
|
|
491
758
|
return;
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
const on = rows.filter(row => backend.isFlagged(row)).length;
|
|
497
|
-
markOn = on === 0 ? true : on === rows.length ? false : undefined;
|
|
498
|
-
}
|
|
499
|
-
if (action === "pin") {
|
|
500
|
-
// Every selected record pinned means Unpin; otherwise the ones that are not pinned yet are pinned, as many as
|
|
501
|
-
// the limit leaves room for, in the order the user picked them. The rest stay as they are.
|
|
502
|
-
const adding = rows.filter(row => !backend.isPinned(row));
|
|
503
|
-
markOn = adding.length > 0;
|
|
504
|
-
if (markOn) {
|
|
505
|
-
const room = pinRoom(pinned?.length);
|
|
506
|
-
if (room < 1) {
|
|
507
|
-
toast.warning(pinsFull());
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
510
|
-
sent = adding.slice(0, room);
|
|
511
|
-
over = adding.length - sent.length;
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
const messageAction = action === "flag" && markOn === undefined ? TOGGLED_FLAG : crudMessageAction(action, markOn);
|
|
515
|
-
const options = { body: backend.massRequest(action, sent.map(rawKey)), subject, plural: props.subjectPlural, count: sent.length, action: messageAction };
|
|
516
|
-
const from = modeRef.current;
|
|
517
|
-
const answer = ASKED.has(action) ? await browserApi.confirm(endpoint, options) : await browserApi.send(endpoint, options);
|
|
759
|
+
const options = { body: backend.massRequest(action, rows.map(rawKey)), subject, plural: props.subjectPlural, count: rows.length, action: crudMessageAction(action) };
|
|
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);
|
|
518
763
|
if (!answer?.ok)
|
|
519
764
|
return;
|
|
520
|
-
applyChange(action,
|
|
765
|
+
applyChange(action, rows, { result: answer.result, mass: true, from, asked });
|
|
521
766
|
table.clearSelection();
|
|
522
|
-
if (over > 0)
|
|
523
|
-
toast.warning(`${sent.length} of ${sent.length + over} ${plural} pinned: you can pin up to ${maxPins}.`);
|
|
524
767
|
}
|
|
525
768
|
/* Popups */
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
+
*/
|
|
789
|
+
async function openForm(action, row, values, opened) {
|
|
790
|
+
// A locked record is shown, not edited: its details say what it is, and the page's columns can say why.
|
|
791
|
+
if (action === "edit" && row && actionLockOf(row, "edit")) {
|
|
792
|
+
openDetails(row);
|
|
542
793
|
return;
|
|
543
794
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
const
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
const
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
description: label,
|
|
563
|
-
icon: _jsx(InlineIcon, { icon: action === "create" ? iconPlus : iconPencil }),
|
|
564
|
-
size: options.size ?? (inputs.length > 8 ? "lg" : "md"),
|
|
565
|
-
closeOnBackdrop: false,
|
|
566
|
-
beforeClose: async () => {
|
|
567
|
-
const discard = await confirmDiscard(form);
|
|
568
|
-
if (discard)
|
|
569
|
-
form.dropDraft();
|
|
570
|
-
return discard;
|
|
571
|
-
},
|
|
572
|
-
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) => {
|
|
573
|
-
onSaved(action, row, result, saved);
|
|
574
|
-
popup.close();
|
|
575
|
-
} }) })),
|
|
576
|
-
footer: popup => _jsx(FormButtons, { form: form, submitLabel: action === "create" ? `Add ${subject}` : "Save", onCancel: () => popup.close() }),
|
|
577
|
-
});
|
|
578
|
-
if (action === "create") {
|
|
579
|
-
const entry = { handle, form };
|
|
580
|
-
addPopup.current = entry;
|
|
581
|
-
void handle.closed.then(() => {
|
|
582
|
-
if (addPopup.current === entry)
|
|
583
|
-
addPopup.current = null;
|
|
584
|
-
});
|
|
795
|
+
// A narrowed list adds for the record picked in the other one, and only while it may take one: its value fixed in
|
|
796
|
+
// the form, the form one of its own per picked record ("For Ram Thapa" in its title bar), and its box named from the
|
|
797
|
+
// picked record with no lookup.
|
|
798
|
+
const picked = latest.current.narrow?.picked;
|
|
799
|
+
const narrowed = narrowingRef.current;
|
|
800
|
+
// The scope the form was opened on: a save that lands after a switch is that scope's, on its endpoints and inputs.
|
|
801
|
+
const asked = opened ? opened.asked : scopeIdRef.current;
|
|
802
|
+
const scoped = opened ? opened.props : latest.current;
|
|
803
|
+
let place = { values, label: row ? labelOf(row) : undefined };
|
|
804
|
+
if (action === "create" && narrowed) {
|
|
805
|
+
if (narrowedAddLock() !== null || !picked)
|
|
806
|
+
return;
|
|
807
|
+
place = {
|
|
808
|
+
values: { ...values, [narrowed.key]: narrowed.raw },
|
|
809
|
+
locked: { [narrowed.key]: (latest.current.narrow?.fixedMessage ?? pickWords.fixed)(narrowed.label) },
|
|
810
|
+
known: [picked],
|
|
811
|
+
label: `For ${narrowed.label}`,
|
|
812
|
+
};
|
|
585
813
|
}
|
|
814
|
+
await openCrudForm({
|
|
815
|
+
api: scoped.api,
|
|
816
|
+
action,
|
|
817
|
+
row,
|
|
818
|
+
...place,
|
|
819
|
+
inputs: scoped.inputs,
|
|
820
|
+
form: scoped.form,
|
|
821
|
+
subject,
|
|
822
|
+
// Each scope keeps its own Add (and its draft): one from another scope never comes back here. A record keeps one
|
|
823
|
+
// Edit wherever it is opened from.
|
|
824
|
+
name: action === "create" && asked ? `${name}@${asked}` : name,
|
|
825
|
+
rowKey,
|
|
826
|
+
key: row ? table.keyOf(row) : "",
|
|
827
|
+
onSaved: (result, saved, joined) => run.current.onSaved(action, row, result, saved, joined, asked),
|
|
828
|
+
});
|
|
586
829
|
}
|
|
587
830
|
/**
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
*
|
|
831
|
+
* A saved record read back on its own - only where what the page holds cannot finish it: a backend whose saved record
|
|
832
|
+
* still does not say which list it belongs in (`savedRow` wrote no status; the Nexus backend's always does), or a name
|
|
833
|
+
* the database joins that no picked or held record gives (`crudJoinsMissing`: a `dbJoin` column without `from`). The row
|
|
834
|
+
* goes in at once with what the form sent, and the rest lands a moment later.
|
|
591
835
|
*/
|
|
592
|
-
async function fillSaved(key, record) {
|
|
836
|
+
async function fillSaved(key, record, asked = scopeIdRef.current) {
|
|
593
837
|
const endpoint = api.details;
|
|
594
838
|
if (!endpoint)
|
|
595
839
|
return;
|
|
596
840
|
const id = readPath(record, rowKey);
|
|
597
841
|
const answer = await browserApi.get(endpoint, { params: { id, [rowKey]: id }, toast: false });
|
|
598
842
|
const full = unwrapApiResult(answer);
|
|
599
|
-
if (!isRecord(full))
|
|
843
|
+
if (!isRecord(full) || answeredElsewhere(asked))
|
|
600
844
|
return;
|
|
601
845
|
const before = table.getRow(key);
|
|
602
846
|
const next = { ...(before ?? record), ...full };
|
|
@@ -604,95 +848,222 @@ export function Crud(props) {
|
|
|
604
848
|
if (write && before)
|
|
605
849
|
table.upsert(next, { flash: false });
|
|
606
850
|
syncCaches([{ key, row: next, before }], write);
|
|
851
|
+
patchMarked([next]);
|
|
852
|
+
for (const tree of latest.current.trees ?? [])
|
|
853
|
+
writeTreeChanges(tree.cacheKey, crudTreeChanges({ action: "edit", rows: [next], mode: modeRef.current ?? "normal", before: [record] }, tree, backend), tree.rules);
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* A saved record finished with no call: the fields only the server keeps as the backend writes them (`savedRow`), the
|
|
857
|
+
* names the database joins from the records the form picked or the page holds (`crudJoinedNames`), and the count of its
|
|
858
|
+
* children each tree placement keeps (`crudSavedCount`: none for a new one, the held row's for an edited one). `held` is
|
|
859
|
+
* the record as the page holds it now (an edit). A narrowed list's picked record names the field it narrows by.
|
|
860
|
+
*/
|
|
861
|
+
function finished(action, record, held, joined) {
|
|
862
|
+
let next = backend.savedRow(action, record, held);
|
|
863
|
+
const picked = latest.current.narrow?.picked;
|
|
864
|
+
next = crudJoinedNames(next, held, latest.current.columns, (from, key) => joined(from, key, picked ? [picked] : []));
|
|
865
|
+
for (const tree of latest.current.trees ?? [])
|
|
866
|
+
next = crudSavedCount(next, held, tree);
|
|
867
|
+
return next;
|
|
607
868
|
}
|
|
608
|
-
function onSaved(action, row, result, values) {
|
|
869
|
+
function onSaved(action, row, result, values, joined, asked = scopeIdRef.current) {
|
|
609
870
|
// The record as the server now has it: what was sent, over the row it came from, with whatever the answer added.
|
|
610
|
-
// A backend that answers the record it wrote leaves nothing to load at all.
|
|
611
871
|
const answered = backend.answeredRow(result);
|
|
612
|
-
|
|
872
|
+
let record = { ...row, ...values, ...answered };
|
|
873
|
+
// A new record whose answer is only its key still gets it, so its row goes in.
|
|
874
|
+
if (action === "create" && !answered && !readPath(record, rowKey)) {
|
|
875
|
+
const given = backend.answeredKey(result);
|
|
876
|
+
if (given !== null && !rowKey.includes("."))
|
|
877
|
+
record[rowKey] = given;
|
|
878
|
+
}
|
|
613
879
|
const shown = modeRef.current;
|
|
880
|
+
if (answeredElsewhere(asked)) {
|
|
881
|
+
latest.current.onChange?.({ action, rows: [record], mode: shown ?? "normal" });
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
614
884
|
if (action === "edit") {
|
|
885
|
+
// The record as the page holds it now - flagged or counted since the form opened, perhaps - or, off the page shown
|
|
886
|
+
// (a pinned record, another page), as the form opened it.
|
|
615
887
|
const key = table.keyOf(record);
|
|
616
|
-
const
|
|
888
|
+
const held = (key ? table.getRow(key) : undefined) ?? row ?? undefined;
|
|
889
|
+
record = finished(action, record, held, joined);
|
|
617
890
|
const write = holds(undefined);
|
|
618
|
-
|
|
891
|
+
// Into the table when it belongs in the list on screen; out of it when an edit took it elsewhere - to another
|
|
892
|
+
// record of a narrowed list's, say. Another list's record, saved while this one shows, never goes in.
|
|
893
|
+
if (write && shownMember(record))
|
|
619
894
|
table.upsert(record);
|
|
620
|
-
|
|
621
|
-
|
|
895
|
+
else if (write && key && table.getRow(key))
|
|
896
|
+
table.remove([key]);
|
|
897
|
+
patchMarked([record]);
|
|
898
|
+
afterChange("edit", [record], key ? [{ key, row: record, before: held }] : [], write);
|
|
899
|
+
if (key && api.details && crudJoinsMissing(record, held, latest.current.columns))
|
|
900
|
+
void fillSaved(key, record, asked);
|
|
622
901
|
return;
|
|
623
902
|
}
|
|
624
|
-
|
|
625
|
-
//
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
if (given !== null && !rowKey.includes("."))
|
|
629
|
-
record[rowKey] = given;
|
|
630
|
-
}
|
|
903
|
+
record = finished(action, record, undefined, joined);
|
|
904
|
+
// A new record belongs to the list of every record - in a narrowed list, of the record it names, which may no longer
|
|
905
|
+
// be the one picked when its form was opened; without even a key the lists are dropped and the one on screen loads
|
|
906
|
+
// again (`syncCaches`).
|
|
631
907
|
const key = table.keyOf(record);
|
|
632
|
-
// Without a key the lists are dropped and the one on screen loads again (`syncCaches`).
|
|
633
908
|
const write = Boolean(key) && holds(undefined);
|
|
634
|
-
if (write && shown === "normal")
|
|
909
|
+
if (write && shown === "normal" && inScope(record))
|
|
635
910
|
table.upsert(record);
|
|
636
911
|
afterChange("create", [record], key ? [{ key, row: record, before: undefined }] : [], write);
|
|
637
|
-
|
|
638
|
-
|
|
912
|
+
// Read back only what the page cannot finish: a list unknown (no list can place a record without it), or a name.
|
|
913
|
+
if (key && api.details && (member(record, "normal") === undefined || crudJoinsMissing(record, undefined, latest.current.columns)))
|
|
914
|
+
void fillSaved(key, record, asked);
|
|
915
|
+
}
|
|
916
|
+
/** A record's history, in a popup of its own: one per record, brought back if it is open. */
|
|
917
|
+
function openHistory(row) {
|
|
918
|
+
if (!api.audit)
|
|
919
|
+
return;
|
|
920
|
+
openAuditHistory({ endpoint: api.audit, id: rawKey(row), subject, subjectPlural: props.subjectPlural, label: labelOf(row), backend });
|
|
639
921
|
}
|
|
640
922
|
function openDetails(row) {
|
|
641
923
|
const details = latest.current.details;
|
|
642
924
|
if (details === false)
|
|
643
925
|
return;
|
|
644
926
|
const key = table.keyOf(row);
|
|
645
|
-
const canEdit = allowed.edit && modeRef.current !== undefined && CRUD_ROW_ACTIONS[modeRef.current].includes("edit");
|
|
927
|
+
const canEdit = allowed.edit && modeRef.current !== undefined && CRUD_ROW_ACTIONS[modeRef.current].includes("edit") && !actionLockOf(row, "edit");
|
|
646
928
|
const endpoint = api.details;
|
|
647
929
|
const status = detailsLoad(endpoint ? "loading" : "done");
|
|
930
|
+
// A record's workspace: a profile and tabs, the details one tab among the page's own (`details.tabs`, `profile`).
|
|
931
|
+
const workspace = Boolean(details?.tabs || details?.profile);
|
|
648
932
|
// The panel's record: the row at once, so it has something to show while the full one loads, and that one after.
|
|
649
|
-
const shown = typeof details?.aside === "function" ? new FormStore() : null;
|
|
933
|
+
const shown = typeof details?.aside === "function" || workspace ? new FormStore() : null;
|
|
934
|
+
// The forms a workspace's tabs draw: closing it with changes in one asks first, as a CRUD form's popup does.
|
|
935
|
+
const guard = new FormLeaveGuard();
|
|
936
|
+
const leave = () => confirmLeaving(guard);
|
|
650
937
|
shown?.load(row, { draft: false });
|
|
651
938
|
const aside = typeof details?.aside === "function" ? details.aside({ form: shown, row }) : details?.aside;
|
|
652
|
-
|
|
939
|
+
const idOf = () => rawKey(row);
|
|
940
|
+
// The scope the details were opened on, and its props: a refresh that lands after a switch is that scope's, and so is
|
|
941
|
+
// an Edit pressed in them.
|
|
942
|
+
const asked = scopeIdRef.current;
|
|
943
|
+
const opened = { asked, props: latest.current };
|
|
944
|
+
// The record from the details endpoint, over the row; a panel's record is loaded only when it adds something, since a
|
|
945
|
+
// panel that renders from the record would otherwise ask again for exactly what it is already showing.
|
|
946
|
+
const readDetails = (signal) => {
|
|
947
|
+
const loading = browserApi
|
|
948
|
+
.get(endpoint, { params: { id: idOf(), [rowKey]: idOf() }, signal, toast: false })
|
|
949
|
+
.then(unwrapApiResult)
|
|
950
|
+
.then(full => {
|
|
951
|
+
if (shown && isRecord(full) && addsToRow(row, full))
|
|
952
|
+
shown.load({ ...row, ...full }, { draft: false });
|
|
953
|
+
return full;
|
|
954
|
+
});
|
|
955
|
+
// The footer follows the very promise the details do, with the same test for a popup closed meanwhile.
|
|
956
|
+
void loading.then(() => status.set("done"), error => {
|
|
957
|
+
if (!signal.aborted && !isAbortError(error))
|
|
958
|
+
status.set("failed");
|
|
959
|
+
});
|
|
960
|
+
return loading;
|
|
961
|
+
};
|
|
962
|
+
// A workspace's Details tab leaves out the fields its profile shows (`profile.fields`).
|
|
963
|
+
const fields = (record, load, without) => (_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 }) }));
|
|
964
|
+
// The workspace reads the record once for the whole popup - the profile, every tab and the fields share it - and
|
|
965
|
+
// again on each `refresh()`. Closing the popup stops the first read, never a refresh's: a tab saved something on
|
|
966
|
+
// the server, and the lists must hear of it even when the popup closed before the record came back.
|
|
967
|
+
const reading = new AbortController();
|
|
968
|
+
let pending;
|
|
969
|
+
const startReading = () => (pending ??= readDetails(reading.signal));
|
|
970
|
+
const refresh = async () => {
|
|
971
|
+
if (!endpoint)
|
|
972
|
+
return table.getRow(key) ?? shown.getValue();
|
|
973
|
+
const answer = await browserApi.get(endpoint, { params: { id: idOf(), [rowKey]: idOf() } });
|
|
974
|
+
const full = answer.ok ? answer.result : null;
|
|
975
|
+
if (!isRecord(full))
|
|
976
|
+
return null;
|
|
977
|
+
// The record as the page holds it when the answer lands: the table's row, or, off the page shown (from the pinned
|
|
978
|
+
// band, a page turned since), the popup's.
|
|
979
|
+
const held = table.getRow(key) ?? shown.getValue();
|
|
980
|
+
// Into the table, the marks and every list kept, as an edit's answer is; and into every tree that shows the record.
|
|
981
|
+
const next = { ...held, ...full };
|
|
982
|
+
shown.load({ ...shown.getValue(), ...full }, { draft: false });
|
|
983
|
+
run.current.applyRows([next], false, new Map([[key, held]]), asked);
|
|
984
|
+
for (const tree of latest.current.trees ?? [])
|
|
985
|
+
writeTreeChanges(tree.cacheKey, crudTreeChanges({ action: "edit", rows: [next], mode: modeRef.current ?? "normal", before: [held] }, tree, backend), tree.rules);
|
|
986
|
+
return next;
|
|
987
|
+
};
|
|
988
|
+
const handle = openPopup({
|
|
653
989
|
key: `${name}-view-${key}`,
|
|
654
990
|
reopen: "restore",
|
|
655
991
|
title: `${capitalize(subject)} ${details?.label ?? "details"}`,
|
|
656
992
|
description: labelOf(row),
|
|
657
993
|
icon: _jsx(InlineIcon, { icon: iconEye }),
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
//
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
if (shown && isRecord(full) && addsToRow(row, full))
|
|
671
|
-
shown.load({ ...row, ...full }, { draft: false });
|
|
672
|
-
return full;
|
|
673
|
-
});
|
|
674
|
-
// The footer follows the very promise the details do, with the same test for a popup closed meanwhile.
|
|
675
|
-
void loading.then(() => status.set("done"), error => {
|
|
676
|
-
if (!signal.aborted && !isAbortError(error))
|
|
677
|
-
status.set("failed");
|
|
678
|
-
});
|
|
679
|
-
return loading;
|
|
680
|
-
}
|
|
681
|
-
: undefined }) })),
|
|
994
|
+
// A workspace is wide: a column of its own, and beside it a tab that holds a form or a list of its own.
|
|
995
|
+
size: details?.size ?? (details?.tabs ? "full" : "md"),
|
|
996
|
+
// The workspace meets the popup's edges: its column runs from the title bar to the footer.
|
|
997
|
+
flush: workspace,
|
|
998
|
+
// Focus goes to the chosen tab in a workspace. Elsewhere it goes where it goes without a panel, the footer, rather
|
|
999
|
+
// than to the first button the panel happens to draw: a panel that renders at once (a Copy button) would otherwise
|
|
1000
|
+
// take it, and one that loads would not.
|
|
1001
|
+
initialFocus: workspace ? '.nx-crud-workspace [role="tab"][aria-selected="true"], .nx-popup__foot button' : aside ? ".nx-popup__foot button" : undefined,
|
|
1002
|
+
beforeClose: workspace ? leave : undefined,
|
|
1003
|
+
content: workspace
|
|
1004
|
+
? 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 => fields(shown.getValue(), endpoint && status.get() !== "done" ? () => startReading() : undefined, without) }))
|
|
1005
|
+
: fields(row, endpoint ? readDetails : undefined),
|
|
682
1006
|
footer: popup => (_jsxs(DetailsFooter, { load: status, children: [details?.actions
|
|
683
1007
|
?.filter(action => (!action.endpoint || (ready && can(action.endpoint))) && !action.hidden?.(row))
|
|
684
|
-
.map(action => (_jsx(Button, { icon: action.icon, "data-action": action.id, onClick: () => action.onSelect(table.getRow(key) ?? row, popup), children: action.label }, action.id))), canEdit && (_jsx(Button, { icon: _jsx(InlineIcon, { icon: iconPencil }), onClick: () => {
|
|
1008
|
+
.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 () => {
|
|
1009
|
+
if (!(await leave()))
|
|
1010
|
+
return;
|
|
685
1011
|
popup.close();
|
|
686
|
-
void openForm("edit", table.getRow(key) ?? row);
|
|
687
|
-
}, children: "Edit" })), _jsx(Button, { variant: "primary", onClick: () =>
|
|
1012
|
+
void openForm("edit", table.getRow(key) ?? row, undefined, opened);
|
|
1013
|
+
}, children: "Edit" })), _jsx(Button, { variant: "primary", onClick: async () => {
|
|
1014
|
+
if (await leave())
|
|
1015
|
+
popup.close();
|
|
1016
|
+
}, children: "Close" })] })),
|
|
1017
|
+
});
|
|
1018
|
+
void handle.closed.then(() => reading.abort());
|
|
1019
|
+
}
|
|
1020
|
+
/** A record as the CRUD holds it now: the table's row, else one of the lists kept, else a marked section's. */
|
|
1021
|
+
function heldRecord(key) {
|
|
1022
|
+
const sections = marked ? [...marked.rows.pin, ...marked.rows.favorite] : [];
|
|
1023
|
+
return table.getRow(key) ?? heldRows().find(row => table.keyOf(row) === key) ?? sections.find(row => table.keyOf(row) === key);
|
|
1024
|
+
}
|
|
1025
|
+
/** Opens one of the page's own row forms (`rowForms`) for a record; its save writes the fields its answer names. */
|
|
1026
|
+
function openRowForm(form, row) {
|
|
1027
|
+
const key = table.keyOf(row);
|
|
1028
|
+
// The scope the form was opened on: a save that lands after a switch is that scope's.
|
|
1029
|
+
const asked = scopeIdRef.current;
|
|
1030
|
+
openCrudRowForm({
|
|
1031
|
+
form,
|
|
1032
|
+
row,
|
|
1033
|
+
key,
|
|
1034
|
+
id: form.target ? form.target(row) : rawKey(row),
|
|
1035
|
+
rowKey,
|
|
1036
|
+
name,
|
|
1037
|
+
label: labelOf(row),
|
|
1038
|
+
onSaved: (result, values) => {
|
|
1039
|
+
const patch = crudRowFormWrites(form.writes, result, row, values);
|
|
1040
|
+
const now = patch ? run.current.writeRow(key, patch, asked) : run.current.heldRecord(key);
|
|
1041
|
+
form.onSaved?.(result, now ?? row, values);
|
|
1042
|
+
},
|
|
688
1043
|
});
|
|
689
1044
|
}
|
|
1045
|
+
/**
|
|
1046
|
+
* Fields a row form's answer changed, merged over the record as the CRUD holds it when the answer lands - never over
|
|
1047
|
+
* the record as the form was opened on, which an Edit since may have changed - and written wherever it is held, as the
|
|
1048
|
+
* handle's `apply` writes. A record held nowhere (another page, a list not kept) has nothing to write into.
|
|
1049
|
+
*/
|
|
1050
|
+
function writeRow(key, patch, asked) {
|
|
1051
|
+
if (answeredElsewhere(asked))
|
|
1052
|
+
return undefined;
|
|
1053
|
+
const held = heldRecord(key);
|
|
1054
|
+
if (!held)
|
|
1055
|
+
return undefined;
|
|
1056
|
+
const next = { ...held, ...patch };
|
|
1057
|
+
applyRows([next], false, new Map([[key, held]]));
|
|
1058
|
+
return next;
|
|
1059
|
+
}
|
|
690
1060
|
function openDescription(rows) {
|
|
691
1061
|
const endpoint = api.mass?.description;
|
|
692
1062
|
if (!endpoint)
|
|
693
1063
|
return;
|
|
694
1064
|
const form = new FormStore();
|
|
695
1065
|
const count = rows.length;
|
|
1066
|
+
const asked = scopeIdRef.current;
|
|
696
1067
|
openPopup({
|
|
697
1068
|
key: `${name}-description`,
|
|
698
1069
|
title: "Description",
|
|
@@ -701,8 +1072,8 @@ export function Crud(props) {
|
|
|
701
1072
|
size: "sm",
|
|
702
1073
|
closeOnBackdrop: false,
|
|
703
1074
|
beforeClose: () => confirmDiscard(form),
|
|
704
|
-
content: popup => (_jsx(ApiForm, { form: form, buttons: false, inputs: [{ type:
|
|
705
|
-
applyChange("description", rows, { result, mass: true, values });
|
|
1075
|
+
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) => {
|
|
1076
|
+
applyChange("description", rows, { result, mass: true, values, asked });
|
|
706
1077
|
table.clearSelection();
|
|
707
1078
|
popup.close();
|
|
708
1079
|
} })),
|
|
@@ -710,8 +1081,8 @@ export function Crud(props) {
|
|
|
710
1081
|
});
|
|
711
1082
|
}
|
|
712
1083
|
// Row actions and the handle call these through a ref, so the table's callbacks stay the same between renders.
|
|
713
|
-
const run = useRef({ runRowAction, runMassAction, openForm, openDetails, applyRows });
|
|
714
|
-
run.current = { runRowAction, runMassAction, openForm, openDetails, applyRows };
|
|
1084
|
+
const run = useRef({ runRowAction, runMassAction, switchMark, switchMarkMany, switchFlagMany, openForm, openDetails, openHistory, applyRows, onSaved, openRowForm, writeRow, heldRecord });
|
|
1085
|
+
run.current = { runRowAction, runMassAction, switchMark, switchMarkMany, switchFlagMany, openForm, openDetails, openHistory, applyRows, onSaved, openRowForm, writeRow, heldRecord };
|
|
715
1086
|
/* The table's callbacks */
|
|
716
1087
|
const rowActions = useCallback((row) => {
|
|
717
1088
|
if (!mode)
|
|
@@ -719,49 +1090,111 @@ export function Crud(props) {
|
|
|
719
1090
|
const list = [];
|
|
720
1091
|
const primary = mode === "normal" || mode === "flag";
|
|
721
1092
|
const flagged = backend.isFlagged(row);
|
|
722
|
-
const
|
|
1093
|
+
const marks = marksOf(row);
|
|
1094
|
+
// A mark goes the way the record's marks say; while they are not known yet, it waits and says why.
|
|
1095
|
+
const markEntry = (mark) => {
|
|
1096
|
+
const info = MARK_INFO[mark];
|
|
1097
|
+
const off = hasMark(marks, mark);
|
|
1098
|
+
return { label: off ? info.off : info.on, icon: _jsx(InlineIcon, { icon: off ? info.offIcon : info.icon }), disabled: Boolean(marksWait), description: marksWait };
|
|
1099
|
+
};
|
|
723
1100
|
const info = {
|
|
724
1101
|
view: { label: `View ${(latest.current.details || undefined)?.label ?? "details"}`, icon: _jsx(InlineIcon, { icon: iconEye }) },
|
|
725
|
-
edit: {
|
|
1102
|
+
edit: { ...crudActionEntry("edit"), inline: true },
|
|
726
1103
|
flag: flagged ? { label: "Unflag", icon: _jsx(InlineIcon, { icon: iconFlagOff }) } : { label: "Flag", icon: _jsx(InlineIcon, { icon: iconFlag }) },
|
|
727
|
-
pin:
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
1104
|
+
pin: markEntry("pin"),
|
|
1105
|
+
favorite: markEntry("favorite"),
|
|
1106
|
+
history: crudActionEntry("history"),
|
|
1107
|
+
trash: crudActionEntry("trash"),
|
|
1108
|
+
recover: { ...crudActionEntry("recover"), inline: true },
|
|
1109
|
+
delete: crudActionEntry("delete"),
|
|
1110
|
+
trashFromDelete: { ...crudActionEntry("trashFromDelete"), inline: true },
|
|
732
1111
|
};
|
|
733
1112
|
for (const action of crudRowActions(mode, candidate => allowed[candidate], trashless)) {
|
|
734
1113
|
if (action === "view" && latest.current.details === false)
|
|
735
1114
|
continue;
|
|
736
|
-
|
|
1115
|
+
// A record the backend will not flag either way (a pending one) is offered neither.
|
|
1116
|
+
if (action === "flag" && !backend.canFlag(row))
|
|
1117
|
+
continue;
|
|
1118
|
+
// Listed rather than left out, so the menu says why the record cannot change instead of looking short; in the
|
|
1119
|
+
// menu, not on the row, since a switched-off icon button could not say it.
|
|
1120
|
+
// One action's own lock (`rowActionLock`) is listed the same way, beside the actions that still run.
|
|
1121
|
+
const locked = actionLockOf(row, action);
|
|
1122
|
+
if (locked) {
|
|
1123
|
+
list.push({ ...info[action], id: action, inline: false, disabled: true, description: locked, onSelect: () => undefined });
|
|
1124
|
+
continue;
|
|
1125
|
+
}
|
|
1126
|
+
const onSelect = action === "view"
|
|
1127
|
+
? () => run.current.openDetails(row)
|
|
1128
|
+
: action === "edit"
|
|
1129
|
+
? () => void run.current.openForm("edit", row)
|
|
1130
|
+
: action === "pin" || action === "favorite"
|
|
1131
|
+
? () => run.current.switchMark(action, row)
|
|
1132
|
+
: action === "history"
|
|
1133
|
+
? () => run.current.openHistory(row)
|
|
1134
|
+
: () => run.current.runRowAction(action, row);
|
|
737
1135
|
const entry = info[action];
|
|
738
1136
|
list.push({ ...entry, id: action, inline: entry.inline && (primary ? action === "edit" : true), onSelect });
|
|
739
1137
|
}
|
|
1138
|
+
// The page's own forms on a record (`rowForms`), where offered; one locked for it is listed switched off, saying why.
|
|
1139
|
+
for (const form of latest.current.rowForms ?? []) {
|
|
1140
|
+
if (!crudRowFormOffered(form, row, mode, can))
|
|
1141
|
+
continue;
|
|
1142
|
+
const locked = form.lock?.(row) || null;
|
|
1143
|
+
list.push({
|
|
1144
|
+
id: form.id,
|
|
1145
|
+
label: form.label,
|
|
1146
|
+
icon: form.icon,
|
|
1147
|
+
description: locked ?? form.description,
|
|
1148
|
+
inline: Boolean(form.inline) && !locked,
|
|
1149
|
+
disabled: Boolean(locked),
|
|
1150
|
+
onSelect: locked ? () => undefined : () => run.current.openRowForm(form, row),
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
740
1153
|
const extra = latest.current.rowActions?.(row, mode) ?? [];
|
|
741
1154
|
return [...list, ...extra];
|
|
742
|
-
}, [mode, allowed, backend, trashless]);
|
|
1155
|
+
}, [mode, allowed, backend, trashless, marksOf, marksWait, can]);
|
|
743
1156
|
const massActionsFor = useCallback((rows) => {
|
|
744
1157
|
if (!mode)
|
|
745
1158
|
return [];
|
|
746
1159
|
const list = [];
|
|
747
|
-
const
|
|
748
|
-
|
|
1160
|
+
const marks = rows.map(marksOf);
|
|
1161
|
+
// A locked record in the selection switches off every action that would change it (`rowLock`), and one whose own
|
|
1162
|
+
// lock refuses an action (`rowActionLock`) switches that action off.
|
|
1163
|
+
const holdsLocked = (action) => rows.some(row => Boolean(actionLockOf(row, action)));
|
|
749
1164
|
for (const action of crudMassActions(mode, candidate => massAllowed[candidate], massTrashless)) {
|
|
1165
|
+
if (action === "flag") {
|
|
1166
|
+
// The same for the flag: Flag while one of them can be flagged, Unflag while one of them is.
|
|
1167
|
+
const flaggable = rows.filter(row => backend.canFlag(row));
|
|
1168
|
+
if (flaggable.some(row => !backend.isFlagged(row)))
|
|
1169
|
+
list.push({ id: "flag", label: "Flag", icon: _jsx(InlineIcon, { icon: iconFlag }), disabled: holdsLocked("flag"), onSelect: selected => run.current.switchFlagMany(selected, true) });
|
|
1170
|
+
if (flaggable.some(row => backend.isFlagged(row)))
|
|
1171
|
+
list.push({ id: "unflag", label: "Unflag", icon: _jsx(InlineIcon, { icon: iconFlagOff }), disabled: holdsLocked("flag"), onSelect: selected => run.current.switchFlagMany(selected, false) });
|
|
1172
|
+
continue;
|
|
1173
|
+
}
|
|
1174
|
+
if (action === "pin" || action === "favorite") {
|
|
1175
|
+
// Only a way some selected record can still go (the owner, 2026-10-07); a mixed selection offers both. While the
|
|
1176
|
+
// marks are not known, which way it can go is not known either: both show, and neither runs yet.
|
|
1177
|
+
const info = MARK_INFO[action];
|
|
1178
|
+
const ways = marksWait ? { on: true, off: true } : markDirections(marks, action);
|
|
1179
|
+
if (ways.on)
|
|
1180
|
+
list.push({ id: action, label: info.on, icon: _jsx(InlineIcon, { icon: info.icon }), disabled: Boolean(marksWait), onSelect: selected => run.current.switchMarkMany(action, selected, true) });
|
|
1181
|
+
if (ways.off)
|
|
1182
|
+
list.push({ id: `un${action}`, label: info.off, icon: _jsx(InlineIcon, { icon: info.offIcon }), disabled: Boolean(marksWait), onSelect: selected => run.current.switchMarkMany(action, selected, false) });
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
750
1185
|
const onSelect = (selected) => run.current.runMassAction(action, selected);
|
|
751
1186
|
const info = {
|
|
752
|
-
flag: flagged === rows.length ? { label: "Unflag", icon: _jsx(InlineIcon, { icon: iconFlagOff }) } : { label: flagged ? "Flag / unflag" : "Flag", icon: _jsx(InlineIcon, { icon: iconFlag }) },
|
|
753
|
-
pin: unpinned === 0 ? { label: "Unpin", icon: _jsx(InlineIcon, { icon: iconPinnedOff }) } : { label: "Pin", icon: _jsx(InlineIcon, { icon: iconPin }) },
|
|
754
1187
|
trash: { label: "Move to trash", icon: _jsx(InlineIcon, { icon: iconTrash }), tone: "danger" },
|
|
755
1188
|
recover: { label: "Recover", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }) },
|
|
756
1189
|
delete: { label: "Delete permanently", icon: _jsx(InlineIcon, { icon: iconTrashX }), tone: "danger" },
|
|
757
1190
|
trashFromDelete: { label: "Move back to trash", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }) },
|
|
758
1191
|
description: { label: "Description", icon: _jsx(InlineIcon, { icon: iconNotes }) },
|
|
759
1192
|
};
|
|
760
|
-
list.push({ ...info[action], id: action, onSelect });
|
|
1193
|
+
list.push({ ...info[action], id: action, disabled: holdsLocked(action), onSelect });
|
|
761
1194
|
}
|
|
762
1195
|
const extra = latest.current.massActions ? latest.current.massActions(rows, mode) : [];
|
|
763
1196
|
return [...list, ...extra];
|
|
764
|
-
}, [mode, massAllowed, backend, massTrashless]);
|
|
1197
|
+
}, [mode, massAllowed, backend, massTrashless, marksOf, marksWait]);
|
|
765
1198
|
const onRowOpen = useCallback((row) => {
|
|
766
1199
|
if (allowed.view && latest.current.details !== false)
|
|
767
1200
|
run.current.openDetails(row);
|
|
@@ -774,17 +1207,28 @@ export function Crud(props) {
|
|
|
774
1207
|
return modeRef.current ?? "normal";
|
|
775
1208
|
},
|
|
776
1209
|
setMode: next => setChosenMode(next),
|
|
1210
|
+
get scope() {
|
|
1211
|
+
return scopeIdRef.current || null;
|
|
1212
|
+
},
|
|
1213
|
+
setScope: next => chooseScope.current(next),
|
|
777
1214
|
reload: () => {
|
|
778
|
-
|
|
779
|
-
|
|
1215
|
+
clearCaches();
|
|
1216
|
+
reloadMarks();
|
|
780
1217
|
return table.reload({ quiet: true });
|
|
781
1218
|
},
|
|
782
1219
|
apply: (rows, options) => run.current.applyRows(rows, options?.gone ?? false),
|
|
783
1220
|
create: values => void run.current.openForm("create", null, values),
|
|
784
1221
|
edit: row => void run.current.openForm("edit", row),
|
|
785
1222
|
view: row => run.current.openDetails(row),
|
|
786
|
-
|
|
787
|
-
|
|
1223
|
+
openRowForm: (formId, row) => {
|
|
1224
|
+
const form = latest.current.rowForms?.find(entry => entry.id === formId);
|
|
1225
|
+
if (form)
|
|
1226
|
+
run.current.openRowForm(form, row);
|
|
1227
|
+
},
|
|
1228
|
+
}), [table, clearCaches, reloadMarks]);
|
|
1229
|
+
const ownLabel = props["aria-label"] ?? (props["aria-labelledby"] ? undefined : capitalize(plural));
|
|
1230
|
+
// A narrowed table is named with the record it shows the records of: "Places: Ram Thapa".
|
|
1231
|
+
const tableLabel = ownLabel && narrowing && narrowing.value !== null ? `${ownLabel}: ${narrowing.label}` : ownLabel;
|
|
788
1232
|
const configured = CRUD_MODES.some(value => wantedModes.includes(value) && crudListEndpoint(api, value, paging));
|
|
789
1233
|
if (!configured) {
|
|
790
1234
|
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, "."] }) }));
|
|
@@ -792,17 +1236,43 @@ export function Crud(props) {
|
|
|
792
1236
|
if (ready && !mode) {
|
|
793
1237
|
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." })] }));
|
|
794
1238
|
}
|
|
1239
|
+
// The switch (or tabs) between the CRUD's own list and its scopes, while one is offered. It is drawn before the kept
|
|
1240
|
+
// choice is read too (on the CRUD's own list), so the bar does not move when it is.
|
|
1241
|
+
const scopeControl = scoping.offered.length > 0 ? _jsx(CrudScopeControl, { scopes: given.scopes, state: scoping, plural: plural }) : null;
|
|
795
1242
|
const modeOptions = modes.map(value => ({ value, label: MODE_INFO[value].label, icon: _jsx(InlineIcon, { icon: MODE_INFO[value].icon }) }));
|
|
796
|
-
|
|
797
|
-
//
|
|
1243
|
+
// One fixed label on every CRUD's list, whatever its subject (the owner, 2026-10-10); the form it opens says "Add <subject>".
|
|
1244
|
+
// A narrowed list's Add says for whom on its tooltip, and while it cannot add, why, on a press.
|
|
1245
|
+
const addLock = narrowedAddLock();
|
|
1246
|
+
const add = allowed.create
|
|
1247
|
+
? {
|
|
1248
|
+
label: CRUD_ACTION_WORDS.create.label,
|
|
1249
|
+
onClick: () => void run.current.openForm("create", null),
|
|
1250
|
+
disabled: addLock !== null,
|
|
1251
|
+
description: addLock ?? (narrowing ? `Adds a new ${subject} for ${narrowing.label}.` : undefined),
|
|
1252
|
+
// Nothing picked in the CRUD's own box: a press sends the cursor there, rather than a note under Add.
|
|
1253
|
+
onDisabledPress: () => narrowPicker.askForAdd(),
|
|
1254
|
+
}
|
|
1255
|
+
: undefined;
|
|
1256
|
+
const nonePicked = narrowing !== null && narrowing.value === null;
|
|
1257
|
+
/** 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. */
|
|
1258
|
+
const emptyMessage = (shown) => {
|
|
1259
|
+
if (nonePicked)
|
|
1260
|
+
return props.narrow?.emptyMessage ?? pickWords.empty;
|
|
1261
|
+
if (narrowing && shown === "normal")
|
|
1262
|
+
return `${narrowing.label} has no ${plural} yet.`;
|
|
1263
|
+
return MODE_INFO[shown].empty(plural);
|
|
1264
|
+
};
|
|
1265
|
+
// In the main list, select again by mark and by flag, the way the mass actions act on them - marks once they are known.
|
|
1266
|
+
const markSelects = (mark, label, otherwise) => massAllowed[mark] && !marksWait
|
|
1267
|
+
? [
|
|
1268
|
+
{ label, icon: _jsx(InlineIcon, { icon: MARK_INFO[mark].icon }), test: (row) => hasMark(marksOf(row), mark) },
|
|
1269
|
+
{ label: otherwise, icon: _jsx(InlineIcon, { icon: MARK_INFO[mark].offIcon }), test: (row) => !hasMark(marksOf(row), mark) },
|
|
1270
|
+
]
|
|
1271
|
+
: [];
|
|
798
1272
|
const selectBy = mode === "normal"
|
|
799
1273
|
? [
|
|
800
|
-
...(
|
|
801
|
-
|
|
802
|
-
{ label: "Pinned", icon: _jsx(InlineIcon, { icon: iconPin }), test: (row) => backend.isPinned(row) },
|
|
803
|
-
{ label: "Not pinned", icon: _jsx(InlineIcon, { icon: iconPinnedOff }), test: (row) => !backend.isPinned(row) },
|
|
804
|
-
]
|
|
805
|
-
: []),
|
|
1274
|
+
...markSelects("pin", "Pinned", "Not pinned"),
|
|
1275
|
+
...markSelects("favorite", "Favourites", "Not favourites"),
|
|
806
1276
|
...(massAllowed.flag
|
|
807
1277
|
? [
|
|
808
1278
|
{ label: "Flagged", icon: _jsx(InlineIcon, { icon: iconFlag }), test: (row) => backend.isFlagged(row) },
|
|
@@ -812,15 +1282,39 @@ export function Crud(props) {
|
|
|
812
1282
|
]
|
|
813
1283
|
: [];
|
|
814
1284
|
const selectable = props.massActions !== false && Boolean(mode) && (crudMassActions(mode, action => massAllowed[action], massTrashless).length > 0 || Boolean(props.massActions));
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
1285
|
+
// The sections above the active list: one per mark this CRUD shows, Pinned first.
|
|
1286
|
+
const sections = mode === "normal" && marksOn && marksState !== "failed"
|
|
1287
|
+
? shownMarks.map(mark => ({
|
|
1288
|
+
key: MARK_INFO[mark].section,
|
|
1289
|
+
label: `${MARK_INFO[mark].title} ${plural}`,
|
|
1290
|
+
icon: MARK_INFO[mark].icon,
|
|
1291
|
+
// A narrowed list's sections hold the picked record's marked records alone, as its list does.
|
|
1292
|
+
rows: (marked?.rows[mark] ?? []).filter(inScope),
|
|
1293
|
+
loading: marksState === "loading",
|
|
1294
|
+
max: markLimit(mark),
|
|
1295
|
+
}))
|
|
1296
|
+
: undefined;
|
|
1297
|
+
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, onPick: props.onPick, onReload: () => {
|
|
1298
|
+
clearCaches();
|
|
1299
|
+
reloadMarks();
|
|
1300
|
+
}, emptyMessage: tableOptions?.emptyMessage ?? (mode ? emptyMessage(mode) : undefined),
|
|
1301
|
+
// A list that asks for a pick in the CRUD's own box points at it: its button takes the cursor there.
|
|
1302
|
+
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 && {
|
|
819
1303
|
...tableOptions.excel,
|
|
1304
|
+
// The export writes the list on screen, as the backend names that list - a narrowed one by its record, as its
|
|
1305
|
+
// pages are asked for; the page's own filters win a clash.
|
|
1306
|
+
filters: () => {
|
|
1307
|
+
const narrowed = narrowingRef.current;
|
|
1308
|
+
const own = narrowed && narrowed.value !== null ? { [narrowed.key]: narrowed.raw } : {};
|
|
1309
|
+
return { ...backend.exportFilters(modeRef.current ?? "normal"), ...own, ...tableOptions.excel?.filters?.() };
|
|
1310
|
+
},
|
|
820
1311
|
// A spreadsheet wrote records straight into the database: every list this CRUD kept is out of date.
|
|
821
1312
|
onImported: () => {
|
|
822
|
-
|
|
823
|
-
|
|
1313
|
+
clearCaches();
|
|
1314
|
+
reloadMarks();
|
|
1315
|
+
// Records nobody here has seen: a tree elsewhere cannot have them written in, so its pages go.
|
|
1316
|
+
for (const tree of latest.current.trees ?? [])
|
|
1317
|
+
expireTreePages(tree.cacheKey);
|
|
824
1318
|
tableOptions.excel?.onImported?.();
|
|
825
1319
|
},
|
|
826
1320
|
} }) }));
|