@multiplatform.one/frappe-ui 7.22.0 → 7.23.1
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/README.md +14 -0
- package/dist/cjs/FrappeForm.cjs +96 -52
- package/dist/cjs/FrappeForm.native.js +103 -52
- package/dist/cjs/FrappeForm.native.js.map +1 -1
- package/dist/cjs/FrappeFormActions.cjs +161 -0
- package/dist/cjs/FrappeFormActions.native.js +167 -0
- package/dist/cjs/FrappeFormActions.native.js.map +1 -0
- package/dist/cjs/FrappeTable.cjs +42 -13
- package/dist/cjs/FrappeTable.native.js +55 -19
- package/dist/cjs/FrappeTable.native.js.map +1 -1
- package/dist/cjs/desk/DeskFormPage.cjs +2 -1
- package/dist/cjs/desk/DeskFormPage.native.js +3 -0
- package/dist/cjs/desk/DeskFormPage.native.js.map +1 -1
- package/dist/cjs/filterFields.cjs +277 -34
- package/dist/cjs/filterFields.native.js +311 -36
- package/dist/cjs/filterFields.native.js.map +1 -1
- package/dist/cjs/formEngine/docLifecycle.cjs +98 -0
- package/dist/cjs/formEngine/docLifecycle.native.js +131 -0
- package/dist/cjs/formEngine/docLifecycle.native.js.map +1 -0
- package/dist/cjs/formEngine/useAmendedAs.cjs +48 -0
- package/dist/cjs/formEngine/useAmendedAs.native.js +60 -0
- package/dist/cjs/formEngine/useAmendedAs.native.js.map +1 -0
- package/dist/cjs/formEngine/useDocRights.cjs +2 -2
- package/dist/cjs/formEngine/useDocRights.native.js +2 -2
- package/dist/cjs/formEngine/useDocRights.native.js.map +1 -1
- package/dist/cjs/formEngine/useFormEngine.cjs +1 -0
- package/dist/cjs/formEngine/useFormEngine.native.js +1 -0
- package/dist/cjs/formEngine/useFormEngine.native.js.map +1 -1
- package/dist/cjs/index.cjs +30 -1
- package/dist/cjs/index.native.js +30 -1
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/list/listColumns.cjs +397 -0
- package/dist/cjs/list/listColumns.native.js +447 -0
- package/dist/cjs/list/listColumns.native.js.map +1 -0
- package/dist/cjs/list/listFields.cjs +50 -0
- package/dist/cjs/list/listFields.native.js +79 -0
- package/dist/cjs/list/listFields.native.js.map +1 -0
- package/dist/cjs/list/listFormat.cjs +68 -0
- package/dist/cjs/list/listFormat.native.js +74 -0
- package/dist/cjs/list/listFormat.native.js.map +1 -0
- package/dist/cjs/list/statusColors.cjs +102 -0
- package/dist/cjs/list/statusColors.native.js +109 -0
- package/dist/cjs/list/statusColors.native.js.map +1 -0
- package/dist/cjs/useFrappeAggregate.cjs +1 -1
- package/dist/cjs/useFrappeAggregate.native.js +1 -1
- package/dist/cjs/useFrappeAggregate.native.js.map +1 -1
- package/dist/esm/FrappeForm.mjs +97 -53
- package/dist/esm/FrappeForm.mjs.map +1 -1
- package/dist/esm/FrappeForm.native.js +104 -53
- package/dist/esm/FrappeForm.native.js.map +1 -1
- package/dist/esm/FrappeFormActions.mjs +136 -0
- package/dist/esm/FrappeFormActions.mjs.map +1 -0
- package/dist/esm/FrappeFormActions.native.js +139 -0
- package/dist/esm/FrappeFormActions.native.js.map +1 -0
- package/dist/esm/FrappeTable.mjs +42 -13
- package/dist/esm/FrappeTable.mjs.map +1 -1
- package/dist/esm/FrappeTable.native.js +55 -19
- package/dist/esm/FrappeTable.native.js.map +1 -1
- package/dist/esm/desk/DeskFormPage.mjs +2 -1
- package/dist/esm/desk/DeskFormPage.mjs.map +1 -1
- package/dist/esm/desk/DeskFormPage.native.js +3 -0
- package/dist/esm/desk/DeskFormPage.native.js.map +1 -1
- package/dist/esm/filterFields.mjs +263 -34
- package/dist/esm/filterFields.mjs.map +1 -1
- package/dist/esm/filterFields.native.js +297 -36
- package/dist/esm/filterFields.native.js.map +1 -1
- package/dist/esm/formEngine/docLifecycle.mjs +68 -0
- package/dist/esm/formEngine/docLifecycle.mjs.map +1 -0
- package/dist/esm/formEngine/docLifecycle.native.js +98 -0
- package/dist/esm/formEngine/docLifecycle.native.js.map +1 -0
- package/dist/esm/formEngine/useAmendedAs.mjs +23 -0
- package/dist/esm/formEngine/useAmendedAs.mjs.map +1 -0
- package/dist/esm/formEngine/useAmendedAs.native.js +32 -0
- package/dist/esm/formEngine/useAmendedAs.native.js.map +1 -0
- package/dist/esm/formEngine/useDocRights.mjs +2 -2
- package/dist/esm/formEngine/useDocRights.mjs.map +1 -1
- package/dist/esm/formEngine/useDocRights.native.js +2 -2
- package/dist/esm/formEngine/useDocRights.native.js.map +1 -1
- package/dist/esm/formEngine/useFormEngine.mjs +1 -1
- package/dist/esm/formEngine/useFormEngine.mjs.map +1 -1
- package/dist/esm/formEngine/useFormEngine.native.js +1 -1
- package/dist/esm/formEngine/useFormEngine.native.js.map +1 -1
- package/dist/esm/index.mjs +6 -2
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +6 -2
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/list/listColumns.mjs +372 -0
- package/dist/esm/list/listColumns.mjs.map +1 -0
- package/dist/esm/list/listColumns.native.js +419 -0
- package/dist/esm/list/listColumns.native.js.map +1 -0
- package/dist/esm/list/listFields.mjs +23 -0
- package/dist/esm/list/listFields.mjs.map +1 -0
- package/dist/esm/list/listFields.native.js +49 -0
- package/dist/esm/list/listFields.native.js.map +1 -0
- package/dist/esm/list/listFormat.mjs +38 -0
- package/dist/esm/list/listFormat.mjs.map +1 -0
- package/dist/esm/list/listFormat.native.js +41 -0
- package/dist/esm/list/listFormat.native.js.map +1 -0
- package/dist/esm/list/statusColors.mjs +77 -0
- package/dist/esm/list/statusColors.mjs.map +1 -0
- package/dist/esm/list/statusColors.native.js +81 -0
- package/dist/esm/list/statusColors.native.js.map +1 -0
- package/dist/esm/useFrappeAggregate.mjs +1 -1
- package/dist/esm/useFrappeAggregate.mjs.map +1 -1
- package/dist/esm/useFrappeAggregate.native.js +1 -1
- package/dist/esm/useFrappeAggregate.native.js.map +1 -1
- package/dist/jsx/FrappeForm.mjs +97 -53
- package/dist/jsx/FrappeForm.mjs.map +1 -1
- package/dist/jsx/FrappeForm.native.js +103 -52
- package/dist/jsx/FrappeForm.native.js.map +1 -1
- package/dist/jsx/FrappeFormActions.mjs +136 -0
- package/dist/jsx/FrappeFormActions.mjs.map +1 -0
- package/dist/jsx/FrappeFormActions.native.js +167 -0
- package/dist/jsx/FrappeFormActions.native.js.map +1 -0
- package/dist/jsx/FrappeTable.mjs +42 -13
- package/dist/jsx/FrappeTable.mjs.map +1 -1
- package/dist/jsx/FrappeTable.native.js +55 -19
- package/dist/jsx/FrappeTable.native.js.map +1 -1
- package/dist/jsx/desk/DeskFormPage.mjs +2 -1
- package/dist/jsx/desk/DeskFormPage.mjs.map +1 -1
- package/dist/jsx/desk/DeskFormPage.native.js +3 -0
- package/dist/jsx/desk/DeskFormPage.native.js.map +1 -1
- package/dist/jsx/filterFields.mjs +263 -34
- package/dist/jsx/filterFields.mjs.map +1 -1
- package/dist/jsx/filterFields.native.js +311 -36
- package/dist/jsx/filterFields.native.js.map +1 -1
- package/dist/jsx/formEngine/docLifecycle.mjs +68 -0
- package/dist/jsx/formEngine/docLifecycle.mjs.map +1 -0
- package/dist/jsx/formEngine/docLifecycle.native.js +131 -0
- package/dist/jsx/formEngine/docLifecycle.native.js.map +1 -0
- package/dist/jsx/formEngine/useAmendedAs.mjs +23 -0
- package/dist/jsx/formEngine/useAmendedAs.mjs.map +1 -0
- package/dist/jsx/formEngine/useAmendedAs.native.js +60 -0
- package/dist/jsx/formEngine/useAmendedAs.native.js.map +1 -0
- package/dist/jsx/formEngine/useDocRights.mjs +2 -2
- package/dist/jsx/formEngine/useDocRights.mjs.map +1 -1
- package/dist/jsx/formEngine/useDocRights.native.js +2 -2
- package/dist/jsx/formEngine/useDocRights.native.js.map +1 -1
- package/dist/jsx/formEngine/useFormEngine.mjs +1 -1
- package/dist/jsx/formEngine/useFormEngine.mjs.map +1 -1
- package/dist/jsx/formEngine/useFormEngine.native.js +1 -0
- package/dist/jsx/formEngine/useFormEngine.native.js.map +1 -1
- package/dist/jsx/index.js +6 -2
- package/dist/jsx/index.js.map +1 -1
- package/dist/jsx/index.mjs +6 -2
- package/dist/jsx/index.mjs.map +1 -1
- package/dist/jsx/index.native.js +30 -1
- package/dist/jsx/index.native.js.map +1 -1
- package/dist/jsx/list/listColumns.mjs +372 -0
- package/dist/jsx/list/listColumns.mjs.map +1 -0
- package/dist/jsx/list/listColumns.native.js +447 -0
- package/dist/jsx/list/listColumns.native.js.map +1 -0
- package/dist/jsx/list/listFields.mjs +23 -0
- package/dist/jsx/list/listFields.mjs.map +1 -0
- package/dist/jsx/list/listFields.native.js +79 -0
- package/dist/jsx/list/listFields.native.js.map +1 -0
- package/dist/jsx/list/listFormat.mjs +38 -0
- package/dist/jsx/list/listFormat.mjs.map +1 -0
- package/dist/jsx/list/listFormat.native.js +74 -0
- package/dist/jsx/list/listFormat.native.js.map +1 -0
- package/dist/jsx/list/statusColors.mjs +77 -0
- package/dist/jsx/list/statusColors.mjs.map +1 -0
- package/dist/jsx/list/statusColors.native.js +109 -0
- package/dist/jsx/list/statusColors.native.js.map +1 -0
- package/dist/jsx/useFrappeAggregate.mjs +1 -1
- package/dist/jsx/useFrappeAggregate.mjs.map +1 -1
- package/dist/jsx/useFrappeAggregate.native.js +1 -1
- package/dist/jsx/useFrappeAggregate.native.js.map +1 -1
- package/package.json +22 -16
- package/src/FrappeForm.deskSemantics.spec.tsx +1 -1
- package/src/FrappeForm.lifecycle.spec.tsx +463 -0
- package/src/FrappeForm.lifecycle.stories.tsx +84 -0
- package/src/FrappeForm.tsx +124 -84
- package/src/FrappeFormActions.tsx +216 -0
- package/src/FrappeTable.defaultColumns.spec.tsx +229 -0
- package/src/FrappeTable.tsx +79 -32
- package/src/desk/DeskFormPage.tsx +1 -0
- package/src/filterFields.spec.ts +57 -2
- package/src/filterFields.ts +365 -38
- package/src/formEngine/docLifecycle.spec.ts +195 -0
- package/src/formEngine/docLifecycle.ts +148 -0
- package/src/formEngine/useAmendedAs.ts +38 -0
- package/src/formEngine/useDocRights.ts +11 -3
- package/src/formEngine/useFormEngine.ts +1 -1
- package/src/frappeViews.designLaw.spec.ts +6 -0
- package/src/index.ts +47 -1
- package/src/list/listColumns.tsx +488 -0
- package/src/list/listFields.spec.ts +147 -0
- package/src/list/listFields.ts +68 -0
- package/src/list/listFormat.spec.ts +151 -0
- package/src/list/listFormat.ts +80 -0
- package/src/list/seam.spec.ts +116 -0
- package/src/list/statusColors.spec.ts +100 -0
- package/src/list/statusColors.ts +94 -0
- package/src/useFrappeAggregate.ts +1 -1
- package/types/FrappeForm.d.ts +13 -3
- package/types/FrappeForm.d.ts.map +1 -1
- package/types/FrappeFormActions.d.ts +36 -0
- package/types/FrappeFormActions.d.ts.map +1 -0
- package/types/FrappeTable.d.ts +11 -5
- package/types/FrappeTable.d.ts.map +1 -1
- package/types/desk/DeskFormPage.d.ts.map +1 -1
- package/types/filterFields.d.ts +81 -10
- package/types/filterFields.d.ts.map +1 -1
- package/types/formEngine/docLifecycle.d.ts +59 -0
- package/types/formEngine/docLifecycle.d.ts.map +1 -0
- package/types/formEngine/useAmendedAs.d.ts +9 -0
- package/types/formEngine/useAmendedAs.d.ts.map +1 -0
- package/types/formEngine/useDocRights.d.ts +1 -1
- package/types/formEngine/useDocRights.d.ts.map +1 -1
- package/types/formEngine/useFormEngine.d.ts +1 -0
- package/types/formEngine/useFormEngine.d.ts.map +1 -1
- package/types/index.d.ts +5 -1
- package/types/index.d.ts.map +1 -1
- package/types/list/listColumns.d.ts +41 -0
- package/types/list/listColumns.d.ts.map +1 -0
- package/types/list/listFields.d.ts +16 -0
- package/types/list/listFields.d.ts.map +1 -0
- package/types/list/listFormat.d.ts +30 -0
- package/types/list/listFormat.d.ts.map +1 -0
- package/types/list/statusColors.d.ts +14 -0
- package/types/list/statusColors.d.ts.map +1 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FrappeForm runs a submittable doctype's docstatus lifecycle the way desk
|
|
3
|
+
* does (MPO-15 AC1): Save, Submit, Update, Cancel and Amend, one action at
|
|
4
|
+
* a time, gated by the roles' submit/cancel/amend rights.
|
|
5
|
+
*
|
|
6
|
+
* The "Asset Loan" fixture (tests/fixtures/assetLoanLifecycle.ts) is a
|
|
7
|
+
* submittable doctype with one document per state. Transitions are asserted
|
|
8
|
+
* against the fixture corpus, which is what the form wrote.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { InMemoryFixtureProvider } from "@multiplatform.one/frappe";
|
|
12
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
13
|
+
import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
|
|
14
|
+
import i18next from "i18next";
|
|
15
|
+
import { useState } from "react";
|
|
16
|
+
import { initReactI18next } from "react-i18next";
|
|
17
|
+
import { Text } from "tamagui";
|
|
18
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
19
|
+
import { assetLoanLifecycleFixtures } from "../tests/fixtures/assetLoanLifecycle";
|
|
20
|
+
import { takeCreatePrefill } from "./formEngine/createPrefill";
|
|
21
|
+
import { FrappeForm } from "./FrappeForm";
|
|
22
|
+
import type { FrappeNavigation } from "./frappeNavigation";
|
|
23
|
+
import { FrappeProvider } from "./FrappeProvider";
|
|
24
|
+
import type { FieldOverride } from "./schemaForm";
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
await i18next.use(initReactI18next).init({
|
|
28
|
+
lng: "en",
|
|
29
|
+
interpolation: { escapeValue: false },
|
|
30
|
+
resources: { en: { translation: {} } },
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
let confirm: ReturnType<typeof vi.fn>;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
confirm = vi.fn(() => true);
|
|
38
|
+
vi.stubGlobal("confirm", confirm);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
cleanup();
|
|
43
|
+
vi.unstubAllGlobals();
|
|
44
|
+
vi.restoreAllMocks();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const corpus = assetLoanLifecycleFixtures;
|
|
48
|
+
|
|
49
|
+
const probe: FieldOverride = (props) => {
|
|
50
|
+
const name = props.fieldMeta?.name ?? "";
|
|
51
|
+
const value = props.value === undefined || props.value === null ? "" : String(props.value);
|
|
52
|
+
return (
|
|
53
|
+
<>
|
|
54
|
+
<Text testID={`probe-${name}`}>
|
|
55
|
+
{`ro=${!!props.readOnly} val=${value} err=${props.error ?? ""} off=${!!props.disabled}`}
|
|
56
|
+
</Text>
|
|
57
|
+
<button type="button" data-testid={`set-${name}`} onClick={() => props.onChange("edited")}>
|
|
58
|
+
set
|
|
59
|
+
</button>
|
|
60
|
+
</>
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const overrides = { employee_name: probe, serial_no: probe, remarks: probe };
|
|
65
|
+
|
|
66
|
+
interface RenderOptions {
|
|
67
|
+
name?: string;
|
|
68
|
+
roles?: string[];
|
|
69
|
+
onAmend?: (draft: Record<string, unknown>) => void;
|
|
70
|
+
onDelete?: () => void;
|
|
71
|
+
onCancel?: () => void;
|
|
72
|
+
onEdit?: () => void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function renderForm(fixtures: InMemoryFixtureProvider, options: RenderOptions = {}) {
|
|
76
|
+
return renderWithProviders(
|
|
77
|
+
<FrappeProvider baseURL="http://test" fixtures={fixtures}>
|
|
78
|
+
<FrappeForm
|
|
79
|
+
doctype="Asset Loan"
|
|
80
|
+
name={options.name}
|
|
81
|
+
roles={options.roles ?? ["Loan Manager"]}
|
|
82
|
+
overrides={overrides}
|
|
83
|
+
showSidebar={false}
|
|
84
|
+
showTimeline={false}
|
|
85
|
+
onAmend={options.onAmend}
|
|
86
|
+
onDelete={options.onDelete}
|
|
87
|
+
onCancel={options.onCancel}
|
|
88
|
+
onEdit={options.onEdit}
|
|
89
|
+
/>
|
|
90
|
+
</FrappeProvider>,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function renderPage(fixtures: InMemoryFixtureProvider, pathname: string) {
|
|
95
|
+
const pushed: string[] = [];
|
|
96
|
+
const navigation: FrappeNavigation = {
|
|
97
|
+
usePathname: () => pathname,
|
|
98
|
+
useNavigate: () => (href) => pushed.push(href),
|
|
99
|
+
};
|
|
100
|
+
renderWithProviders(
|
|
101
|
+
<FrappeProvider baseURL="http://test" fixtures={fixtures} navigation={navigation}>
|
|
102
|
+
<FrappeForm
|
|
103
|
+
doctype="Asset Loan"
|
|
104
|
+
route="/loans"
|
|
105
|
+
roles={["Loan Manager"]}
|
|
106
|
+
overrides={overrides}
|
|
107
|
+
showSidebar={false}
|
|
108
|
+
showTimeline={false}
|
|
109
|
+
/>
|
|
110
|
+
</FrappeProvider>,
|
|
111
|
+
);
|
|
112
|
+
return pushed;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function expectProbe(field: string, text: string): Promise<void> {
|
|
116
|
+
await waitFor(() => {
|
|
117
|
+
expect(screen.getByTestId(`probe-${field}`).textContent).toContain(text);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function expectStatus(status: string): Promise<void> {
|
|
122
|
+
await waitFor(() => expect(screen.getByTestId(`frappe-form-status-${status}`)).toBeTruthy());
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function docstatusOf(fixtures: InMemoryFixtureProvider, name: string) {
|
|
126
|
+
return (await fixtures.getDoc("Asset Loan", name))?.docstatus;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
describe("FrappeForm runs the desk docstatus lifecycle", () => {
|
|
130
|
+
it("a saved draft offers Submit, which asks first and locks the document", async () => {
|
|
131
|
+
const fixtures = corpus();
|
|
132
|
+
renderForm(fixtures, { name: "LOAN-DRAFT" });
|
|
133
|
+
await expectProbe("employee_name", "val=Ada Lovelace");
|
|
134
|
+
await expectStatus("draft");
|
|
135
|
+
expect(screen.queryByText("Save")).toBeNull();
|
|
136
|
+
|
|
137
|
+
fireEvent.click(screen.getByText("Submit"));
|
|
138
|
+
await waitFor(() => expect(confirm).toHaveBeenCalledWith("Permanently Submit LOAN-DRAFT?"));
|
|
139
|
+
await expectStatus("submitted");
|
|
140
|
+
await waitFor(async () => expect(await docstatusOf(fixtures, "LOAN-DRAFT")).toBe(1));
|
|
141
|
+
await expectProbe("employee_name", "ro=true");
|
|
142
|
+
await expectProbe("remarks", "ro=false");
|
|
143
|
+
expect(screen.getByTestId("frappe-form-cancel-document")).toBeTruthy();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("a declined confirmation leaves the draft alone", async () => {
|
|
147
|
+
confirm.mockReturnValue(false);
|
|
148
|
+
const fixtures = corpus();
|
|
149
|
+
renderForm(fixtures, { name: "LOAN-DRAFT" });
|
|
150
|
+
await expectStatus("draft");
|
|
151
|
+
|
|
152
|
+
fireEvent.click(screen.getByText("Submit"));
|
|
153
|
+
await waitFor(() => expect(confirm).toHaveBeenCalled());
|
|
154
|
+
expect(await docstatusOf(fixtures, "LOAN-DRAFT")).toBe(0);
|
|
155
|
+
expect(screen.getByTestId("frappe-form-status-draft")).toBeTruthy();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("Submit refuses a draft whose mandatory field is empty", async () => {
|
|
159
|
+
const fixtures = corpus();
|
|
160
|
+
renderForm(fixtures, { name: "LOAN-UNNAMED" });
|
|
161
|
+
await expectProbe("serial_no", "val=SN-2");
|
|
162
|
+
|
|
163
|
+
fireEvent.click(screen.getByText("Submit"));
|
|
164
|
+
await expectProbe("employee_name", "err=Employee Name is required");
|
|
165
|
+
expect(confirm).not.toHaveBeenCalled();
|
|
166
|
+
expect(await docstatusOf(fixtures, "LOAN-UNNAMED")).toBe(0);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("an edited draft saves first; Submit waits for a clean document", async () => {
|
|
170
|
+
renderForm(corpus(), { name: "LOAN-DRAFT" });
|
|
171
|
+
await expectStatus("draft");
|
|
172
|
+
|
|
173
|
+
fireEvent.click(screen.getByTestId("set-remarks"));
|
|
174
|
+
await expectStatus("notSaved");
|
|
175
|
+
expect(screen.getByText("Save")).toBeTruthy();
|
|
176
|
+
expect(screen.queryByText("Submit")).toBeNull();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("a submitted document cancels, is never deleted, and the host's leave button reads Close", async () => {
|
|
180
|
+
const fixtures = corpus();
|
|
181
|
+
renderForm(fixtures, {
|
|
182
|
+
name: "LOAN-SUBMITTED",
|
|
183
|
+
onDelete: vi.fn(),
|
|
184
|
+
onCancel: vi.fn(),
|
|
185
|
+
onAmend: vi.fn(),
|
|
186
|
+
});
|
|
187
|
+
await expectStatus("submitted");
|
|
188
|
+
expect(screen.queryByText("Delete")).toBeNull();
|
|
189
|
+
expect(screen.getByText("Close")).toBeTruthy();
|
|
190
|
+
expect(screen.getByTestId("frappe-form-cancel-document").textContent).toContain(
|
|
191
|
+
"Cancel Document",
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
fireEvent.click(screen.getByTestId("frappe-form-cancel-document"));
|
|
195
|
+
await waitFor(() => expect(confirm).toHaveBeenCalledWith("Permanently Cancel LOAN-SUBMITTED?"));
|
|
196
|
+
await expectStatus("cancelled");
|
|
197
|
+
await waitFor(async () => expect(await docstatusOf(fixtures, "LOAN-SUBMITTED")).toBe(2));
|
|
198
|
+
expect(screen.getByText("Amend")).toBeTruthy();
|
|
199
|
+
expect(screen.getByText("Delete")).toBeTruthy();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("an allow_on_submit edit turns Cancel into Update, which keeps the document submitted", async () => {
|
|
203
|
+
const fixtures = corpus();
|
|
204
|
+
renderForm(fixtures, { name: "LOAN-SUBMITTED" });
|
|
205
|
+
await expectStatus("submitted");
|
|
206
|
+
|
|
207
|
+
fireEvent.click(screen.getByTestId("set-remarks"));
|
|
208
|
+
await waitFor(() => expect(screen.getByText("Update")).toBeTruthy());
|
|
209
|
+
expect(screen.queryByTestId("frappe-form-cancel-document")).toBeNull();
|
|
210
|
+
|
|
211
|
+
fireEvent.click(screen.getByText("Update"));
|
|
212
|
+
await waitFor(async () =>
|
|
213
|
+
expect((await fixtures.getDoc("Asset Loan", "LOAN-SUBMITTED"))?.remarks).toBe("edited"),
|
|
214
|
+
);
|
|
215
|
+
expect(await docstatusOf(fixtures, "LOAN-SUBMITTED")).toBe(1);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("Amend hands the host a draft that a create-mode form opens and saves as the amendment", async () => {
|
|
219
|
+
const fixtures = corpus();
|
|
220
|
+
const onAmend = vi.fn();
|
|
221
|
+
function Host() {
|
|
222
|
+
const [name, setName] = useState<string | undefined>("LOAN-CANCELLED");
|
|
223
|
+
return (
|
|
224
|
+
<FrappeProvider baseURL="http://test" fixtures={fixtures}>
|
|
225
|
+
<FrappeForm
|
|
226
|
+
doctype="Asset Loan"
|
|
227
|
+
name={name}
|
|
228
|
+
roles={["Loan Manager"]}
|
|
229
|
+
overrides={overrides}
|
|
230
|
+
showSidebar={false}
|
|
231
|
+
showTimeline={false}
|
|
232
|
+
onAmend={(draft) => {
|
|
233
|
+
onAmend(draft);
|
|
234
|
+
setName(undefined);
|
|
235
|
+
}}
|
|
236
|
+
/>
|
|
237
|
+
</FrappeProvider>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
renderWithProviders(<Host />);
|
|
241
|
+
await expectStatus("cancelled");
|
|
242
|
+
|
|
243
|
+
fireEvent.click(screen.getByText("Amend"));
|
|
244
|
+
await waitFor(() => expect(onAmend).toHaveBeenCalled());
|
|
245
|
+
const draft = onAmend.mock.calls[0]![0] as Record<string, unknown>;
|
|
246
|
+
expect(draft).toMatchObject({
|
|
247
|
+
amended_from: "LOAN-CANCELLED",
|
|
248
|
+
employee_name: "Katherine Johnson",
|
|
249
|
+
serial_no: "SN-4",
|
|
250
|
+
remarks: "Wrong serial",
|
|
251
|
+
docstatus: 0,
|
|
252
|
+
});
|
|
253
|
+
expect(draft).not.toHaveProperty("name");
|
|
254
|
+
|
|
255
|
+
await expectStatus("notSaved");
|
|
256
|
+
await expectProbe("employee_name", "val=Katherine Johnson");
|
|
257
|
+
await expectProbe("serial_no", "ro=false");
|
|
258
|
+
fireEvent.click(screen.getByText("Save"));
|
|
259
|
+
await waitFor(async () => {
|
|
260
|
+
const amendments = await fixtures.getList("Asset Loan", {
|
|
261
|
+
filters: { amended_from: "LOAN-CANCELLED" },
|
|
262
|
+
});
|
|
263
|
+
expect(amendments).toHaveLength(1);
|
|
264
|
+
expect(amendments[0]).toMatchObject({ docstatus: 0, serial_no: "SN-4" });
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("Amend stays disabled while a live amendment exists", async () => {
|
|
269
|
+
const onAmend = vi.fn();
|
|
270
|
+
renderForm(corpus(), { name: "LOAN-REDONE", onAmend });
|
|
271
|
+
await expectStatus("cancelled");
|
|
272
|
+
await waitFor(() => expect(screen.getByText("Already amended as LOAN-REDONE-1")).toBeTruthy());
|
|
273
|
+
|
|
274
|
+
fireEvent.click(screen.getByText("Amend"));
|
|
275
|
+
expect(onAmend).not.toHaveBeenCalled();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("the roles decide: a Loan Officer submits but neither cancels nor amends", async () => {
|
|
279
|
+
renderForm(corpus(), { name: "LOAN-SUBMITTED", roles: ["Loan Officer"], onCancel: vi.fn() });
|
|
280
|
+
await expectStatus("submitted");
|
|
281
|
+
expect(screen.queryByTestId("frappe-form-cancel-document")).toBeNull();
|
|
282
|
+
expect(screen.getByText("Cancel")).toBeTruthy();
|
|
283
|
+
cleanup();
|
|
284
|
+
|
|
285
|
+
renderForm(corpus(), { name: "LOAN-CANCELLED", roles: ["Loan Officer"], onAmend: vi.fn() });
|
|
286
|
+
await expectStatus("cancelled");
|
|
287
|
+
expect(screen.queryByText("Amend")).toBeNull();
|
|
288
|
+
cleanup();
|
|
289
|
+
|
|
290
|
+
renderForm(corpus(), { name: "LOAN-DRAFT", roles: ["Loan Clerk"] });
|
|
291
|
+
await expectStatus("draft");
|
|
292
|
+
expect(screen.getByText("Save")).toBeTruthy();
|
|
293
|
+
expect(screen.queryByText("Submit")).toBeNull();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("a role the doctype grants nothing reads the document with no lifecycle action", async () => {
|
|
297
|
+
renderForm(corpus(), { name: "LOAN-DRAFT", roles: ["Auditor"], onDelete: vi.fn() });
|
|
298
|
+
await expectStatus("draft");
|
|
299
|
+
await expectProbe("employee_name", "off=true");
|
|
300
|
+
for (const label of ["Save", "Submit", "Delete"]) {
|
|
301
|
+
expect(screen.queryByText(label)).toBeNull();
|
|
302
|
+
}
|
|
303
|
+
cleanup();
|
|
304
|
+
|
|
305
|
+
renderForm(corpus(), { name: "LOAN-SUBMITTED", roles: ["Auditor"], onCancel: vi.fn() });
|
|
306
|
+
await expectStatus("submitted");
|
|
307
|
+
expect(screen.queryByTestId("frappe-form-cancel-document")).toBeNull();
|
|
308
|
+
expect(screen.getByText("Cancel")).toBeTruthy();
|
|
309
|
+
cleanup();
|
|
310
|
+
|
|
311
|
+
renderForm(corpus(), { name: "LOAN-CANCELLED", roles: ["Auditor"], onAmend: vi.fn() });
|
|
312
|
+
await expectStatus("cancelled");
|
|
313
|
+
expect(screen.queryByText("Amend")).toBeNull();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("without onAmend a cancelled document offers no Amend", async () => {
|
|
317
|
+
renderForm(corpus(), { name: "LOAN-CANCELLED" });
|
|
318
|
+
await expectStatus("cancelled");
|
|
319
|
+
expect(screen.queryByText("Amend")).toBeNull();
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe("a read-only record view keeps desk's lifecycle beside Edit", () => {
|
|
324
|
+
it("a saved draft offers Edit and Submit, never Save or a discard button", async () => {
|
|
325
|
+
const fixtures = corpus();
|
|
326
|
+
const onEdit = vi.fn();
|
|
327
|
+
renderForm(fixtures, { name: "LOAN-DRAFT", onEdit, onCancel: vi.fn(), onDelete: vi.fn() });
|
|
328
|
+
await expectStatus("draft");
|
|
329
|
+
expect(screen.queryByText("Save")).toBeNull();
|
|
330
|
+
expect(screen.queryByText("Cancel")).toBeNull();
|
|
331
|
+
expect(screen.getByText("Delete")).toBeTruthy();
|
|
332
|
+
|
|
333
|
+
fireEvent.click(screen.getByText("Edit"));
|
|
334
|
+
expect(onEdit).toHaveBeenCalledTimes(1);
|
|
335
|
+
|
|
336
|
+
fireEvent.click(screen.getByText("Submit"));
|
|
337
|
+
await waitFor(() => expect(confirm).toHaveBeenCalledWith("Permanently Submit LOAN-DRAFT?"));
|
|
338
|
+
await waitFor(async () => expect(await docstatusOf(fixtures, "LOAN-DRAFT")).toBe(1));
|
|
339
|
+
await expectStatus("submitted");
|
|
340
|
+
expect(screen.getByText("Cancel Document")).toBeTruthy();
|
|
341
|
+
expect(screen.getByText("Edit")).toBeTruthy();
|
|
342
|
+
expect(screen.queryByText("Delete")).toBeNull();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("a submitted document offers Cancel Document, and its allow_on_submit field keeps Edit", async () => {
|
|
346
|
+
const fixtures = corpus();
|
|
347
|
+
renderForm(fixtures, { name: "LOAN-SUBMITTED", onEdit: vi.fn(), onCancel: vi.fn() });
|
|
348
|
+
await expectStatus("submitted");
|
|
349
|
+
expect(screen.getByText("Edit")).toBeTruthy();
|
|
350
|
+
expect(screen.queryByText("Cancel")).toBeNull();
|
|
351
|
+
expect(screen.queryByText("Close")).toBeNull();
|
|
352
|
+
|
|
353
|
+
fireEvent.click(screen.getByText("Cancel Document"));
|
|
354
|
+
await waitFor(() => expect(confirm).toHaveBeenCalledWith("Permanently Cancel LOAN-SUBMITTED?"));
|
|
355
|
+
await waitFor(async () => expect(await docstatusOf(fixtures, "LOAN-SUBMITTED")).toBe(2));
|
|
356
|
+
await expectStatus("cancelled");
|
|
357
|
+
expect(screen.queryByText("Edit")).toBeNull();
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("a cancelled document offers Amend and no Edit", async () => {
|
|
361
|
+
const onAmend = vi.fn();
|
|
362
|
+
renderForm(corpus(), { name: "LOAN-CANCELLED", onEdit: vi.fn(), onAmend });
|
|
363
|
+
await expectStatus("cancelled");
|
|
364
|
+
expect(screen.queryByText("Edit")).toBeNull();
|
|
365
|
+
|
|
366
|
+
fireEvent.click(screen.getByText("Amend"));
|
|
367
|
+
await waitFor(() => expect(onAmend).toHaveBeenCalled());
|
|
368
|
+
expect(onAmend.mock.calls[0]![0]).toMatchObject({ amended_from: "LOAN-CANCELLED" });
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
describe("a submittable doctype's routed pages", () => {
|
|
373
|
+
it("the record page offers Edit and Submit, and Edit opens the edit page", async () => {
|
|
374
|
+
const pushed = renderPage(corpus(), "/loans/LOAN-DRAFT");
|
|
375
|
+
await expectStatus("draft");
|
|
376
|
+
expect(screen.getByText("Submit")).toBeTruthy();
|
|
377
|
+
expect(screen.queryByText("Save")).toBeNull();
|
|
378
|
+
|
|
379
|
+
fireEvent.click(screen.getByText("Edit"));
|
|
380
|
+
expect(pushed).toEqual(["/loans/LOAN-DRAFT/edit"]);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("the edit page offers Submit on a clean draft, Save once edited, and a discard back to the record", async () => {
|
|
384
|
+
const pushed = renderPage(corpus(), "/loans/LOAN-DRAFT/edit");
|
|
385
|
+
await expectStatus("draft");
|
|
386
|
+
expect(screen.getByText("Submit")).toBeTruthy();
|
|
387
|
+
expect(screen.queryByText("Edit")).toBeNull();
|
|
388
|
+
|
|
389
|
+
fireEvent.click(screen.getByTestId("set-remarks"));
|
|
390
|
+
await expectStatus("notSaved");
|
|
391
|
+
expect(screen.getByText("Save")).toBeTruthy();
|
|
392
|
+
expect(screen.queryByText("Submit")).toBeNull();
|
|
393
|
+
|
|
394
|
+
fireEvent.click(screen.getByText("Cancel"));
|
|
395
|
+
expect(pushed).toEqual(["/loans/LOAN-DRAFT"]);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("a cancelled record's Amend opens the amended draft on the new page", async () => {
|
|
399
|
+
const pushed = renderPage(corpus(), "/loans/LOAN-CANCELLED");
|
|
400
|
+
await expectStatus("cancelled");
|
|
401
|
+
|
|
402
|
+
fireEvent.click(screen.getByText("Amend"));
|
|
403
|
+
await waitFor(() => expect(pushed).toEqual(["/loans/new"]));
|
|
404
|
+
expect(takeCreatePrefill("Asset Loan")).toMatchObject({
|
|
405
|
+
amended_from: "LOAN-CANCELLED",
|
|
406
|
+
employee_name: "Katherine Johnson",
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
describe("the leave button stays live while a write is in flight (LC-30)", () => {
|
|
412
|
+
function holdWrites(fixtures: InMemoryFixtureProvider, method: "createDoc" | "updateDoc") {
|
|
413
|
+
vi.spyOn(fixtures, method).mockReturnValue(new Promise(() => {}));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function isInert(element: HTMLElement) {
|
|
417
|
+
return element.hasAttribute("disabled") || element.getAttribute("aria-disabled") === "true";
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function expectLive(element: HTMLElement) {
|
|
421
|
+
expect(element.getAttribute("disabled")).toBeNull();
|
|
422
|
+
expect(element.getAttribute("aria-disabled")).toBeNull();
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
it("Save disables while it waits on the server; Cancel stays enabled and still leaves", async () => {
|
|
426
|
+
const fixtures = corpus();
|
|
427
|
+
holdWrites(fixtures, "createDoc");
|
|
428
|
+
const onCancel = vi.fn();
|
|
429
|
+
renderForm(fixtures, { onCancel });
|
|
430
|
+
await expectProbe("employee_name", "ro=false");
|
|
431
|
+
|
|
432
|
+
fireEvent.click(screen.getByTestId("set-employee_name"));
|
|
433
|
+
await expectProbe("employee_name", "val=edited");
|
|
434
|
+
fireEvent.click(screen.getByTestId("frappe-form-save"));
|
|
435
|
+
await waitFor(() => expect(isInert(screen.getByTestId("frappe-form-save"))).toBe(true));
|
|
436
|
+
await waitFor(() => expect(fixtures.createDoc).toHaveBeenCalled());
|
|
437
|
+
|
|
438
|
+
const cancel = screen.getByTestId("frappe-form-close");
|
|
439
|
+
expectLive(cancel);
|
|
440
|
+
fireEvent.click(cancel);
|
|
441
|
+
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("Cancel Document in flight leaves Close enabled beside it", async () => {
|
|
445
|
+
const fixtures = corpus();
|
|
446
|
+
holdWrites(fixtures, "updateDoc");
|
|
447
|
+
const onCancel = vi.fn();
|
|
448
|
+
renderForm(fixtures, { name: "LOAN-SUBMITTED", onCancel });
|
|
449
|
+
await expectStatus("submitted");
|
|
450
|
+
expect(screen.getByTestId("frappe-form-close").textContent).toContain("Close");
|
|
451
|
+
|
|
452
|
+
fireEvent.click(screen.getByTestId("frappe-form-cancel-document"));
|
|
453
|
+
await waitFor(() =>
|
|
454
|
+
expect(isInert(screen.getByTestId("frappe-form-cancel-document"))).toBe(true),
|
|
455
|
+
);
|
|
456
|
+
await waitFor(() => expect(fixtures.updateDoc).toHaveBeenCalled());
|
|
457
|
+
|
|
458
|
+
const close = screen.getByTestId("frappe-form-close");
|
|
459
|
+
expectLive(close);
|
|
460
|
+
fireEvent.click(close);
|
|
461
|
+
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
462
|
+
});
|
|
463
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { assetLoanLifecycleFixtures } from "../tests/fixtures/assetLoanLifecycle";
|
|
3
|
+
import { FrappeForm } from "./FrappeForm";
|
|
4
|
+
import { FrappeProvider } from "./FrappeProvider";
|
|
5
|
+
import { FrappeStoryProvider, frappeStoriesLive } from "./frappeStoryTarget";
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
title: "FrappeUI/FrappeForm Lifecycle",
|
|
9
|
+
component: FrappeForm,
|
|
10
|
+
tags: ["!test"],
|
|
11
|
+
parameters: {
|
|
12
|
+
status: { type: "stable" },
|
|
13
|
+
docs: {
|
|
14
|
+
description: {
|
|
15
|
+
component:
|
|
16
|
+
"A submittable doctype's docstatus lifecycle with desk's rules. A saved, clean draft offers Submit; an edited one saves first. A submitted document is read-only except its allow_on_submit fields, offers Cancel, and turns Cancel into Update while such a field is edited. A cancelled document offers Amend, which hands the host a new draft carrying `amended_from`; the doctype's next create-mode FrappeForm opens seeded with it. Submit and Cancel ask first. Each action shows only for roles holding the matching right. The fixture doctype is Asset Loan.",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const host = "https://form-lifecycle.mock.test";
|
|
23
|
+
const roles = ["Loan Manager"];
|
|
24
|
+
|
|
25
|
+
function AssetLoan({ start, initialRoles = roles }: { start?: string; initialRoles?: string[] }) {
|
|
26
|
+
const fixtures = useMemo(() => assetLoanLifecycleFixtures(), []);
|
|
27
|
+
const [name, setName] = useState<string | undefined>(start);
|
|
28
|
+
return (
|
|
29
|
+
<FrappeProvider baseURL={host} fixtures={fixtures}>
|
|
30
|
+
<FrappeForm
|
|
31
|
+
doctype="Asset Loan"
|
|
32
|
+
name={name}
|
|
33
|
+
roles={initialRoles}
|
|
34
|
+
showSidebar={false}
|
|
35
|
+
showTimeline={false}
|
|
36
|
+
onAmend={() => setName(undefined)}
|
|
37
|
+
onCancel={() => setName(start)}
|
|
38
|
+
onDelete={() => setName(undefined)}
|
|
39
|
+
/>
|
|
40
|
+
</FrappeProvider>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Starts on a saved draft: Submit, Cancel, then Amend, on one live fixture corpus. */
|
|
45
|
+
export const Lifecycle = () => <AssetLoan start="LOAN-DRAFT" />;
|
|
46
|
+
|
|
47
|
+
export const Submitted = () => <AssetLoan start="LOAN-SUBMITTED" />;
|
|
48
|
+
|
|
49
|
+
export const Cancelled = () => <AssetLoan start="LOAN-CANCELLED" />;
|
|
50
|
+
|
|
51
|
+
/** Desk disables Amend while a live amendment of the document exists. */
|
|
52
|
+
export const AlreadyAmended = () => <AssetLoan start="LOAN-REDONE" />;
|
|
53
|
+
|
|
54
|
+
/** Loan Officer holds submit but neither cancel nor amend. */
|
|
55
|
+
export const SubmitOnlyRole = () => (
|
|
56
|
+
<AssetLoan start="LOAN-SUBMITTED" initialRoles={["Loan Officer"]} />
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
function BenchLoan({ start }: { start: string }) {
|
|
60
|
+
const [name, setName] = useState<string | undefined>(start);
|
|
61
|
+
return (
|
|
62
|
+
<FrappeStoryProvider>
|
|
63
|
+
<FrappeForm
|
|
64
|
+
doctype="Asset Loan"
|
|
65
|
+
name={name}
|
|
66
|
+
showSidebar={false}
|
|
67
|
+
showTimeline={false}
|
|
68
|
+
onAmend={() => setName(undefined)}
|
|
69
|
+
onCancel={() => setName(start)}
|
|
70
|
+
/>
|
|
71
|
+
</FrappeStoryProvider>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The same walk on the bench the Storybook dev server proxies
|
|
77
|
+
* (STORYBOOK_FRAPPE_URL), with the session's own roles. `name` picks an
|
|
78
|
+
* Asset Loan on that bench; without a bench the fixture draft stands in.
|
|
79
|
+
*/
|
|
80
|
+
export const OnBench = {
|
|
81
|
+
args: { name: "LOAN-DRAFT" },
|
|
82
|
+
render: ({ name }: { name: string }) =>
|
|
83
|
+
frappeStoriesLive ? <BenchLoan key={name} start={name} /> : <AssetLoan start="LOAN-DRAFT" />,
|
|
84
|
+
};
|