@multiplatform.one/frappe-ui 7.77.2 → 7.79.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 (136) hide show
  1. package/dist/cjs/FrappeForm.cjs +20 -26
  2. package/dist/cjs/FrappeForm.native.js +26 -26
  3. package/dist/cjs/FrappeForm.native.js.map +1 -1
  4. package/dist/cjs/FrappeListView.cjs +31 -13
  5. package/dist/cjs/FrappeListView.native.js +46 -13
  6. package/dist/cjs/FrappeListView.native.js.map +1 -1
  7. package/dist/cjs/fields/Password/index.cjs +125 -36
  8. package/dist/cjs/fields/Password/index.native.js +101 -13
  9. package/dist/cjs/fields/Password/index.native.js.map +1 -1
  10. package/dist/cjs/list/ListControlsSheet.cjs +5 -11
  11. package/dist/cjs/list/ListControlsSheet.native.js +5 -11
  12. package/dist/cjs/list/ListControlsSheet.native.js.map +1 -1
  13. package/dist/cjs/list/ListFiltersButton.cjs +56 -0
  14. package/dist/cjs/list/ListFiltersButton.native.js +60 -0
  15. package/dist/cjs/list/ListFiltersButton.native.js.map +1 -0
  16. package/dist/cjs/list/ListSidebarToolbar.cjs +5 -17
  17. package/dist/cjs/list/ListSidebarToolbar.native.js +7 -19
  18. package/dist/cjs/list/ListSidebarToolbar.native.js.map +1 -1
  19. package/dist/cjs/list/ListToolsSheet.cjs +5 -18
  20. package/dist/cjs/list/ListToolsSheet.native.js +7 -20
  21. package/dist/cjs/list/ListToolsSheet.native.js.map +1 -1
  22. package/dist/cjs/list/RecordCard.native.js +4 -3
  23. package/dist/cjs/list/RecordCard.native.js.map +1 -1
  24. package/dist/cjs/list/defaultProjection.cjs +56 -0
  25. package/dist/cjs/list/defaultProjection.native.js +107 -0
  26. package/dist/cjs/list/defaultProjection.native.js.map +1 -0
  27. package/dist/cjs/shared/devWarn.native.js.map +1 -1
  28. package/dist/esm/FrappeForm.mjs +21 -27
  29. package/dist/esm/FrappeForm.mjs.map +1 -1
  30. package/dist/esm/FrappeForm.native.js +27 -27
  31. package/dist/esm/FrappeForm.native.js.map +1 -1
  32. package/dist/esm/FrappeListView.mjs +31 -13
  33. package/dist/esm/FrappeListView.mjs.map +1 -1
  34. package/dist/esm/FrappeListView.native.js +46 -13
  35. package/dist/esm/FrappeListView.native.js.map +1 -1
  36. package/dist/esm/fields/Password/index.mjs +125 -36
  37. package/dist/esm/fields/Password/index.mjs.map +1 -1
  38. package/dist/esm/fields/Password/index.native.js +101 -13
  39. package/dist/esm/fields/Password/index.native.js.map +1 -1
  40. package/dist/esm/list/ListControlsSheet.mjs +7 -13
  41. package/dist/esm/list/ListControlsSheet.mjs.map +1 -1
  42. package/dist/esm/list/ListControlsSheet.native.js +7 -13
  43. package/dist/esm/list/ListControlsSheet.native.js.map +1 -1
  44. package/dist/esm/list/ListFiltersButton.mjs +31 -0
  45. package/dist/esm/list/ListFiltersButton.mjs.map +1 -0
  46. package/dist/esm/list/ListFiltersButton.native.js +32 -0
  47. package/dist/esm/list/ListFiltersButton.native.js.map +1 -0
  48. package/dist/esm/list/ListSidebarToolbar.mjs +7 -19
  49. package/dist/esm/list/ListSidebarToolbar.mjs.map +1 -1
  50. package/dist/esm/list/ListSidebarToolbar.native.js +9 -21
  51. package/dist/esm/list/ListSidebarToolbar.native.js.map +1 -1
  52. package/dist/esm/list/ListToolsSheet.mjs +7 -20
  53. package/dist/esm/list/ListToolsSheet.mjs.map +1 -1
  54. package/dist/esm/list/ListToolsSheet.native.js +9 -22
  55. package/dist/esm/list/ListToolsSheet.native.js.map +1 -1
  56. package/dist/esm/list/RecordCard.native.js +4 -3
  57. package/dist/esm/list/RecordCard.native.js.map +1 -1
  58. package/dist/esm/list/defaultProjection.mjs +29 -0
  59. package/dist/esm/list/defaultProjection.mjs.map +1 -0
  60. package/dist/esm/list/defaultProjection.native.js +77 -0
  61. package/dist/esm/list/defaultProjection.native.js.map +1 -0
  62. package/dist/esm/shared/devWarn.mjs.map +1 -1
  63. package/dist/esm/shared/devWarn.native.js.map +1 -1
  64. package/dist/jsx/FrappeForm.mjs +21 -27
  65. package/dist/jsx/FrappeForm.mjs.map +1 -1
  66. package/dist/jsx/FrappeForm.native.js +26 -26
  67. package/dist/jsx/FrappeForm.native.js.map +1 -1
  68. package/dist/jsx/FrappeListView.mjs +31 -13
  69. package/dist/jsx/FrappeListView.mjs.map +1 -1
  70. package/dist/jsx/FrappeListView.native.js +46 -13
  71. package/dist/jsx/FrappeListView.native.js.map +1 -1
  72. package/dist/jsx/fields/Password/index.mjs +125 -36
  73. package/dist/jsx/fields/Password/index.mjs.map +1 -1
  74. package/dist/jsx/fields/Password/index.native.js +101 -13
  75. package/dist/jsx/fields/Password/index.native.js.map +1 -1
  76. package/dist/jsx/list/ListControlsSheet.mjs +7 -13
  77. package/dist/jsx/list/ListControlsSheet.mjs.map +1 -1
  78. package/dist/jsx/list/ListControlsSheet.native.js +5 -11
  79. package/dist/jsx/list/ListControlsSheet.native.js.map +1 -1
  80. package/dist/jsx/list/ListFiltersButton.mjs +31 -0
  81. package/dist/jsx/list/ListFiltersButton.mjs.map +1 -0
  82. package/dist/jsx/list/ListFiltersButton.native.js +60 -0
  83. package/dist/jsx/list/ListFiltersButton.native.js.map +1 -0
  84. package/dist/jsx/list/ListSidebarToolbar.mjs +7 -19
  85. package/dist/jsx/list/ListSidebarToolbar.mjs.map +1 -1
  86. package/dist/jsx/list/ListSidebarToolbar.native.js +7 -19
  87. package/dist/jsx/list/ListSidebarToolbar.native.js.map +1 -1
  88. package/dist/jsx/list/ListToolsSheet.mjs +7 -20
  89. package/dist/jsx/list/ListToolsSheet.mjs.map +1 -1
  90. package/dist/jsx/list/ListToolsSheet.native.js +7 -20
  91. package/dist/jsx/list/ListToolsSheet.native.js.map +1 -1
  92. package/dist/jsx/list/RecordCard.native.js +4 -3
  93. package/dist/jsx/list/RecordCard.native.js.map +1 -1
  94. package/dist/jsx/list/defaultProjection.mjs +29 -0
  95. package/dist/jsx/list/defaultProjection.mjs.map +1 -0
  96. package/dist/jsx/list/defaultProjection.native.js +107 -0
  97. package/dist/jsx/list/defaultProjection.native.js.map +1 -0
  98. package/dist/jsx/shared/devWarn.mjs.map +1 -1
  99. package/dist/jsx/shared/devWarn.native.js.map +1 -1
  100. package/package.json +16 -16
  101. package/src/FrappeForm.recordChanged.spec.tsx +179 -0
  102. package/src/FrappeForm.recordChanged.stories.tsx +83 -0
  103. package/src/FrappeForm.successIsCallback.spec.tsx +154 -0
  104. package/src/FrappeForm.tsx +34 -33
  105. package/src/FrappeListView.projection.spec.tsx +193 -0
  106. package/src/FrappeListView.sidebar.spec.tsx +20 -2
  107. package/src/FrappeListView.spec.tsx +17 -0
  108. package/src/FrappeListView.successIsCallback.spec.tsx +143 -0
  109. package/src/FrappeListView.tsx +66 -28
  110. package/src/fields/Password/Password.secret.spec.tsx +194 -0
  111. package/src/fields/Password/Password.spec.tsx +15 -8
  112. package/src/fields/Password/index.tsx +134 -35
  113. package/src/list/ListControls.spec.tsx +10 -1
  114. package/src/list/ListControlsSheet.spec.tsx +4 -2
  115. package/src/list/ListControlsSheet.tsx +5 -9
  116. package/src/list/ListFiltersButton.tsx +33 -0
  117. package/src/list/ListSidebarToolbar.tsx +13 -20
  118. package/src/list/ListToolsSheet.tsx +10 -18
  119. package/src/list/RecordCard.native.tsx +3 -2
  120. package/src/list/defaultProjection.ts +66 -0
  121. package/src/shared/devWarn.ts +2 -1
  122. package/types/FrappeForm.d.ts.map +1 -1
  123. package/types/FrappeListView.d.ts.map +1 -1
  124. package/types/fields/Password/index.d.ts +6 -0
  125. package/types/fields/Password/index.d.ts.map +1 -1
  126. package/types/list/ListControlsSheet.d.ts.map +1 -1
  127. package/types/list/ListFiltersButton.d.ts +11 -0
  128. package/types/list/ListFiltersButton.d.ts.map +1 -0
  129. package/types/list/ListSidebarToolbar.d.ts +5 -3
  130. package/types/list/ListSidebarToolbar.d.ts.map +1 -1
  131. package/types/list/ListToolsSheet.d.ts.map +1 -1
  132. package/types/list/RecordCard.native.d.ts.map +1 -1
  133. package/types/list/defaultProjection.d.ts +26 -0
  134. package/types/list/defaultProjection.d.ts.map +1 -0
  135. package/types/shared/devWarn.d.ts +1 -1
  136. package/types/shared/devWarn.d.ts.map +1 -1
@@ -0,0 +1,179 @@
1
+ /**
2
+ * LC-191 RECORD-CHANGED-UNDER-YOU: an open record that changes on the server
3
+ * says so and keeps the work. A newer server version pushed mid-edit keeps
4
+ * the edit, names the field by its label and offers Reload or Overwrite; a
5
+ * record deleted mid-edit stays mounted, turns read-only and says so, which
6
+ * is not the not-found state.
7
+ *
8
+ * The server is the fixture corpus; a snapshot refresh of the shared
9
+ * SyncModule plays the change the live socket would deliver.
10
+ */
11
+
12
+ import { getFrappeSyncModule, type InMemoryFixtureProvider } from "@multiplatform.one/frappe";
13
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
14
+ import { act, cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
15
+ import i18next from "i18next";
16
+ import { initReactI18next } from "react-i18next";
17
+ import { Text } from "tamagui";
18
+ import { afterEach, beforeAll, describe, expect, it } from "vitest";
19
+ import { assetLoanLifecycleFixtures } from "../tests/fixtures/assetLoanLifecycle";
20
+ import { FrappeForm } from "./FrappeForm";
21
+ import { FrappeProvider } from "./FrappeProvider";
22
+ import type { FieldOverride } from "./schemaForm";
23
+
24
+ beforeAll(async () => {
25
+ await i18next.use(initReactI18next).init({
26
+ lng: "en",
27
+ interpolation: { escapeValue: false },
28
+ resources: { en: { translation: {} } },
29
+ });
30
+ });
31
+
32
+ afterEach(cleanup);
33
+
34
+ const probe: FieldOverride = (props) => {
35
+ const name = props.fieldMeta?.name ?? "";
36
+ const value = props.value === undefined || props.value === null ? "" : String(props.value);
37
+ return (
38
+ <>
39
+ <Text
40
+ testID={`probe-${name}`}
41
+ >{`ro=${!!props.readOnly || !!props.disabled} val=${value}`}</Text>
42
+ <button type="button" data-testid={`set-${name}`} onClick={() => props.onChange("mine")}>
43
+ set
44
+ </button>
45
+ </>
46
+ );
47
+ };
48
+
49
+ const overrides = { employee_name: probe, serial_no: probe, remarks: probe };
50
+
51
+ function renderDraft(fixtures: InMemoryFixtureProvider) {
52
+ return renderWithProviders(
53
+ <FrappeProvider baseURL="http://test" fixtures={fixtures}>
54
+ <FrappeForm
55
+ doctype="Asset Loan"
56
+ name="LOAN-DRAFT"
57
+ roles={["Loan Manager"]}
58
+ overrides={overrides}
59
+ showSidebar={false}
60
+ showTimeline={false}
61
+ onDelete={() => {}}
62
+ />
63
+ </FrappeProvider>,
64
+ );
65
+ }
66
+
67
+ async function serverWrites(fixtures: InMemoryFixtureProvider, write: () => Promise<unknown>) {
68
+ await act(async () => {
69
+ await write();
70
+ await getFrappeSyncModule({ fixtures })!.refreshSubscriptions({ doctypes: ["Asset Loan"] });
71
+ });
72
+ }
73
+
74
+ async function expectProbe(field: string, text: string) {
75
+ await waitFor(() => expect(screen.getByTestId(`probe-${field}`).textContent).toContain(text));
76
+ }
77
+
78
+ describe("a newer server version lands mid-edit (LC-191)", () => {
79
+ it("keeps the edit, names the field by its label and offers Reload and Overwrite", async () => {
80
+ const fixtures = assetLoanLifecycleFixtures();
81
+ renderDraft(fixtures);
82
+ await expectProbe("employee_name", "val=Ada Lovelace");
83
+ fireEvent.click(screen.getByTestId("set-employee_name"));
84
+ await expectProbe("employee_name", "val=mine");
85
+
86
+ await serverWrites(fixtures, () =>
87
+ fixtures.updateDoc("Asset Loan", "LOAN-DRAFT", { employee_name: "Grace Hopper" }),
88
+ );
89
+
90
+ const banner = await screen.findByTestId("frappe-form-conflict-banner");
91
+ expect(banner.textContent).toContain("Employee Name");
92
+ expect(banner.textContent).not.toContain("employee_name");
93
+ expect(screen.getByTestId("probe-employee_name").textContent).toContain("val=mine");
94
+ expect(banner.querySelector("[role='button'], button")).toBeTruthy();
95
+ expect(screen.getByText("Reload")).toBeTruthy();
96
+ expect(screen.getByText("Overwrite")).toBeTruthy();
97
+ });
98
+
99
+ it("a server change to a field the user left alone lands without a banner", async () => {
100
+ const fixtures = assetLoanLifecycleFixtures();
101
+ renderDraft(fixtures);
102
+ await expectProbe("remarks", "val=");
103
+ fireEvent.click(screen.getByTestId("set-employee_name"));
104
+
105
+ await serverWrites(fixtures, () =>
106
+ fixtures.updateDoc("Asset Loan", "LOAN-DRAFT", { remarks: "from the server" }),
107
+ );
108
+
109
+ await expectProbe("remarks", "val=from the server");
110
+ expect(screen.getByTestId("probe-employee_name").textContent).toContain("val=mine");
111
+ expect(screen.queryByTestId("frappe-form-conflict-banner")).toBeNull();
112
+ });
113
+
114
+ it("Reload drops the edit for the server copy", async () => {
115
+ const fixtures = assetLoanLifecycleFixtures();
116
+ renderDraft(fixtures);
117
+ await expectProbe("employee_name", "val=Ada Lovelace");
118
+ fireEvent.click(screen.getByTestId("set-employee_name"));
119
+ await serverWrites(fixtures, () =>
120
+ fixtures.updateDoc("Asset Loan", "LOAN-DRAFT", { employee_name: "Grace Hopper" }),
121
+ );
122
+ await screen.findByTestId("frappe-form-conflict-banner");
123
+
124
+ fireEvent.click(screen.getByText("Reload"));
125
+
126
+ await expectProbe("employee_name", "val=Grace Hopper");
127
+ expect(screen.queryByTestId("frappe-form-conflict-banner")).toBeNull();
128
+ });
129
+
130
+ it("Overwrite writes the edit over the newer server value", async () => {
131
+ const fixtures = assetLoanLifecycleFixtures();
132
+ renderDraft(fixtures);
133
+ await expectProbe("employee_name", "val=Ada Lovelace");
134
+ fireEvent.click(screen.getByTestId("set-employee_name"));
135
+ await serverWrites(fixtures, () =>
136
+ fixtures.updateDoc("Asset Loan", "LOAN-DRAFT", { employee_name: "Grace Hopper" }),
137
+ );
138
+ await screen.findByTestId("frappe-form-conflict-banner");
139
+
140
+ fireEvent.click(screen.getByText("Overwrite"));
141
+
142
+ await waitFor(async () =>
143
+ expect((await fixtures.getDoc("Asset Loan", "LOAN-DRAFT"))?.employee_name).toBe("mine"),
144
+ );
145
+ await waitFor(() => expect(screen.queryByTestId("frappe-form-conflict-banner")).toBeNull());
146
+ });
147
+ });
148
+
149
+ describe("the record is deleted mid-edit (LC-191)", () => {
150
+ it("stays mounted, turns read-only and says the edits can no longer be saved", async () => {
151
+ const fixtures = assetLoanLifecycleFixtures();
152
+ renderDraft(fixtures);
153
+ await expectProbe("employee_name", "ro=false val=Ada Lovelace");
154
+ fireEvent.click(screen.getByTestId("set-employee_name"));
155
+
156
+ await serverWrites(fixtures, () => fixtures.deleteDoc("Asset Loan", "LOAN-DRAFT"));
157
+
158
+ const banner = await screen.findByTestId("frappe-form-deleted-banner");
159
+ expect(banner.textContent).toContain("deleted on the server");
160
+ expect(banner.textContent).toContain("unsaved edits can no longer be saved");
161
+ await expectProbe("employee_name", "ro=true val=mine");
162
+ expect(screen.queryByTestId("frappe-form-status-notFound")).toBeNull();
163
+ expect(screen.queryByText("Asset Loan not found")).toBeNull();
164
+ expect(screen.queryByTestId("frappe-form-conflict-banner")).toBeNull();
165
+ });
166
+
167
+ it("a clean record says only that it is read-only now", async () => {
168
+ const fixtures = assetLoanLifecycleFixtures();
169
+ renderDraft(fixtures);
170
+ await expectProbe("employee_name", "ro=false val=Ada Lovelace");
171
+
172
+ await serverWrites(fixtures, () => fixtures.deleteDoc("Asset Loan", "LOAN-DRAFT"));
173
+
174
+ const banner = await screen.findByTestId("frappe-form-deleted-banner");
175
+ expect(banner.textContent).toContain("Fields are read-only.");
176
+ expect(banner.textContent).not.toContain("unsaved edits");
177
+ await expectProbe("employee_name", "ro=true val=Ada Lovelace");
178
+ });
179
+ });
@@ -0,0 +1,83 @@
1
+ import { getFrappeSyncModule, type InMemoryFixtureProvider } from "@multiplatform.one/frappe";
2
+ import { useEffect, useMemo, useRef } from "react";
3
+ import { assetLoanLifecycleFixtures } from "../tests/fixtures/assetLoanLifecycle";
4
+ import { FrappeForm } from "./FrappeForm";
5
+ import { FrappeProvider } from "./FrappeProvider";
6
+ import { useFrappeForm } from "./useFrappeForm";
7
+
8
+ export default {
9
+ title: "FrappeUI/FrappeForm Record Changed",
10
+ component: FrappeForm,
11
+ tags: ["!test"],
12
+ parameters: {
13
+ status: { type: "beta" },
14
+ docs: {
15
+ description: {
16
+ component:
17
+ "LC-191: the open record changes on the server. A newer version that touches a field the reader edited keeps the edit, names the field by its label and offers Reload or Overwrite. A record deleted mid-edit stays on screen, read-only, and says the unsaved edits can no longer be saved. Each story edits Employee Name, then plays the server change on the fixture corpus.",
18
+ },
19
+ },
20
+ },
21
+ };
22
+
23
+ type Loan = { name: string; doctype: string; employee_name?: string };
24
+
25
+ function Changed({
26
+ fixtures,
27
+ serverChange,
28
+ }: {
29
+ fixtures: InMemoryFixtureProvider;
30
+ serverChange: (fixtures: InMemoryFixtureProvider) => Promise<unknown>;
31
+ }) {
32
+ const form = useFrappeForm<Loan>({
33
+ doctype: "Asset Loan",
34
+ name: "LOAN-DRAFT",
35
+ roles: ["Loan Manager"],
36
+ });
37
+ const played = useRef(false);
38
+ const loaded = form.status === "ready" && form.baseline?.employee_name === "Ada Lovelace";
39
+ useEffect(() => {
40
+ if (!loaded || played.current) return;
41
+ played.current = true;
42
+ form.setValue("employee_name", "Ada King");
43
+ void serverChange(fixtures).then(() =>
44
+ getFrappeSyncModule({ fixtures })?.refreshSubscriptions({ doctypes: ["Asset Loan"] }),
45
+ );
46
+ }, [loaded, form, fixtures, serverChange]);
47
+ return (
48
+ <FrappeForm<Loan>
49
+ form={form}
50
+ doctype="Asset Loan"
51
+ name="LOAN-DRAFT"
52
+ showSidebar={false}
53
+ showTimeline={false}
54
+ />
55
+ );
56
+ }
57
+
58
+ function Story({
59
+ serverChange,
60
+ }: {
61
+ serverChange: (fixtures: InMemoryFixtureProvider) => Promise<unknown>;
62
+ }) {
63
+ const fixtures = useMemo(() => assetLoanLifecycleFixtures(), []);
64
+ return (
65
+ <FrappeProvider baseURL="https://form-record-changed.mock.test" fixtures={fixtures}>
66
+ <Changed fixtures={fixtures} serverChange={serverChange} />
67
+ </FrappeProvider>
68
+ );
69
+ }
70
+
71
+ /** Someone else saved Employee Name while it was being edited. */
72
+ export const ChangedOnServer = () => (
73
+ <Story
74
+ serverChange={(fixtures) =>
75
+ fixtures.updateDoc("Asset Loan", "LOAN-DRAFT", { employee_name: "Grace Hopper" })
76
+ }
77
+ />
78
+ );
79
+
80
+ /** The record was deleted while it was open with an unsaved edit. */
81
+ export const DeletedOnServer = () => (
82
+ <Story serverChange={(fixtures) => fixtures.deleteDoc("Asset Loan", "LOAN-DRAFT")} />
83
+ );
@@ -0,0 +1,154 @@
1
+ /**
2
+ * LC-194 SUCCESS-IS-A-CALLBACK: a data component reports, the page reacts.
3
+ * FrappeForm completes a create, a save and a delete and hands each result to
4
+ * its callback, with no toast or notify of its own and no navigation, even
5
+ * when the host has a router it could push.
6
+ */
7
+
8
+ import type { InMemoryFixtureProvider } from "@multiplatform.one/frappe";
9
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
10
+ import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
11
+ import i18next from "i18next";
12
+ import { initReactI18next } from "react-i18next";
13
+ import { Text } from "tamagui";
14
+ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
15
+ import { assetLoanLifecycleFixtures } from "../tests/fixtures/assetLoanLifecycle";
16
+ import { FrappeForm } from "./FrappeForm";
17
+ import type { FrappeNavigation } from "./frappeNavigation";
18
+ import { FrappeProvider } from "./FrappeProvider";
19
+ import type { FieldOverride } from "./schemaForm";
20
+
21
+ const feedback = vi.hoisted(() => ({ notify: vi.fn(), showToast: vi.fn() }));
22
+
23
+ vi.mock("@multiplatform.one/components", async (importOriginal) => {
24
+ const actual = await importOriginal<typeof import("@multiplatform.one/components")>();
25
+ return {
26
+ ...actual,
27
+ notify: (...args: Parameters<typeof actual.notify>) => {
28
+ feedback.notify(...args);
29
+ return actual.notify(...args);
30
+ },
31
+ showToast: (...args: Parameters<typeof actual.showToast>) => {
32
+ feedback.showToast(...args);
33
+ return actual.showToast(...args);
34
+ },
35
+ };
36
+ });
37
+
38
+ beforeAll(async () => {
39
+ await i18next.use(initReactI18next).init({
40
+ lng: "en",
41
+ interpolation: { escapeValue: false },
42
+ resources: { en: { translation: {} } },
43
+ });
44
+ });
45
+
46
+ beforeEach(() => {
47
+ vi.stubGlobal(
48
+ "confirm",
49
+ vi.fn(() => true),
50
+ );
51
+ });
52
+
53
+ afterEach(() => {
54
+ cleanup();
55
+ vi.unstubAllGlobals();
56
+ feedback.notify.mockClear();
57
+ feedback.showToast.mockClear();
58
+ });
59
+
60
+ const probe: FieldOverride = (props) => {
61
+ const name = props.fieldMeta?.name ?? "";
62
+ const value = props.value === undefined || props.value === null ? "" : String(props.value);
63
+ return (
64
+ <>
65
+ <Text testID={`probe-${name}`}>{`val=${value}`}</Text>
66
+ <button type="button" data-testid={`set-${name}`} onClick={() => props.onChange("Edited")}>
67
+ set
68
+ </button>
69
+ </>
70
+ );
71
+ };
72
+
73
+ const overrides = { employee_name: probe, serial_no: probe, remarks: probe };
74
+
75
+ function renderForm(fixtures: InMemoryFixtureProvider, name?: string) {
76
+ const pushed: string[] = [];
77
+ const navigation: FrappeNavigation = {
78
+ usePathname: () => "/loans",
79
+ useNavigate: () => (href) => pushed.push(href),
80
+ };
81
+ const onSave = vi.fn();
82
+ const onDelete = vi.fn();
83
+ renderWithProviders(
84
+ <FrappeProvider baseURL="http://test" fixtures={fixtures} navigation={navigation}>
85
+ <FrappeForm
86
+ doctype="Asset Loan"
87
+ name={name}
88
+ roles={["Loan Manager"]}
89
+ overrides={overrides}
90
+ showSidebar={false}
91
+ showTimeline={false}
92
+ onSave={onSave}
93
+ onDelete={onDelete}
94
+ />
95
+ </FrappeProvider>,
96
+ );
97
+ return { pushed, onSave, onDelete };
98
+ }
99
+
100
+ function press(name: string) {
101
+ const byLabel = document.querySelector(`[aria-label='${name}']`);
102
+ fireEvent.click((byLabel as HTMLElement | null) ?? screen.getByText(name));
103
+ }
104
+
105
+ function expectNoOwnFeedback(pushed: string[]) {
106
+ expect(feedback.notify).not.toHaveBeenCalled();
107
+ expect(feedback.showToast).not.toHaveBeenCalled();
108
+ expect(pushed).toEqual([]);
109
+ }
110
+
111
+ describe("FrappeForm reports a mutation through its callback only (LC-194)", () => {
112
+ it("a create hands the new record to onSave", async () => {
113
+ const fixtures = assetLoanLifecycleFixtures();
114
+ const { pushed, onSave } = renderForm(fixtures);
115
+ await screen.findByTestId("probe-employee_name");
116
+ fireEvent.click(screen.getByTestId("set-employee_name"));
117
+
118
+ press("Save");
119
+
120
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
121
+ expect(onSave.mock.calls[0]?.[0]).toMatchObject({ employee_name: "Edited" });
122
+ expect(typeof onSave.mock.calls[0]?.[0].name).toBe("string");
123
+ expectNoOwnFeedback(pushed);
124
+ });
125
+
126
+ it("a save hands the saved record to onSave", async () => {
127
+ const fixtures = assetLoanLifecycleFixtures();
128
+ const { pushed, onSave } = renderForm(fixtures, "LOAN-DRAFT");
129
+ await waitFor(() => expect(screen.getByTestId("probe-remarks").textContent).toBe("val="));
130
+ fireEvent.click(screen.getByTestId("set-remarks"));
131
+
132
+ press("Save");
133
+
134
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
135
+ expect(onSave.mock.calls[0]?.[0]).toMatchObject({ name: "LOAN-DRAFT", remarks: "Edited" });
136
+ expect((await fixtures.getDoc("Asset Loan", "LOAN-DRAFT"))?.remarks).toBe("Edited");
137
+ expectNoOwnFeedback(pushed);
138
+ });
139
+
140
+ it("a delete hands the removed record to onDelete", async () => {
141
+ const fixtures = assetLoanLifecycleFixtures();
142
+ const { pushed, onDelete } = renderForm(fixtures, "LOAN-DRAFT");
143
+ await waitFor(() =>
144
+ expect(screen.getByTestId("probe-employee_name").textContent).toBe("val=Ada Lovelace"),
145
+ );
146
+
147
+ press("Delete");
148
+
149
+ await waitFor(() => expect(onDelete).toHaveBeenCalledTimes(1));
150
+ expect(onDelete.mock.calls[0]?.[0]).toMatchObject({ name: "LOAN-DRAFT" });
151
+ expect(await fixtures.getDoc("Asset Loan", "LOAN-DRAFT")).toBeFalsy();
152
+ expectNoOwnFeedback(pushed);
153
+ });
154
+ });
@@ -5,6 +5,7 @@ import {
5
5
  type FormSection,
6
6
  } from "@multiplatform.one/frappe";
7
7
  import {
8
+ Alert,
8
9
  AsyncBoundary,
9
10
  AsyncSkeleton,
10
11
  DetailPageLayout,
@@ -516,6 +517,7 @@ function FrappeFormView<TDoc extends { name: string; doctype: string }>({
516
517
  canWrite: canWriteRecord,
517
518
  wasDeleted,
518
519
  conflictFields,
520
+ conflict,
519
521
  docError,
520
522
  tabs: formTabs,
521
523
  promptNamed,
@@ -968,44 +970,43 @@ function FrappeFormView<TDoc extends { name: string; doctype: string }>({
968
970
  ))
969
971
  : null}
970
972
 
973
+ {/* LC-191: the record changed under the open view. Deleted keeps the
974
+ view mounted and read-only; a conflict keeps the edits, names the
975
+ fields and lets the reader pick reload or overwrite. */}
971
976
  {showBanners && wasDeleted && (
972
- <XStack
973
- role="alert"
974
- alignItems="center"
975
- {...knobProps.containerRadius}
976
- {...knobProps.panelPadding}
977
- backgroundColor="$red3"
978
- testID="frappe-form-deleted-banner"
979
- >
980
- <TamaguiText color={formCommonColors.error} {...knobProps.body}>
981
- {hasUnsavedChanges
982
- ? t(
983
- "This {{doctype}} was deleted on the server. Fields are read-only and your unsaved edits can no longer be saved.",
984
- { doctype },
985
- )
986
- : t("This {{doctype}} was deleted on the server. Fields are read-only.", {
987
- doctype,
988
- })}
989
- </TamaguiText>
990
- </XStack>
977
+ <Alert intent="error" testID="frappe-form-deleted-banner">
978
+ {hasUnsavedChanges
979
+ ? t(
980
+ "This {{doctype}} was deleted on the server. Fields are read-only and your unsaved edits can no longer be saved.",
981
+ { doctype },
982
+ )
983
+ : t("This {{doctype}} was deleted on the server. Fields are read-only.", {
984
+ doctype,
985
+ })}
986
+ </Alert>
991
987
  )}
992
988
 
993
- {showBanners && !wasDeleted && conflictFields.length > 0 && (
994
- <XStack
995
- role="status"
996
- alignItems="center"
997
- {...knobProps.containerRadius}
998
- {...knobProps.panelPadding}
999
- backgroundColor="$orange3"
989
+ {showBanners && !wasDeleted && conflict && (
990
+ <Alert
991
+ intent="warning"
1000
992
  testID="frappe-form-conflict-banner"
993
+ title={t("Changed on the server")}
994
+ actionLabel={t("Reload")}
995
+ onAction={() => form.reset()}
996
+ {...(canWriteRecord && !effectiveDisabled
997
+ ? { secondaryActionLabel: t("Overwrite"), onSecondaryAction: handleSave }
998
+ : {})}
1001
999
  >
1002
- <TamaguiText color="$orange11" {...knobProps.body}>
1003
- {t(
1004
- "Changed elsewhere while you were editing: {{fields}}. Your edits are kept — saving will overwrite the newer server values.",
1005
- { fields: conflictLabels },
1006
- )}
1007
- </TamaguiText>
1008
- </XStack>
1000
+ {conflictLabels
1001
+ ? t(
1002
+ "{{fields}} changed while you were editing. Your edits are kept until you reload or overwrite.",
1003
+ { fields: conflictLabels },
1004
+ )
1005
+ : t(
1006
+ "This {{doctype}} changed after you opened it. Your edits are kept until you reload or overwrite.",
1007
+ { doctype },
1008
+ )}
1009
+ </Alert>
1009
1010
  )}
1010
1011
 
1011
1012
  {/* flexBasis auto — see the root stack note (zero-height collapse in