nexus-shared 3.2.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2359 -0
- package/README.md +1911 -1123
- package/dist/Client.Index.d.ts +27 -0
- package/dist/Client.Index.js +27 -0
- package/dist/Components/Chats/ChatComposer.d.ts +7 -1
- package/dist/Components/Chats/ChatComposer.js +9 -6
- package/dist/Components/Chats/ChatParts.d.ts +15 -2
- package/dist/Components/Chats/ChatParts.js +15 -4
- package/dist/Components/Chats/ChatPopup.d.ts +46 -0
- package/dist/Components/Chats/ChatPopup.js +34 -0
- package/dist/Components/Chats/ChatThread.d.ts +9 -1
- package/dist/Components/Chats/ChatThread.js +54 -6
- package/dist/Components/Dashboards/DashboardBoard.d.ts +62 -0
- package/dist/Components/Dashboards/DashboardBoard.js +594 -0
- package/dist/Components/Dashboards/DashboardWidgets.d.ts +155 -0
- package/dist/Components/Dashboards/DashboardWidgets.js +159 -0
- package/dist/Components/Dashboards/WidgetFrame.d.ts +51 -0
- package/dist/Components/Dashboards/WidgetFrame.js +45 -0
- package/dist/Components/Documents/Button.d.ts +6 -1
- package/dist/Components/Documents/Button.js +2 -2
- package/dist/Components/Documents/DocumentFrame.d.ts +50 -0
- package/dist/Components/Documents/DocumentFrame.js +83 -0
- package/dist/Components/Documents/Menu.d.ts +4 -0
- package/dist/Components/Documents/Menu.js +40 -24
- package/dist/Components/Documents/PrintDocument.d.ts +95 -0
- package/dist/Components/Documents/PrintDocument.js +62 -0
- package/dist/Components/Documents/TabButtons.d.ts +6 -0
- package/dist/Components/Documents/TabButtons.js +5 -4
- package/dist/Components/Documents/Tabs.d.ts +59 -0
- package/dist/Components/Documents/Tabs.js +70 -0
- package/dist/Components/Domains/DomainCard.d.ts +98 -0
- package/dist/Components/Domains/DomainCard.js +182 -0
- package/dist/Components/Forms/ApiForm.js +24 -3
- package/dist/Components/Forms/Crud.d.ts +2 -2
- package/dist/Components/Forms/Crud.js +628 -247
- package/dist/Components/Forms/CrudActions.d.ts +137 -0
- package/dist/Components/Forms/CrudActions.js +214 -0
- package/dist/Components/Forms/CrudHistory.d.ts +6 -1
- package/dist/Components/Forms/CrudHistory.js +14 -6
- package/dist/Components/Forms/CrudNarrowPicker.d.ts +31 -0
- package/dist/Components/Forms/CrudNarrowPicker.js +180 -0
- package/dist/Components/Forms/CrudParts.d.ts +37 -2
- package/dist/Components/Forms/CrudParts.js +123 -7
- package/dist/Components/Forms/CrudRowForms.d.ts +26 -0
- package/dist/Components/Forms/CrudRowForms.js +174 -0
- package/dist/Components/Forms/CrudScopes.d.ts +28 -0
- package/dist/Components/Forms/CrudScopes.js +76 -0
- package/dist/Components/Forms/CrudTree.d.ts +90 -0
- package/dist/Components/Forms/CrudTree.js +363 -0
- package/dist/Components/Forms/CrudWorkspace.d.ts +73 -0
- package/dist/Components/Forms/CrudWorkspace.js +177 -0
- package/dist/Components/Forms/ExcelImport.js +3 -3
- package/dist/Components/Forms/Form.d.ts +6 -5
- package/dist/Components/Forms/Form.js +134 -21
- package/dist/Components/Forms/FormLeaveGuard.d.ts +44 -0
- package/dist/Components/Forms/FormLeaveGuard.js +65 -0
- package/dist/Components/Forms/FormQuestion.d.ts +6 -0
- package/dist/Components/Forms/FormQuestion.js +28 -0
- package/dist/Components/Forms/RecordFlows.d.ts +50 -0
- package/dist/Components/Forms/RecordFlows.js +282 -0
- package/dist/Components/Forms/SubmitForm.d.ts +37 -2
- package/dist/Components/Forms/SubmitForm.js +182 -10
- package/dist/Components/Inputs/CheckBox.js +1 -1
- package/dist/Components/Inputs/ChoiceField.d.ts +21 -1
- package/dist/Components/Inputs/ChoiceField.js +28 -2
- package/dist/Components/Inputs/ComputedInput.d.ts +51 -0
- package/dist/Components/Inputs/ComputedInput.js +179 -0
- package/dist/Components/Inputs/Dropdown.d.ts +13 -0
- package/dist/Components/Inputs/Dropdown.js +8 -6
- package/dist/Components/Inputs/DropdownParts.d.ts +3 -1
- package/dist/Components/Inputs/DropdownParts.js +16 -4
- package/dist/Components/Inputs/Field.d.ts +7 -1
- package/dist/Components/Inputs/Field.js +2 -2
- package/dist/Components/Inputs/GroupForm.d.ts +6 -6
- package/dist/Components/Inputs/GroupForm.js +12 -12
- package/dist/Components/Inputs/InputRenderer.d.ts +13 -6
- package/dist/Components/Inputs/InputRenderer.js +37 -32
- package/dist/Components/Inputs/MarkdownEditor.d.ts +6 -0
- package/dist/Components/Inputs/MarkdownEditor.js +32 -6
- package/dist/Components/Inputs/PictureInput.d.ts +41 -0
- package/dist/Components/Inputs/PictureInput.js +75 -0
- package/dist/Components/Inputs/RadioGroup.d.ts +16 -3
- package/dist/Components/Inputs/RadioGroup.js +8 -3
- package/dist/Components/Inputs/RowsInput.d.ts +15 -2
- package/dist/Components/Inputs/RowsInput.js +96 -8
- package/dist/Components/Inputs/TabularForm.d.ts +5 -5
- package/dist/Components/Inputs/TabularForm.js +45 -32
- package/dist/Components/Inputs/TextArea.d.ts +6 -0
- package/dist/Components/Inputs/TextArea.js +11 -2
- package/dist/Components/Inputs/TextBoxTypes.d.ts +6 -0
- package/dist/Components/Inputs/TextBoxTypes.js +17 -0
- package/dist/Components/Inputs/TreeView.d.ts +76 -2
- package/dist/Components/Inputs/TreeView.js +227 -16
- package/dist/Components/Layouts/MessageBox.d.ts +15 -2
- package/dist/Components/Layouts/MessageBox.js +56 -7
- package/dist/Components/Layouts/Popup.d.ts +34 -7
- package/dist/Components/Layouts/Popup.js +143 -13
- package/dist/Components/Layouts/PopupDock.d.ts +3 -1
- package/dist/Components/Layouts/PopupDock.js +2 -1
- package/dist/Components/Layouts/PopupHost.js +5 -4
- package/dist/Components/Messages/MailClient.d.ts +50 -0
- package/dist/Components/Messages/MailClient.js +410 -0
- package/dist/Components/Messages/MailColumns.d.ts +21 -0
- package/dist/Components/Messages/MailColumns.js +92 -0
- package/dist/Components/Messages/MailCompose.d.ts +46 -0
- package/dist/Components/Messages/MailCompose.js +154 -0
- package/dist/Components/Messages/MailReader.d.ts +43 -0
- package/dist/Components/Messages/MailReader.js +207 -0
- package/dist/Components/Messages/MailThread.d.ts +71 -0
- package/dist/Components/Messages/MailThread.js +185 -0
- package/dist/Components/Messages/MessageBell.d.ts +41 -0
- package/dist/Components/Messages/MessageBell.js +76 -0
- package/dist/Components/Viewers/DataTable.js +153 -49
- package/dist/Components/Viewers/DataTableColumns.d.ts +2 -2
- package/dist/Components/Viewers/DataTableColumns.js +2 -1
- package/dist/Components/Viewers/DataTableFilters.d.ts +1 -1
- package/dist/Components/Viewers/DataTableFilters.js +17 -2
- package/dist/Components/Viewers/DataTableParts.d.ts +23 -5
- package/dist/Components/Viewers/DataTableParts.js +26 -14
- package/dist/Components/Viewers/OwnerFiles.d.ts +227 -0
- package/dist/Components/Viewers/OwnerFiles.js +700 -0
- package/dist/Components/Viewers/QrCodeImage.d.ts +22 -0
- package/dist/Components/Viewers/QrCodeImage.js +15 -0
- package/dist/Components/Viewers/RecordCards.d.ts +16 -0
- package/dist/Components/Viewers/RecordCards.js +509 -0
- package/dist/Helpers/ApiModules.js +4 -0
- package/dist/Helpers/AuditEntries.d.ts +50 -0
- package/dist/Helpers/AuditEntries.js +96 -0
- package/dist/Helpers/BusyHooks.d.ts +6 -0
- package/dist/Helpers/BusyHooks.js +39 -0
- package/dist/Helpers/ChatBackend.d.ts +8 -1
- package/dist/Helpers/ChatBackend.js +18 -0
- package/dist/Helpers/ChatHelpers.d.ts +2 -0
- package/dist/Helpers/ChatHelpers.js +4 -1
- package/dist/Helpers/CrudBackend.d.ts +20 -2
- package/dist/Helpers/CrudBackend.js +46 -2
- package/dist/Helpers/CrudColumns.d.ts +91 -6
- package/dist/Helpers/CrudColumns.js +148 -7
- package/dist/Helpers/CrudDetailsTabs.d.ts +58 -0
- package/dist/Helpers/CrudDetailsTabs.js +126 -0
- package/dist/Helpers/CrudHelpers.d.ts +116 -2
- package/dist/Helpers/CrudHelpers.js +256 -0
- package/dist/Helpers/CrudNarrowPick.d.ts +83 -0
- package/dist/Helpers/CrudNarrowPick.js +157 -0
- package/dist/Helpers/CrudPickerPlan.d.ts +58 -0
- package/dist/Helpers/CrudPickerPlan.js +46 -0
- package/dist/Helpers/CrudRowForms.d.ts +32 -0
- package/dist/Helpers/CrudRowForms.js +76 -0
- package/dist/Helpers/CrudScopes.d.ts +32 -0
- package/dist/Helpers/CrudScopes.js +79 -0
- package/dist/Helpers/DashboardHelpers.d.ts +115 -0
- package/dist/Helpers/DashboardHelpers.js +305 -0
- package/dist/Helpers/DomainCards.d.ts +140 -0
- package/dist/Helpers/DomainCards.js +370 -0
- package/dist/Helpers/DropdownHelpers.d.ts +3 -1
- package/dist/Helpers/DropdownHelpers.js +6 -2
- package/dist/Helpers/ExcelHelpers.d.ts +1 -1
- package/dist/Helpers/ExcelHelpers.js +12 -11
- package/dist/Helpers/FileAdapter.d.ts +158 -0
- package/dist/Helpers/FileAdapter.js +173 -0
- package/dist/Helpers/FilePrimaries.d.ts +48 -0
- package/dist/Helpers/FilePrimaries.js +138 -0
- package/dist/Helpers/FormConfirm.d.ts +32 -0
- package/dist/Helpers/FormConfirm.js +40 -0
- package/dist/Helpers/FormStore.d.ts +100 -5
- package/dist/Helpers/FormStore.js +368 -25
- package/dist/Helpers/InputConditions.d.ts +56 -0
- package/dist/Helpers/InputConditions.js +192 -0
- package/dist/Helpers/InputMemory.d.ts +106 -0
- package/dist/Helpers/InputMemory.js +395 -0
- package/dist/Helpers/InputNarrowing.d.ts +50 -0
- package/dist/Helpers/InputNarrowing.js +148 -0
- package/dist/Helpers/InputParamsHelpers.d.ts +44 -1
- package/dist/Helpers/InputParamsHelpers.js +157 -74
- package/dist/Helpers/MailClientHelpers.d.ts +96 -0
- package/dist/Helpers/MailClientHelpers.js +211 -0
- package/dist/Helpers/MessageBackend.d.ts +152 -0
- package/dist/Helpers/MessageBackend.js +329 -0
- package/dist/Helpers/MessageHelpers.d.ts +160 -0
- package/dist/Helpers/MessageHelpers.js +573 -0
- package/dist/Helpers/MessageUnread.d.ts +95 -0
- package/dist/Helpers/MessageUnread.js +245 -0
- package/dist/Helpers/NexusDriveAdapter.d.ts +71 -0
- package/dist/Helpers/NexusDriveAdapter.js +236 -0
- package/dist/Helpers/NumberHelpers.d.ts +156 -0
- package/dist/Helpers/NumberHelpers.js +242 -0
- package/dist/Helpers/PagedPicker.d.ts +42 -0
- package/dist/Helpers/PagedPicker.js +85 -0
- package/dist/Helpers/Permissions.js +2 -1
- package/dist/Helpers/QrCode.d.ts +26 -0
- package/dist/Helpers/QrCode.js +463 -0
- package/dist/Helpers/RecordCards.d.ts +205 -0
- package/dist/Helpers/RecordCards.js +378 -0
- package/dist/Helpers/RequestCache.d.ts +14 -2
- package/dist/Helpers/RequestCache.js +22 -3
- package/dist/Helpers/RowsStore.d.ts +61 -2
- package/dist/Helpers/RowsStore.js +203 -16
- package/dist/Helpers/ServerPreview.d.ts +69 -0
- package/dist/Helpers/ServerPreview.js +99 -0
- package/dist/Helpers/TableColumns.js +4 -3
- package/dist/Helpers/TableExport.d.ts +7 -1
- package/dist/Helpers/TableExport.js +9 -3
- package/dist/Helpers/TableFilters.d.ts +1 -1
- package/dist/Helpers/TableFilters.js +24 -23
- package/dist/Helpers/TableHelpers.d.ts +38 -4
- package/dist/Helpers/TableHelpers.js +78 -10
- package/dist/Helpers/TableJoins.d.ts +13 -0
- package/dist/Helpers/TableJoins.js +34 -0
- package/dist/Helpers/TableStore.d.ts +28 -4
- package/dist/Helpers/TableStore.js +73 -11
- package/dist/Helpers/ToolbarFit.d.ts +23 -0
- package/dist/Helpers/ToolbarFit.js +30 -0
- package/dist/Helpers/TreeStore.d.ts +156 -6
- package/dist/Helpers/TreeStore.js +547 -18
- package/dist/Interfaces/ApiInterfaces.d.ts +10 -1
- package/dist/Interfaces/ApiInterfaces.js +10 -1
- package/dist/Interfaces/ChatInterfaces.d.ts +22 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +539 -6
- package/dist/Interfaces/DashboardInterfaces.d.ts +123 -0
- package/dist/Interfaces/DashboardInterfaces.js +1 -0
- package/dist/Interfaces/DomainInterfaces.d.ts +145 -0
- package/dist/Interfaces/DomainInterfaces.js +1 -0
- package/dist/Interfaces/FileInterfaces.d.ts +183 -0
- package/dist/Interfaces/FileInterfaces.js +6 -0
- package/dist/Interfaces/FormInterfaces.d.ts +460 -32
- package/dist/Interfaces/FormInterfaces.js +79 -1
- package/dist/Interfaces/InputInterfaces.d.ts +22 -3
- package/dist/Interfaces/MailInterfaces.d.ts +192 -0
- package/dist/Interfaces/MailInterfaces.js +1 -0
- package/dist/Interfaces/RecordInterfaces.d.ts +367 -0
- package/dist/Interfaces/RecordInterfaces.js +1 -0
- package/dist/Interfaces/TableInterfaces.d.ts +102 -3
- package/dist/Services/ExcelApi.js +5 -0
- package/dist/Services/FileApi.d.ts +13 -0
- package/dist/Services/FileApi.js +29 -0
- package/dist/Services/ImageEditorLoader.d.ts +14 -0
- package/dist/Services/ImageEditorLoader.js +51 -0
- package/dist/Services/JoinSources.d.ts +79 -6
- package/dist/Services/JoinSources.js +132 -21
- package/dist/Services/MessageApi.d.ts +18 -0
- package/dist/Services/MessageApi.js +64 -0
- package/dist/Services/ModuleImport.d.ts +7 -0
- package/dist/Services/ModuleImport.js +22 -0
- package/dist/Shared.Index.d.ts +28 -0
- package/dist/Shared.Index.js +28 -0
- package/package.json +13 -2
- package/src/Styles/Nexus.Avatar.css +6 -5
- package/src/Styles/Nexus.Button.css +39 -4
- package/src/Styles/Nexus.Chat.css +114 -22
- package/src/Styles/Nexus.Choice.css +16 -0
- package/src/Styles/Nexus.Crud.css +486 -21
- package/src/Styles/Nexus.Dashboard.css +763 -0
- package/src/Styles/Nexus.Domain.css +384 -0
- package/src/Styles/Nexus.Dropdown.css +8 -0
- package/src/Styles/Nexus.Editor.css +20 -0
- package/src/Styles/Nexus.Files.css +437 -0
- package/src/Styles/Nexus.Form.css +5 -2
- package/src/Styles/Nexus.Index.css +7 -0
- package/src/Styles/Nexus.Input.css +73 -0
- package/src/Styles/Nexus.Mail.css +1062 -0
- package/src/Styles/Nexus.Menu.css +10 -1
- package/src/Styles/Nexus.Popup.css +135 -1
- package/src/Styles/Nexus.Print.css +288 -0
- package/src/Styles/Nexus.QrCode.css +12 -0
- package/src/Styles/Nexus.Record.css +430 -0
- package/src/Styles/Nexus.Tab.Buttons.css +87 -22
- package/src/Styles/Nexus.Table.css +1458 -1387
- package/src/Styles/Nexus.Theme.Picker.css +1 -0
- package/src/Styles/Nexus.Themes.css +12 -6
- package/src/Styles/Nexus.Tokens.css +8 -3
- package/src/Styles/Nexus.Tree.css +41 -1
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { iconLoader2, InlineIcon } from "nexus-icons";
|
|
4
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
5
|
+
import { apiRequestOptions, toApiResult } from "../../Helpers/ApiFormHelpers.js";
|
|
6
|
+
import { readApiResponse } from "../../Helpers/ApiResponses.js";
|
|
7
|
+
import { isApiEndpoint } from "../../Helpers/ApiRoutes.js";
|
|
8
|
+
import { FormStore } from "../../Helpers/FormStore.js";
|
|
9
|
+
import { recordOpenedValues, recordStartStep, recordStatusLine, recordSteps, secondsUntil } from "../../Helpers/RecordCards.js";
|
|
10
|
+
import { api as browserApi, getApiReader } from "../../Services/BrowserApi.js";
|
|
11
|
+
import { Button } from "../Documents/Button.js";
|
|
12
|
+
import { messageBox } from "../Layouts/MessageBox.js";
|
|
13
|
+
import { openPopup } from "../Layouts/PopupHost.js";
|
|
14
|
+
import { toast } from "../Layouts/Toaster.js";
|
|
15
|
+
import { ApiForm } from "./ApiForm.js";
|
|
16
|
+
import { confirmDiscard, FormButtons } from "./SubmitForm.js";
|
|
17
|
+
/*
|
|
18
|
+
* The popups of a record's page of cards (`RecordCards`): a card's Edit, a list's Add and an item's actions open a popup
|
|
19
|
+
* of one step or several (a code sent, then typed), each step a form whose answer is written into the card's data at
|
|
20
|
+
* once; a Remove and the like ask first, then call. The page holds the data; the popup asks it for the data as held now
|
|
21
|
+
* every time it draws, so a write made meanwhile (another popup, a reload) is what the next step sees and sends.
|
|
22
|
+
*/
|
|
23
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
24
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
25
|
+
/** Where a change goes, sent through the app's `api` without its toasts: an endpoint, a URL (POST), a request, or a function. */
|
|
26
|
+
export async function sendRecordCall(submit, body) {
|
|
27
|
+
try {
|
|
28
|
+
if (typeof submit === "function")
|
|
29
|
+
return await toApiResult(await submit(body), getApiReader() ?? readApiResponse);
|
|
30
|
+
if (isApiEndpoint(submit))
|
|
31
|
+
return await browserApi.send(submit, { body, toast: false });
|
|
32
|
+
if (typeof submit === "string")
|
|
33
|
+
return await browserApi.send(submit, { method: "POST", body: body ?? {}, toast: false });
|
|
34
|
+
return await browserApi.send(submit.url, { ...apiRequestOptions(submit, body), toast: false });
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
return { ok: false, message: error instanceof Error ? error.message : "The server did not answer." };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** A step with nowhere to send its values says so, rather than saving nothing. */
|
|
41
|
+
const NOWHERE = async () => ({ ok: false, message: "This step has nowhere to send its values." });
|
|
42
|
+
/** Which step a popup shows, and the answer that led to it. */
|
|
43
|
+
function flowState(initial) {
|
|
44
|
+
let state = initial;
|
|
45
|
+
const listeners = new Set();
|
|
46
|
+
return {
|
|
47
|
+
get: () => state,
|
|
48
|
+
set(next) {
|
|
49
|
+
state = next;
|
|
50
|
+
for (const listener of listeners)
|
|
51
|
+
listener();
|
|
52
|
+
},
|
|
53
|
+
subscribe(listener) {
|
|
54
|
+
listeners.add(listener);
|
|
55
|
+
return () => {
|
|
56
|
+
listeners.delete(listener);
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Opens a record page's popup: one per key, brought back as it was when it is open. Each step is a form (none of its
|
|
63
|
+
* inputs is required: a step can be its sentence and its button); its answer is written into the card's data, then the
|
|
64
|
+
* step's `next` shows, or the popup closes and `done` runs. Closing with changes asks first, unless the step says there
|
|
65
|
+
* is nothing worth keeping.
|
|
66
|
+
*/
|
|
67
|
+
export function openRecordFlow(request) {
|
|
68
|
+
const { flow, host, key, kind } = request;
|
|
69
|
+
const steps = recordSteps(flow);
|
|
70
|
+
const state = flowState({ step: recordStartStep(flow, host.context()), result: undefined, round: 0 });
|
|
71
|
+
// A form per step, kept while the popup is open, so a step shown again has what was typed in it - unless it is shown
|
|
72
|
+
// afresh (`fresh`), which drops its form: a new one opens on the step's values again.
|
|
73
|
+
const forms = new Map();
|
|
74
|
+
const formOf = (id) => {
|
|
75
|
+
let form = forms.get(id);
|
|
76
|
+
if (!form)
|
|
77
|
+
forms.set(id, (form = new FormStore()));
|
|
78
|
+
return form;
|
|
79
|
+
};
|
|
80
|
+
const shown = new Set();
|
|
81
|
+
let rounds = 0;
|
|
82
|
+
const stepOf = (id) => steps.find(step => step.id === id) ?? steps[0];
|
|
83
|
+
let popup = null;
|
|
84
|
+
const contextOf = () => ({
|
|
85
|
+
...host.context(),
|
|
86
|
+
step: state.get().step,
|
|
87
|
+
result: state.get().result,
|
|
88
|
+
form: formOf(state.get().step),
|
|
89
|
+
goTo: (id, options) => go(id, state.get().result, options),
|
|
90
|
+
close: () => popup?.close(),
|
|
91
|
+
});
|
|
92
|
+
const resolve = (value) => (typeof value === "function" ? value(contextOf()) : value);
|
|
93
|
+
const inputsOf = (step) => (step.inputs === undefined ? (kind === "edit" ? (request.inputs ?? []) : []) : typeof step.inputs === "function" ? step.inputs(contextOf()) : step.inputs);
|
|
94
|
+
const heading = () => {
|
|
95
|
+
const step = stepOf(state.get().step);
|
|
96
|
+
return { title: resolve(step.title ?? flow.title) ?? request.title, description: resolve(step.description ?? flow.description) ?? request.description };
|
|
97
|
+
};
|
|
98
|
+
function go(id, result, options) {
|
|
99
|
+
const step = steps.find(one => one.id === id);
|
|
100
|
+
if (!step)
|
|
101
|
+
return;
|
|
102
|
+
const fresh = options?.fresh ?? (step.fresh === true && shown.has(id));
|
|
103
|
+
if (fresh)
|
|
104
|
+
forms.delete(id);
|
|
105
|
+
shown.add(id);
|
|
106
|
+
state.set({ step: id, result, round: fresh ? ++rounds : state.get().round });
|
|
107
|
+
popup?.update(heading());
|
|
108
|
+
}
|
|
109
|
+
shown.add(state.get().step);
|
|
110
|
+
async function saved(step, result, values) {
|
|
111
|
+
await host.write({ writes: step.writes, result, values, fallback: kind === "edit" ? "values" : "reload" });
|
|
112
|
+
const answer = { ...contextOf(), result, values };
|
|
113
|
+
if (typeof step.successMessage === "function") {
|
|
114
|
+
const message = step.successMessage(answer);
|
|
115
|
+
if (message !== false && message !== null && message !== undefined && message !== "")
|
|
116
|
+
toast.success(message);
|
|
117
|
+
}
|
|
118
|
+
const next = typeof step.next === "function" ? step.next(answer) : step.next;
|
|
119
|
+
if (next && steps.some(one => one.id === next))
|
|
120
|
+
return go(next, result);
|
|
121
|
+
const closing = popup;
|
|
122
|
+
closing?.close();
|
|
123
|
+
if (step.done && closing)
|
|
124
|
+
void closing.closed.then(() => step.done?.({ ...contextOf(), result, values }));
|
|
125
|
+
}
|
|
126
|
+
const size = flow.size ?? (kind === "edit" && inputsOf(stepOf(state.get().step)).length > 2 ? "md" : "sm");
|
|
127
|
+
popup = openPopup({
|
|
128
|
+
key,
|
|
129
|
+
reopen: "restore",
|
|
130
|
+
...heading(),
|
|
131
|
+
icon: flow.icon,
|
|
132
|
+
size,
|
|
133
|
+
closeOnBackdrop: false,
|
|
134
|
+
beforeClose: () => {
|
|
135
|
+
const step = stepOf(state.get().step);
|
|
136
|
+
return step.confirmCancel === false ? true : confirmDiscard(formOf(step.id));
|
|
137
|
+
},
|
|
138
|
+
content: () => _jsx(RecordFlowBody, { state: state, stepOf: stepOf, formOf: formOf, inputsOf: inputsOf, contextOf: contextOf, resolve: resolve, host: host, kind: kind, onSaved: saved }),
|
|
139
|
+
footer: handle => _jsx(RecordFlowFooter, { state: state, stepOf: stepOf, formOf: formOf, inputsOf: inputsOf, onCancel: () => handle.close() }),
|
|
140
|
+
});
|
|
141
|
+
host.track(popup);
|
|
142
|
+
return popup;
|
|
143
|
+
}
|
|
144
|
+
/** What a step works on: its own values, else the card's data for an Edit, the item for an item's step, nothing for an Add. */
|
|
145
|
+
function workedOn(step, context, kind) {
|
|
146
|
+
const given = step.values ? step.values(context) : kind === "edit" ? context.data : kind === "item" ? context.item : undefined;
|
|
147
|
+
return isRecord(given) ? given : {};
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The values a step's form opens on: its own `values` as given; else, of the record or the item, only what its inputs
|
|
151
|
+
* name and an input can hold (a code step's `code` box never opens on the item's `code` record).
|
|
152
|
+
*/
|
|
153
|
+
function openedOn(step, context, kind, inputs) {
|
|
154
|
+
const given = workedOn(step, context, kind);
|
|
155
|
+
return step.values ? given : recordOpenedValues(given, inputs);
|
|
156
|
+
}
|
|
157
|
+
/** The step shown: its sentence, its form, the button that stays on it, and the page's own part. */
|
|
158
|
+
function RecordFlowBody({ state, stepOf, formOf, inputsOf, contextOf, resolve, host, kind, onSaved, }) {
|
|
159
|
+
// Drawn again on every write of the page's data, so the sentence and the item are as held now.
|
|
160
|
+
const version = useSyncExternalStore(host.subscribe, host.snapshot, host.snapshot);
|
|
161
|
+
const { step: id, round } = useSyncExternalStore(state.subscribe, state.get, state.get);
|
|
162
|
+
const step = stepOf(id);
|
|
163
|
+
const context = contextOf();
|
|
164
|
+
// Inputs of a function follow the data (and the form, which a step shown afresh has new).
|
|
165
|
+
const inputs = useMemo(() => inputsOf(step), [step, round, typeof step.inputs === "function" ? version : null]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
166
|
+
const opened = useMemo(() => openedOn(step, context, kind, inputs), [id, round]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
167
|
+
const rootRef = useRef(null);
|
|
168
|
+
const shownBefore = useRef(false);
|
|
169
|
+
// A step shown after another takes the cursor, where its own input did not: its first box, else its first button.
|
|
170
|
+
useLayoutEffect(() => {
|
|
171
|
+
if (!shownBefore.current) {
|
|
172
|
+
shownBefore.current = true;
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const root = rootRef.current;
|
|
176
|
+
const frame = window.requestAnimationFrame(() => {
|
|
177
|
+
if (!root?.isConnected || root.contains(document.activeElement))
|
|
178
|
+
return;
|
|
179
|
+
const dialog = root.closest("dialog");
|
|
180
|
+
dialog?.querySelector(".nx-popup__body :is(input, textarea, select, [contenteditable='true']):not([disabled]), .nx-popup__foot .nx-btn--primary, .nx-popup__foot .nx-btn--danger")?.focus();
|
|
181
|
+
});
|
|
182
|
+
return () => window.cancelAnimationFrame(frame);
|
|
183
|
+
}, [id, round]);
|
|
184
|
+
const lead = resolve(step.lead);
|
|
185
|
+
const title = resolve(step.title) ?? null;
|
|
186
|
+
const sent = (values) => {
|
|
187
|
+
if (step.transform)
|
|
188
|
+
return step.transform(values, contextOf());
|
|
189
|
+
if (kind === "add" && !step.values)
|
|
190
|
+
return values;
|
|
191
|
+
const now = contextOf();
|
|
192
|
+
return { ...workedOn(step, now, kind), ...values };
|
|
193
|
+
};
|
|
194
|
+
return (_jsxs("div", { ref: rootRef, className: "nx-record-step", children: [lead !== undefined && lead !== null && lead !== false && lead !== "" && _jsx("div", { className: "nx-record-step__lead", children: typeof lead === "string" ? _jsx("p", { children: lead }) : lead }), _jsx(ApiForm, { form: formOf(id), inputs: inputs, defaultValue: opened, validate: step.validate ? values => step.validate(values, contextOf()) : undefined,
|
|
195
|
+
// A step with nothing worth keeping (a code step) never asks before the page is left, as its Cancel does not.
|
|
196
|
+
confirmLeave: step.confirmCancel !== false, api: typeof step.submit === "string" ? { url: step.submit } : (step.submit ?? NOWHERE), transform: values => sent(values), buttons: false, successMessage: typeof step.successMessage === "function" ? false : step.successMessage, "aria-label": typeof title === "string" ? title : undefined, onSuccess: (result, values) => void onSaved(step, result, values), onError: answer => {
|
|
197
|
+
if (!host.ended(answer))
|
|
198
|
+
step.onError?.(answer, contextOf());
|
|
199
|
+
} }, `${id}:${round}`), step.also && _jsx(RecordCall, { call: step.also, context: context, host: host, form: formOf(id) }, `${id}:${round}:also`), step.render?.(context)] }));
|
|
200
|
+
}
|
|
201
|
+
/** The step's bar: Cancel, and its Save under its own name. */
|
|
202
|
+
function RecordFlowFooter({ state, stepOf, formOf, inputsOf, onCancel }) {
|
|
203
|
+
const { step: id, round } = useSyncExternalStore(state.subscribe, state.get, state.get);
|
|
204
|
+
const step = stepOf(id);
|
|
205
|
+
return (_jsx(FormButtons, { form: formOf(id), submitLabel: step.submitLabel ?? "Save",
|
|
206
|
+
// A floppy disk says Save; a step of its own name ("Send a code") has none unless it gives one.
|
|
207
|
+
submitIcon: step.submitIcon !== undefined ? step.submitIcon : step.submitLabel === undefined ? undefined : null, submitTone: step.submitTone, statusLine: recordStatusLine(inputsOf(step).length), confirmCancel: step.confirmCancel !== false, onCancel: onCancel }, `${id}:${round}`));
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* A button in a step that calls an endpoint and stays: "Send a new code". While the server says to wait it counts the
|
|
211
|
+
* seconds down in its label, held (`aria-disabled`, so the focus stays on it); while it calls, a spinner.
|
|
212
|
+
*/
|
|
213
|
+
function RecordCall({ call, context, host, form }) {
|
|
214
|
+
const until = call.waitUntil?.(context) ?? null;
|
|
215
|
+
const [now, setNow] = useState(() => Date.now());
|
|
216
|
+
const [busy, setBusy] = useState(false);
|
|
217
|
+
const live = useRef(true);
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
live.current = true;
|
|
220
|
+
return () => {
|
|
221
|
+
live.current = false;
|
|
222
|
+
};
|
|
223
|
+
}, []);
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
setNow(Date.now());
|
|
226
|
+
if (secondsUntil(until) <= 0)
|
|
227
|
+
return;
|
|
228
|
+
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
|
229
|
+
return () => window.clearInterval(timer);
|
|
230
|
+
}, [until]);
|
|
231
|
+
const wait = secondsUntil(until, now);
|
|
232
|
+
const held = busy || wait > 0;
|
|
233
|
+
async function press() {
|
|
234
|
+
if (held)
|
|
235
|
+
return;
|
|
236
|
+
setBusy(true);
|
|
237
|
+
const answer = await sendRecordCall(call.submit, call.transform?.(context));
|
|
238
|
+
if (answer.ok) {
|
|
239
|
+
await host.write({ writes: call.writes, result: answer.result, values: undefined, fallback: "reload" });
|
|
240
|
+
if (call.successMessage !== false)
|
|
241
|
+
toast.success(call.successMessage ?? answer.message ?? "Done");
|
|
242
|
+
if (call.focus)
|
|
243
|
+
form.focus(call.focus);
|
|
244
|
+
}
|
|
245
|
+
else if (!host.ended(answer))
|
|
246
|
+
toast.error(answer.message ?? "It did not work just now. Try again in a moment.");
|
|
247
|
+
if (live.current)
|
|
248
|
+
setBusy(false);
|
|
249
|
+
}
|
|
250
|
+
const label = typeof call.label === "function" ? call.label(context) : call.label;
|
|
251
|
+
return (_jsx("p", { className: "nx-record-step__also", children: _jsx(Button, { size: "sm", variant: "ghost", icon: busy ? _jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin" }) : call.icon, "aria-disabled": held || undefined, "aria-busy": busy || undefined, onClick: () => void press(), children: wait > 0 ? (call.waitLabel?.(wait) ?? `Again in ${wait}s`) : label }) }));
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* A change that needs no form: the question first ("Remove ram@example.com?"), then the call, its answer written into
|
|
255
|
+
* the card's data. `busy` marks the item while it runs. A refusal is said in a toast, in the server's words.
|
|
256
|
+
*/
|
|
257
|
+
export async function runRecordConfirm(confirm, host, busy) {
|
|
258
|
+
const asked = host.context();
|
|
259
|
+
const resolve = (value) => (typeof value === "function" ? value(asked) : value);
|
|
260
|
+
const tone = confirm.tone ?? "danger";
|
|
261
|
+
const sure = await messageBox.confirm({ title: resolve(confirm.title), message: resolve(confirm.message), tone, confirmLabel: confirm.confirmLabel ?? (tone === "danger" ? "Remove" : "OK") });
|
|
262
|
+
if (!sure)
|
|
263
|
+
return;
|
|
264
|
+
busy(true);
|
|
265
|
+
try {
|
|
266
|
+
const now = host.context();
|
|
267
|
+
const answer = await sendRecordCall(confirm.submit, confirm.transform ? confirm.transform(now) : (now.item ?? now.data));
|
|
268
|
+
if (answer.ok) {
|
|
269
|
+
await host.write({ writes: confirm.writes, result: answer.result, values: undefined, fallback: "reload" });
|
|
270
|
+
const after = host.context();
|
|
271
|
+
const message = typeof confirm.successMessage === "function" ? confirm.successMessage(after) : confirm.successMessage;
|
|
272
|
+
if (message !== false)
|
|
273
|
+
toast.success(message ?? answer.message ?? (tone === "danger" ? "Removed" : "Done"));
|
|
274
|
+
confirm.done?.({ ...after, result: answer.result });
|
|
275
|
+
}
|
|
276
|
+
else if (!host.ended(answer))
|
|
277
|
+
toast.error(answer.message ?? "It could not be done just now. Try again in a moment.");
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
busy(false);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
2
|
import type { AnyFormStore, FormStore } from "../../Helpers/FormStore.ts";
|
|
3
|
-
import type { FormStatusLineMode, FormValues, SubmitFormProps } from "../../Interfaces/FormInterfaces.ts";
|
|
3
|
+
import type { FormStatusLineMode, FormSubmitTone, FormValues, SubmitFormProps } from "../../Interfaces/FormInterfaces.ts";
|
|
4
4
|
import type { Size } from "../../Interfaces/TypeInterfaces.ts";
|
|
5
5
|
export interface FormButtonsProps<TValues extends FormValues = FormValues> {
|
|
6
6
|
/** The form's store: the one given to the form as `form`. */
|
|
@@ -9,6 +9,11 @@ export interface FormButtonsProps<TValues extends FormValues = FormValues> {
|
|
|
9
9
|
submitLabel?: ReactNode;
|
|
10
10
|
/** Default: a floppy disk. `null` shows none. */
|
|
11
11
|
submitIcon?: ReactNode | null;
|
|
12
|
+
/**
|
|
13
|
+
* The submit button's fill: `primary` (default), or `danger` for a form whose save removes something - "Remove",
|
|
14
|
+
* "Turn two-step off" - filled with the danger colour and its light text, as every solid danger fill is.
|
|
15
|
+
*/
|
|
16
|
+
submitTone?: FormSubmitTone;
|
|
12
17
|
/** Shows a Cancel button. */
|
|
13
18
|
onCancel?: () => void;
|
|
14
19
|
/** Cancel asks before it drops changed values. Default true. */
|
|
@@ -40,6 +45,36 @@ export interface FormButtonsProps<TValues extends FormValues = FormValues> {
|
|
|
40
45
|
* Asks "Discard your changes?" when the form has changes. Resolves `true` to go on (nothing changed, or the user chose
|
|
41
46
|
* Discard), `false` to keep editing. Cancel buttons and popups that close a form ask it.
|
|
42
47
|
*/
|
|
48
|
+
/**
|
|
49
|
+
* Asks before a navigation the app makes itself takes forms away: "Discard your changes?" while one of them has
|
|
50
|
+
* changes, the question a link inside the app asks. Answers whether to go on.
|
|
51
|
+
*
|
|
52
|
+
* - Default, a move inside the app (`router.push` from a menu pick): the page's own forms are asked about; a form drawn in
|
|
53
|
+
* a `PopupHost` popup stays open through such a move and is not.
|
|
54
|
+
* - `unload: true`, a move that loads a whole page (a sign-out, `location.assign`): every form is asked about, the
|
|
55
|
+
* popups' too, since the load takes them all; once it is confirmed nothing asks again - neither this nor the browser's
|
|
56
|
+
* close question - until the page goes, however long the call before the move takes. A move that then does not happen
|
|
57
|
+
* (the call failed) gives the questions back with `endPageLeave()`.
|
|
58
|
+
*
|
|
59
|
+
* @example if (await confirmPageLeave()) router.push(href);
|
|
60
|
+
* @example
|
|
61
|
+
* if (!(await confirmPageLeave({ unload: true }))) return;
|
|
62
|
+
* const answer = await api.post(SIGN_OUT);
|
|
63
|
+
* if (answer.ok) window.location.assign("/");
|
|
64
|
+
* else endPageLeave();
|
|
65
|
+
*/
|
|
66
|
+
export declare function confirmPageLeave(options?: {
|
|
67
|
+
unload?: boolean;
|
|
68
|
+
}): Promise<boolean>;
|
|
69
|
+
/** Gives the leave questions back after `confirmPageLeave({ unload: true })` when the page did not go after all. */
|
|
70
|
+
export declare function endPageLeave(): void;
|
|
71
|
+
/**
|
|
72
|
+
* Whether a move to `href` leaves nothing of this page behind in this tab, so no form is lost and nothing is asked: one
|
|
73
|
+
* for another tab or a download, one to another site (the browser's close question asks there), one to this page
|
|
74
|
+
* itself or a place on it. The same test for a link and for a menu row the app moves by `router.push`.
|
|
75
|
+
*/
|
|
76
|
+
export declare function keepsPage(href: string, target?: string | null, download?: boolean): boolean;
|
|
77
|
+
/** Asks "Discard your changes?" when the form holds changes worth keeping (`isWorthKeeping`): true to go on. */
|
|
43
78
|
export declare function confirmDiscard(form: AnyFormStore): Promise<boolean>;
|
|
44
79
|
/**
|
|
45
80
|
* A form's bar: its status line (loading, saving, what needs attention, unsaved changes, saved), Reset, Cancel, and Save.
|
|
@@ -52,7 +87,7 @@ export declare function confirmDiscard(form: AnyFormStore): Promise<boolean>;
|
|
|
52
87
|
* <ApiForm form={form} buttons={false} inputs={inputs} api={api} />
|
|
53
88
|
* </Popup>
|
|
54
89
|
*/
|
|
55
|
-
export declare function FormButtons<TValues extends FormValues = FormValues>({ form, submitLabel, submitIcon, onCancel, confirmCancel, cancelLabel, resettable, resetLabel, align, size, readOnly, statusLine, block, loadingLabel, className, }: FormButtonsProps<TValues>): import("react").JSX.Element;
|
|
90
|
+
export declare function FormButtons<TValues extends FormValues = FormValues>({ form, submitLabel, submitIcon, submitTone, onCancel, confirmCancel, cancelLabel, resettable, resetLabel, align, size, readOnly, statusLine, block, loadingLabel, className, }: FormButtonsProps<TValues>): import("react").JSX.Element;
|
|
56
91
|
/**
|
|
57
92
|
* A `Form` that submits: every value is checked on Save (the inputs' own rules, the form's `validate`, rows not on the
|
|
58
93
|
* page), each message shows, and the first problem takes focus; then `onSubmit` gets the values, and while it runs Save
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { iconAlertCircle, iconCircleCheck, iconDeviceFloppy, iconLoader2, iconRefresh, InlineIcon } from "nexus-icons";
|
|
4
|
-
import { useEffect, useId, useLayoutEffect, useRef } from "react";
|
|
4
|
+
import { useContext, useEffect, useId, useLayoutEffect, useRef } from "react";
|
|
5
5
|
import { errorMessage, isAbortError } from "../../Helpers/ApiResponses.js";
|
|
6
6
|
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
7
7
|
import { useDatePreferences } from "../../Helpers/DatePreferences.js";
|
|
8
|
+
import { formConfirmAsks, formConfirmQuestion } from "../../Helpers/FormConfirm.js";
|
|
9
|
+
import { FORM_SAVED_EVENT } from "../../Helpers/InputMemory.js";
|
|
8
10
|
import { Button } from "../Documents/Button.js";
|
|
9
11
|
import { messageBox } from "../Layouts/MessageBox.js";
|
|
12
|
+
import { askFormQuestion } from "./FormQuestion.js";
|
|
10
13
|
import { Form, formatFormTime, formInputLabel, namesToShow, useFormStatus, useFormStore } from "./Form.js";
|
|
14
|
+
import { FormLeaveGuardContext, pageLeaveGuard, PageLeaveGuardContext, unloadLeaveGuard } from "./FormLeaveGuard.js";
|
|
11
15
|
/** The problems after a submit, each a button that goes to its input: "2 fields need attention: Email, Phone." */
|
|
12
16
|
function ProblemList({ form, names }) {
|
|
13
17
|
const { shown, more } = namesToShow(names);
|
|
@@ -50,8 +54,116 @@ function FormStatusLine({ form, status, mode }) {
|
|
|
50
54
|
* Asks "Discard your changes?" when the form has changes. Resolves `true` to go on (nothing changed, or the user chose
|
|
51
55
|
* Discard), `false` to keep editing. Cancel buttons and popups that close a form ask it.
|
|
52
56
|
*/
|
|
57
|
+
/**
|
|
58
|
+
* Asks before a navigation the app makes itself takes forms away: "Discard your changes?" while one of them has
|
|
59
|
+
* changes, the question a link inside the app asks. Answers whether to go on.
|
|
60
|
+
*
|
|
61
|
+
* - Default, a move inside the app (`router.push` from a menu pick): the page's own forms are asked about; a form drawn in
|
|
62
|
+
* a `PopupHost` popup stays open through such a move and is not.
|
|
63
|
+
* - `unload: true`, a move that loads a whole page (a sign-out, `location.assign`): every form is asked about, the
|
|
64
|
+
* popups' too, since the load takes them all; once it is confirmed nothing asks again - neither this nor the browser's
|
|
65
|
+
* close question - until the page goes, however long the call before the move takes. A move that then does not happen
|
|
66
|
+
* (the call failed) gives the questions back with `endPageLeave()`.
|
|
67
|
+
*
|
|
68
|
+
* @example if (await confirmPageLeave()) router.push(href);
|
|
69
|
+
* @example
|
|
70
|
+
* if (!(await confirmPageLeave({ unload: true }))) return;
|
|
71
|
+
* const answer = await api.post(SIGN_OUT);
|
|
72
|
+
* if (answer.ok) window.location.assign("/");
|
|
73
|
+
* else endPageLeave();
|
|
74
|
+
*/
|
|
75
|
+
export async function confirmPageLeave(options = {}) {
|
|
76
|
+
if (options.unload) {
|
|
77
|
+
if (pageLeaveGuard.unloadAllowed)
|
|
78
|
+
return true;
|
|
79
|
+
const form = unloadLeaveGuard.changed();
|
|
80
|
+
if (form && !(await confirmDiscard(form)))
|
|
81
|
+
return false;
|
|
82
|
+
pageLeaveGuard.allowUnload();
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
if (pageLeaveGuard.allowed)
|
|
86
|
+
return true;
|
|
87
|
+
const form = pageLeaveGuard.changed();
|
|
88
|
+
if (!form)
|
|
89
|
+
return true;
|
|
90
|
+
const go = await confirmDiscard(form);
|
|
91
|
+
if (go)
|
|
92
|
+
pageLeaveGuard.allow();
|
|
93
|
+
return go;
|
|
94
|
+
}
|
|
95
|
+
/** Gives the leave questions back after `confirmPageLeave({ unload: true })` when the page did not go after all. */
|
|
96
|
+
export function endPageLeave() {
|
|
97
|
+
pageLeaveGuard.end();
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Whether a move to `href` leaves nothing of this page behind in this tab, so no form is lost and nothing is asked: one
|
|
101
|
+
* for another tab or a download, one to another site (the browser's close question asks there), one to this page
|
|
102
|
+
* itself or a place on it. The same test for a link and for a menu row the app moves by `router.push`.
|
|
103
|
+
*/
|
|
104
|
+
export function keepsPage(href, target, download = false) {
|
|
105
|
+
if (typeof window === "undefined")
|
|
106
|
+
return true;
|
|
107
|
+
if ((target && target !== "_self") || download)
|
|
108
|
+
return true;
|
|
109
|
+
const to = new URL(href, window.location.href);
|
|
110
|
+
if (to.origin !== window.location.origin)
|
|
111
|
+
return true;
|
|
112
|
+
return to.pathname === window.location.pathname && to.search === window.location.search;
|
|
113
|
+
}
|
|
114
|
+
let linksGuarded = false;
|
|
115
|
+
/**
|
|
116
|
+
* Every link inside the app asks before it takes the page's own forms away: a press on one - next/link or a plain `<a>`,
|
|
117
|
+
* a menu's, the brand's, the side menu's - is held first (in the capture phase, before any other handler) while a
|
|
118
|
+
* form has changes, and followed once "Discard" is chosen. Set up once, by the first form that asks before leaving.
|
|
119
|
+
*/
|
|
120
|
+
function guardLinks() {
|
|
121
|
+
if (linksGuarded || typeof window === "undefined")
|
|
122
|
+
return;
|
|
123
|
+
linksGuarded = true;
|
|
124
|
+
window.addEventListener("click", event => {
|
|
125
|
+
if (pageLeaveGuard.allowed || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
|
|
126
|
+
return;
|
|
127
|
+
const link = event.target instanceof Element ? event.target.closest("a[href]") : null;
|
|
128
|
+
if (!(link instanceof HTMLAnchorElement) || keepsPage(link.href, link.target, link.hasAttribute("download")) || !pageLeaveGuard.changed())
|
|
129
|
+
return;
|
|
130
|
+
// A link inside text being edited (a rich-text editor's) is never followed by the browser: a click there places
|
|
131
|
+
// the caret, and nothing would be left.
|
|
132
|
+
if (link.isContentEditable || link.closest('[contenteditable]:not([contenteditable="false"])'))
|
|
133
|
+
return;
|
|
134
|
+
event.preventDefault();
|
|
135
|
+
event.stopPropagation();
|
|
136
|
+
void confirmPageLeave().then(go => {
|
|
137
|
+
if (!go)
|
|
138
|
+
return;
|
|
139
|
+
// Followed as it was pressed (the app's router for its own links), or by its address if it has gone meanwhile.
|
|
140
|
+
if (link.isConnected)
|
|
141
|
+
link.click();
|
|
142
|
+
else
|
|
143
|
+
window.location.assign(link.href);
|
|
144
|
+
});
|
|
145
|
+
}, true);
|
|
146
|
+
}
|
|
147
|
+
/** Ctrl+S (or ⌘S) while a form's question is open does nothing: neither a second save nor the browser's Save page. */
|
|
148
|
+
function holdSaveKey(event) {
|
|
149
|
+
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "s")
|
|
150
|
+
event.preventDefault();
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* A form's question before it saves (`SubmitFormProps.confirm`): asked when `when` holds, with what will be sent worked
|
|
154
|
+
* out first (`makeBody`, an ApiForm's transform) and handed to every text. Resolves whether to save.
|
|
155
|
+
*/
|
|
156
|
+
async function askBeforeSaving(confirm, values, context, makeBody) {
|
|
157
|
+
if (!formConfirmAsks(confirm, values))
|
|
158
|
+
return true;
|
|
159
|
+
const body = makeBody ? await makeBody(values, context) : values;
|
|
160
|
+
if (context.signal.aborted)
|
|
161
|
+
return false;
|
|
162
|
+
return askFormQuestion(formConfirmQuestion(confirm, values, body), context.signal);
|
|
163
|
+
}
|
|
164
|
+
/** Asks "Discard your changes?" when the form holds changes worth keeping (`isWorthKeeping`): true to go on. */
|
|
53
165
|
export async function confirmDiscard(form) {
|
|
54
|
-
if (!form.
|
|
166
|
+
if (!form.isWorthKeeping())
|
|
55
167
|
return true;
|
|
56
168
|
return messageBox.confirm({
|
|
57
169
|
title: "Discard your changes?",
|
|
@@ -72,7 +184,7 @@ export async function confirmDiscard(form) {
|
|
|
72
184
|
* <ApiForm form={form} buttons={false} inputs={inputs} api={api} />
|
|
73
185
|
* </Popup>
|
|
74
186
|
*/
|
|
75
|
-
export function FormButtons({ form, submitLabel = "Save", submitIcon, onCancel, confirmCancel = true, cancelLabel = "Cancel", resettable = false, resetLabel = "Reset", align = "end", size = "md", readOnly = false, statusLine = "full", block = false, loadingLabel = "Loading…", className, }) {
|
|
187
|
+
export function FormButtons({ form, submitLabel = "Save", submitIcon, submitTone = "primary", onCancel, confirmCancel = true, cancelLabel = "Cancel", resettable = false, resetLabel = "Reset", align = "end", size = "md", readOnly = false, statusLine = "full", block = false, loadingLabel = "Loading…", className, }) {
|
|
76
188
|
const store = form;
|
|
77
189
|
const status = useFormStatus(store);
|
|
78
190
|
useEffect(() => store.addButtons(), [store]);
|
|
@@ -88,7 +200,7 @@ export function FormButtons({ form, submitLabel = "Save", submitIcon, onCancel,
|
|
|
88
200
|
const actions = [
|
|
89
201
|
resettable && !readOnly && (_jsx(Button, { size: buttonSize, variant: "ghost", icon: _jsx(InlineIcon, { icon: iconRefresh }), disabled: !status.changed || status.submitting || status.loading, onClick: () => store.reset(), children: resetLabel }, "reset")),
|
|
90
202
|
onCancel && (_jsx(Button, { size: buttonSize, onClick: () => void cancel(), children: cancelLabel }, "cancel")),
|
|
91
|
-
!readOnly && (_jsx(Button, { type: "submit", form: status.formId ?? undefined, size: buttonSize, variant: "primary", loading: status.submitting || loadingInButton, disabled: status.loading, icon: submitIcon === undefined ? _jsx(InlineIcon, { icon: iconDeviceFloppy }) : (submitIcon ?? undefined), children: loadingInButton ? loadingLabel : submitLabel }, "submit")),
|
|
203
|
+
!readOnly && (_jsx(Button, { type: "submit", form: status.formId ?? undefined, size: buttonSize, variant: submitTone === "danger" ? "danger" : "primary", loading: status.submitting || loadingInButton, disabled: status.loading, icon: submitIcon === undefined ? _jsx(InlineIcon, { icon: iconDeviceFloppy }) : (submitIcon ?? undefined), children: loadingInButton ? loadingLabel : submitLabel }, "submit")),
|
|
92
204
|
].filter(Boolean);
|
|
93
205
|
if (align === "start")
|
|
94
206
|
actions.reverse();
|
|
@@ -115,12 +227,14 @@ export function FormButtons({ form, submitLabel = "Save", submitIcon, onCancel,
|
|
|
115
227
|
* />
|
|
116
228
|
*/
|
|
117
229
|
export function SubmitForm(props) {
|
|
118
|
-
const { onSubmit: _onSubmit, onSaved: _onSaved, onCancel, confirmCancel, submitLabel, submitIcon, cancelLabel, resettable, resetLabel, resetOnSuccess: _resetOnSuccess, buttons = "end", statusLine, confirmLeave, id, className, style, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, ...formProps } = props;
|
|
230
|
+
const { onSubmit: _onSubmit, onSaved: _onSaved, onCancel, confirmCancel, submitLabel, submitIcon, submitTone, cancelLabel, resettable, resetLabel, resetOnSuccess: _resetOnSuccess, buttons = "end", statusLine, confirmLeave, confirm: _confirm, makeBody: _makeBody, id, className, style, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, ...formProps } = props;
|
|
119
231
|
const store = useFormStore(props.form);
|
|
120
232
|
const formRef = useRef(null);
|
|
121
233
|
const generatedId = useId();
|
|
122
234
|
const formId = id ?? `${generatedId}form`;
|
|
123
235
|
const controllerRef = useRef(null);
|
|
236
|
+
// True while the question before a save is open (or what it shows is being worked out): nothing else submits.
|
|
237
|
+
const confirmingRef = useRef(false);
|
|
124
238
|
const latest = useRef(props);
|
|
125
239
|
latest.current = props;
|
|
126
240
|
const locked = Boolean(props.readOnly || props.disabled);
|
|
@@ -138,11 +252,17 @@ export function SubmitForm(props) {
|
|
|
138
252
|
useEffect(() => () => controllerRef.current?.abort(), []);
|
|
139
253
|
// Closing or reloading the page with unsaved changes asks first, unless a draft keeps them.
|
|
140
254
|
const guardLeave = confirmLeave ?? !props.draftKey;
|
|
255
|
+
// Whether this form is the page's own (in the page guard) or a popup's: the close question follows it.
|
|
256
|
+
const pageGuardRef = useRef(useContext(PageLeaveGuardContext));
|
|
141
257
|
useEffect(() => {
|
|
142
258
|
if (!guardLeave)
|
|
143
259
|
return;
|
|
144
260
|
const onBeforeUnload = (event) => {
|
|
145
|
-
|
|
261
|
+
// A navigation the person already confirmed is not asked about twice: for a page form, a link inside the app or
|
|
262
|
+
// `confirmPageLeave`; for a form in a popup, which those never asked about, only a confirmed full-page move.
|
|
263
|
+
if (!store.isWorthKeeping() || locked)
|
|
264
|
+
return;
|
|
265
|
+
if (pageGuardRef.current ? pageLeaveGuard.allowed : pageLeaveGuard.unloadAllowed)
|
|
146
266
|
return;
|
|
147
267
|
event.preventDefault();
|
|
148
268
|
// Older browsers show the prompt only when this is set.
|
|
@@ -151,9 +271,33 @@ export function SubmitForm(props) {
|
|
|
151
271
|
window.addEventListener("beforeunload", onBeforeUnload);
|
|
152
272
|
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
|
153
273
|
}, [store, guardLeave, locked]);
|
|
274
|
+
// Inside a place that can go away with the form (a tab of a record's workspace), that place asks the same question.
|
|
275
|
+
const leaveGuard = useContext(FormLeaveGuardContext);
|
|
276
|
+
const lockedRef = useRef(locked);
|
|
277
|
+
lockedRef.current = locked;
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
if (!guardLeave || !leaveGuard)
|
|
280
|
+
return;
|
|
281
|
+
return leaveGuard.watch({ form: store, changed: () => !lockedRef.current && store.isWorthKeeping() });
|
|
282
|
+
}, [store, guardLeave, leaveGuard]);
|
|
283
|
+
// On the page itself, a navigation inside the app asks the same question before it takes the form away (a link,
|
|
284
|
+
// `confirmPageLeave`); a form in a `PopupHost` popup is left out, as the popup stays open through a navigation. Every
|
|
285
|
+
// such form, popups' too, is one a full-page load takes away (`confirmPageLeave({ unload: true })`).
|
|
286
|
+
const pageGuard = useContext(PageLeaveGuardContext);
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
if (!guardLeave)
|
|
289
|
+
return;
|
|
290
|
+
return unloadLeaveGuard.watch({ form: store, changed: () => !lockedRef.current && store.isWorthKeeping() });
|
|
291
|
+
}, [store, guardLeave]);
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
if (!guardLeave || !pageGuard)
|
|
294
|
+
return;
|
|
295
|
+
guardLinks();
|
|
296
|
+
return pageGuard.watch({ form: store, changed: () => !lockedRef.current && store.isWorthKeeping() });
|
|
297
|
+
}, [store, guardLeave, pageGuard]);
|
|
154
298
|
async function submit() {
|
|
155
299
|
const status = store.getStatus();
|
|
156
|
-
if (status.submitting || status.loading || locked)
|
|
300
|
+
if (confirmingRef.current || status.submitting || status.loading || locked)
|
|
157
301
|
return;
|
|
158
302
|
if (!store.validate())
|
|
159
303
|
return;
|
|
@@ -161,6 +305,32 @@ export function SubmitForm(props) {
|
|
|
161
305
|
controllerRef.current = controller;
|
|
162
306
|
const values = store.getValue();
|
|
163
307
|
const context = { changes: store.getChanges(), initial: store.getInitial(), form: store, signal: controller.signal };
|
|
308
|
+
const { confirm, makeBody } = latest.current;
|
|
309
|
+
if (confirm) {
|
|
310
|
+
// The question comes after the checks and before "Saving…": while it is open nothing else submits, and Back, Esc
|
|
311
|
+
// or the close button end here with nothing sent and the form as it was.
|
|
312
|
+
confirmingRef.current = true;
|
|
313
|
+
window.addEventListener("keydown", holdSaveKey, true);
|
|
314
|
+
let go;
|
|
315
|
+
try {
|
|
316
|
+
go = await askBeforeSaving(confirm, values, context, makeBody);
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
// A transform or a rule of the question that throws shows under the form, as a failed save does, and asks nothing.
|
|
320
|
+
go = false;
|
|
321
|
+
if (!controller.signal.aborted && !isAbortError(error))
|
|
322
|
+
store.setErrors({}, errorMessage(error));
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
confirmingRef.current = false;
|
|
326
|
+
window.removeEventListener("keydown", holdSaveKey, true);
|
|
327
|
+
}
|
|
328
|
+
if (!go || controller.signal.aborted) {
|
|
329
|
+
if (controllerRef.current === controller)
|
|
330
|
+
controllerRef.current = null;
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
164
334
|
store.beginSubmit();
|
|
165
335
|
let result;
|
|
166
336
|
try {
|
|
@@ -183,7 +353,9 @@ export function SubmitForm(props) {
|
|
|
183
353
|
store.focus(first);
|
|
184
354
|
return;
|
|
185
355
|
}
|
|
186
|
-
// Saved.
|
|
356
|
+
// Saved. Remembered inputs keep what was saved, before a reset opens them on it; so do rows' (the last row added).
|
|
357
|
+
store.rememberSaved(values);
|
|
358
|
+
formRef.current?.dispatchEvent(new Event(FORM_SAVED_EVENT));
|
|
187
359
|
if (latest.current.resetOnSuccess) {
|
|
188
360
|
store.reset();
|
|
189
361
|
store.markSaved();
|
|
@@ -202,8 +374,8 @@ export function SubmitForm(props) {
|
|
|
202
374
|
formRef.current?.requestSubmit();
|
|
203
375
|
}
|
|
204
376
|
}
|
|
205
|
-
return (_jsxs("form", { ref: formRef, id: formId, className: cx("nx-submit-form", className), style: style, noValidate: true, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, onSubmit: event => {
|
|
377
|
+
return (_jsxs("form", { ref: formRef, id: formId, className: cx("nx-submit-form", className), style: style, noValidate: true, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, onSubmit: event => {
|
|
206
378
|
event.preventDefault();
|
|
207
379
|
void submit();
|
|
208
|
-
}, onKeyDown: onKeyDown, children: [_jsx(Form, { ...formProps, form: store }), buttons !== false && (_jsx(FormButtons, { form: store, align: buttons, submitLabel: submitLabel, submitIcon: submitIcon, onCancel: onCancel, confirmCancel: confirmCancel, cancelLabel: cancelLabel, resettable: resettable, resetLabel: resetLabel, size: formProps.size, readOnly: locked, statusLine: statusLine }))] }));
|
|
380
|
+
}, onKeyDown: onKeyDown, children: [_jsx(Form, { ...formProps, form: store }), buttons !== false && (_jsx(FormButtons, { form: store, align: buttons, submitLabel: submitLabel, submitIcon: submitIcon, submitTone: submitTone, onCancel: onCancel, confirmCancel: confirmCancel, cancelLabel: cancelLabel, resettable: resettable, resetLabel: resetLabel, size: formProps.size, readOnly: locked, statusLine: statusLine }))] }));
|
|
209
381
|
}
|
|
@@ -83,5 +83,5 @@ function Toggle(props) {
|
|
|
83
83
|
// With a description, the name comes from the label text alone; the description is read as a description.
|
|
84
84
|
const labelId = descriptionId && hasContent(label) && !inputProps["aria-label"] ? `${field.fieldId}-label` : undefined;
|
|
85
85
|
const describedBy = [inputProps["aria-describedby"], descriptionId, field.error && field.messageId].filter(Boolean).join(" ") || undefined;
|
|
86
|
-
return (_jsxs("div", { ref: field.containerRef, className: cx("nx-choice-field", className), style: style, "data-invalid": field.error ? true : undefined, "data-readonly": readOnly || undefined, onBlur: handleBlur, children: [_jsxs("label", { className: cx("nx-choice", `nx-choice--${size}`, labelPosition === "start" && "nx-choice--start"), children: [_jsx("input", { ...inputProps, [OPTION_ATTRIBUTE]: 0, ref: setRef, id: field.fieldId, type: kind === "radio" ? "radio" : "checkbox", role: kind === "switch" ? "switch" : undefined, className: cx("nx-choice__control", `nx-choice__control--${kind}`, inputClassName), checked: checked, disabled: disabled, "aria-invalid": field.error ? true : undefined, "aria-required": required || undefined, "aria-readonly": readOnly || undefined, "aria-labelledby": inputProps["aria-labelledby"] ?? labelId, "aria-describedby": describedBy, onClick: handleClick, onKeyDown: handleKeyDown, onChange: handleChange, onInvalid: field.onInvalid }), (hasContent(label) || descriptionId) && (_jsxs("span", { className: "nx-choice__text", children: [hasContent(label) && (_jsxs("span", { id: labelId, className: "nx-choice__label", children: [label, readOnlyNotice.lock] })), descriptionId && (_jsx("span", { id: descriptionId, className: "nx-choice__description", children: description }))] }))] }), _jsx(GroupFooter, { id: field.messageId, error: field.error }), readOnlyNotice.notice] }));
|
|
86
|
+
return (_jsxs("div", { ref: field.containerRef, className: cx("nx-choice-field", className), style: style, "data-invalid": field.error ? true : undefined, "data-readonly": readOnly || undefined, onBlur: handleBlur, children: [_jsxs("label", { className: cx("nx-choice", `nx-choice--${size}`, labelPosition === "start" && "nx-choice--start"), title: inputProps.title, children: [_jsx("input", { ...inputProps, [OPTION_ATTRIBUTE]: 0, ref: setRef, id: field.fieldId, type: kind === "radio" ? "radio" : "checkbox", role: kind === "switch" ? "switch" : undefined, className: cx("nx-choice__control", `nx-choice__control--${kind}`, inputClassName), checked: checked, disabled: disabled, "aria-invalid": field.error ? true : undefined, "aria-required": required || undefined, "aria-readonly": readOnly || undefined, "aria-labelledby": inputProps["aria-labelledby"] ?? labelId, "aria-describedby": describedBy, onClick: handleClick, onKeyDown: handleKeyDown, onChange: handleChange, onInvalid: field.onInvalid }), (hasContent(label) || descriptionId) && (_jsxs("span", { className: "nx-choice__text", children: [hasContent(label) && (_jsxs("span", { id: labelId, className: "nx-choice__label", children: [label, readOnlyNotice.lock] })), descriptionId && (_jsx("span", { id: descriptionId, className: "nx-choice__description", children: description }))] }))] }), _jsx(GroupFooter, { id: field.messageId, error: field.error }), readOnlyNotice.notice] }));
|
|
87
87
|
}
|