@multiplatform.one/frappe-ui 7.6.3 → 7.7.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/dist/cjs/ChartCard.cjs +52 -0
- package/dist/cjs/ChartCard.native.js +72 -0
- package/dist/cjs/ChartCard.native.js.map +1 -0
- package/dist/cjs/FileBrowser/AttachLibrary.cjs +2 -2
- package/dist/cjs/FileBrowser/AttachLibrary.native.js +8 -2
- package/dist/cjs/FileBrowser/AttachLibrary.native.js.map +1 -1
- package/dist/cjs/FrappeChartCard.cjs +3 -3
- package/dist/cjs/FrappeChartCard.native.js +3 -3
- package/dist/cjs/FrappeChartCard.native.js.map +1 -1
- package/dist/cjs/FrappeDashboard.cjs +6 -27
- package/dist/cjs/FrappeDashboard.native.js +6 -33
- package/dist/cjs/FrappeDashboard.native.js.map +1 -1
- package/dist/cjs/FrappeImageView.cjs +2 -8
- package/dist/cjs/FrappeImageView.native.js +2 -9
- package/dist/cjs/FrappeImageView.native.js.map +1 -1
- package/dist/cjs/fields/CodeEditor/index.cjs +4 -0
- package/dist/cjs/fields/CodeEditor/index.native.js +4 -0
- package/dist/cjs/fields/CodeEditor/index.native.js.map +1 -1
- package/dist/cjs/fields/LinkTree.cjs +5 -3
- package/dist/cjs/fields/LinkTree.native.js +9 -5
- package/dist/cjs/fields/LinkTree.native.js.map +1 -1
- package/dist/cjs/formEngine/createPrefill.cjs +37 -0
- package/dist/cjs/formEngine/createPrefill.native.js +40 -0
- package/dist/cjs/formEngine/createPrefill.native.js.map +1 -0
- package/dist/cjs/formEngine/dirtyTracking.cjs +55 -0
- package/dist/cjs/formEngine/dirtyTracking.native.js +77 -0
- package/dist/cjs/formEngine/dirtyTracking.native.js.map +1 -0
- package/dist/cjs/formEngine/doctypeMeta.cjs +18 -0
- package/dist/cjs/formEngine/doctypeMeta.native.js +21 -0
- package/dist/cjs/formEngine/doctypeMeta.native.js.map +1 -0
- package/dist/cjs/formEngine/fetchFrom.cjs +67 -0
- package/dist/cjs/formEngine/fetchFrom.native.js +110 -0
- package/dist/cjs/formEngine/fetchFrom.native.js.map +1 -0
- package/dist/cjs/formEngine/fieldPermissions.cjs +55 -0
- package/dist/cjs/formEngine/fieldPermissions.native.js +80 -0
- package/dist/cjs/formEngine/fieldPermissions.native.js.map +1 -0
- package/dist/cjs/formEngine/formActions.cjs +189 -0
- package/dist/cjs/formEngine/formActions.native.js +199 -0
- package/dist/cjs/formEngine/formActions.native.js.map +1 -0
- package/dist/cjs/formEngine/formLayout.cjs +245 -0
- package/dist/cjs/formEngine/formLayout.native.js +388 -0
- package/dist/cjs/formEngine/formLayout.native.js.map +1 -0
- package/dist/cjs/formEngine/index.cjs +48 -0
- package/dist/cjs/formEngine/index.native.js +51 -0
- package/dist/cjs/formEngine/index.native.js.map +1 -0
- package/dist/cjs/formEngine/useFormEngine.cjs +387 -0
- package/dist/cjs/formEngine/useFormEngine.native.js +476 -0
- package/dist/cjs/formEngine/useFormEngine.native.js.map +1 -0
- package/dist/cjs/frappeStoryTarget.cjs +4 -2
- package/dist/cjs/frappeStoryTarget.native.js +4 -3
- package/dist/cjs/frappeStoryTarget.native.js.map +1 -1
- package/dist/cjs/index.cjs +19 -1
- package/dist/cjs/index.native.js +19 -1
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/esm/ChartCard.mjs +27 -0
- package/dist/esm/ChartCard.mjs.map +1 -0
- package/dist/esm/ChartCard.native.js +33 -0
- package/dist/esm/ChartCard.native.js.map +1 -0
- package/dist/esm/FileBrowser/AttachLibrary.mjs +2 -2
- package/dist/esm/FileBrowser/AttachLibrary.mjs.map +1 -1
- package/dist/esm/FileBrowser/AttachLibrary.native.js +8 -2
- package/dist/esm/FileBrowser/AttachLibrary.native.js.map +1 -1
- package/dist/esm/FrappeChartCard.mjs +1 -1
- package/dist/esm/FrappeChartCard.native.js +1 -1
- package/dist/esm/FrappeChartCard.native.js.map +1 -1
- package/dist/esm/FrappeDashboard.mjs +6 -27
- package/dist/esm/FrappeDashboard.mjs.map +1 -1
- package/dist/esm/FrappeDashboard.native.js +6 -33
- package/dist/esm/FrappeDashboard.native.js.map +1 -1
- package/dist/esm/FrappeImageView.mjs +2 -7
- package/dist/esm/FrappeImageView.mjs.map +1 -1
- package/dist/esm/FrappeImageView.native.js +2 -8
- package/dist/esm/FrappeImageView.native.js.map +1 -1
- package/dist/esm/fields/CodeEditor/index.mjs +4 -0
- package/dist/esm/fields/CodeEditor/index.mjs.map +1 -1
- package/dist/esm/fields/CodeEditor/index.native.js +4 -0
- package/dist/esm/fields/CodeEditor/index.native.js.map +1 -1
- package/dist/esm/fields/LinkTree.mjs +5 -3
- package/dist/esm/fields/LinkTree.mjs.map +1 -1
- package/dist/esm/fields/LinkTree.native.js +9 -5
- package/dist/esm/fields/LinkTree.native.js.map +1 -1
- package/dist/esm/formEngine/createPrefill.mjs +11 -0
- package/dist/esm/formEngine/createPrefill.mjs.map +1 -0
- package/dist/esm/formEngine/createPrefill.native.js +11 -0
- package/dist/esm/formEngine/createPrefill.native.js.map +1 -0
- package/dist/esm/formEngine/dirtyTracking.mjs +28 -0
- package/dist/esm/formEngine/dirtyTracking.mjs.map +1 -0
- package/dist/esm/formEngine/dirtyTracking.native.js +47 -0
- package/dist/esm/formEngine/dirtyTracking.native.js.map +1 -0
- package/dist/esm/formEngine/doctypeMeta.mjs +2 -0
- package/dist/esm/formEngine/doctypeMeta.mjs.map +1 -0
- package/dist/esm/formEngine/doctypeMeta.native.js +2 -0
- package/dist/esm/formEngine/doctypeMeta.native.js.map +1 -0
- package/dist/esm/formEngine/fetchFrom.mjs +40 -0
- package/dist/esm/formEngine/fetchFrom.mjs.map +1 -0
- package/dist/esm/formEngine/fetchFrom.native.js +80 -0
- package/dist/esm/formEngine/fetchFrom.native.js.map +1 -0
- package/dist/esm/formEngine/fieldPermissions.mjs +28 -0
- package/dist/esm/formEngine/fieldPermissions.mjs.map +1 -0
- package/dist/esm/formEngine/fieldPermissions.native.js +50 -0
- package/dist/esm/formEngine/fieldPermissions.native.js.map +1 -0
- package/dist/esm/formEngine/formActions.mjs +146 -0
- package/dist/esm/formEngine/formActions.mjs.map +1 -0
- package/dist/esm/formEngine/formActions.native.js +153 -0
- package/dist/esm/formEngine/formActions.native.js.map +1 -0
- package/dist/esm/formEngine/formLayout.mjs +217 -0
- package/dist/esm/formEngine/formLayout.mjs.map +1 -0
- package/dist/esm/formEngine/formLayout.native.js +357 -0
- package/dist/esm/formEngine/formLayout.native.js.map +1 -0
- package/dist/esm/formEngine/index.mjs +8 -0
- package/dist/esm/formEngine/index.mjs.map +1 -0
- package/dist/esm/formEngine/index.native.js +8 -0
- package/dist/esm/formEngine/index.native.js.map +1 -0
- package/dist/esm/formEngine/useFormEngine.mjs +362 -0
- package/dist/esm/formEngine/useFormEngine.mjs.map +1 -0
- package/dist/esm/formEngine/useFormEngine.native.js +448 -0
- package/dist/esm/formEngine/useFormEngine.native.js.map +1 -0
- package/dist/esm/frappeStoryTarget.mjs +4 -1
- package/dist/esm/frappeStoryTarget.mjs.map +1 -1
- package/dist/esm/frappeStoryTarget.native.js +4 -2
- package/dist/esm/frappeStoryTarget.native.js.map +1 -1
- package/dist/esm/index.mjs +4 -2
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +4 -2
- package/dist/esm/index.native.js.map +1 -1
- package/dist/jsx/ChartCard.mjs +27 -0
- package/dist/jsx/ChartCard.mjs.map +1 -0
- package/dist/jsx/ChartCard.native.js +72 -0
- package/dist/jsx/ChartCard.native.js.map +1 -0
- package/dist/jsx/FileBrowser/AttachLibrary.mjs +2 -2
- package/dist/jsx/FileBrowser/AttachLibrary.mjs.map +1 -1
- package/dist/jsx/FileBrowser/AttachLibrary.native.js +8 -2
- package/dist/jsx/FileBrowser/AttachLibrary.native.js.map +1 -1
- package/dist/jsx/FrappeChartCard.mjs +1 -1
- package/dist/jsx/FrappeChartCard.native.js +3 -3
- package/dist/jsx/FrappeChartCard.native.js.map +1 -1
- package/dist/jsx/FrappeDashboard.mjs +6 -27
- package/dist/jsx/FrappeDashboard.mjs.map +1 -1
- package/dist/jsx/FrappeDashboard.native.js +6 -33
- package/dist/jsx/FrappeDashboard.native.js.map +1 -1
- package/dist/jsx/FrappeImageView.mjs +2 -7
- package/dist/jsx/FrappeImageView.mjs.map +1 -1
- package/dist/jsx/FrappeImageView.native.js +2 -9
- package/dist/jsx/FrappeImageView.native.js.map +1 -1
- package/dist/jsx/fields/CodeEditor/index.mjs +4 -0
- package/dist/jsx/fields/CodeEditor/index.mjs.map +1 -1
- package/dist/jsx/fields/CodeEditor/index.native.js +4 -0
- package/dist/jsx/fields/CodeEditor/index.native.js.map +1 -1
- package/dist/jsx/fields/LinkTree.mjs +5 -3
- package/dist/jsx/fields/LinkTree.mjs.map +1 -1
- package/dist/jsx/fields/LinkTree.native.js +9 -5
- package/dist/jsx/fields/LinkTree.native.js.map +1 -1
- package/dist/jsx/formEngine/createPrefill.mjs +11 -0
- package/dist/jsx/formEngine/createPrefill.mjs.map +1 -0
- package/dist/jsx/formEngine/createPrefill.native.js +40 -0
- package/dist/jsx/formEngine/createPrefill.native.js.map +1 -0
- package/dist/jsx/formEngine/dirtyTracking.mjs +28 -0
- package/dist/jsx/formEngine/dirtyTracking.mjs.map +1 -0
- package/dist/jsx/formEngine/dirtyTracking.native.js +77 -0
- package/dist/jsx/formEngine/dirtyTracking.native.js.map +1 -0
- package/dist/jsx/formEngine/doctypeMeta.mjs +2 -0
- package/dist/jsx/formEngine/doctypeMeta.mjs.map +1 -0
- package/dist/jsx/formEngine/doctypeMeta.native.js +21 -0
- package/dist/jsx/formEngine/doctypeMeta.native.js.map +1 -0
- package/dist/jsx/formEngine/fetchFrom.mjs +40 -0
- package/dist/jsx/formEngine/fetchFrom.mjs.map +1 -0
- package/dist/jsx/formEngine/fetchFrom.native.js +110 -0
- package/dist/jsx/formEngine/fetchFrom.native.js.map +1 -0
- package/dist/jsx/formEngine/fieldPermissions.mjs +28 -0
- package/dist/jsx/formEngine/fieldPermissions.mjs.map +1 -0
- package/dist/jsx/formEngine/fieldPermissions.native.js +80 -0
- package/dist/jsx/formEngine/fieldPermissions.native.js.map +1 -0
- package/dist/jsx/formEngine/formActions.mjs +146 -0
- package/dist/jsx/formEngine/formActions.mjs.map +1 -0
- package/dist/jsx/formEngine/formActions.native.js +199 -0
- package/dist/jsx/formEngine/formActions.native.js.map +1 -0
- package/dist/jsx/formEngine/formLayout.mjs +217 -0
- package/dist/jsx/formEngine/formLayout.mjs.map +1 -0
- package/dist/jsx/formEngine/formLayout.native.js +388 -0
- package/dist/jsx/formEngine/formLayout.native.js.map +1 -0
- package/dist/jsx/formEngine/index.mjs +8 -0
- package/dist/jsx/formEngine/index.mjs.map +1 -0
- package/dist/jsx/formEngine/index.native.js +51 -0
- package/dist/jsx/formEngine/index.native.js.map +1 -0
- package/dist/jsx/formEngine/useFormEngine.mjs +362 -0
- package/dist/jsx/formEngine/useFormEngine.mjs.map +1 -0
- package/dist/jsx/formEngine/useFormEngine.native.js +476 -0
- package/dist/jsx/formEngine/useFormEngine.native.js.map +1 -0
- package/dist/jsx/frappeStoryTarget.mjs +4 -1
- package/dist/jsx/frappeStoryTarget.mjs.map +1 -1
- package/dist/jsx/frappeStoryTarget.native.js +4 -3
- package/dist/jsx/frappeStoryTarget.native.js.map +1 -1
- package/dist/jsx/index.js +4 -2
- package/dist/jsx/index.js.map +1 -1
- package/dist/jsx/index.mjs +4 -2
- package/dist/jsx/index.mjs.map +1 -1
- package/dist/jsx/index.native.js +19 -1
- package/dist/jsx/index.native.js.map +1 -1
- package/package.json +23 -17
- package/src/ChartCard.tsx +31 -0
- package/src/FileBrowser/AttachLibrary.tsx +13 -1
- package/src/FileBrowser/FileBrowser.spec.tsx +16 -0
- package/src/FileBrowser/FileBrowser.stories.tsx +18 -1
- package/src/FileBrowser/folderTree.spec.tsx +68 -0
- package/src/FrappeCalendar.stories.tsx +1 -2
- package/src/FrappeChartCard.stories.tsx +1 -1
- package/src/FrappeChartCard.tsx +1 -1
- package/src/FrappeDashboard.stories.tsx +6 -5
- package/src/FrappeDashboard.tsx +6 -33
- package/src/FrappeGantt.stories.tsx +1 -2
- package/src/FrappeImageView.tsx +16 -15
- package/src/FrappeList.stories.tsx +3 -10
- package/src/classicScriptSafety.spec.ts +47 -0
- package/src/components/attachments.stories.tsx +9 -2
- package/src/dependsOn.ts +2 -2
- package/src/desk/DeskFormSidebar.stories.tsx +19 -1
- package/src/fields/CodeEditor/CodeEditor.spec.tsx +37 -0
- package/src/fields/CodeEditor/CodeEditor.stories.tsx +83 -66
- package/src/fields/CodeEditor/index.tsx +4 -0
- package/src/fields/DateRange/DateRange.spec.tsx +143 -0
- package/src/fields/FileUpload.spec.tsx +334 -0
- package/src/fields/FileUploadField/FileUploadField.spec.tsx +166 -0
- package/src/fields/HTML/Html.spec.tsx +179 -0
- package/src/fields/Icon/Icon.stories.tsx +138 -0
- package/src/fields/Link.stories.tsx +2 -0
- package/src/fields/LinkTree.tsx +18 -3
- package/src/fields/LongText/LongText.spec.tsx +217 -0
- package/src/fields/OTPInput/OTPInput.spec.tsx +167 -0
- package/src/fields/Password/Password.stories.tsx +21 -0
- package/src/fields/Phone/Phone.spec.tsx +151 -0
- package/src/fields/Progress/Progress.spec.tsx +160 -0
- package/src/fields/ReadOnly/ReadOnly.spec.tsx +229 -0
- package/src/fields/SmallText/SmallText.spec.tsx +175 -0
- package/src/fields/shared/EditorPreviewPanes.spec.tsx +122 -0
- package/src/formEngine/createPrefill.ts +28 -0
- package/src/formEngine/dirtyTracking.spec.ts +91 -0
- package/src/formEngine/dirtyTracking.ts +64 -0
- package/src/formEngine/doctypeMeta.ts +117 -0
- package/src/formEngine/fetchFrom.spec.ts +92 -0
- package/src/formEngine/fetchFrom.ts +111 -0
- package/src/formEngine/fieldPermissions.spec.ts +61 -0
- package/src/formEngine/fieldPermissions.ts +68 -0
- package/src/formEngine/formActions.ts +285 -0
- package/src/formEngine/formLayout.spec.ts +373 -0
- package/src/formEngine/formLayout.ts +392 -0
- package/src/formEngine/index.ts +33 -0
- package/src/formEngine/seam.spec.ts +148 -0
- package/src/formEngine/useFormEngine.ts +496 -0
- package/src/frappeRemainder.designLaw.spec.ts +9 -0
- package/src/frappeStoryTarget.tsx +25 -9
- package/src/frappeViews.designLaw.spec.ts +1 -0
- package/src/index.spec.ts +7 -3
- package/src/index.ts +51 -1
- package/src/schemaForm/SchemaForm.spec.tsx +4 -1
- package/types/ChartCard.d.ts +13 -0
- package/types/ChartCard.d.ts.map +1 -0
- package/types/FileBrowser/AttachLibrary.d.ts.map +1 -1
- package/types/FrappeDashboard.d.ts +2 -10
- package/types/FrappeDashboard.d.ts.map +1 -1
- package/types/FrappeImageView.d.ts.map +1 -1
- package/types/dependsOn.d.ts +2 -2
- package/types/fields/CodeEditor/index.d.ts.map +1 -1
- package/types/fields/LinkTree.d.ts.map +1 -1
- package/types/formEngine/createPrefill.d.ts +19 -0
- package/types/formEngine/createPrefill.d.ts.map +1 -0
- package/types/formEngine/dirtyTracking.d.ts +36 -0
- package/types/formEngine/dirtyTracking.d.ts.map +1 -0
- package/types/formEngine/doctypeMeta.d.ts +117 -0
- package/types/formEngine/doctypeMeta.d.ts.map +1 -0
- package/types/formEngine/fetchFrom.d.ts +43 -0
- package/types/formEngine/fetchFrom.d.ts.map +1 -0
- package/types/formEngine/fieldPermissions.d.ts +38 -0
- package/types/formEngine/fieldPermissions.d.ts.map +1 -0
- package/types/formEngine/formActions.d.ts +95 -0
- package/types/formEngine/formActions.d.ts.map +1 -0
- package/types/formEngine/formLayout.d.ts +91 -0
- package/types/formEngine/formLayout.d.ts.map +1 -0
- package/types/formEngine/index.d.ts +27 -0
- package/types/formEngine/index.d.ts.map +1 -0
- package/types/formEngine/useFormEngine.d.ts +61 -0
- package/types/formEngine/useFormEngine.d.ts.map +1 -0
- package/types/frappeStoryTarget.d.ts +2 -2
- package/types/frappeStoryTarget.d.ts.map +1 -1
- package/types/index.d.ts +4 -1
- package/types/index.d.ts.map +1 -1
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DateRangeField contract specs. The `Date Range` fieldtype composes the forms
|
|
3
|
+
* DateRangePicker, so the adapter's contract is which props survive the hop.
|
|
4
|
+
*
|
|
5
|
+
* One of them does not, and that is pinned here deliberately: `value` and
|
|
6
|
+
* `onChange` are DECLARED on DateRangeFieldProps and then destructured away
|
|
7
|
+
* (`value: _value`, `onChange: _onChange`) instead of being handed to
|
|
8
|
+
* DateRangePicker. The registry feeds `props.value` straight into the adapter,
|
|
9
|
+
* so a saved Date Range never paints and a picked range never emits. These
|
|
10
|
+
* tests describe today's behaviour and will go red the moment someone wires it
|
|
11
|
+
* up — which is the point: that is the signal to update them alongside the fix.
|
|
12
|
+
* Reported on MPO-123.
|
|
13
|
+
*/
|
|
14
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
15
|
+
import { cleanup } from "@testing-library/react";
|
|
16
|
+
import type React from "react";
|
|
17
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
18
|
+
import { getFieldComponent, type FieldRenderProps } from "../../fieldRegistry";
|
|
19
|
+
import { DateRangeField } from ".";
|
|
20
|
+
|
|
21
|
+
afterEach(cleanup);
|
|
22
|
+
|
|
23
|
+
const trigger = (container: HTMLElement) =>
|
|
24
|
+
container.querySelector("input") as HTMLInputElement | null;
|
|
25
|
+
|
|
26
|
+
/** The accessible control: role="combobox", not the decorative inner input. */
|
|
27
|
+
const combobox = (container: HTMLElement) =>
|
|
28
|
+
container.querySelector('[data-testid="date-picker-trigger"]');
|
|
29
|
+
|
|
30
|
+
describe("DateRangeField composes the forms DateRangePicker", () => {
|
|
31
|
+
it("renders a single range trigger, not two date inputs", () => {
|
|
32
|
+
const result = renderWithProviders(<DateRangeField label="Period" />);
|
|
33
|
+
expect(result.container.querySelectorAll("input")).toHaveLength(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("carries the picker's own placeholder when the field sets none", () => {
|
|
37
|
+
const result = renderWithProviders(<DateRangeField label="Period" />);
|
|
38
|
+
expect(trigger(result.container)?.getAttribute("placeholder")).toContain("date");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("forwards an explicit placeholder", () => {
|
|
42
|
+
const result = renderWithProviders(<DateRangeField label="Period" placeholder="From – To" />);
|
|
43
|
+
expect(trigger(result.container)?.getAttribute("placeholder")).toBe("From – To");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("forwards readOnly, which swaps the popover trigger for a flat read-only box", () => {
|
|
47
|
+
const result = renderWithProviders(<DateRangeField label="Period" readOnly />);
|
|
48
|
+
expect(trigger(result.container)?.hasAttribute("readonly")).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("forwards required to the control, not just to the label", () => {
|
|
52
|
+
// The accessible control is the combobox box, not the inner <input>: that
|
|
53
|
+
// input is readOnly / tabIndex -1 / pointerEvents none, so the aria state
|
|
54
|
+
// rides the role="combobox" wrapper. Asserting it on the input passes
|
|
55
|
+
// vacuously against a picker that sets neither.
|
|
56
|
+
const result = renderWithProviders(<DateRangeField label="Period" required />);
|
|
57
|
+
expect(combobox(result.container)?.getAttribute("aria-required")).toBe("true");
|
|
58
|
+
expect(combobox(result.container)?.getAttribute("role")).toBe("combobox");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("forwards error, which marks the control invalid", () => {
|
|
62
|
+
const result = renderWithProviders(<DateRangeField label="Period" error="pick a range" />);
|
|
63
|
+
expect(combobox(result.container)?.getAttribute("aria-invalid")).toBe("true");
|
|
64
|
+
expect(result.container.textContent).toContain("pick a range");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("leaves both aria states off a field that is neither required nor errored", () => {
|
|
68
|
+
const result = renderWithProviders(<DateRangeField label="Period" />);
|
|
69
|
+
expect(combobox(result.container)?.getAttribute("aria-required")).toBeNull();
|
|
70
|
+
expect(combobox(result.container)?.getAttribute("aria-invalid")).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("forwards the label and helperText", () => {
|
|
74
|
+
const result = renderWithProviders(
|
|
75
|
+
<DateRangeField label="Period" helperText="inclusive of both ends" />,
|
|
76
|
+
);
|
|
77
|
+
expect(result.container.textContent).toContain("Period");
|
|
78
|
+
expect(result.container.textContent).toContain("inclusive of both ends");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("skeleton branch renders no trigger", () => {
|
|
82
|
+
const result = renderWithProviders(<DateRangeField label="Period" skeleton />);
|
|
83
|
+
expect(trigger(result.container)).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("DateRangeField value gap (MPO-123)", () => {
|
|
88
|
+
it("declares value on its props type", () => {
|
|
89
|
+
// Type-level promise: the adapter advertises a controlled value.
|
|
90
|
+
const props: React.ComponentProps<typeof DateRangeField> = { value: "2026-01-01" };
|
|
91
|
+
expect(props.value).toBe("2026-01-01");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("but a controlled value does not reach the control — the trigger stays empty", () => {
|
|
95
|
+
const result = renderWithProviders(
|
|
96
|
+
<DateRangeField label="Period" value="2026-01-01,2026-01-31" />,
|
|
97
|
+
);
|
|
98
|
+
expect(trigger(result.container)?.value).toBe("");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("and the registry hands that value straight to the adapter, so the drop is on this file", () => {
|
|
102
|
+
const Component = getFieldComponent("Date Range")!;
|
|
103
|
+
const element = (Component as Function)({
|
|
104
|
+
value: "2026-01-01,2026-01-31",
|
|
105
|
+
onChange: () => {},
|
|
106
|
+
} as FieldRenderProps) as React.ReactElement<Record<string, any>>;
|
|
107
|
+
expect(element.type).toBe(DateRangeField);
|
|
108
|
+
expect(element.props.value).toBe("2026-01-01,2026-01-31");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("registry resolution (Date Range fieldtype)", () => {
|
|
113
|
+
function propsFor(overrides: Partial<FieldRenderProps> = {}): FieldRenderProps {
|
|
114
|
+
return { value: "", onChange: () => {}, ...overrides };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
it("resolves 'Date Range' to DateRangeField, and not to the Date adapter", () => {
|
|
118
|
+
const Component = getFieldComponent("Date Range")!;
|
|
119
|
+
expect(Component).toBeTruthy();
|
|
120
|
+
expect(Component).not.toBe(getFieldComponent("Date"));
|
|
121
|
+
const element = (Component as Function)(propsFor({ label: "Period" })) as React.ReactElement<
|
|
122
|
+
Record<string, any>
|
|
123
|
+
>;
|
|
124
|
+
expect(element.type).toBe(DateRangeField);
|
|
125
|
+
expect(element.props.label).toBe("Period");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("passes helperText / readOnly / skeleton / compact through", () => {
|
|
129
|
+
const Component = getFieldComponent("Date Range")!;
|
|
130
|
+
const element = (Component as Function)(
|
|
131
|
+
propsFor({
|
|
132
|
+
helperText: "both ends inclusive",
|
|
133
|
+
readOnly: true,
|
|
134
|
+
skeleton: true,
|
|
135
|
+
compact: true,
|
|
136
|
+
}),
|
|
137
|
+
) as React.ReactElement<Record<string, any>>;
|
|
138
|
+
expect(element.props.helperText).toBe("both ends inclusive");
|
|
139
|
+
expect(element.props.readOnly).toBe(true);
|
|
140
|
+
expect(element.props.skeleton).toBe(true);
|
|
141
|
+
expect(element.props.compact).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frappe FileUpload contract specs (the live-data uploader behind the
|
|
3
|
+
* "FrappeUI/FileUpload" catalog entry — not the forms dropzone, and not the
|
|
4
|
+
* FileUploadField registry adapter).
|
|
5
|
+
*
|
|
6
|
+
* Everything worth pinning here is transport state, because that is what the
|
|
7
|
+
* component owns and what a screenshot cannot show: the size gate before a
|
|
8
|
+
* byte is read, the progress channel, the failure row that keeps the existing
|
|
9
|
+
* value instead of wiping it (DG-VAL-04), retry, and the abort that has to
|
|
10
|
+
* actually reach the handler's AbortSignal.
|
|
11
|
+
*
|
|
12
|
+
* The default transport inserts into the File doctype, so every test injects
|
|
13
|
+
* `onUpload` — the documented override — and drives it by hand.
|
|
14
|
+
*/
|
|
15
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
16
|
+
import { act, cleanup, fireEvent, waitFor } from "@testing-library/react";
|
|
17
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
18
|
+
import { FileUpload } from "./FileUpload";
|
|
19
|
+
|
|
20
|
+
afterEach(cleanup);
|
|
21
|
+
|
|
22
|
+
const hiddenInput = (container: HTMLElement) =>
|
|
23
|
+
container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
24
|
+
|
|
25
|
+
function makeFile(name = "report.pdf", size = 1024, type = "application/pdf") {
|
|
26
|
+
const file = new File(["x"], name, { type });
|
|
27
|
+
Object.defineProperty(file, "size", { value: size });
|
|
28
|
+
return file;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function selectFile(container: HTMLElement, file: File) {
|
|
32
|
+
await act(async () => {
|
|
33
|
+
fireEvent.change(hiddenInput(container), { target: { files: [file] } });
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A handler the test resolves or rejects on demand. */
|
|
38
|
+
function deferredUpload() {
|
|
39
|
+
let resolve!: (url: string) => void;
|
|
40
|
+
let reject!: (err: Error) => void;
|
|
41
|
+
let progress!: (percent: number) => void;
|
|
42
|
+
let signal!: AbortSignal;
|
|
43
|
+
const handler = vi.fn(
|
|
44
|
+
(_file: File, ctx: { onProgress: (p: number) => void; signal: AbortSignal }) => {
|
|
45
|
+
progress = ctx.onProgress;
|
|
46
|
+
signal = ctx.signal;
|
|
47
|
+
return new Promise<string>((res, rej) => {
|
|
48
|
+
resolve = res;
|
|
49
|
+
reject = rej;
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
return {
|
|
54
|
+
handler,
|
|
55
|
+
resolve: (url: string) => resolve(url),
|
|
56
|
+
reject: (err: Error) => reject(err),
|
|
57
|
+
progress: (p: number) => progress(p),
|
|
58
|
+
get signal() {
|
|
59
|
+
return signal;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("FileUpload empty state", () => {
|
|
65
|
+
it("offers a Choose File affordance and nothing else", () => {
|
|
66
|
+
const result = renderWithProviders(<FileUpload label="Attachment" onChange={() => {}} />);
|
|
67
|
+
expect(result.getButton("Choose File")).toBeTruthy();
|
|
68
|
+
expect(result.container.querySelector('[data-file-upload="transfer"]')).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("marks a required field on its label", () => {
|
|
72
|
+
const result = renderWithProviders(
|
|
73
|
+
<FileUpload label="Attachment" required onChange={() => {}} />,
|
|
74
|
+
);
|
|
75
|
+
expect(result.container.textContent).toContain("Attachment");
|
|
76
|
+
expect(result.container.textContent).toContain("*");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("skeleton branch offers no affordance at all", () => {
|
|
80
|
+
const result = renderWithProviders(
|
|
81
|
+
<FileUpload label="Attachment" skeleton onChange={() => {}} />,
|
|
82
|
+
);
|
|
83
|
+
expect(result.getButton("Choose File")).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("shows helperText while nothing is in flight", () => {
|
|
87
|
+
const result = renderWithProviders(
|
|
88
|
+
<FileUpload label="Attachment" helperText="PDF or PNG, under 5MB" onChange={() => {}} />,
|
|
89
|
+
);
|
|
90
|
+
expect(result.container.textContent).toContain("PDF or PNG, under 5MB");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("FileUpload size gate", () => {
|
|
95
|
+
it("refuses a file over maxSize before the transport is ever called", async () => {
|
|
96
|
+
const onUpload = vi.fn();
|
|
97
|
+
const onChange = vi.fn();
|
|
98
|
+
const result = renderWithProviders(
|
|
99
|
+
<FileUpload label="Attachment" maxSize={1024} onUpload={onUpload} onChange={onChange} />,
|
|
100
|
+
);
|
|
101
|
+
await selectFile(result.container, makeFile("big.pdf", 2048));
|
|
102
|
+
expect(onUpload).not.toHaveBeenCalled();
|
|
103
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
104
|
+
expect(result.container.textContent).toContain("exceeds");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("accepts a file at exactly maxSize", async () => {
|
|
108
|
+
const onUpload = vi.fn().mockResolvedValue("/files/ok.pdf");
|
|
109
|
+
const result = renderWithProviders(
|
|
110
|
+
<FileUpload label="Attachment" maxSize={1024} onUpload={onUpload} onChange={() => {}} />,
|
|
111
|
+
);
|
|
112
|
+
await selectFile(result.container, makeFile("ok.pdf", 1024));
|
|
113
|
+
expect(onUpload).toHaveBeenCalledTimes(1);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* i18n interpolation is INERT under `renderWithProviders`: the provider hands
|
|
119
|
+
* back the raw key, so `t("Retry {{name}}", { name })` renders the accessible
|
|
120
|
+
* name literally as `Retry {{name}}` rather than `Retry report.pdf`. Buttons
|
|
121
|
+
* are therefore looked up by the stable half of the label, and the per-file
|
|
122
|
+
* parameterisation is asserted separately (`ariaNames` below) so a dropped
|
|
123
|
+
* `aria-label` still fails. Recorded on MPO-123.
|
|
124
|
+
*/
|
|
125
|
+
const buttonLabelled = (container: HTMLElement, prefix: string) =>
|
|
126
|
+
Array.from(container.querySelectorAll("button")).find((b) =>
|
|
127
|
+
(b.getAttribute("aria-label") ?? "").startsWith(prefix),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
describe("FileUpload transfer", () => {
|
|
131
|
+
it("shows the file name and a live progressbar while uploading", async () => {
|
|
132
|
+
const upload = deferredUpload();
|
|
133
|
+
const result = renderWithProviders(
|
|
134
|
+
<FileUpload label="Attachment" onUpload={upload.handler} onChange={() => {}} />,
|
|
135
|
+
);
|
|
136
|
+
await selectFile(result.container, makeFile("report.pdf"));
|
|
137
|
+
|
|
138
|
+
const row = result.container.querySelector('[data-file-upload="transfer"]');
|
|
139
|
+
expect(row?.getAttribute("data-transfer-status")).toBe("uploading");
|
|
140
|
+
expect(result.container.textContent).toContain("report.pdf");
|
|
141
|
+
|
|
142
|
+
const bar = result.container.querySelector('[data-file-upload="progress"]');
|
|
143
|
+
expect(bar?.getAttribute("aria-valuenow")).toBe("0");
|
|
144
|
+
await act(async () => {
|
|
145
|
+
upload.progress(42);
|
|
146
|
+
});
|
|
147
|
+
expect(
|
|
148
|
+
result.container
|
|
149
|
+
.querySelector('[data-file-upload="progress"]')
|
|
150
|
+
?.getAttribute("aria-valuenow"),
|
|
151
|
+
).toBe("42");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("clamps a nonsense progress report into 0..100", async () => {
|
|
155
|
+
const upload = deferredUpload();
|
|
156
|
+
const result = renderWithProviders(
|
|
157
|
+
<FileUpload label="Attachment" onUpload={upload.handler} onChange={() => {}} />,
|
|
158
|
+
);
|
|
159
|
+
await selectFile(result.container, makeFile());
|
|
160
|
+
await act(async () => {
|
|
161
|
+
upload.progress(999);
|
|
162
|
+
});
|
|
163
|
+
expect(
|
|
164
|
+
result.container
|
|
165
|
+
.querySelector('[data-file-upload="progress"]')
|
|
166
|
+
?.getAttribute("aria-valuenow"),
|
|
167
|
+
).toBe("100");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("emits the committed URL through onChange and clears the transfer row", async () => {
|
|
171
|
+
const upload = deferredUpload();
|
|
172
|
+
const onChange = vi.fn();
|
|
173
|
+
const result = renderWithProviders(
|
|
174
|
+
<FileUpload label="Attachment" onUpload={upload.handler} onChange={onChange} />,
|
|
175
|
+
);
|
|
176
|
+
await selectFile(result.container, makeFile());
|
|
177
|
+
await act(async () => {
|
|
178
|
+
upload.resolve("/files/report.pdf");
|
|
179
|
+
});
|
|
180
|
+
await waitFor(() => expect(onChange).toHaveBeenCalledWith("/files/report.pdf"));
|
|
181
|
+
expect(result.container.querySelector('[data-file-upload="transfer"]')).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("keeps the existing value when the transfer fails (DG-VAL-04)", async () => {
|
|
185
|
+
const upload = deferredUpload();
|
|
186
|
+
const onChange = vi.fn();
|
|
187
|
+
const result = renderWithProviders(
|
|
188
|
+
<FileUpload
|
|
189
|
+
label="Attachment"
|
|
190
|
+
value="/files/previous.pdf"
|
|
191
|
+
onUpload={upload.handler}
|
|
192
|
+
onChange={onChange}
|
|
193
|
+
/>,
|
|
194
|
+
);
|
|
195
|
+
await selectFile(result.container, makeFile());
|
|
196
|
+
await act(async () => {
|
|
197
|
+
upload.reject(new Error("network is down"));
|
|
198
|
+
});
|
|
199
|
+
await waitFor(() =>
|
|
200
|
+
expect(
|
|
201
|
+
result.container
|
|
202
|
+
.querySelector('[data-file-upload="transfer"]')
|
|
203
|
+
?.getAttribute("data-transfer-status"),
|
|
204
|
+
).toBe("failed"),
|
|
205
|
+
);
|
|
206
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
207
|
+
expect(result.container.textContent).toContain("network is down");
|
|
208
|
+
expect(result.container.textContent).toContain("previous.pdf");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("offers Retry and Remove on a failed row, and Retry re-runs the transport", async () => {
|
|
212
|
+
const upload = deferredUpload();
|
|
213
|
+
const result = renderWithProviders(
|
|
214
|
+
<FileUpload label="Attachment" onUpload={upload.handler} onChange={() => {}} />,
|
|
215
|
+
);
|
|
216
|
+
await selectFile(result.container, makeFile("report.pdf"));
|
|
217
|
+
await act(async () => {
|
|
218
|
+
upload.reject(new Error("boom"));
|
|
219
|
+
});
|
|
220
|
+
await waitFor(() => expect(buttonLabelled(result.container, "Retry ")).toBeTruthy());
|
|
221
|
+
const remove = buttonLabelled(result.container, "Remove ");
|
|
222
|
+
expect(remove).toBeTruthy();
|
|
223
|
+
// Both affordances carry a per-file accessible name, so a screen reader
|
|
224
|
+
// can tell one failed row's Retry from another's.
|
|
225
|
+
expect(buttonLabelled(result.container, "Retry ")?.getAttribute("aria-label")).toMatch(
|
|
226
|
+
/^Retry \S/,
|
|
227
|
+
);
|
|
228
|
+
expect(remove?.getAttribute("aria-label")).toMatch(/^Remove \S/);
|
|
229
|
+
// The visible text stays the short verb; the file name lives on the label.
|
|
230
|
+
expect(buttonLabelled(result.container, "Retry ")?.textContent).toContain("Retry");
|
|
231
|
+
|
|
232
|
+
await act(async () => {
|
|
233
|
+
fireEvent.click(buttonLabelled(result.container, "Retry ") as Element);
|
|
234
|
+
});
|
|
235
|
+
expect(upload.handler).toHaveBeenCalledTimes(2);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("Cancel aborts the in-flight signal and drops the row", async () => {
|
|
239
|
+
const upload = deferredUpload();
|
|
240
|
+
const result = renderWithProviders(
|
|
241
|
+
<FileUpload label="Attachment" onUpload={upload.handler} onChange={() => {}} />,
|
|
242
|
+
);
|
|
243
|
+
await selectFile(result.container, makeFile("report.pdf"));
|
|
244
|
+
expect(upload.signal.aborted).toBe(false);
|
|
245
|
+
|
|
246
|
+
await act(async () => {
|
|
247
|
+
fireEvent.click(buttonLabelled(result.container, "Cancel ") as Element);
|
|
248
|
+
});
|
|
249
|
+
expect(upload.signal.aborted).toBe(true);
|
|
250
|
+
expect(result.container.querySelector('[data-file-upload="transfer"]')).toBeNull();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("an aborted transport never emits onChange", async () => {
|
|
254
|
+
const upload = deferredUpload();
|
|
255
|
+
const onChange = vi.fn();
|
|
256
|
+
const result = renderWithProviders(
|
|
257
|
+
<FileUpload label="Attachment" onUpload={upload.handler} onChange={onChange} />,
|
|
258
|
+
);
|
|
259
|
+
await selectFile(result.container, makeFile("report.pdf"));
|
|
260
|
+
await act(async () => {
|
|
261
|
+
fireEvent.click(buttonLabelled(result.container, "Cancel ") as Element);
|
|
262
|
+
upload.resolve("/files/late.pdf");
|
|
263
|
+
});
|
|
264
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe("FileUpload with a value", () => {
|
|
269
|
+
it("previews an image value as an <img> with a string src", () => {
|
|
270
|
+
const result = renderWithProviders(
|
|
271
|
+
<FileUpload label="Photo" isImage value="/files/photo.png" onChange={() => {}} />,
|
|
272
|
+
);
|
|
273
|
+
const img = result.container.querySelector("img");
|
|
274
|
+
expect(img?.getAttribute("src")).toBe("/files/photo.png");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("shows the file name for a non-image value", () => {
|
|
278
|
+
const result = renderWithProviders(
|
|
279
|
+
<FileUpload label="Attachment" value="/files/deep/report.pdf" onChange={() => {}} />,
|
|
280
|
+
);
|
|
281
|
+
expect(result.container.textContent).toContain("report.pdf");
|
|
282
|
+
expect(result.container.querySelector("img")).toBeNull();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("offers Replace and Remove, and Remove clears the value", () => {
|
|
286
|
+
const onChange = vi.fn();
|
|
287
|
+
const result = renderWithProviders(
|
|
288
|
+
<FileUpload label="Attachment" value="/files/report.pdf" onChange={onChange} />,
|
|
289
|
+
);
|
|
290
|
+
expect(result.getButton("Replace")).toBeTruthy();
|
|
291
|
+
fireEvent.click(result.getButton("Remove") as Element);
|
|
292
|
+
expect(onChange).toHaveBeenCalledWith("");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("readOnly hides Replace and Remove — the value is visible, not editable", () => {
|
|
296
|
+
const result = renderWithProviders(
|
|
297
|
+
<FileUpload label="Attachment" value="/files/report.pdf" readOnly onChange={() => {}} />,
|
|
298
|
+
);
|
|
299
|
+
expect(result.getButton("Replace")).toBeUndefined();
|
|
300
|
+
expect(result.getButton("Remove")).toBeUndefined();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("disabled refuses a Remove even though the button is on screen", () => {
|
|
304
|
+
const onChange = vi.fn();
|
|
305
|
+
const result = renderWithProviders(
|
|
306
|
+
<FileUpload label="Attachment" value="/files/report.pdf" disabled onChange={onChange} />,
|
|
307
|
+
);
|
|
308
|
+
fireEvent.click(result.getButton("Remove") as Element);
|
|
309
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("disabled refuses a file selection outright", async () => {
|
|
313
|
+
const onUpload = vi.fn();
|
|
314
|
+
const result = renderWithProviders(
|
|
315
|
+
<FileUpload label="Attachment" disabled onUpload={onUpload} onChange={() => {}} />,
|
|
316
|
+
);
|
|
317
|
+
await selectFile(result.container, makeFile());
|
|
318
|
+
expect(onUpload).not.toHaveBeenCalled();
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
describe("FileUpload accept", () => {
|
|
323
|
+
it("passes an explicit accept list to the native picker", () => {
|
|
324
|
+
const result = renderWithProviders(
|
|
325
|
+
<FileUpload label="Attachment" accept=".pdf,.docx" onChange={() => {}} />,
|
|
326
|
+
);
|
|
327
|
+
expect(hiddenInput(result.container).getAttribute("accept")).toBe(".pdf,.docx");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("falls back to image/* when the field is an image field", () => {
|
|
331
|
+
const result = renderWithProviders(<FileUpload label="Photo" isImage onChange={() => {}} />);
|
|
332
|
+
expect(hiddenInput(result.container).getAttribute("accept")).toBe("image/*");
|
|
333
|
+
});
|
|
334
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileUploadField contract specs. This is the registry adapter behind Frappe's
|
|
3
|
+
* `Attach` and `Attach Image` fieldtypes, so it is the field a DocType actually
|
|
4
|
+
* gets. Three things are its own — everything else it forwards to the forms
|
|
5
|
+
* FileUpload:
|
|
6
|
+
*
|
|
7
|
+
* 1. the choose-from-library tab (MPO-53 AC-11), on by default, which is
|
|
8
|
+
* what makes this field Attach rather than a plain dropzone,
|
|
9
|
+
* 2. the `isImage` accept default, which is what makes Attach Image narrower
|
|
10
|
+
* than Attach,
|
|
11
|
+
* 3. a controlled `value` winning over `defaultValue`, so a saved attachment
|
|
12
|
+
* paints on a re-render instead of the mount-time default.
|
|
13
|
+
*/
|
|
14
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
15
|
+
import { cleanup } from "@testing-library/react";
|
|
16
|
+
import type React from "react";
|
|
17
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
18
|
+
import { getFieldComponent, type FieldRenderProps } from "../../fieldRegistry";
|
|
19
|
+
import { FileUploadField } from ".";
|
|
20
|
+
|
|
21
|
+
afterEach(cleanup);
|
|
22
|
+
|
|
23
|
+
const nativePicker = (container: HTMLElement) =>
|
|
24
|
+
container.querySelector('input[type="file"]') as HTMLInputElement | null;
|
|
25
|
+
|
|
26
|
+
describe("FileUploadField library tab", () => {
|
|
27
|
+
it("offers the From Library action by default — this field IS Attach", () => {
|
|
28
|
+
const result = renderWithProviders(<FileUploadField label="Attachment" />);
|
|
29
|
+
expect(result.getButton("From Library")).toBeTruthy();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("drops the action when the field opts out", () => {
|
|
33
|
+
const result = renderWithProviders(<FileUploadField label="Attachment" library={false} />);
|
|
34
|
+
expect(result.getButton("From Library")).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("disables the action on a disabled field", () => {
|
|
38
|
+
const result = renderWithProviders(<FileUploadField label="Attachment" disabled />);
|
|
39
|
+
const button = result.getButton("From Library");
|
|
40
|
+
expect(button).toBeTruthy();
|
|
41
|
+
expect(
|
|
42
|
+
button?.getAttribute("aria-disabled") === "true" || (button as HTMLButtonElement).disabled,
|
|
43
|
+
).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("disables the action on a read-only field", () => {
|
|
47
|
+
const result = renderWithProviders(<FileUploadField label="Attachment" readOnly />);
|
|
48
|
+
const button = result.getButton("From Library");
|
|
49
|
+
expect(button).toBeTruthy();
|
|
50
|
+
expect(
|
|
51
|
+
button?.getAttribute("aria-disabled") === "true" || (button as HTMLButtonElement).disabled,
|
|
52
|
+
).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("FileUploadField accept", () => {
|
|
57
|
+
it("sends no accept list for a plain Attach", () => {
|
|
58
|
+
const result = renderWithProviders(<FileUploadField label="Attachment" />);
|
|
59
|
+
expect(nativePicker(result.container)?.getAttribute("accept")).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("narrows to the image list for Attach Image", () => {
|
|
63
|
+
const result = renderWithProviders(<FileUploadField label="Photo" isImage />);
|
|
64
|
+
const accept = nativePicker(result.container)?.getAttribute("accept") ?? "";
|
|
65
|
+
expect(accept).toContain("image/png");
|
|
66
|
+
expect(accept).toContain("image/jpeg");
|
|
67
|
+
expect(accept).toContain("image/svg+xml");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("an explicit accept wins over the isImage default", () => {
|
|
71
|
+
const result = renderWithProviders(
|
|
72
|
+
<FileUploadField label="Photo" isImage accept="image/png" />,
|
|
73
|
+
);
|
|
74
|
+
expect(nativePicker(result.container)?.getAttribute("accept")).toBe("image/png");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("FileUploadField chrome", () => {
|
|
79
|
+
it("shows the label and the helperText when the field is clean", () => {
|
|
80
|
+
const result = renderWithProviders(
|
|
81
|
+
<FileUploadField label="Attachment" helperText="under 5MB" />,
|
|
82
|
+
);
|
|
83
|
+
expect(result.container.textContent).toContain("Attachment");
|
|
84
|
+
expect(result.container.textContent).toContain("under 5MB");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("an error REPLACES the helperText rather than stacking under it", () => {
|
|
88
|
+
// FieldLayout's law, and the reason a field never shows contradictory
|
|
89
|
+
// guidance: one message slot, error wins.
|
|
90
|
+
const result = renderWithProviders(
|
|
91
|
+
<FileUploadField label="Attachment" helperText="under 5MB" error="required" />,
|
|
92
|
+
);
|
|
93
|
+
expect(result.container.textContent).toContain("Attachment");
|
|
94
|
+
expect(result.container.textContent).toContain("required");
|
|
95
|
+
expect(result.container.textContent).not.toContain("under 5MB");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("skeleton branch renders no picker", () => {
|
|
99
|
+
const result = renderWithProviders(<FileUploadField label="Attachment" skeleton />);
|
|
100
|
+
expect(nativePicker(result.container)).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("registry resolution (Attach / Attach Image fieldtypes)", () => {
|
|
105
|
+
function propsFor(overrides: Partial<FieldRenderProps> = {}): FieldRenderProps {
|
|
106
|
+
return { value: "", onChange: () => {}, ...overrides };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
it("resolves 'Attach' to FileUploadField with the library on", () => {
|
|
110
|
+
const Component = getFieldComponent("Attach")!;
|
|
111
|
+
expect(Component).toBeTruthy();
|
|
112
|
+
const element = (Component as Function)(
|
|
113
|
+
propsFor({ value: "/files/a.pdf", label: "Attachment" }),
|
|
114
|
+
) as React.ReactElement<Record<string, any>>;
|
|
115
|
+
expect(element.type).toBe(FileUploadField);
|
|
116
|
+
expect(element.props.value).toBe("/files/a.pdf");
|
|
117
|
+
expect(element.props.library).toBe(true);
|
|
118
|
+
expect(element.props.isImage).toBeUndefined();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("resolves 'Attach Image' to the same component with isImage set", () => {
|
|
122
|
+
const Component = getFieldComponent("Attach Image")!;
|
|
123
|
+
expect(Component).toBeTruthy();
|
|
124
|
+
expect(Component).not.toBe(getFieldComponent("Attach"));
|
|
125
|
+
const element = (Component as Function)(propsFor({ label: "Photo" })) as React.ReactElement<
|
|
126
|
+
Record<string, any>
|
|
127
|
+
>;
|
|
128
|
+
expect(element.type).toBe(FileUploadField);
|
|
129
|
+
expect(element.props.isImage).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("honours an explicit library:false from the schema", () => {
|
|
133
|
+
const Component = getFieldComponent("Attach")!;
|
|
134
|
+
const element = (Component as Function)(
|
|
135
|
+
propsFor({ library: false } as Partial<FieldRenderProps>),
|
|
136
|
+
) as React.ReactElement<Record<string, any>>;
|
|
137
|
+
expect(element.props.library).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("feeds the library its file list and folder from the schema", () => {
|
|
141
|
+
const Component = getFieldComponent("Attach")!;
|
|
142
|
+
const libraryFiles = [{ name: "a.png", file_url: "/files/a.png" }];
|
|
143
|
+
const element = (Component as Function)(
|
|
144
|
+
propsFor({ libraryFiles, libraryFolder: "Home/Attachments" } as Partial<FieldRenderProps>),
|
|
145
|
+
) as React.ReactElement<Record<string, any>>;
|
|
146
|
+
expect(element.props.libraryFiles).toBe(libraryFiles);
|
|
147
|
+
expect(element.props.libraryFolder).toBe("Home/Attachments");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("does not coerce a null value — an unset Attach must stay unset", () => {
|
|
151
|
+
const Component = getFieldComponent("Attach")!;
|
|
152
|
+
const element = (Component as Function)(
|
|
153
|
+
propsFor({ value: null as unknown as string }),
|
|
154
|
+
) as React.ReactElement<Record<string, any>>;
|
|
155
|
+
expect(element.props.value).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("wires the canonical onChange straight through", () => {
|
|
159
|
+
const onChange = vi.fn();
|
|
160
|
+
const Component = getFieldComponent("Attach")!;
|
|
161
|
+
const element = (Component as Function)(propsFor({ onChange })) as React.ReactElement<
|
|
162
|
+
Record<string, any>
|
|
163
|
+
>;
|
|
164
|
+
expect(element.props.onChange).toBe(onChange);
|
|
165
|
+
});
|
|
166
|
+
});
|