@multiplatform.one/frappe-ui 7.28.1 → 7.29.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/dist/cjs/FrappeCalendar.cjs +3 -2
- package/dist/cjs/FrappeCalendar.native.js +3 -2
- package/dist/cjs/FrappeCalendar.native.js.map +1 -1
- package/dist/cjs/FrappeForm.cjs +148 -464
- package/dist/cjs/FrappeForm.native.js +152 -633
- package/dist/cjs/FrappeForm.native.js.map +1 -1
- package/dist/cjs/FrappeListView.cjs +5 -27
- package/dist/cjs/FrappeListView.native.js +11 -32
- package/dist/cjs/FrappeListView.native.js.map +1 -1
- package/dist/cjs/FrappeReportBuilder.cjs +523 -309
- package/dist/cjs/FrappeReportBuilder.native.js +658 -353
- package/dist/cjs/FrappeReportBuilder.native.js.map +1 -1
- package/dist/cjs/FrappeReportView.cjs +35 -234
- package/dist/cjs/FrappeReportView.native.js +39 -283
- package/dist/cjs/FrappeReportView.native.js.map +1 -1
- package/dist/cjs/dependsOn.cjs +2 -2
- package/dist/cjs/dependsOn.native.js +1 -2
- package/dist/cjs/dependsOn.native.js.map +1 -1
- package/dist/cjs/fields/shared/EditorPreviewPanes.cjs +2 -2
- package/dist/cjs/fields/shared/EditorPreviewPanes.native.js +2 -2
- package/dist/cjs/fields/shared/EditorPreviewPanes.native.js.map +1 -1
- package/dist/cjs/formEngine/docPermissions.cjs +20 -0
- package/dist/cjs/formEngine/docPermissions.native.js +69 -0
- package/dist/cjs/formEngine/docPermissions.native.js.map +1 -1
- package/dist/cjs/formEngine/index.cjs +2 -0
- package/dist/cjs/formEngine/index.native.js +2 -0
- package/dist/cjs/formEngine/index.native.js.map +1 -1
- package/dist/cjs/index.cjs +67 -7
- package/dist/cjs/index.native.js +67 -7
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/liveErrors.cjs +8 -2
- package/dist/cjs/liveErrors.native.js +8 -2
- package/dist/cjs/liveErrors.native.js.map +1 -1
- package/dist/cjs/measures.cjs +31 -0
- package/dist/cjs/measures.native.js +34 -0
- package/dist/cjs/measures.native.js.map +1 -0
- package/dist/cjs/recordShape.cjs +103 -0
- package/dist/cjs/recordShape.native.js +115 -0
- package/dist/cjs/recordShape.native.js.map +1 -0
- package/dist/cjs/report/ReportGrid.cjs +453 -0
- package/dist/cjs/report/ReportGrid.native.js +510 -0
- package/dist/cjs/report/ReportGrid.native.js.map +1 -0
- package/dist/cjs/report/ReportViewToolbar.cjs +253 -0
- package/dist/cjs/report/ReportViewToolbar.native.js +303 -0
- package/dist/cjs/report/ReportViewToolbar.native.js.map +1 -0
- package/dist/cjs/report/queryReport.cjs +444 -0
- package/dist/cjs/report/queryReport.native.js +588 -0
- package/dist/cjs/report/queryReport.native.js.map +1 -0
- package/dist/cjs/report/queryReportColumns.cjs +131 -0
- package/dist/cjs/report/queryReportColumns.native.js +159 -0
- package/dist/cjs/report/queryReportColumns.native.js.map +1 -0
- package/dist/cjs/report/reportViewModel.cjs +386 -0
- package/dist/cjs/report/reportViewModel.native.js +657 -0
- package/dist/cjs/report/reportViewModel.native.js.map +1 -0
- package/dist/cjs/report/savedReports.cjs +137 -0
- package/dist/cjs/report/savedReports.native.js +169 -0
- package/dist/cjs/report/savedReports.native.js.map +1 -0
- package/dist/cjs/report/useFrappeReport.cjs +184 -0
- package/dist/cjs/report/useFrappeReport.native.js +214 -0
- package/dist/cjs/report/useFrappeReport.native.js.map +1 -0
- package/dist/cjs/report/useQueryReport.cjs +201 -0
- package/dist/cjs/report/useQueryReport.native.js +233 -0
- package/dist/cjs/report/useQueryReport.native.js.map +1 -0
- package/dist/cjs/useFrappeForm.cjs +558 -0
- package/dist/cjs/useFrappeForm.native.js +714 -0
- package/dist/cjs/useFrappeForm.native.js.map +1 -0
- package/dist/esm/FrappeCalendar.mjs +4 -3
- package/dist/esm/FrappeCalendar.mjs.map +1 -1
- package/dist/esm/FrappeCalendar.native.js +4 -3
- package/dist/esm/FrappeCalendar.native.js.map +1 -1
- package/dist/esm/FrappeForm.mjs +151 -467
- package/dist/esm/FrappeForm.mjs.map +1 -1
- package/dist/esm/FrappeForm.native.js +155 -636
- package/dist/esm/FrappeForm.native.js.map +1 -1
- package/dist/esm/FrappeListView.mjs +6 -28
- package/dist/esm/FrappeListView.mjs.map +1 -1
- package/dist/esm/FrappeListView.native.js +12 -33
- package/dist/esm/FrappeListView.native.js.map +1 -1
- package/dist/esm/FrappeReportBuilder.mjs +526 -312
- package/dist/esm/FrappeReportBuilder.mjs.map +1 -1
- package/dist/esm/FrappeReportBuilder.native.js +661 -356
- package/dist/esm/FrappeReportBuilder.native.js.map +1 -1
- package/dist/esm/FrappeReportView.mjs +30 -229
- package/dist/esm/FrappeReportView.mjs.map +1 -1
- package/dist/esm/FrappeReportView.native.js +34 -278
- package/dist/esm/FrappeReportView.native.js.map +1 -1
- package/dist/esm/dependsOn.mjs +2 -2
- package/dist/esm/dependsOn.mjs.map +1 -1
- package/dist/esm/dependsOn.native.js +1 -2
- package/dist/esm/dependsOn.native.js.map +1 -1
- package/dist/esm/fields/shared/EditorPreviewPanes.mjs +2 -2
- package/dist/esm/fields/shared/EditorPreviewPanes.mjs.map +1 -1
- package/dist/esm/fields/shared/EditorPreviewPanes.native.js +2 -2
- package/dist/esm/fields/shared/EditorPreviewPanes.native.js.map +1 -1
- package/dist/esm/formEngine/docPermissions.mjs +19 -1
- package/dist/esm/formEngine/docPermissions.mjs.map +1 -1
- package/dist/esm/formEngine/docPermissions.native.js +68 -1
- package/dist/esm/formEngine/docPermissions.native.js.map +1 -1
- package/dist/esm/formEngine/index.mjs +2 -2
- package/dist/esm/formEngine/index.mjs.map +1 -1
- package/dist/esm/formEngine/index.native.js +2 -2
- package/dist/esm/formEngine/index.native.js.map +1 -1
- package/dist/esm/index.mjs +16 -4
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +16 -4
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/liveErrors.mjs +8 -3
- package/dist/esm/liveErrors.mjs.map +1 -1
- package/dist/esm/liveErrors.native.js +8 -3
- package/dist/esm/liveErrors.native.js.map +1 -1
- package/dist/esm/measures.mjs +5 -0
- package/dist/esm/measures.mjs.map +1 -0
- package/dist/esm/measures.native.js +5 -0
- package/dist/esm/measures.native.js.map +1 -0
- package/dist/esm/recordShape.mjs +74 -0
- package/dist/esm/recordShape.mjs.map +1 -0
- package/dist/esm/recordShape.native.js +83 -0
- package/dist/esm/recordShape.native.js.map +1 -0
- package/dist/esm/report/ReportGrid.mjs +428 -0
- package/dist/esm/report/ReportGrid.mjs.map +1 -0
- package/dist/esm/report/ReportGrid.native.js +471 -0
- package/dist/esm/report/ReportGrid.native.js.map +1 -0
- package/dist/esm/report/ReportViewToolbar.mjs +228 -0
- package/dist/esm/report/ReportViewToolbar.mjs.map +1 -0
- package/dist/esm/report/ReportViewToolbar.native.js +264 -0
- package/dist/esm/report/ReportViewToolbar.native.js.map +1 -0
- package/dist/esm/report/queryReport.mjs +404 -0
- package/dist/esm/report/queryReport.mjs.map +1 -0
- package/dist/esm/report/queryReport.native.js +545 -0
- package/dist/esm/report/queryReport.native.js.map +1 -0
- package/dist/esm/report/queryReportColumns.mjs +105 -0
- package/dist/esm/report/queryReportColumns.mjs.map +1 -0
- package/dist/esm/report/queryReportColumns.native.js +119 -0
- package/dist/esm/report/queryReportColumns.native.js.map +1 -0
- package/dist/esm/report/reportViewModel.mjs +343 -0
- package/dist/esm/report/reportViewModel.mjs.map +1 -0
- package/dist/esm/report/reportViewModel.native.js +611 -0
- package/dist/esm/report/reportViewModel.native.js.map +1 -0
- package/dist/esm/report/savedReports.mjs +112 -0
- package/dist/esm/report/savedReports.mjs.map +1 -0
- package/dist/esm/report/savedReports.native.js +141 -0
- package/dist/esm/report/savedReports.native.js.map +1 -0
- package/dist/esm/report/useFrappeReport.mjs +158 -0
- package/dist/esm/report/useFrappeReport.mjs.map +1 -0
- package/dist/esm/report/useFrappeReport.native.js +185 -0
- package/dist/esm/report/useFrappeReport.native.js.map +1 -0
- package/dist/esm/report/useQueryReport.mjs +176 -0
- package/dist/esm/report/useQueryReport.mjs.map +1 -0
- package/dist/esm/report/useQueryReport.native.js +205 -0
- package/dist/esm/report/useQueryReport.native.js.map +1 -0
- package/dist/esm/useFrappeForm.mjs +533 -0
- package/dist/esm/useFrappeForm.mjs.map +1 -0
- package/dist/esm/useFrappeForm.native.js +686 -0
- package/dist/esm/useFrappeForm.native.js.map +1 -0
- package/dist/jsx/FrappeCalendar.mjs +4 -3
- package/dist/jsx/FrappeCalendar.mjs.map +1 -1
- package/dist/jsx/FrappeCalendar.native.js +3 -2
- package/dist/jsx/FrappeCalendar.native.js.map +1 -1
- package/dist/jsx/FrappeForm.mjs +151 -467
- package/dist/jsx/FrappeForm.mjs.map +1 -1
- package/dist/jsx/FrappeForm.native.js +152 -633
- package/dist/jsx/FrappeForm.native.js.map +1 -1
- package/dist/jsx/FrappeListView.mjs +6 -28
- package/dist/jsx/FrappeListView.mjs.map +1 -1
- package/dist/jsx/FrappeListView.native.js +11 -32
- package/dist/jsx/FrappeListView.native.js.map +1 -1
- package/dist/jsx/FrappeReportBuilder.mjs +526 -312
- package/dist/jsx/FrappeReportBuilder.mjs.map +1 -1
- package/dist/jsx/FrappeReportBuilder.native.js +658 -353
- package/dist/jsx/FrappeReportBuilder.native.js.map +1 -1
- package/dist/jsx/FrappeReportView.mjs +30 -229
- package/dist/jsx/FrappeReportView.mjs.map +1 -1
- package/dist/jsx/FrappeReportView.native.js +39 -283
- package/dist/jsx/FrappeReportView.native.js.map +1 -1
- package/dist/jsx/dependsOn.mjs +2 -2
- package/dist/jsx/dependsOn.mjs.map +1 -1
- package/dist/jsx/dependsOn.native.js +1 -2
- package/dist/jsx/dependsOn.native.js.map +1 -1
- package/dist/jsx/fields/shared/EditorPreviewPanes.mjs +2 -2
- package/dist/jsx/fields/shared/EditorPreviewPanes.mjs.map +1 -1
- package/dist/jsx/fields/shared/EditorPreviewPanes.native.js +2 -2
- package/dist/jsx/fields/shared/EditorPreviewPanes.native.js.map +1 -1
- package/dist/jsx/formEngine/docPermissions.mjs +19 -1
- package/dist/jsx/formEngine/docPermissions.mjs.map +1 -1
- package/dist/jsx/formEngine/docPermissions.native.js +69 -0
- package/dist/jsx/formEngine/docPermissions.native.js.map +1 -1
- package/dist/jsx/formEngine/index.mjs +2 -2
- package/dist/jsx/formEngine/index.mjs.map +1 -1
- package/dist/jsx/formEngine/index.native.js +2 -0
- package/dist/jsx/formEngine/index.native.js.map +1 -1
- package/dist/jsx/index.js +16 -4
- package/dist/jsx/index.js.map +1 -1
- package/dist/jsx/index.mjs +16 -4
- package/dist/jsx/index.mjs.map +1 -1
- package/dist/jsx/index.native.js +67 -7
- package/dist/jsx/index.native.js.map +1 -1
- package/dist/jsx/liveErrors.mjs +8 -3
- package/dist/jsx/liveErrors.mjs.map +1 -1
- package/dist/jsx/liveErrors.native.js +8 -2
- package/dist/jsx/liveErrors.native.js.map +1 -1
- package/dist/jsx/measures.mjs +5 -0
- package/dist/jsx/measures.mjs.map +1 -0
- package/dist/jsx/measures.native.js +34 -0
- package/dist/jsx/measures.native.js.map +1 -0
- package/dist/jsx/recordShape.mjs +74 -0
- package/dist/jsx/recordShape.mjs.map +1 -0
- package/dist/jsx/recordShape.native.js +115 -0
- package/dist/jsx/recordShape.native.js.map +1 -0
- package/dist/jsx/report/ReportGrid.mjs +428 -0
- package/dist/jsx/report/ReportGrid.mjs.map +1 -0
- package/dist/jsx/report/ReportGrid.native.js +510 -0
- package/dist/jsx/report/ReportGrid.native.js.map +1 -0
- package/dist/jsx/report/ReportViewToolbar.mjs +228 -0
- package/dist/jsx/report/ReportViewToolbar.mjs.map +1 -0
- package/dist/jsx/report/ReportViewToolbar.native.js +303 -0
- package/dist/jsx/report/ReportViewToolbar.native.js.map +1 -0
- package/dist/jsx/report/queryReport.mjs +404 -0
- package/dist/jsx/report/queryReport.mjs.map +1 -0
- package/dist/jsx/report/queryReport.native.js +588 -0
- package/dist/jsx/report/queryReport.native.js.map +1 -0
- package/dist/jsx/report/queryReportColumns.mjs +105 -0
- package/dist/jsx/report/queryReportColumns.mjs.map +1 -0
- package/dist/jsx/report/queryReportColumns.native.js +159 -0
- package/dist/jsx/report/queryReportColumns.native.js.map +1 -0
- package/dist/jsx/report/reportViewModel.mjs +343 -0
- package/dist/jsx/report/reportViewModel.mjs.map +1 -0
- package/dist/jsx/report/reportViewModel.native.js +657 -0
- package/dist/jsx/report/reportViewModel.native.js.map +1 -0
- package/dist/jsx/report/savedReports.mjs +112 -0
- package/dist/jsx/report/savedReports.mjs.map +1 -0
- package/dist/jsx/report/savedReports.native.js +169 -0
- package/dist/jsx/report/savedReports.native.js.map +1 -0
- package/dist/jsx/report/useFrappeReport.mjs +158 -0
- package/dist/jsx/report/useFrappeReport.mjs.map +1 -0
- package/dist/jsx/report/useFrappeReport.native.js +214 -0
- package/dist/jsx/report/useFrappeReport.native.js.map +1 -0
- package/dist/jsx/report/useQueryReport.mjs +176 -0
- package/dist/jsx/report/useQueryReport.mjs.map +1 -0
- package/dist/jsx/report/useQueryReport.native.js +233 -0
- package/dist/jsx/report/useQueryReport.native.js.map +1 -0
- package/dist/jsx/useFrappeForm.mjs +533 -0
- package/dist/jsx/useFrappeForm.mjs.map +1 -0
- package/dist/jsx/useFrappeForm.native.js +714 -0
- package/dist/jsx/useFrappeForm.native.js.map +1 -0
- package/package.json +16 -16
- package/src/FrappeCalendar.tsx +8 -2
- package/src/FrappeForm.stories.tsx +52 -0
- package/src/FrappeForm.tsx +176 -565
- package/src/FrappeListView.cards.stories.tsx +3 -6
- package/src/FrappeListView.tsx +15 -55
- package/src/FrappeReportBuilder.stories.tsx +34 -7
- package/src/FrappeReportBuilder.tsx +790 -380
- package/src/FrappeReportView.spec.tsx +10 -6
- package/src/FrappeReportView.stories.tsx +20 -1
- package/src/FrappeReportView.tsx +89 -415
- package/src/dependsOn.ts +4 -1
- package/src/fields/shared/EditorPreviewPanes.tsx +2 -2
- package/src/formEngine/docPermissions.spec.ts +36 -1
- package/src/formEngine/docPermissions.ts +34 -0
- package/src/formEngine/index.ts +8 -1
- package/src/frappeRemainder.designLaw.spec.ts +9 -1
- package/src/frappeRoutes.spec.tsx +9 -0
- package/src/frappeViews.designLaw.spec.ts +15 -2
- package/src/guestFallback.spec.tsx +236 -0
- package/src/index.ts +117 -16
- package/src/liveErrors.spec.ts +10 -0
- package/src/liveErrors.ts +16 -3
- package/src/measures.ts +11 -0
- package/src/recordShape.spec.ts +100 -0
- package/src/recordShape.ts +133 -0
- package/src/report/ReportGrid.spec.tsx +175 -0
- package/src/report/ReportGrid.tsx +530 -0
- package/src/report/ReportViewToolbar.spec.tsx +90 -0
- package/src/report/ReportViewToolbar.tsx +314 -0
- package/src/report/queryReport.spec.ts +483 -0
- package/src/report/queryReport.ts +722 -0
- package/src/report/queryReportColumns.tsx +123 -0
- package/src/report/reportViewModel.spec.ts +393 -0
- package/src/report/reportViewModel.ts +617 -0
- package/src/report/savedReports.ts +172 -0
- package/src/report/seam.spec.ts +216 -0
- package/src/report/useFrappeReport.ts +241 -0
- package/src/report/useQueryReport.spec.tsx +131 -0
- package/src/report/useQueryReport.ts +309 -0
- package/src/useFrappeForm.spec.tsx +209 -0
- package/src/useFrappeForm.ts +778 -0
- package/types/FrappeCalendar.d.ts.map +1 -1
- package/types/FrappeForm.d.ts +21 -1
- package/types/FrappeForm.d.ts.map +1 -1
- package/types/FrappeListView.d.ts.map +1 -1
- package/types/FrappeReportBuilder.d.ts +66 -16
- package/types/FrappeReportBuilder.d.ts.map +1 -1
- package/types/FrappeReportView.d.ts +9 -127
- package/types/FrappeReportView.d.ts.map +1 -1
- package/types/dependsOn.d.ts.map +1 -1
- package/types/formEngine/docPermissions.d.ts +14 -0
- package/types/formEngine/docPermissions.d.ts.map +1 -1
- package/types/formEngine/index.d.ts +1 -1
- package/types/formEngine/index.d.ts.map +1 -1
- package/types/index.d.ts +15 -3
- package/types/index.d.ts.map +1 -1
- package/types/liveErrors.d.ts +9 -1
- package/types/liveErrors.d.ts.map +1 -1
- package/types/measures.d.ts +3 -0
- package/types/measures.d.ts.map +1 -0
- package/types/recordShape.d.ts +45 -0
- package/types/recordShape.d.ts.map +1 -0
- package/types/report/ReportGrid.d.ts +20 -0
- package/types/report/ReportGrid.d.ts.map +1 -0
- package/types/report/ReportViewToolbar.d.ts +31 -0
- package/types/report/ReportViewToolbar.d.ts.map +1 -0
- package/types/report/queryReport.d.ts +181 -0
- package/types/report/queryReport.d.ts.map +1 -0
- package/types/report/queryReportColumns.d.ts +12 -0
- package/types/report/queryReportColumns.d.ts.map +1 -0
- package/types/report/reportViewModel.d.ts +146 -0
- package/types/report/reportViewModel.d.ts.map +1 -0
- package/types/report/savedReports.d.ts +27 -0
- package/types/report/savedReports.d.ts.map +1 -0
- package/types/report/useFrappeReport.d.ts +60 -0
- package/types/report/useFrappeReport.d.ts.map +1 -0
- package/types/report/useQueryReport.d.ts +37 -0
- package/types/report/useQueryReport.d.ts.map +1 -0
- package/types/useFrappeForm.d.ts +142 -0
- package/types/useFrappeForm.d.ts.map +1 -0
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getFrappeSyncModule,
|
|
3
|
+
useDocTypeMeta,
|
|
4
|
+
useFrappeCollection,
|
|
5
|
+
useFrappeDoctypeEvents,
|
|
6
|
+
useLiveQuery,
|
|
7
|
+
type DocTypeField,
|
|
8
|
+
type DocTypeMeta,
|
|
9
|
+
type FormSection,
|
|
10
|
+
} from "@multiplatform.one/frappe";
|
|
11
|
+
import { eq } from "@tanstack/db";
|
|
12
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
13
|
+
import { useTranslation } from "react-i18next";
|
|
14
|
+
import { useFrappeConfig } from "./FrappeProvider";
|
|
15
|
+
import { setCreatePrefill, takeCreatePrefill } from "./formEngine/createPrefill";
|
|
16
|
+
import { computeDirtyFields, isEmptyValue, isSameValue } from "./formEngine/dirtyTracking";
|
|
17
|
+
import { buildAmendValues } from "./formEngine/docLifecycle";
|
|
18
|
+
import type { DocRights, DocShareGrant } from "./formEngine/docPermissions";
|
|
19
|
+
import { applyFetchedValues, buildFetchMap } from "./formEngine/fetchFrom";
|
|
20
|
+
import { buildPermLevelMatrix } from "./formEngine/fieldPermissions";
|
|
21
|
+
import { createHttpClient, validateLink, type FrappeConnection } from "./formEngine/formActions";
|
|
22
|
+
import {
|
|
23
|
+
buildCopyValues,
|
|
24
|
+
buildCreateDefaults,
|
|
25
|
+
buildFormTabs,
|
|
26
|
+
isPromptNamed,
|
|
27
|
+
type FormTab,
|
|
28
|
+
} from "./formEngine/formLayout";
|
|
29
|
+
import { useAmendedAs } from "./formEngine/useAmendedAs";
|
|
30
|
+
import { useDocRights } from "./formEngine/useDocRights";
|
|
31
|
+
import { extractServerError } from "./formEngine/useFormEngine";
|
|
32
|
+
|
|
33
|
+
/** Where a form's DocType meta comes from when the host already loaded it. */
|
|
34
|
+
export interface FrappeFormMetaSource {
|
|
35
|
+
meta?: DocTypeMeta;
|
|
36
|
+
isLoading: boolean;
|
|
37
|
+
error?: Error | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface UseFrappeFormOptions<TDoc extends { name: string; doctype: string }> {
|
|
41
|
+
doctype: string;
|
|
42
|
+
/** The record; omitted, the form creates one. */
|
|
43
|
+
name?: string;
|
|
44
|
+
baseURL?: string;
|
|
45
|
+
auth?: FrappeConnection["auth"];
|
|
46
|
+
syncOptions?: Record<string, unknown>;
|
|
47
|
+
/** Columns the live read asks for; every field when omitted. */
|
|
48
|
+
fields?: readonly (keyof TDoc & string)[];
|
|
49
|
+
/** Replaces the meta layout; laid out by the same engine. */
|
|
50
|
+
sections?: FormSection[];
|
|
51
|
+
hideFields?: readonly string[];
|
|
52
|
+
/** Section or Tab Breaks to drop, by fieldname or label. */
|
|
53
|
+
hideSections?: readonly string[];
|
|
54
|
+
/** Roles to resolve rights and permlevels against instead of the session's. */
|
|
55
|
+
roles?: string[];
|
|
56
|
+
/** Rights the host already resolved (a desk page's docinfo); they win over the session's. */
|
|
57
|
+
rights?: DocRights;
|
|
58
|
+
/** DocShare rows the host already holds; otherwise read live when `readShares`. */
|
|
59
|
+
shares?: readonly DocShareGrant[];
|
|
60
|
+
/** Read the record's DocShare rows live (the sidebar's Shared With, share rights). */
|
|
61
|
+
readShares?: boolean;
|
|
62
|
+
/** Locks every field and action. */
|
|
63
|
+
disabled?: boolean;
|
|
64
|
+
/** A server-rendered copy of the record, shown until the live read lands. */
|
|
65
|
+
initialDoc?: Partial<TDoc>;
|
|
66
|
+
/** The host's own meta load; the form then loads none. */
|
|
67
|
+
meta?: FrappeFormMetaSource;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Where the record stands: still reading, missing, unreadable, or on screen. */
|
|
71
|
+
export type FrappeFormStatus = "loading" | "ready" | "notFound" | "error";
|
|
72
|
+
|
|
73
|
+
/** A laid-out field, for a jump-to-field list. */
|
|
74
|
+
export interface FrappeFormFieldTarget {
|
|
75
|
+
name: string;
|
|
76
|
+
label: string;
|
|
77
|
+
tabKey: string;
|
|
78
|
+
tabLabel: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A FrappeForm's state and actions, owned by whoever called `useFrappeForm`.
|
|
83
|
+
* FrappeForm renders the fields from it; a host page builds its own chrome
|
|
84
|
+
* (toolbar, confirms, banners) on the same object.
|
|
85
|
+
*/
|
|
86
|
+
export interface FrappeFormController<TDoc extends { name: string; doctype: string }> {
|
|
87
|
+
doctype: string;
|
|
88
|
+
name?: string;
|
|
89
|
+
isCreate: boolean;
|
|
90
|
+
/** The connection the form reads and writes through; undefined when no bench is configured. */
|
|
91
|
+
connection?: FrappeFormConnectionConfig;
|
|
92
|
+
meta?: DocTypeMeta;
|
|
93
|
+
metaLoading: boolean;
|
|
94
|
+
metaError?: Error | null;
|
|
95
|
+
doc: Partial<TDoc>;
|
|
96
|
+
/** The last server copy the edits are measured against. */
|
|
97
|
+
baseline?: Partial<TDoc>;
|
|
98
|
+
setValue: (field: keyof TDoc | string, value: unknown) => void;
|
|
99
|
+
errors: Record<string, string>;
|
|
100
|
+
setErrors: (errors: Record<string, string>) => void;
|
|
101
|
+
dirtyFields: string[];
|
|
102
|
+
isDirty: boolean;
|
|
103
|
+
isSubmitting: boolean;
|
|
104
|
+
docstatus: number;
|
|
105
|
+
isSubmittable: boolean;
|
|
106
|
+
rights: DocRights;
|
|
107
|
+
/** The session's roles are still loading, so `rights` withholds what they may grant. */
|
|
108
|
+
rightsPending: boolean;
|
|
109
|
+
roles?: string[];
|
|
110
|
+
/** The session user, once known. */
|
|
111
|
+
user?: string;
|
|
112
|
+
/** The record's DocShare rows: the host's, the live read, or undefined when neither. */
|
|
113
|
+
shareRows?: readonly DocShareGrant[];
|
|
114
|
+
/** Create on a new record, write on a saved one. */
|
|
115
|
+
canWrite: boolean;
|
|
116
|
+
/** Nothing on the record can change: disabled, deleted, a read-only doctype, or no write. */
|
|
117
|
+
readOnly: boolean;
|
|
118
|
+
wasDeleted: boolean;
|
|
119
|
+
/** Fields the user edited that also changed on the server. */
|
|
120
|
+
conflictFields: string[];
|
|
121
|
+
/** A save lost to a newer server copy, or fields changed on both sides. */
|
|
122
|
+
conflict: boolean;
|
|
123
|
+
status: FrappeFormStatus;
|
|
124
|
+
docError: Error | null;
|
|
125
|
+
/** A server-rendered copy stands in while the live read is out. */
|
|
126
|
+
showsSeed: boolean;
|
|
127
|
+
/** The laid-out tabs; undefined until meta is known. */
|
|
128
|
+
tabs?: FormTab[];
|
|
129
|
+
/** The doctype names records by prompt, so a new one asks for a name. */
|
|
130
|
+
promptNamed: boolean;
|
|
131
|
+
fieldTargets: FrappeFormFieldTarget[];
|
|
132
|
+
activeTab?: string;
|
|
133
|
+
setActiveTab: (tab: string | undefined) => void;
|
|
134
|
+
/** Opens the tab a field sits on; false when no tab shows it. */
|
|
135
|
+
revealField: (name: string) => boolean;
|
|
136
|
+
/** The live amendment of a cancelled record, if one exists. */
|
|
137
|
+
amendedAs?: string;
|
|
138
|
+
/** Required fields that are empty, keyed by field. */
|
|
139
|
+
validate: () => Record<string, string>;
|
|
140
|
+
/** Saves; resolves with the record's name, undefined when nothing was written. */
|
|
141
|
+
save: () => Promise<string | undefined>;
|
|
142
|
+
/** Draft to submitted, through the record's live collection. No confirm. */
|
|
143
|
+
submitDocument: () => Promise<boolean>;
|
|
144
|
+
/** Submitted to cancelled, through the record's live collection. No confirm. */
|
|
145
|
+
cancelDocument: () => Promise<boolean>;
|
|
146
|
+
/** Deletes the record. No confirm. */
|
|
147
|
+
remove: () => Promise<boolean>;
|
|
148
|
+
/** Drops the edits, conflicts and errors: back to the server copy. */
|
|
149
|
+
reset: () => void;
|
|
150
|
+
/** Stashes and returns the amended draft of a cancelled record (desk copy_doc from_amend). */
|
|
151
|
+
amend: () => Partial<TDoc> | undefined;
|
|
152
|
+
/** Stashes and returns the copyable values for a duplicate (desk copy_doc). */
|
|
153
|
+
duplicate: () => Record<string, unknown> | undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The connection a form reads and writes through. */
|
|
157
|
+
export type FrappeFormConnectionConfig = FrappeConnection;
|
|
158
|
+
|
|
159
|
+
/** A `sections` override as the flat break-delimited list the engine lays out. */
|
|
160
|
+
function sectionsToLayoutFields(sections: FormSection[]): DocTypeField[] {
|
|
161
|
+
const fields: DocTypeField[] = [];
|
|
162
|
+
sections.forEach((section, sectionIndex) => {
|
|
163
|
+
fields.push({
|
|
164
|
+
fieldname: `__section_${sectionIndex}`,
|
|
165
|
+
fieldtype: section.tab ? "Tab Break" : "Section Break",
|
|
166
|
+
label: section.label,
|
|
167
|
+
collapsible: section.collapsible ? 1 : 0,
|
|
168
|
+
});
|
|
169
|
+
section.columns.forEach((column, columnIndex) => {
|
|
170
|
+
if (columnIndex > 0) {
|
|
171
|
+
fields.push({
|
|
172
|
+
fieldname: `__column_${sectionIndex}_${columnIndex}`,
|
|
173
|
+
fieldtype: "Column Break",
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
fields.push(...column.fields);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
return fields;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isConflictError(err: unknown): boolean {
|
|
183
|
+
const e = err as { status?: number; exc_type?: string; message?: string } | undefined;
|
|
184
|
+
if (!e) return false;
|
|
185
|
+
if (e.status === 409) return true;
|
|
186
|
+
if (e.exc_type === "TimestampMismatchError") return true;
|
|
187
|
+
return /TimestampMismatch|has been modified/i.test(e.message ?? "");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The state behind a FrappeForm: meta, the live record, edits measured
|
|
192
|
+
* against the server copy, rights, the desk layout (`buildFormTabs`), and
|
|
193
|
+
* save, submit, cancel, amend and delete. `<FrappeForm form={controller} />`
|
|
194
|
+
* renders the fields from it, so a page that wants its own chrome keeps one
|
|
195
|
+
* engine with FrappeForm.
|
|
196
|
+
*/
|
|
197
|
+
export function useFrappeForm<TDoc extends { name: string; doctype: string }>(
|
|
198
|
+
options: UseFrappeFormOptions<TDoc>,
|
|
199
|
+
): FrappeFormController<TDoc> {
|
|
200
|
+
const {
|
|
201
|
+
doctype,
|
|
202
|
+
name,
|
|
203
|
+
baseURL,
|
|
204
|
+
auth,
|
|
205
|
+
syncOptions,
|
|
206
|
+
fields,
|
|
207
|
+
sections,
|
|
208
|
+
hideFields,
|
|
209
|
+
hideSections,
|
|
210
|
+
roles,
|
|
211
|
+
rights: rightsOverride,
|
|
212
|
+
shares: sharesOverride,
|
|
213
|
+
readShares = true,
|
|
214
|
+
disabled = false,
|
|
215
|
+
initialDoc,
|
|
216
|
+
meta: metaSource,
|
|
217
|
+
} = options;
|
|
218
|
+
const config = useFrappeConfig();
|
|
219
|
+
const { t } = useTranslation();
|
|
220
|
+
const isCreate = !name;
|
|
221
|
+
const seedDoc =
|
|
222
|
+
name && initialDoc && (initialDoc as { name?: string }).name === name ? initialDoc : undefined;
|
|
223
|
+
const seedRef = useRef(seedDoc);
|
|
224
|
+
seedRef.current = seedDoc;
|
|
225
|
+
const [localDoc, setLocalDoc] = useState<Partial<TDoc>>(() => seedDoc ?? {});
|
|
226
|
+
const [baselineDoc, setBaselineDoc] = useState<Partial<TDoc> | undefined>(() => seedDoc);
|
|
227
|
+
const [docError, setDocError] = useState<Error | null>(null);
|
|
228
|
+
const [conflictFields, setConflictFields] = useState<string[]>([]);
|
|
229
|
+
const [saveConflict, setSaveConflict] = useState(false);
|
|
230
|
+
const [wasDeleted, setWasDeleted] = useState(false);
|
|
231
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
232
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
233
|
+
const [activeTab, setActiveTab] = useState<string | undefined>(undefined);
|
|
234
|
+
const everLoadedRef = useRef(false);
|
|
235
|
+
const localDocRef = useRef(localDoc);
|
|
236
|
+
localDocRef.current = localDoc;
|
|
237
|
+
const baselineRef = useRef(baselineDoc);
|
|
238
|
+
baselineRef.current = baselineDoc;
|
|
239
|
+
// Taken once per create session: effects re-run (StrictMode) must not
|
|
240
|
+
// lose a draft the one-shot stash already handed over.
|
|
241
|
+
const createDraftRef = useRef<{ doctype: string; values?: Record<string, unknown> }>(undefined);
|
|
242
|
+
const createSeededRef = useRef<string | undefined>(undefined);
|
|
243
|
+
|
|
244
|
+
const finalBaseURL = baseURL ?? config?.baseURL;
|
|
245
|
+
const finalAuth = auth ?? config?.auth;
|
|
246
|
+
const finalSyncOptions = syncOptions ?? config?.syncOptions;
|
|
247
|
+
|
|
248
|
+
const frappeConfig = useMemo<FrappeFormConnectionConfig | undefined>(
|
|
249
|
+
() =>
|
|
250
|
+
finalBaseURL
|
|
251
|
+
? {
|
|
252
|
+
baseURL: finalBaseURL,
|
|
253
|
+
socketPort: config?.socketPort,
|
|
254
|
+
siteName: config?.siteName,
|
|
255
|
+
socketBaseURL: config?.socketBaseURL,
|
|
256
|
+
auth: finalAuth,
|
|
257
|
+
syncOptions: finalSyncOptions,
|
|
258
|
+
realtime: config?.realtime,
|
|
259
|
+
fixtures: config?.fixtures,
|
|
260
|
+
}
|
|
261
|
+
: undefined,
|
|
262
|
+
[
|
|
263
|
+
finalBaseURL,
|
|
264
|
+
config?.socketPort,
|
|
265
|
+
config?.siteName,
|
|
266
|
+
config?.socketBaseURL,
|
|
267
|
+
config?.realtime,
|
|
268
|
+
finalAuth,
|
|
269
|
+
finalSyncOptions,
|
|
270
|
+
config?.fixtures,
|
|
271
|
+
],
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const ownMeta = useDocTypeMeta(metaSource ? undefined : frappeConfig, doctype);
|
|
275
|
+
const meta = metaSource ? metaSource.meta : ownMeta.meta;
|
|
276
|
+
const isMetaLoading = metaSource ? metaSource.isLoading : ownMeta.isLoading;
|
|
277
|
+
const metaError = metaSource ? metaSource.error : ownMeta.error;
|
|
278
|
+
|
|
279
|
+
const isSubmittable = !!meta?.is_submittable;
|
|
280
|
+
const docstatus = Number((baselineDoc as Record<string, unknown> | undefined)?.docstatus ?? 0);
|
|
281
|
+
const layoutFields = useMemo(() => {
|
|
282
|
+
if (!meta) return undefined;
|
|
283
|
+
return sections ? sectionsToLayoutFields(sections) : meta.fields;
|
|
284
|
+
}, [meta, sections]);
|
|
285
|
+
const amendedAs = useAmendedAs(
|
|
286
|
+
isSubmittable && docstatus === 2 ? frappeConfig : undefined,
|
|
287
|
+
doctype,
|
|
288
|
+
name,
|
|
289
|
+
);
|
|
290
|
+
const promptNamed = isCreate && !!meta && isPromptNamed(meta.autoname, meta.naming_rule);
|
|
291
|
+
const fetchMap = useMemo(() => (meta ? buildFetchMap(meta.fields) : undefined), [meta]);
|
|
292
|
+
|
|
293
|
+
const handleDocSyncError = useCallback((err: unknown) => {
|
|
294
|
+
setDocError(err instanceof Error ? err : new Error(String(err)));
|
|
295
|
+
}, []);
|
|
296
|
+
const docCollection = useFrappeCollection<TDoc>(name ? frappeConfig : undefined, doctype, {
|
|
297
|
+
fields,
|
|
298
|
+
filters: name ? { name } : undefined,
|
|
299
|
+
onSyncError: handleDocSyncError,
|
|
300
|
+
});
|
|
301
|
+
const { data: rawDocData, isLoading: isDocLoading } = useLiveQuery(
|
|
302
|
+
(q) => {
|
|
303
|
+
if (!docCollection || !name) return null;
|
|
304
|
+
return q.from({ _doc: docCollection }).where(({ _doc }) => {
|
|
305
|
+
if (!_doc) return eq(name, name);
|
|
306
|
+
return eq(_doc.name, name);
|
|
307
|
+
});
|
|
308
|
+
},
|
|
309
|
+
[docCollection, name],
|
|
310
|
+
);
|
|
311
|
+
const liveDoc = useMemo(() => {
|
|
312
|
+
const arr = Array.isArray(rawDocData) ? rawDocData : rawDocData ? [rawDocData] : [];
|
|
313
|
+
const doc = (arr[0] ?? null) as TDoc | null;
|
|
314
|
+
return doc
|
|
315
|
+
? { data: doc, isLoading: isDocLoading }
|
|
316
|
+
: name
|
|
317
|
+
? { data: null, isLoading: isDocLoading }
|
|
318
|
+
: null;
|
|
319
|
+
}, [rawDocData, isDocLoading, name]);
|
|
320
|
+
|
|
321
|
+
// Create-mode collection (insert path). Edit-mode saves/deletes go through
|
|
322
|
+
// docCollection above, the name-filtered subscription that owns the doc.
|
|
323
|
+
const collection = useFrappeCollection<TDoc>(name ? undefined : frappeConfig, doctype, {
|
|
324
|
+
fields,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const shareCollection = useFrappeCollection<DocShareGrant & { name: string; doctype: string }>(
|
|
328
|
+
readShares && !sharesOverride && name ? frappeConfig : undefined,
|
|
329
|
+
"DocShare",
|
|
330
|
+
{
|
|
331
|
+
fields: ["name", "user", "everyone", "read", "write", "share"],
|
|
332
|
+
filters: { share_doctype: doctype, share_name: name },
|
|
333
|
+
},
|
|
334
|
+
);
|
|
335
|
+
const { data: rawShareRows } = useLiveQuery(
|
|
336
|
+
(q) => (shareCollection ? q.from({ _share: shareCollection }) : null),
|
|
337
|
+
[shareCollection],
|
|
338
|
+
);
|
|
339
|
+
const shareRows = useMemo(
|
|
340
|
+
() =>
|
|
341
|
+
sharesOverride ??
|
|
342
|
+
(shareCollection ? ((rawShareRows as DocShareGrant[] | undefined) ?? []) : undefined),
|
|
343
|
+
[sharesOverride, shareCollection, rawShareRows],
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const owner = (baselineDoc as Record<string, unknown> | undefined)?.owner;
|
|
347
|
+
const {
|
|
348
|
+
rights: sessionRights,
|
|
349
|
+
pending: sessionPending,
|
|
350
|
+
roles: effectiveRoles,
|
|
351
|
+
user,
|
|
352
|
+
} = useDocRights(frappeConfig, meta, {
|
|
353
|
+
roles,
|
|
354
|
+
owner: typeof owner === "string" ? owner : undefined,
|
|
355
|
+
shares: shareRows,
|
|
356
|
+
});
|
|
357
|
+
const rights = rightsOverride ?? sessionRights;
|
|
358
|
+
const rightsPending = rightsOverride ? false : sessionPending;
|
|
359
|
+
const permLevels = useMemo(
|
|
360
|
+
() => buildPermLevelMatrix(meta?.permissions, effectiveRoles),
|
|
361
|
+
[meta?.permissions, effectiveRoles],
|
|
362
|
+
);
|
|
363
|
+
const tabs = useMemo(() => {
|
|
364
|
+
if (!layoutFields) return undefined;
|
|
365
|
+
return buildFormTabs({
|
|
366
|
+
fields: layoutFields,
|
|
367
|
+
doc: {
|
|
368
|
+
...(localDoc as Record<string, unknown>),
|
|
369
|
+
__islocal: isCreate ? 1 : 0,
|
|
370
|
+
...(user ? { __session_user: user } : {}),
|
|
371
|
+
},
|
|
372
|
+
docstatus,
|
|
373
|
+
formReadOnly: !!meta?.read_only,
|
|
374
|
+
isCreate,
|
|
375
|
+
detailsLabel: t("Details"),
|
|
376
|
+
permLevels,
|
|
377
|
+
hiddenFields: hideFields,
|
|
378
|
+
hiddenSections: hideSections,
|
|
379
|
+
});
|
|
380
|
+
}, [
|
|
381
|
+
layoutFields,
|
|
382
|
+
localDoc,
|
|
383
|
+
isCreate,
|
|
384
|
+
user,
|
|
385
|
+
docstatus,
|
|
386
|
+
meta?.read_only,
|
|
387
|
+
permLevels,
|
|
388
|
+
hideFields,
|
|
389
|
+
hideSections,
|
|
390
|
+
t,
|
|
391
|
+
]);
|
|
392
|
+
|
|
393
|
+
// Reset per-document state when the target document changes.
|
|
394
|
+
useEffect(() => {
|
|
395
|
+
everLoadedRef.current = false;
|
|
396
|
+
if (name) createDraftRef.current = undefined;
|
|
397
|
+
createSeededRef.current = undefined;
|
|
398
|
+
setWasDeleted(false);
|
|
399
|
+
setConflictFields([]);
|
|
400
|
+
setSaveConflict(false);
|
|
401
|
+
setDocError(null);
|
|
402
|
+
const seed = seedRef.current;
|
|
403
|
+
setBaselineDoc(seed);
|
|
404
|
+
if (seed) setLocalDoc(seed);
|
|
405
|
+
}, [doctype, name]);
|
|
406
|
+
|
|
407
|
+
// Immediate honest deleted state: the live app emits doc_deleted for the
|
|
408
|
+
// open doc's doctype room; the eviction branch below is the durable fallback.
|
|
409
|
+
useFrappeDoctypeEvents(
|
|
410
|
+
name ? frappeConfig : undefined,
|
|
411
|
+
doctype,
|
|
412
|
+
(event) => {
|
|
413
|
+
if (event.type !== "doc_deleted") return;
|
|
414
|
+
const payload = event.payload as { name?: string } | undefined;
|
|
415
|
+
if (payload?.name === name) setWasDeleted(true);
|
|
416
|
+
},
|
|
417
|
+
Boolean(name),
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
// Field-level in-place sync: external updates land on clean fields, the
|
|
421
|
+
// user's dirty edits are kept, and a field both sides changed is flagged.
|
|
422
|
+
useEffect(() => {
|
|
423
|
+
const incoming = liveDoc?.data as Record<string, unknown> | undefined;
|
|
424
|
+
if (incoming) {
|
|
425
|
+
everLoadedRef.current = true;
|
|
426
|
+
setWasDeleted(false);
|
|
427
|
+
const baseline = baselineRef.current as Record<string, unknown> | undefined;
|
|
428
|
+
if (!baseline) {
|
|
429
|
+
setBaselineDoc(incoming as Partial<TDoc>);
|
|
430
|
+
setLocalDoc(incoming as Partial<TDoc>);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const local = localDocRef.current as Record<string, unknown>;
|
|
434
|
+
const merged: Record<string, unknown> = { ...incoming };
|
|
435
|
+
const newConflicts: string[] = [];
|
|
436
|
+
for (const key of Object.keys(local)) {
|
|
437
|
+
const localValue = local[key];
|
|
438
|
+
if (isSameValue(localValue, baseline[key])) continue;
|
|
439
|
+
merged[key] = localValue;
|
|
440
|
+
const remote = incoming[key];
|
|
441
|
+
if (!isSameValue(remote, baseline[key]) && !isSameValue(remote, localValue)) {
|
|
442
|
+
newConflicts.push(key);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
setBaselineDoc(incoming as Partial<TDoc>);
|
|
446
|
+
setLocalDoc(merged as Partial<TDoc>);
|
|
447
|
+
if (newConflicts.length > 0) {
|
|
448
|
+
setConflictFields((prev) => Array.from(new Set([...prev, ...newConflicts])));
|
|
449
|
+
}
|
|
450
|
+
} else if (name && everLoadedRef.current && !liveDoc?.isLoading) {
|
|
451
|
+
// The doc was live and is now gone from the subscription: deleted on
|
|
452
|
+
// the server (doc_deleted event, CDC tombstone, or 404 eviction).
|
|
453
|
+
setWasDeleted(true);
|
|
454
|
+
}
|
|
455
|
+
}, [liveDoc?.data, liveDoc?.isLoading, doctype, name]);
|
|
456
|
+
|
|
457
|
+
// A new record starts from the doctype's defaults (desk create_new), the
|
|
458
|
+
// stashed draft (?d=, Duplicate, Amend) over them. Meta or the session user
|
|
459
|
+
// arriving later fills in what they add without touching typed values.
|
|
460
|
+
useEffect(() => {
|
|
461
|
+
if (name) return;
|
|
462
|
+
if (createDraftRef.current?.doctype !== doctype) {
|
|
463
|
+
createDraftRef.current = { doctype, values: takeCreatePrefill(doctype) };
|
|
464
|
+
}
|
|
465
|
+
const draft = createDraftRef.current.values;
|
|
466
|
+
const defaults = meta ? buildCreateDefaults(meta.fields, user) : {};
|
|
467
|
+
const fresh = createSeededRef.current !== doctype;
|
|
468
|
+
createSeededRef.current = doctype;
|
|
469
|
+
setLocalDoc(
|
|
470
|
+
(prev) => ({ ...defaults, ...draft, ...(fresh ? {} : prev), doctype }) as Partial<TDoc>,
|
|
471
|
+
);
|
|
472
|
+
}, [name, doctype, meta, user]);
|
|
473
|
+
|
|
474
|
+
const dirtyFields = useMemo(
|
|
475
|
+
() =>
|
|
476
|
+
computeDirtyFields(
|
|
477
|
+
localDoc as Record<string, unknown>,
|
|
478
|
+
baselineDoc as Record<string, unknown> | undefined,
|
|
479
|
+
),
|
|
480
|
+
[localDoc, baselineDoc],
|
|
481
|
+
);
|
|
482
|
+
const isDirty = dirtyFields.length > 0;
|
|
483
|
+
|
|
484
|
+
// fetch_from preview: a changed Link pulls its dependent columns off the
|
|
485
|
+
// linked doc (validate_link on a bench, the fixture corpus otherwise) and
|
|
486
|
+
// patches the targets; clearing the link blanks them.
|
|
487
|
+
const runFetchFrom = (field: string, value: unknown) => {
|
|
488
|
+
const rules = fetchMap?.get(field);
|
|
489
|
+
if (!rules || rules.length === 0 || !frappeConfig) return;
|
|
490
|
+
const applyPatch = (fetched: Record<string, unknown>) => {
|
|
491
|
+
setLocalDoc((prev) => {
|
|
492
|
+
const current = prev as Record<string, unknown>;
|
|
493
|
+
if (current[field] !== value) return prev;
|
|
494
|
+
const patch = applyFetchedValues(rules, fetched, current);
|
|
495
|
+
return Object.keys(patch).length > 0 ? ({ ...current, ...patch } as Partial<TDoc>) : prev;
|
|
496
|
+
});
|
|
497
|
+
};
|
|
498
|
+
if (isEmptyValue(value)) {
|
|
499
|
+
applyPatch({});
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const linkField = meta?.fields.find((metaField) => metaField.fieldname === field);
|
|
503
|
+
const targetDoctype = typeof linkField?.options === "string" ? linkField.options : "";
|
|
504
|
+
if (!targetDoctype) return;
|
|
505
|
+
const sources = Array.from(new Set(rules.map((rule) => rule.source)));
|
|
506
|
+
const fixtures = frappeConfig.fixtures;
|
|
507
|
+
const fetched = fixtures
|
|
508
|
+
? fixtures
|
|
509
|
+
.getDoc(targetDoctype, String(value), sources)
|
|
510
|
+
.then((doc) => (doc ?? {}) as Record<string, unknown>)
|
|
511
|
+
: validateLink(createHttpClient(frappeConfig), targetDoctype, String(value), sources);
|
|
512
|
+
// An unknown link leaves the targets as they are; the server re-validates on save.
|
|
513
|
+
fetched.then(applyPatch).catch(() => undefined);
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const setValue = (field: keyof TDoc | string, value: unknown) => {
|
|
517
|
+
setLocalDoc((prev) => ({
|
|
518
|
+
...prev,
|
|
519
|
+
[field]: value,
|
|
520
|
+
}));
|
|
521
|
+
setErrors((prev) => {
|
|
522
|
+
if (!(String(field) in prev)) return prev;
|
|
523
|
+
const next = { ...prev };
|
|
524
|
+
delete next[String(field)];
|
|
525
|
+
return next;
|
|
526
|
+
});
|
|
527
|
+
runFetchFrom(String(field), value);
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
// Required = what the engine resolved: reqd or a truthy
|
|
531
|
+
// mandatory_depends_on, on fields that are actually laid out (depends_on,
|
|
532
|
+
// permlevel and host-hidden fields never reach the tabs).
|
|
533
|
+
const validate = (): Record<string, string> => {
|
|
534
|
+
if (!tabs) return {};
|
|
535
|
+
const validationErrors: Record<string, string> = {};
|
|
536
|
+
const doc = localDoc as Record<string, unknown>;
|
|
537
|
+
if (promptNamed && isEmptyValue(doc.name)) {
|
|
538
|
+
validationErrors.name = t("Name is required");
|
|
539
|
+
}
|
|
540
|
+
for (const tab of tabs) {
|
|
541
|
+
for (const field of tab.schema.fields) {
|
|
542
|
+
if (!field.required) continue;
|
|
543
|
+
if (isEmptyValue(doc[field.name])) {
|
|
544
|
+
validationErrors[field.name] = t("{{label}} is required", {
|
|
545
|
+
label: field.label || field.name,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return validationErrors;
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
const canWrite = isCreate ? rights.create : rights.write;
|
|
554
|
+
|
|
555
|
+
const save = async (): Promise<string | undefined> => {
|
|
556
|
+
if (wasDeleted || !canWrite || disabled) return undefined;
|
|
557
|
+
|
|
558
|
+
const requiredErrors = validate();
|
|
559
|
+
if (Object.keys(requiredErrors).length > 0) {
|
|
560
|
+
setErrors(requiredErrors);
|
|
561
|
+
return undefined;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
setIsSubmitting(true);
|
|
565
|
+
setErrors({});
|
|
566
|
+
|
|
567
|
+
try {
|
|
568
|
+
if (name) {
|
|
569
|
+
if (!docCollection) return undefined;
|
|
570
|
+
// Write only the dirty fields through the optimistic mutation path;
|
|
571
|
+
// clean fields stay untouched so concurrent server edits survive.
|
|
572
|
+
if (dirtyFields.length > 0) {
|
|
573
|
+
const local = localDoc as Record<string, unknown>;
|
|
574
|
+
const tx = docCollection.update(name, (draft) => {
|
|
575
|
+
for (const key of dirtyFields) {
|
|
576
|
+
(draft as Record<string, unknown>)[key] = local[key];
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
await tx.isPersisted.promise;
|
|
580
|
+
}
|
|
581
|
+
setConflictFields([]);
|
|
582
|
+
setSaveConflict(false);
|
|
583
|
+
return name;
|
|
584
|
+
}
|
|
585
|
+
if (!collection || !frappeConfig) return undefined;
|
|
586
|
+
const temporaryName =
|
|
587
|
+
((localDoc as Record<string, unknown>).name as string | undefined) ?? `new-${Date.now()}`;
|
|
588
|
+
const docToInsert = { ...localDoc, doctype, name: temporaryName } as TDoc;
|
|
589
|
+
const tx = collection.insert(docToInsert);
|
|
590
|
+
await tx.isPersisted.promise;
|
|
591
|
+
return getFrappeSyncModule(frappeConfig)?.createdName(temporaryName) ?? temporaryName;
|
|
592
|
+
} catch (err: unknown) {
|
|
593
|
+
const e = err as { exc_type?: string; _server_messages?: string; message?: string };
|
|
594
|
+
if (isConflictError(err)) setSaveConflict(true);
|
|
595
|
+
if (e?.exc_type === "ValidationError") {
|
|
596
|
+
const validationErrors: Record<string, string> = {};
|
|
597
|
+
if (e._server_messages) {
|
|
598
|
+
try {
|
|
599
|
+
const messages = JSON.parse(e._server_messages) as string[];
|
|
600
|
+
for (const msg of messages) {
|
|
601
|
+
const parsed =
|
|
602
|
+
typeof msg === "string" ? (JSON.parse(msg) as { message?: string }) : msg;
|
|
603
|
+
if (parsed.message) {
|
|
604
|
+
validationErrors.general = parsed.message;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
} catch {
|
|
608
|
+
validationErrors.general = t("Validation error occurred");
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
setErrors(validationErrors);
|
|
612
|
+
} else {
|
|
613
|
+
setErrors({ general: e?.message || t("Failed to save document") });
|
|
614
|
+
}
|
|
615
|
+
return undefined;
|
|
616
|
+
} finally {
|
|
617
|
+
setIsSubmitting(false);
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
const reset = () => {
|
|
622
|
+
if (baselineDoc) {
|
|
623
|
+
setLocalDoc(baselineDoc);
|
|
624
|
+
} else if (liveDoc?.data) {
|
|
625
|
+
setLocalDoc(liveDoc.data as Partial<TDoc>);
|
|
626
|
+
} else {
|
|
627
|
+
setLocalDoc({ doctype } as Partial<TDoc>);
|
|
628
|
+
}
|
|
629
|
+
setConflictFields([]);
|
|
630
|
+
setSaveConflict(false);
|
|
631
|
+
setErrors({});
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
const remove = async (): Promise<boolean> => {
|
|
635
|
+
if (!docCollection || !name || wasDeleted || !rights.delete) return false;
|
|
636
|
+
setIsSubmitting(true);
|
|
637
|
+
setErrors({});
|
|
638
|
+
try {
|
|
639
|
+
const tx = docCollection.delete(name);
|
|
640
|
+
await tx.isPersisted.promise;
|
|
641
|
+
return true;
|
|
642
|
+
} catch (err) {
|
|
643
|
+
setErrors({ general: (err as Error)?.message || t("Failed to delete") });
|
|
644
|
+
return false;
|
|
645
|
+
} finally {
|
|
646
|
+
setIsSubmitting(false);
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
// Submit and Cancel write docstatus through the collection that owns the
|
|
651
|
+
// doc: the server's save runs the transition (0 → 1 submits, 1 → 2
|
|
652
|
+
// cancels) under the same OCC as any other edit.
|
|
653
|
+
const transitionDocstatus = async (to: 1 | 2, failure: string): Promise<boolean> => {
|
|
654
|
+
if (!docCollection || !name || wasDeleted || !rights[to === 1 ? "submit" : "cancel"]) {
|
|
655
|
+
return false;
|
|
656
|
+
}
|
|
657
|
+
if (to === 1) {
|
|
658
|
+
const requiredErrors = validate();
|
|
659
|
+
if (Object.keys(requiredErrors).length > 0) {
|
|
660
|
+
setErrors(requiredErrors);
|
|
661
|
+
return false;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
setIsSubmitting(true);
|
|
665
|
+
setErrors({});
|
|
666
|
+
try {
|
|
667
|
+
const tx = docCollection.update(name, (draft) => {
|
|
668
|
+
(draft as Record<string, unknown>).docstatus = to;
|
|
669
|
+
});
|
|
670
|
+
await tx.isPersisted.promise;
|
|
671
|
+
return true;
|
|
672
|
+
} catch (err) {
|
|
673
|
+
if (isConflictError(err)) setSaveConflict(true);
|
|
674
|
+
setErrors({ general: extractServerError(err, failure) });
|
|
675
|
+
return false;
|
|
676
|
+
} finally {
|
|
677
|
+
setIsSubmitting(false);
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
const submitDocument = () => transitionDocstatus(1, t("Failed to submit"));
|
|
682
|
+
const cancelDocument = () => transitionDocstatus(2, t("Failed to cancel"));
|
|
683
|
+
|
|
684
|
+
const amend = (): Partial<TDoc> | undefined => {
|
|
685
|
+
if (!meta || !name || !rights.amend) return undefined;
|
|
686
|
+
const draft = buildAmendValues(meta.fields, localDoc as Record<string, unknown>, name);
|
|
687
|
+
setCreatePrefill(doctype, draft);
|
|
688
|
+
return draft as Partial<TDoc>;
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
const duplicate = (): Record<string, unknown> | undefined => {
|
|
692
|
+
if (!meta) return undefined;
|
|
693
|
+
const values = buildCopyValues(meta.fields, localDoc as Record<string, unknown>);
|
|
694
|
+
setCreatePrefill(doctype, values);
|
|
695
|
+
return values;
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
const fieldTargets = useMemo<FrappeFormFieldTarget[]>(
|
|
699
|
+
() =>
|
|
700
|
+
(tabs ?? []).flatMap((tab) =>
|
|
701
|
+
tab.schema.fields.map((field) => ({
|
|
702
|
+
name: field.name,
|
|
703
|
+
label: field.label || field.name,
|
|
704
|
+
tabKey: tab.key,
|
|
705
|
+
tabLabel: tab.label,
|
|
706
|
+
})),
|
|
707
|
+
),
|
|
708
|
+
[tabs],
|
|
709
|
+
);
|
|
710
|
+
const revealField = (fieldName: string): boolean => {
|
|
711
|
+
const target = fieldTargets.find((field) => field.name === fieldName);
|
|
712
|
+
if (!target) return false;
|
|
713
|
+
setActiveTab(target.tabKey);
|
|
714
|
+
return true;
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
// No collection yet (server render, hydration pass) reads as loading: the
|
|
718
|
+
// document is not known to be missing until the live read answers.
|
|
719
|
+
const isDocReading = Boolean(name) && (!docCollection || Boolean(liveDoc?.isLoading));
|
|
720
|
+
const showsSeed = Boolean(seedDoc) && isDocReading;
|
|
721
|
+
const status: FrappeFormStatus = !name
|
|
722
|
+
? "ready"
|
|
723
|
+
: docError && !liveDoc?.data && !wasDeleted
|
|
724
|
+
? "error"
|
|
725
|
+
: isDocReading && !showsSeed && !wasDeleted
|
|
726
|
+
? "loading"
|
|
727
|
+
: !isDocReading && !liveDoc?.data && !wasDeleted
|
|
728
|
+
? "notFound"
|
|
729
|
+
: "ready";
|
|
730
|
+
|
|
731
|
+
return {
|
|
732
|
+
doctype,
|
|
733
|
+
name,
|
|
734
|
+
isCreate,
|
|
735
|
+
connection: frappeConfig,
|
|
736
|
+
meta,
|
|
737
|
+
metaLoading: isMetaLoading,
|
|
738
|
+
metaError,
|
|
739
|
+
doc: localDoc,
|
|
740
|
+
baseline: baselineDoc,
|
|
741
|
+
setValue,
|
|
742
|
+
errors,
|
|
743
|
+
setErrors,
|
|
744
|
+
dirtyFields,
|
|
745
|
+
isDirty,
|
|
746
|
+
isSubmitting,
|
|
747
|
+
docstatus,
|
|
748
|
+
isSubmittable,
|
|
749
|
+
rights,
|
|
750
|
+
rightsPending,
|
|
751
|
+
roles: effectiveRoles,
|
|
752
|
+
user,
|
|
753
|
+
shareRows,
|
|
754
|
+
canWrite,
|
|
755
|
+
readOnly: disabled || wasDeleted || !!meta?.read_only || !canWrite,
|
|
756
|
+
wasDeleted,
|
|
757
|
+
conflictFields,
|
|
758
|
+
conflict: saveConflict || conflictFields.length > 0,
|
|
759
|
+
status,
|
|
760
|
+
docError,
|
|
761
|
+
showsSeed,
|
|
762
|
+
tabs,
|
|
763
|
+
promptNamed,
|
|
764
|
+
fieldTargets,
|
|
765
|
+
activeTab,
|
|
766
|
+
setActiveTab,
|
|
767
|
+
revealField,
|
|
768
|
+
amendedAs,
|
|
769
|
+
validate,
|
|
770
|
+
save,
|
|
771
|
+
submitDocument,
|
|
772
|
+
cancelDocument,
|
|
773
|
+
remove,
|
|
774
|
+
reset,
|
|
775
|
+
amend,
|
|
776
|
+
duplicate,
|
|
777
|
+
};
|
|
778
|
+
}
|