@multiplatform.one/frappe-ui 7.22.0 → 7.23.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.
Files changed (210) hide show
  1. package/README.md +14 -0
  2. package/dist/cjs/FrappeForm.cjs +96 -52
  3. package/dist/cjs/FrappeForm.native.js +103 -52
  4. package/dist/cjs/FrappeForm.native.js.map +1 -1
  5. package/dist/cjs/FrappeFormActions.cjs +161 -0
  6. package/dist/cjs/FrappeFormActions.native.js +167 -0
  7. package/dist/cjs/FrappeFormActions.native.js.map +1 -0
  8. package/dist/cjs/FrappeTable.cjs +42 -13
  9. package/dist/cjs/FrappeTable.native.js +55 -19
  10. package/dist/cjs/FrappeTable.native.js.map +1 -1
  11. package/dist/cjs/desk/DeskFormPage.cjs +2 -1
  12. package/dist/cjs/desk/DeskFormPage.native.js +3 -0
  13. package/dist/cjs/desk/DeskFormPage.native.js.map +1 -1
  14. package/dist/cjs/filterFields.cjs +277 -34
  15. package/dist/cjs/filterFields.native.js +311 -36
  16. package/dist/cjs/filterFields.native.js.map +1 -1
  17. package/dist/cjs/formEngine/docLifecycle.cjs +98 -0
  18. package/dist/cjs/formEngine/docLifecycle.native.js +131 -0
  19. package/dist/cjs/formEngine/docLifecycle.native.js.map +1 -0
  20. package/dist/cjs/formEngine/useAmendedAs.cjs +48 -0
  21. package/dist/cjs/formEngine/useAmendedAs.native.js +60 -0
  22. package/dist/cjs/formEngine/useAmendedAs.native.js.map +1 -0
  23. package/dist/cjs/formEngine/useDocRights.cjs +2 -2
  24. package/dist/cjs/formEngine/useDocRights.native.js +2 -2
  25. package/dist/cjs/formEngine/useDocRights.native.js.map +1 -1
  26. package/dist/cjs/formEngine/useFormEngine.cjs +1 -0
  27. package/dist/cjs/formEngine/useFormEngine.native.js +1 -0
  28. package/dist/cjs/formEngine/useFormEngine.native.js.map +1 -1
  29. package/dist/cjs/index.cjs +30 -1
  30. package/dist/cjs/index.native.js +30 -1
  31. package/dist/cjs/index.native.js.map +1 -1
  32. package/dist/cjs/list/listColumns.cjs +397 -0
  33. package/dist/cjs/list/listColumns.native.js +447 -0
  34. package/dist/cjs/list/listColumns.native.js.map +1 -0
  35. package/dist/cjs/list/listFields.cjs +50 -0
  36. package/dist/cjs/list/listFields.native.js +79 -0
  37. package/dist/cjs/list/listFields.native.js.map +1 -0
  38. package/dist/cjs/list/listFormat.cjs +68 -0
  39. package/dist/cjs/list/listFormat.native.js +74 -0
  40. package/dist/cjs/list/listFormat.native.js.map +1 -0
  41. package/dist/cjs/list/statusColors.cjs +102 -0
  42. package/dist/cjs/list/statusColors.native.js +109 -0
  43. package/dist/cjs/list/statusColors.native.js.map +1 -0
  44. package/dist/esm/FrappeForm.mjs +97 -53
  45. package/dist/esm/FrappeForm.mjs.map +1 -1
  46. package/dist/esm/FrappeForm.native.js +104 -53
  47. package/dist/esm/FrappeForm.native.js.map +1 -1
  48. package/dist/esm/FrappeFormActions.mjs +136 -0
  49. package/dist/esm/FrappeFormActions.mjs.map +1 -0
  50. package/dist/esm/FrappeFormActions.native.js +139 -0
  51. package/dist/esm/FrappeFormActions.native.js.map +1 -0
  52. package/dist/esm/FrappeTable.mjs +42 -13
  53. package/dist/esm/FrappeTable.mjs.map +1 -1
  54. package/dist/esm/FrappeTable.native.js +55 -19
  55. package/dist/esm/FrappeTable.native.js.map +1 -1
  56. package/dist/esm/desk/DeskFormPage.mjs +2 -1
  57. package/dist/esm/desk/DeskFormPage.mjs.map +1 -1
  58. package/dist/esm/desk/DeskFormPage.native.js +3 -0
  59. package/dist/esm/desk/DeskFormPage.native.js.map +1 -1
  60. package/dist/esm/filterFields.mjs +263 -34
  61. package/dist/esm/filterFields.mjs.map +1 -1
  62. package/dist/esm/filterFields.native.js +297 -36
  63. package/dist/esm/filterFields.native.js.map +1 -1
  64. package/dist/esm/formEngine/docLifecycle.mjs +68 -0
  65. package/dist/esm/formEngine/docLifecycle.mjs.map +1 -0
  66. package/dist/esm/formEngine/docLifecycle.native.js +98 -0
  67. package/dist/esm/formEngine/docLifecycle.native.js.map +1 -0
  68. package/dist/esm/formEngine/useAmendedAs.mjs +23 -0
  69. package/dist/esm/formEngine/useAmendedAs.mjs.map +1 -0
  70. package/dist/esm/formEngine/useAmendedAs.native.js +32 -0
  71. package/dist/esm/formEngine/useAmendedAs.native.js.map +1 -0
  72. package/dist/esm/formEngine/useDocRights.mjs +2 -2
  73. package/dist/esm/formEngine/useDocRights.mjs.map +1 -1
  74. package/dist/esm/formEngine/useDocRights.native.js +2 -2
  75. package/dist/esm/formEngine/useDocRights.native.js.map +1 -1
  76. package/dist/esm/formEngine/useFormEngine.mjs +1 -1
  77. package/dist/esm/formEngine/useFormEngine.mjs.map +1 -1
  78. package/dist/esm/formEngine/useFormEngine.native.js +1 -1
  79. package/dist/esm/formEngine/useFormEngine.native.js.map +1 -1
  80. package/dist/esm/index.mjs +6 -2
  81. package/dist/esm/index.mjs.map +1 -1
  82. package/dist/esm/index.native.js +6 -2
  83. package/dist/esm/index.native.js.map +1 -1
  84. package/dist/esm/list/listColumns.mjs +372 -0
  85. package/dist/esm/list/listColumns.mjs.map +1 -0
  86. package/dist/esm/list/listColumns.native.js +419 -0
  87. package/dist/esm/list/listColumns.native.js.map +1 -0
  88. package/dist/esm/list/listFields.mjs +23 -0
  89. package/dist/esm/list/listFields.mjs.map +1 -0
  90. package/dist/esm/list/listFields.native.js +49 -0
  91. package/dist/esm/list/listFields.native.js.map +1 -0
  92. package/dist/esm/list/listFormat.mjs +38 -0
  93. package/dist/esm/list/listFormat.mjs.map +1 -0
  94. package/dist/esm/list/listFormat.native.js +41 -0
  95. package/dist/esm/list/listFormat.native.js.map +1 -0
  96. package/dist/esm/list/statusColors.mjs +77 -0
  97. package/dist/esm/list/statusColors.mjs.map +1 -0
  98. package/dist/esm/list/statusColors.native.js +81 -0
  99. package/dist/esm/list/statusColors.native.js.map +1 -0
  100. package/dist/jsx/FrappeForm.mjs +97 -53
  101. package/dist/jsx/FrappeForm.mjs.map +1 -1
  102. package/dist/jsx/FrappeForm.native.js +103 -52
  103. package/dist/jsx/FrappeForm.native.js.map +1 -1
  104. package/dist/jsx/FrappeFormActions.mjs +136 -0
  105. package/dist/jsx/FrappeFormActions.mjs.map +1 -0
  106. package/dist/jsx/FrappeFormActions.native.js +167 -0
  107. package/dist/jsx/FrappeFormActions.native.js.map +1 -0
  108. package/dist/jsx/FrappeTable.mjs +42 -13
  109. package/dist/jsx/FrappeTable.mjs.map +1 -1
  110. package/dist/jsx/FrappeTable.native.js +55 -19
  111. package/dist/jsx/FrappeTable.native.js.map +1 -1
  112. package/dist/jsx/desk/DeskFormPage.mjs +2 -1
  113. package/dist/jsx/desk/DeskFormPage.mjs.map +1 -1
  114. package/dist/jsx/desk/DeskFormPage.native.js +3 -0
  115. package/dist/jsx/desk/DeskFormPage.native.js.map +1 -1
  116. package/dist/jsx/filterFields.mjs +263 -34
  117. package/dist/jsx/filterFields.mjs.map +1 -1
  118. package/dist/jsx/filterFields.native.js +311 -36
  119. package/dist/jsx/filterFields.native.js.map +1 -1
  120. package/dist/jsx/formEngine/docLifecycle.mjs +68 -0
  121. package/dist/jsx/formEngine/docLifecycle.mjs.map +1 -0
  122. package/dist/jsx/formEngine/docLifecycle.native.js +131 -0
  123. package/dist/jsx/formEngine/docLifecycle.native.js.map +1 -0
  124. package/dist/jsx/formEngine/useAmendedAs.mjs +23 -0
  125. package/dist/jsx/formEngine/useAmendedAs.mjs.map +1 -0
  126. package/dist/jsx/formEngine/useAmendedAs.native.js +60 -0
  127. package/dist/jsx/formEngine/useAmendedAs.native.js.map +1 -0
  128. package/dist/jsx/formEngine/useDocRights.mjs +2 -2
  129. package/dist/jsx/formEngine/useDocRights.mjs.map +1 -1
  130. package/dist/jsx/formEngine/useDocRights.native.js +2 -2
  131. package/dist/jsx/formEngine/useDocRights.native.js.map +1 -1
  132. package/dist/jsx/formEngine/useFormEngine.mjs +1 -1
  133. package/dist/jsx/formEngine/useFormEngine.mjs.map +1 -1
  134. package/dist/jsx/formEngine/useFormEngine.native.js +1 -0
  135. package/dist/jsx/formEngine/useFormEngine.native.js.map +1 -1
  136. package/dist/jsx/index.js +6 -2
  137. package/dist/jsx/index.js.map +1 -1
  138. package/dist/jsx/index.mjs +6 -2
  139. package/dist/jsx/index.mjs.map +1 -1
  140. package/dist/jsx/index.native.js +30 -1
  141. package/dist/jsx/index.native.js.map +1 -1
  142. package/dist/jsx/list/listColumns.mjs +372 -0
  143. package/dist/jsx/list/listColumns.mjs.map +1 -0
  144. package/dist/jsx/list/listColumns.native.js +447 -0
  145. package/dist/jsx/list/listColumns.native.js.map +1 -0
  146. package/dist/jsx/list/listFields.mjs +23 -0
  147. package/dist/jsx/list/listFields.mjs.map +1 -0
  148. package/dist/jsx/list/listFields.native.js +79 -0
  149. package/dist/jsx/list/listFields.native.js.map +1 -0
  150. package/dist/jsx/list/listFormat.mjs +38 -0
  151. package/dist/jsx/list/listFormat.mjs.map +1 -0
  152. package/dist/jsx/list/listFormat.native.js +74 -0
  153. package/dist/jsx/list/listFormat.native.js.map +1 -0
  154. package/dist/jsx/list/statusColors.mjs +77 -0
  155. package/dist/jsx/list/statusColors.mjs.map +1 -0
  156. package/dist/jsx/list/statusColors.native.js +109 -0
  157. package/dist/jsx/list/statusColors.native.js.map +1 -0
  158. package/package.json +22 -16
  159. package/src/FrappeForm.deskSemantics.spec.tsx +1 -1
  160. package/src/FrappeForm.lifecycle.spec.tsx +463 -0
  161. package/src/FrappeForm.lifecycle.stories.tsx +84 -0
  162. package/src/FrappeForm.tsx +124 -84
  163. package/src/FrappeFormActions.tsx +216 -0
  164. package/src/FrappeTable.defaultColumns.spec.tsx +229 -0
  165. package/src/FrappeTable.tsx +79 -32
  166. package/src/desk/DeskFormPage.tsx +1 -0
  167. package/src/filterFields.spec.ts +57 -2
  168. package/src/filterFields.ts +365 -38
  169. package/src/formEngine/docLifecycle.spec.ts +195 -0
  170. package/src/formEngine/docLifecycle.ts +148 -0
  171. package/src/formEngine/useAmendedAs.ts +38 -0
  172. package/src/formEngine/useDocRights.ts +11 -3
  173. package/src/formEngine/useFormEngine.ts +1 -1
  174. package/src/frappeViews.designLaw.spec.ts +6 -0
  175. package/src/index.ts +47 -1
  176. package/src/list/listColumns.tsx +488 -0
  177. package/src/list/listFields.spec.ts +147 -0
  178. package/src/list/listFields.ts +68 -0
  179. package/src/list/listFormat.spec.ts +151 -0
  180. package/src/list/listFormat.ts +80 -0
  181. package/src/list/seam.spec.ts +116 -0
  182. package/src/list/statusColors.spec.ts +100 -0
  183. package/src/list/statusColors.ts +94 -0
  184. package/types/FrappeForm.d.ts +13 -3
  185. package/types/FrappeForm.d.ts.map +1 -1
  186. package/types/FrappeFormActions.d.ts +36 -0
  187. package/types/FrappeFormActions.d.ts.map +1 -0
  188. package/types/FrappeTable.d.ts +11 -5
  189. package/types/FrappeTable.d.ts.map +1 -1
  190. package/types/desk/DeskFormPage.d.ts.map +1 -1
  191. package/types/filterFields.d.ts +81 -10
  192. package/types/filterFields.d.ts.map +1 -1
  193. package/types/formEngine/docLifecycle.d.ts +59 -0
  194. package/types/formEngine/docLifecycle.d.ts.map +1 -0
  195. package/types/formEngine/useAmendedAs.d.ts +9 -0
  196. package/types/formEngine/useAmendedAs.d.ts.map +1 -0
  197. package/types/formEngine/useDocRights.d.ts +1 -1
  198. package/types/formEngine/useDocRights.d.ts.map +1 -1
  199. package/types/formEngine/useFormEngine.d.ts +1 -0
  200. package/types/formEngine/useFormEngine.d.ts.map +1 -1
  201. package/types/index.d.ts +5 -1
  202. package/types/index.d.ts.map +1 -1
  203. package/types/list/listColumns.d.ts +41 -0
  204. package/types/list/listColumns.d.ts.map +1 -0
  205. package/types/list/listFields.d.ts +16 -0
  206. package/types/list/listFields.d.ts.map +1 -0
  207. package/types/list/listFormat.d.ts +30 -0
  208. package/types/list/listFormat.d.ts.map +1 -0
  209. package/types/list/statusColors.d.ts +14 -0
  210. package/types/list/statusColors.d.ts.map +1 -0
@@ -21,13 +21,7 @@ import { SchemaForm, type FieldOverride } from "./schemaForm";
21
21
  import type { HeadingLevel } from "./fields/Heading";
22
22
  import { FormSidebar, type SidebarSection, type SidebarMetadataEntry } from "./FormSidebar";
23
23
  import { eq } from "@tanstack/db";
24
- import {
25
- ArrowLeftIcon,
26
- FloppyDiskIcon,
27
- PencilSimpleIcon,
28
- TrashIcon,
29
- XIcon,
30
- } from "@phosphor-icons/react";
24
+ import { ArrowLeftIcon } from "@phosphor-icons/react";
31
25
  import type React from "react";
32
26
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
33
27
  import type { TFunction } from "i18next";
@@ -43,7 +37,19 @@ import { applyFetchedValues, buildFetchMap } from "./formEngine/fetchFrom";
43
37
  import { buildPermLevelMatrix } from "./formEngine/fieldPermissions";
44
38
  import { createHttpClient, validateLink } from "./formEngine/formActions";
45
39
  import { buildFormTabs, isPromptNamed, type FormTab } from "./formEngine/formLayout";
40
+ import { setCreatePrefill, takeCreatePrefill } from "./formEngine/createPrefill";
41
+ import {
42
+ buildAmendValues,
43
+ canDeleteDoc,
44
+ canEditDoc,
45
+ isAmendable,
46
+ resolveDocAction,
47
+ resolveDocIndicator,
48
+ } from "./formEngine/docLifecycle";
49
+ import { useAmendedAs } from "./formEngine/useAmendedAs";
46
50
  import { useDocRights } from "./formEngine/useDocRights";
51
+ import { extractServerError } from "./formEngine/useFormEngine";
52
+ import { FrappeFormActions } from "./FrappeFormActions";
47
53
  import { connectionFields, connectionsFromMeta, type FrappeConnection } from "./frappeConnections";
48
54
  import { FrappeFormConnections } from "./FrappeFormConnections";
49
55
  import { FrappeRowsReader, type FrappeRowsSnapshot } from "./FrappeRowsReader";
@@ -132,9 +138,18 @@ export interface FrappeFormProps<TDoc extends { name: string; doctype: string }>
132
138
  onSave?: (doc: Partial<TDoc>) => void;
133
139
  onCancel?: () => void;
134
140
  onDelete?: (doc: Partial<TDoc>) => void;
141
+ /**
142
+ * Opens the amended draft of a cancelled document (desk Amend). The draft
143
+ * carries `amended_from` and is also handed to the doctype's next
144
+ * create-mode FrappeForm, so navigating to one is enough. Absent, a
145
+ * cancelled document offers no Amend.
146
+ */
147
+ onAmend?: (draft: Partial<TDoc>) => void;
135
148
  /**
136
149
  * Given, the form is a read-only view of the record: its toolbar offers
137
- * Edit (this callback) and Delete instead of Save and Cancel.
150
+ * Edit (this callback) instead of Save and Cancel, beside Delete and the
151
+ * document's Submit, Cancel or Amend. A document with nothing left to
152
+ * edit (cancelled, or submitted without allow_on_submit fields) shows no Edit.
138
153
  */
139
154
  onEdit?: () => void;
140
155
  /**
@@ -142,8 +157,9 @@ export interface FrappeFormProps<TDoc extends { name: string; doctype: string }>
142
157
  * the doctype's record page: it reads the record from the path through
143
158
  * the FrappeProvider's navigation (`route/new` creates, `route/<name>`
144
159
  * views, `route/<name>/edit` edits), carries the detail page template,
145
- * and after a save, cancel, edit or delete moves to the matching page.
146
- * Callbacks given alongside it replace those moves.
160
+ * and after a save, cancel, edit or delete moves to the matching page;
161
+ * Amend opens the amended draft on `route/new`. Callbacks given alongside
162
+ * it replace those moves.
147
163
  */
148
164
  route?: string;
149
165
  showToolbar?: boolean;
@@ -386,6 +402,7 @@ function FrappeFormRoute<TDoc extends { name: string; doctype: string }>({
386
402
  onCancel,
387
403
  onDelete,
388
404
  onEdit,
405
+ onAmend,
389
406
  ...props
390
407
  }: Omit<FrappeFormProps<TDoc>, "route"> & { route: string }) {
391
408
  const router = useFrappeRouter();
@@ -419,6 +436,7 @@ function FrappeFormRoute<TDoc extends { name: string; doctype: string }>({
419
436
  }
420
437
  onCancel={onCancel ?? (() => go(name ? { mode: "view", name } : { mode: "list" }))}
421
438
  onDelete={onDelete ?? (() => go({ mode: "list" }))}
439
+ onAmend={onAmend ?? (() => go({ mode: "create" }))}
422
440
  />
423
441
  );
424
442
  }
@@ -440,6 +458,7 @@ function FrappeFormBody<TDoc extends { name: string; doctype: string }>({
440
458
  onSave,
441
459
  onCancel,
442
460
  onDelete,
461
+ onAmend,
443
462
  onEdit,
444
463
  showToolbar = true,
445
464
  showSidebar = true,
@@ -477,6 +496,9 @@ function FrappeFormBody<TDoc extends { name: string; doctype: string }>({
477
496
  localDocRef.current = localDoc;
478
497
  const baselineRef = useRef(baselineDoc);
479
498
  baselineRef.current = baselineDoc;
499
+ // Taken once per create session: effects re-run (StrictMode) must not
500
+ // lose a draft the one-shot stash already handed over.
501
+ const createDraftRef = useRef<{ doctype: string; values?: Record<string, unknown> }>(undefined);
480
502
 
481
503
  const finalBaseURL = baseURL ?? config?.baseURL;
482
504
  const finalAuth = auth ?? config?.auth;
@@ -516,12 +538,18 @@ function FrappeFormBody<TDoc extends { name: string; doctype: string }>({
516
538
  error: metaError,
517
539
  } = useDocTypeMeta(frappeConfig, doctype);
518
540
 
541
+ const isSubmittable = !!meta?.is_submittable;
519
542
  const isCreate = !name;
520
543
  const docstatus = Number((baselineDoc as Record<string, unknown> | undefined)?.docstatus ?? 0);
521
544
  const layoutFields = useMemo(() => {
522
545
  if (!meta) return undefined;
523
546
  return sections ? sectionsToLayoutFields(sections) : meta.fields;
524
547
  }, [meta, sections]);
548
+ const amendedAs = useAmendedAs(
549
+ isSubmittable && docstatus === 2 ? frappeConfig : undefined,
550
+ doctype,
551
+ name,
552
+ );
525
553
  // Meta is permission-gated (Guest gets 403) while the record may still be
526
554
  // readable: the form then lays the record's own fields out, read-only.
527
555
  const readsWithoutMeta = !children && !meta && Boolean(metaError) && Boolean(name);
@@ -649,6 +677,7 @@ function FrappeFormBody<TDoc extends { name: string; doctype: string }>({
649
677
  // Reset per-document state when the target document changes.
650
678
  useEffect(() => {
651
679
  everLoadedRef.current = false;
680
+ if (name) createDraftRef.current = undefined;
652
681
  setWasDeleted(false);
653
682
  setConflictFields([]);
654
683
  setDocError(null);
@@ -746,7 +775,10 @@ function FrappeFormBody<TDoc extends { name: string; doctype: string }>({
746
775
  // the server (doc_deleted event, CDC tombstone, or 404 eviction).
747
776
  setWasDeleted(true);
748
777
  } else if (!name) {
749
- setLocalDoc({ doctype } as Partial<TDoc>);
778
+ if (createDraftRef.current?.doctype !== doctype) {
779
+ createDraftRef.current = { doctype, values: takeCreatePrefill(doctype) };
780
+ }
781
+ setLocalDoc({ ...createDraftRef.current.values, doctype } as Partial<TDoc>);
750
782
  }
751
783
  }, [liveDoc?.data, liveDoc?.isLoading, doctype, name]);
752
784
 
@@ -930,6 +962,46 @@ function FrappeFormBody<TDoc extends { name: string; doctype: string }>({
930
962
  }
931
963
  };
932
964
 
965
+ // Submit and Cancel write docstatus through the collection that owns the
966
+ // doc: the server's save runs the transition (0 → 1 submits, 1 → 2
967
+ // cancels) under the same OCC as any other edit.
968
+ const transitionDocstatus = async (to: 1 | 2, question: string, failure: string) => {
969
+ if (!docCollection || !name || wasDeleted || !rights[to === 1 ? "submit" : "cancel"]) return;
970
+ if (to === 1) {
971
+ const requiredErrors = validateRequiredFields();
972
+ if (Object.keys(requiredErrors).length > 0) {
973
+ setErrors(requiredErrors);
974
+ return;
975
+ }
976
+ }
977
+ if (!(await dialog.confirm(question))) return;
978
+ setIsSubmitting(true);
979
+ setErrors({});
980
+ try {
981
+ const tx = docCollection.update(name, (draft) => {
982
+ (draft as Record<string, unknown>).docstatus = to;
983
+ });
984
+ await tx.isPersisted.promise;
985
+ } catch (err) {
986
+ setErrors({ general: extractServerError(err, failure) });
987
+ } finally {
988
+ setIsSubmitting(false);
989
+ }
990
+ };
991
+
992
+ const handleSubmitDocument = () =>
993
+ transitionDocstatus(1, t("Permanently Submit {{name}}?", { name }), t("Failed to submit"));
994
+
995
+ const handleCancelDocument = () =>
996
+ transitionDocstatus(2, t("Permanently Cancel {{name}}?", { name }), t("Failed to cancel"));
997
+
998
+ const handleAmend = () => {
999
+ if (!meta || !name || !onAmend || !rights.amend) return;
1000
+ const draft = buildAmendValues(meta.fields, localDoc as Record<string, unknown>, name);
1001
+ setCreatePrefill(doctype, draft);
1002
+ onAmend(draft as Partial<TDoc>);
1003
+ };
1004
+
933
1005
  const titleField = meta?.title_field;
934
1006
  const titleValue = titleField ? (localDoc as Record<string, unknown>)[titleField] : undefined;
935
1007
  const pageTitle = isCreate ? t("New {{doctype}}", { doctype }) : String(titleValue || name);
@@ -1043,15 +1115,35 @@ function FrappeFormBody<TDoc extends { name: string; doctype: string }>({
1043
1115
  const effectiveDisabled = disabled || wasDeleted;
1044
1116
  const readOnlyView = Boolean(onEdit) || readsWithoutMeta;
1045
1117
  const fieldsDisabled = effectiveDisabled || readOnlyView || !canWriteRecord;
1046
- const showDelete = Boolean(name && onDelete && !wasDeleted && rights.delete);
1047
- const showEdit = Boolean(onEdit && rights.write);
1048
- const showSave = !onEdit && canWriteRecord;
1049
- const showCancel = !onEdit && Boolean(onCancel);
1050
- const showNotSaved = hasUnsavedChanges && !onEdit;
1118
+ const indicator = resolveDocIndicator({
1119
+ docstatus,
1120
+ isCreate,
1121
+ isDirty: hasUnsavedChanges && !onEdit,
1122
+ isSubmittable,
1123
+ });
1124
+ const docAction = resolveDocAction({
1125
+ docstatus,
1126
+ isCreate,
1127
+ isDirty: hasUnsavedChanges,
1128
+ isSubmittable,
1129
+ perms: {
1130
+ write: canWriteRecord,
1131
+ submit: rights.submit,
1132
+ cancel: rights.cancel,
1133
+ amend: rights.amend,
1134
+ },
1135
+ isReadOnlyView: Boolean(onEdit),
1136
+ });
1137
+ const showDelete = Boolean(
1138
+ name && onDelete && !wasDeleted && rights.delete && canDeleteDoc(docstatus),
1139
+ );
1140
+ const showEdit = Boolean(onEdit && rights.write && canEditDoc(docstatus, meta));
1141
+ const showAmend = Boolean(onAmend && isAmendable(meta));
1142
+ const showClose = !onEdit && Boolean(onCancel);
1051
1143
  const toolbarShown =
1052
1144
  showToolbar &&
1053
1145
  !readsWithoutMeta &&
1054
- (showDelete || showEdit || showSave || showCancel || showNotSaved);
1146
+ Boolean(indicator || docAction || showDelete || showEdit || showClose);
1055
1147
  const shownTabs = formTabs ?? recordTabs;
1056
1148
  const imageFieldName = meta?.image_field;
1057
1149
  const sidebarImageUrl = imageFieldName
@@ -1227,73 +1319,21 @@ function FrappeFormBody<TDoc extends { name: string; doctype: string }>({
1227
1319
  )}
1228
1320
 
1229
1321
  {toolbarShown && (
1230
- <XStack
1231
- {...knobProps.gap}
1232
- justifyContent="flex-end"
1233
- alignItems="center"
1234
- paddingTop="$3"
1235
- >
1236
- {showNotSaved && (
1237
- <Text color="$orange11" marginInlineEnd="auto" {...knobProps.body}>
1238
- {t("Not Saved")}
1239
- </Text>
1240
- )}
1241
-
1242
- {showDelete && (
1243
- <Button
1244
- outlined
1245
- error
1246
- icon={TrashIcon}
1247
- loading={isSubmitting}
1248
- disabled={effectiveDisabled}
1249
- disabledReason={t("Form is read-only")}
1250
- disabledReasonPlacement="inline"
1251
- onPress={handleDelete}
1252
- >
1253
- {t("Delete")}
1254
- </Button>
1255
- )}
1256
-
1257
- {showEdit && (
1258
- <Button
1259
- accent
1260
- icon={PencilSimpleIcon}
1261
- disabled={effectiveDisabled}
1262
- disabledReason={t("Form is read-only")}
1263
- disabledReasonPlacement="inline"
1264
- onPress={onEdit}
1265
- >
1266
- {t("Edit")}
1267
- </Button>
1268
- )}
1269
-
1270
- {showCancel && (
1271
- <Button
1272
- outlined
1273
- icon={XIcon}
1274
- disabled={disabled}
1275
- disabledReason={t("Form is read-only")}
1276
- disabledReasonPlacement="inline"
1277
- onPress={handleCancel}
1278
- >
1279
- {t("Cancel")}
1280
- </Button>
1281
- )}
1282
-
1283
- {showSave && (
1284
- <Button
1285
- accent
1286
- icon={FloppyDiskIcon}
1287
- loading={isSubmitting}
1288
- disabled={effectiveDisabled}
1289
- disabledReason={t("Form is read-only")}
1290
- disabledReasonPlacement="inline"
1291
- onPress={handleSave}
1292
- >
1293
- {isSubmitting ? t("Saving...") : t("Save")}
1294
- </Button>
1295
- )}
1296
- </XStack>
1322
+ <FrappeFormActions
1323
+ indicator={indicator}
1324
+ action={docAction}
1325
+ busy={isSubmitting}
1326
+ disabled={effectiveDisabled}
1327
+ onSave={handleSave}
1328
+ onSubmit={handleSubmitDocument}
1329
+ onCancelDocument={handleCancelDocument}
1330
+ onAmend={showAmend ? handleAmend : undefined}
1331
+ amendedAs={amendedAs}
1332
+ onDelete={showDelete ? handleDelete : undefined}
1333
+ onEdit={showEdit ? onEdit : undefined}
1334
+ onClose={showClose ? handleCancel : undefined}
1335
+ closeDisabled={disabled}
1336
+ />
1297
1337
  )}
1298
1338
  </YStack>
1299
1339
  </XStack>
@@ -0,0 +1,216 @@
1
+ import { Chip, type ChipColor } from "@multiplatform.one/components";
2
+ import { Button } from "@multiplatform.one/forms";
3
+ import { useResolvedKnobs } from "@multiplatform.one/theme";
4
+ import {
5
+ ArrowCounterClockwiseIcon,
6
+ CheckCircleIcon,
7
+ FloppyDiskIcon,
8
+ PencilSimpleIcon,
9
+ ProhibitIcon,
10
+ TrashIcon,
11
+ XIcon,
12
+ } from "@phosphor-icons/react";
13
+ import { useTranslation } from "react-i18next";
14
+ import { XStack } from "tamagui";
15
+ import type { DocAction, DocStatusIndicator } from "./formEngine/docLifecycle";
16
+
17
+ const indicatorColors: Record<DocStatusIndicator, ChipColor> = {
18
+ notSaved: "orange",
19
+ draft: "red",
20
+ submitted: "blue",
21
+ cancelled: "red",
22
+ };
23
+
24
+ export interface FrappeFormActionsProps {
25
+ /** Desk's state pill: Not Saved, Draft, Submitted or Cancelled. */
26
+ indicator?: DocStatusIndicator;
27
+ /** The one lifecycle action the document's state offers (resolveDocAction). */
28
+ action?: DocAction;
29
+ /** A save or a docstatus transition is in flight. */
30
+ busy: boolean;
31
+ /** The form is read-only: actions render, disabled with the reason. */
32
+ disabled: boolean;
33
+ onSave: () => void;
34
+ onSubmit: () => void;
35
+ /** Cancels the submitted document (docstatus 1 → 2). */
36
+ onCancelDocument: () => void;
37
+ /** Absent, a cancelled document offers no Amend: the host has nowhere to open the draft. */
38
+ onAmend?: () => void;
39
+ /** The live amendment of this cancelled document; Amend stays disabled while it exists. */
40
+ amendedAs?: string;
41
+ onDelete?: () => void;
42
+ /**
43
+ * Given, the form is a read-only view and this opens its editor. The
44
+ * caller leaves it out when the document has nothing left to edit.
45
+ */
46
+ onEdit?: () => void;
47
+ /** The host's leave action (discards unsaved edits first). */
48
+ onClose?: () => void;
49
+ closeDisabled?: boolean;
50
+ }
51
+
52
+ /**
53
+ * FrappeForm's action bar with desk's docstatus lifecycle: one primary
54
+ * action at a time (Save, Submit, Update or Amend), Cancel Document as the
55
+ * secondary destructive action on a submitted document, and the state pill.
56
+ * A read-only view trades Save and the leave button for Edit.
57
+ */
58
+ export function FrappeFormActions({
59
+ indicator,
60
+ action,
61
+ busy,
62
+ disabled,
63
+ onSave,
64
+ onSubmit,
65
+ onCancelDocument,
66
+ onAmend,
67
+ amendedAs,
68
+ onDelete,
69
+ onEdit,
70
+ onClose,
71
+ closeDisabled,
72
+ }: FrappeFormActionsProps) {
73
+ const { t } = useTranslation();
74
+ const { knobProps } = useResolvedKnobs();
75
+ const readOnlyReason = t("Form is read-only");
76
+
77
+ const indicatorLabels: Record<DocStatusIndicator, string> = {
78
+ notSaved: t("Not Saved"),
79
+ draft: t("Draft"),
80
+ submitted: t("Submitted"),
81
+ cancelled: t("Cancelled"),
82
+ };
83
+
84
+ return (
85
+ <XStack
86
+ {...knobProps.gap}
87
+ justifyContent="flex-end"
88
+ alignItems="center"
89
+ paddingTop="$3"
90
+ testID="frappe-form-actions"
91
+ >
92
+ {indicator && (
93
+ <XStack flex={1} alignItems="center" testID={`frappe-form-status-${indicator}`}>
94
+ <Chip size="$2" variant="subtle" color={indicatorColors[indicator]}>
95
+ {indicatorLabels[indicator]}
96
+ </Chip>
97
+ </XStack>
98
+ )}
99
+
100
+ {onDelete && (
101
+ <Button
102
+ outlined
103
+ error
104
+ icon={TrashIcon}
105
+ loading={busy}
106
+ disabled={disabled}
107
+ disabledReason={disabled ? readOnlyReason : undefined}
108
+ disabledReasonPlacement="inline"
109
+ onPress={onDelete}
110
+ >
111
+ {t("Delete")}
112
+ </Button>
113
+ )}
114
+
115
+ {onClose && (
116
+ <Button
117
+ outlined
118
+ icon={XIcon}
119
+ disabled={closeDisabled}
120
+ disabledReason={closeDisabled ? readOnlyReason : undefined}
121
+ disabledReasonPlacement="inline"
122
+ onPress={onClose}
123
+ testID="frappe-form-close"
124
+ >
125
+ {/* A submitted document's own Cancel sits beside this button. */}
126
+ {action === "cancel" ? t("Close") : t("Cancel")}
127
+ </Button>
128
+ )}
129
+
130
+ {action === "cancel" && (
131
+ <Button
132
+ outlined
133
+ error
134
+ actionRole="secondary"
135
+ icon={ProhibitIcon}
136
+ loading={busy}
137
+ disabled={disabled}
138
+ disabledReason={disabled ? readOnlyReason : undefined}
139
+ disabledReasonPlacement="inline"
140
+ onPress={onCancelDocument}
141
+ testID="frappe-form-cancel-document"
142
+ >
143
+ {t("Cancel Document")}
144
+ </Button>
145
+ )}
146
+
147
+ {onEdit && (
148
+ <Button
149
+ accent={action !== "submit"}
150
+ outlined={action === "submit"}
151
+ icon={PencilSimpleIcon}
152
+ disabled={disabled}
153
+ disabledReason={disabled ? readOnlyReason : undefined}
154
+ disabledReasonPlacement="inline"
155
+ onPress={onEdit}
156
+ testID="frappe-form-edit"
157
+ >
158
+ {t("Edit")}
159
+ </Button>
160
+ )}
161
+
162
+ {action === "amend" && onAmend && (
163
+ <Button
164
+ // Desk demotes Amend to a secondary action once the document is amended.
165
+ accent={!amendedAs}
166
+ outlined={Boolean(amendedAs)}
167
+ icon={ArrowCounterClockwiseIcon}
168
+ loading={busy}
169
+ disabled={disabled || Boolean(amendedAs)}
170
+ disabledReason={
171
+ amendedAs
172
+ ? t("Already amended as {{name}}", { name: amendedAs })
173
+ : disabled
174
+ ? readOnlyReason
175
+ : undefined
176
+ }
177
+ disabledReasonPlacement="inline"
178
+ onPress={onAmend}
179
+ testID="frappe-form-amend"
180
+ >
181
+ {t("Amend")}
182
+ </Button>
183
+ )}
184
+
185
+ {action === "submit" && (
186
+ <Button
187
+ accent
188
+ icon={CheckCircleIcon}
189
+ loading={busy}
190
+ disabled={disabled}
191
+ disabledReason={disabled ? readOnlyReason : undefined}
192
+ disabledReasonPlacement="inline"
193
+ onPress={onSubmit}
194
+ testID="frappe-form-submit"
195
+ >
196
+ {t("Submit")}
197
+ </Button>
198
+ )}
199
+
200
+ {(action === "save" || action === "update") && (
201
+ <Button
202
+ accent
203
+ icon={FloppyDiskIcon}
204
+ loading={busy}
205
+ disabled={disabled}
206
+ disabledReason={disabled ? readOnlyReason : undefined}
207
+ disabledReasonPlacement="inline"
208
+ onPress={onSave}
209
+ testID="frappe-form-save"
210
+ >
211
+ {busy ? t("Saving...") : action === "update" ? t("Update") : t("Save")}
212
+ </Button>
213
+ )}
214
+ </XStack>
215
+ );
216
+ }