@multiplatform.one/frappe-ui 7.6.1 → 7.6.3
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/FileBrowser/AttachLibrary.cjs +51 -49
- package/dist/cjs/FileBrowser/AttachLibrary.native.js +58 -49
- package/dist/cjs/FileBrowser/AttachLibrary.native.js.map +1 -1
- package/dist/cjs/FrappeConnectionIndicator.cjs +5 -3
- package/dist/cjs/FrappeConnectionIndicator.native.js +5 -3
- package/dist/cjs/FrappeConnectionIndicator.native.js.map +1 -1
- package/dist/cjs/FrappeForm.cjs +3 -1
- package/dist/cjs/FrappeForm.native.js +3 -1
- package/dist/cjs/FrappeForm.native.js.map +1 -1
- package/dist/cjs/FrappeReconnectIndicator.cjs +4 -2
- package/dist/cjs/FrappeReconnectIndicator.native.js +4 -2
- package/dist/cjs/FrappeReconnectIndicator.native.js.map +1 -1
- package/dist/cjs/index.cjs +2 -0
- package/dist/cjs/index.native.js +2 -0
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/schemaForm/applyFieldOrder.cjs +63 -0
- package/dist/cjs/schemaForm/applyFieldOrder.native.js +116 -0
- package/dist/cjs/schemaForm/applyFieldOrder.native.js.map +1 -0
- package/dist/cjs/schemaForm/index.cjs +13 -7
- package/dist/cjs/schemaForm/index.native.js +15 -7
- package/dist/cjs/schemaForm/index.native.js.map +1 -1
- package/dist/esm/FileBrowser/AttachLibrary.mjs +51 -49
- package/dist/esm/FileBrowser/AttachLibrary.mjs.map +1 -1
- package/dist/esm/FileBrowser/AttachLibrary.native.js +58 -49
- package/dist/esm/FileBrowser/AttachLibrary.native.js.map +1 -1
- package/dist/esm/FrappeConnectionIndicator.mjs +5 -3
- package/dist/esm/FrappeConnectionIndicator.mjs.map +1 -1
- package/dist/esm/FrappeConnectionIndicator.native.js +5 -3
- package/dist/esm/FrappeConnectionIndicator.native.js.map +1 -1
- package/dist/esm/FrappeForm.mjs +3 -1
- package/dist/esm/FrappeForm.mjs.map +1 -1
- package/dist/esm/FrappeForm.native.js +3 -1
- package/dist/esm/FrappeForm.native.js.map +1 -1
- package/dist/esm/FrappeReconnectIndicator.mjs +4 -2
- package/dist/esm/FrappeReconnectIndicator.mjs.map +1 -1
- package/dist/esm/FrappeReconnectIndicator.native.js +4 -2
- package/dist/esm/FrappeReconnectIndicator.native.js.map +1 -1
- package/dist/esm/index.mjs +2 -2
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +2 -2
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/schemaForm/applyFieldOrder.mjs +37 -0
- package/dist/esm/schemaForm/applyFieldOrder.mjs.map +1 -0
- package/dist/esm/schemaForm/applyFieldOrder.native.js +87 -0
- package/dist/esm/schemaForm/applyFieldOrder.native.js.map +1 -0
- package/dist/esm/schemaForm/index.mjs +11 -7
- package/dist/esm/schemaForm/index.mjs.map +1 -1
- package/dist/esm/schemaForm/index.native.js +13 -7
- package/dist/esm/schemaForm/index.native.js.map +1 -1
- package/dist/jsx/FileBrowser/AttachLibrary.mjs +51 -49
- package/dist/jsx/FileBrowser/AttachLibrary.mjs.map +1 -1
- package/dist/jsx/FileBrowser/AttachLibrary.native.js +58 -49
- package/dist/jsx/FileBrowser/AttachLibrary.native.js.map +1 -1
- package/dist/jsx/FrappeConnectionIndicator.mjs +5 -3
- package/dist/jsx/FrappeConnectionIndicator.mjs.map +1 -1
- package/dist/jsx/FrappeConnectionIndicator.native.js +5 -3
- package/dist/jsx/FrappeConnectionIndicator.native.js.map +1 -1
- package/dist/jsx/FrappeForm.mjs +3 -1
- package/dist/jsx/FrappeForm.mjs.map +1 -1
- package/dist/jsx/FrappeForm.native.js +3 -1
- package/dist/jsx/FrappeForm.native.js.map +1 -1
- package/dist/jsx/FrappeReconnectIndicator.mjs +4 -2
- package/dist/jsx/FrappeReconnectIndicator.mjs.map +1 -1
- package/dist/jsx/FrappeReconnectIndicator.native.js +4 -2
- package/dist/jsx/FrappeReconnectIndicator.native.js.map +1 -1
- package/dist/jsx/index.js +2 -2
- package/dist/jsx/index.js.map +1 -1
- package/dist/jsx/index.mjs +2 -2
- package/dist/jsx/index.mjs.map +1 -1
- package/dist/jsx/index.native.js +2 -0
- package/dist/jsx/index.native.js.map +1 -1
- package/dist/jsx/schemaForm/applyFieldOrder.mjs +37 -0
- package/dist/jsx/schemaForm/applyFieldOrder.mjs.map +1 -0
- package/dist/jsx/schemaForm/applyFieldOrder.native.js +116 -0
- package/dist/jsx/schemaForm/applyFieldOrder.native.js.map +1 -0
- package/dist/jsx/schemaForm/index.mjs +11 -7
- package/dist/jsx/schemaForm/index.mjs.map +1 -1
- package/dist/jsx/schemaForm/index.native.js +15 -7
- package/dist/jsx/schemaForm/index.native.js.map +1 -1
- package/package.json +16 -16
- package/src/FileBrowser/AttachLibrary.spec.tsx +74 -0
- package/src/FileBrowser/AttachLibrary.stories.tsx +67 -0
- package/src/FileBrowser/AttachLibrary.tsx +56 -49
- package/src/FrappeConnectionIndicator.spec.tsx +51 -0
- package/src/FrappeConnectionIndicator.stories.tsx +55 -0
- package/src/FrappeConnectionIndicator.tsx +15 -4
- package/src/FrappeForm.tsx +13 -3
- package/src/FrappeReconnectIndicator.spec.tsx +52 -0
- package/src/FrappeReconnectIndicator.stories.tsx +55 -0
- package/src/FrappeReconnectIndicator.tsx +13 -3
- package/src/SyncErrorNotice.stories.tsx +50 -0
- package/src/desk/DeskFormSidebar.spec.tsx +90 -0
- package/src/desk/DeskFormSidebar.stories.tsx +133 -0
- package/src/fields/LinkTree.spec.tsx +109 -0
- package/src/fields/LinkTree.stories.tsx +187 -0
- package/src/index.ts +1 -1
- package/src/schemaForm/SchemaForm.spec.tsx +29 -0
- package/src/schemaForm/SchemaForm.stories.tsx +14 -1
- package/src/schemaForm/applyFieldOrder.spec.ts +75 -0
- package/src/schemaForm/applyFieldOrder.ts +71 -0
- package/src/schemaForm/index.tsx +17 -5
- package/types/FileBrowser/AttachLibrary.d.ts.map +1 -1
- package/types/FrappeConnectionIndicator.d.ts +8 -2
- package/types/FrappeConnectionIndicator.d.ts.map +1 -1
- package/types/FrappeForm.d.ts +7 -1
- package/types/FrappeForm.d.ts.map +1 -1
- package/types/FrappeReconnectIndicator.d.ts +7 -2
- package/types/FrappeReconnectIndicator.d.ts.map +1 -1
- package/types/index.d.ts +1 -1
- package/types/index.d.ts.map +1 -1
- package/types/schemaForm/applyFieldOrder.d.ts +28 -0
- package/types/schemaForm/applyFieldOrder.d.ts.map +1 -0
- package/types/schemaForm/index.d.ts +8 -1
- package/types/schemaForm/index.d.ts.map +1 -1
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { action } from "@multiplatform.one/storybook";
|
|
2
|
+
import { YStack } from "tamagui";
|
|
3
|
+
import { SyncErrorNotice } from "./SyncErrorNotice";
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
title: "FrappeUI/SyncErrorNotice",
|
|
7
|
+
component: SyncErrorNotice,
|
|
8
|
+
parameters: {
|
|
9
|
+
status: { type: "stable" },
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
"Rejected-write notice: role=alert message with Retry (same mutation id) and Dismiss. Each affordance only renders when its handler is provided.",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
argTypes: {
|
|
18
|
+
message: { control: "text" },
|
|
19
|
+
retryLabel: { control: "text" },
|
|
20
|
+
dismissLabel: { control: "text" },
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const Default = () => (
|
|
25
|
+
<SyncErrorNotice
|
|
26
|
+
message="Could not add comment"
|
|
27
|
+
onRetry={action("onRetry")}
|
|
28
|
+
onDismiss={action("onDismiss")}
|
|
29
|
+
/>
|
|
30
|
+
);
|
|
31
|
+
Default.storyName = "Main";
|
|
32
|
+
|
|
33
|
+
export const AllStates = () => (
|
|
34
|
+
<YStack gap="$4" width={480}>
|
|
35
|
+
<SyncErrorNotice
|
|
36
|
+
message="Could not add comment"
|
|
37
|
+
onRetry={action("onRetry")}
|
|
38
|
+
onDismiss={action("onDismiss")}
|
|
39
|
+
/>
|
|
40
|
+
<SyncErrorNotice message="Write rejected — retry only" onRetry={action("onRetry")} />
|
|
41
|
+
<SyncErrorNotice message="No actions" />
|
|
42
|
+
<SyncErrorNotice
|
|
43
|
+
message="Custom labels"
|
|
44
|
+
onRetry={action("onRetry")}
|
|
45
|
+
onDismiss={action("onDismiss")}
|
|
46
|
+
retryLabel="Try again"
|
|
47
|
+
dismissLabel="Ignore"
|
|
48
|
+
/>
|
|
49
|
+
</YStack>
|
|
50
|
+
);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
2
|
+
import { cleanup, screen } from "@testing-library/react";
|
|
3
|
+
import i18next from "i18next";
|
|
4
|
+
import { initReactI18next } from "react-i18next";
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { FrappeProvider } from "../FrappeProvider";
|
|
8
|
+
import { DeskFormSidebar } from "./DeskFormSidebar";
|
|
9
|
+
|
|
10
|
+
const mockUseDocTypeMeta = vi.fn();
|
|
11
|
+
const mockUseFrappeCollection = vi.fn();
|
|
12
|
+
const mockUseLiveQuery = vi.fn();
|
|
13
|
+
|
|
14
|
+
vi.mock("@multiplatform.one/frappe", async (importOriginal) => {
|
|
15
|
+
const actual = await importOriginal<Record<string, unknown>>();
|
|
16
|
+
return {
|
|
17
|
+
...actual,
|
|
18
|
+
useDocTypeMeta: (...args: unknown[]) => mockUseDocTypeMeta(...args),
|
|
19
|
+
useFrappeCollection: (...args: unknown[]) => mockUseFrappeCollection(...args),
|
|
20
|
+
useLiveQuery: (...args: unknown[]) => mockUseLiveQuery(...args),
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
vi.mock("../FormSidebar", () => ({
|
|
25
|
+
FormSidebar: (props: Record<string, unknown>) =>
|
|
26
|
+
React.createElement(
|
|
27
|
+
"div",
|
|
28
|
+
{
|
|
29
|
+
"data-testid": props["data-testid"] ?? "desk-form-sidebar",
|
|
30
|
+
"data-section-keys": Array.isArray(props.sections)
|
|
31
|
+
? (props.sections as Array<{ key: string }>).map((section) => section.key).join(",")
|
|
32
|
+
: "",
|
|
33
|
+
},
|
|
34
|
+
"sidebar",
|
|
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.clearAllMocks();
|
|
48
|
+
mockUseDocTypeMeta.mockReturnValue({
|
|
49
|
+
meta: { image_field: "image" },
|
|
50
|
+
isLoading: false,
|
|
51
|
+
error: null,
|
|
52
|
+
});
|
|
53
|
+
mockUseFrappeCollection.mockReturnValue({ id: "mock" });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(cleanup);
|
|
57
|
+
|
|
58
|
+
function renderSidebar(name = "TODO-001") {
|
|
59
|
+
return renderWithProviders(
|
|
60
|
+
<FrappeProvider baseURL="https://bench.example.com">
|
|
61
|
+
<DeskFormSidebar doctype="Todo" name={name} />
|
|
62
|
+
</FrappeProvider>,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("DeskFormSidebar (MPO-111)", () => {
|
|
67
|
+
it("renders nothing until the document arrives", () => {
|
|
68
|
+
mockUseLiveQuery.mockReturnValue({ data: [] });
|
|
69
|
+
const { container } = renderSidebar();
|
|
70
|
+
expect(container.querySelector('[data-testid="desk-form-sidebar"]')).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("mounts the FormSidebar once the doc query returns a row", async () => {
|
|
74
|
+
const doc = {
|
|
75
|
+
name: "TODO-001",
|
|
76
|
+
doctype: "Todo",
|
|
77
|
+
description: "Review",
|
|
78
|
+
owner: "jane@example.com",
|
|
79
|
+
};
|
|
80
|
+
// Three live queries (doc, share, assignment). Cycle so Strict Mode
|
|
81
|
+
// remounts still see a doc on the first slot.
|
|
82
|
+
let call = 0;
|
|
83
|
+
mockUseLiveQuery.mockImplementation(() => {
|
|
84
|
+
const slot = call++ % 3;
|
|
85
|
+
return { data: slot === 0 ? [doc] : [] };
|
|
86
|
+
});
|
|
87
|
+
renderSidebar();
|
|
88
|
+
expect(await screen.findByTestId("desk-form-sidebar")).toBeTruthy();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { InMemoryFixtureProvider } from "@multiplatform.one/frappe";
|
|
2
|
+
import { YStack } from "tamagui";
|
|
3
|
+
import { FrappeProvider } from "../FrappeProvider";
|
|
4
|
+
import { DeskFormSidebar } from "./DeskFormSidebar";
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
title: "FrappeUI/Desk/DeskFormSidebar",
|
|
8
|
+
component: DeskFormSidebar,
|
|
9
|
+
tags: ["!test"],
|
|
10
|
+
parameters: {
|
|
11
|
+
status: { type: "stable" },
|
|
12
|
+
docs: {
|
|
13
|
+
description: {
|
|
14
|
+
component:
|
|
15
|
+
"Live-wired FormSidebar for the Desk form page's supporting pane. Renders nothing until the document arrives. Assigned To comes from open assignment ToDos; Shared With from DocShare; metadata from the doc row.",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const MOCK_HOST = "https://deskformsidebar.mock.test";
|
|
22
|
+
|
|
23
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
24
|
+
return new Response(JSON.stringify(body), {
|
|
25
|
+
status,
|
|
26
|
+
headers: { "Content-Type": "application/json" },
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function todoMeta(name: string) {
|
|
31
|
+
return {
|
|
32
|
+
name,
|
|
33
|
+
module: "Desk",
|
|
34
|
+
title_field: "description",
|
|
35
|
+
image_field: "image",
|
|
36
|
+
fields: [
|
|
37
|
+
{ fieldname: "description", fieldtype: "Data", label: "Description", reqd: 1 },
|
|
38
|
+
{ fieldname: "status", fieldtype: "Select", label: "Status" },
|
|
39
|
+
{ fieldname: "image", fieldtype: "Attach Image", label: "Image" },
|
|
40
|
+
],
|
|
41
|
+
permissions: [{ role: "All", read: 1, write: 1 }],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
(() => {
|
|
46
|
+
if (typeof window === "undefined") return;
|
|
47
|
+
const w = window as unknown as Record<string, unknown>;
|
|
48
|
+
if (w.__deskFormSidebarMockFetch) return;
|
|
49
|
+
w.__deskFormSidebarMockFetch = true;
|
|
50
|
+
const realFetch = window.fetch.bind(window);
|
|
51
|
+
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
52
|
+
const urlStr =
|
|
53
|
+
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
54
|
+
if (urlStr.startsWith(MOCK_HOST)) {
|
|
55
|
+
const url = new URL(urlStr);
|
|
56
|
+
const metaMatch = url.pathname.match(/^\/api\/resource\/DocType\/(.+)$/);
|
|
57
|
+
if (metaMatch) return jsonResponse({ data: todoMeta(decodeURIComponent(metaMatch[1])) });
|
|
58
|
+
return jsonResponse({ data: [] });
|
|
59
|
+
}
|
|
60
|
+
return realFetch(input as RequestInfo, init);
|
|
61
|
+
};
|
|
62
|
+
})();
|
|
63
|
+
|
|
64
|
+
const todoDocs = [
|
|
65
|
+
{
|
|
66
|
+
name: "TODO-001",
|
|
67
|
+
doctype: "Todo",
|
|
68
|
+
description: "Review the quarterly report",
|
|
69
|
+
status: "Working",
|
|
70
|
+
owner: "jane@example.com",
|
|
71
|
+
modified_by: "john@example.com",
|
|
72
|
+
creation: "2026-02-01",
|
|
73
|
+
image: "/files/todo.png",
|
|
74
|
+
_assign: JSON.stringify(["jane@example.com"]),
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
const shareDocs = [
|
|
79
|
+
{
|
|
80
|
+
name: "SHARE-001",
|
|
81
|
+
doctype: "DocShare",
|
|
82
|
+
user: "sam@example.com",
|
|
83
|
+
everyone: 0,
|
|
84
|
+
read: 1,
|
|
85
|
+
write: 1,
|
|
86
|
+
share: 0,
|
|
87
|
+
share_doctype: "Todo",
|
|
88
|
+
share_name: "TODO-001",
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
const assignmentDocs = [
|
|
93
|
+
{
|
|
94
|
+
name: "TODO-ASSIGN-001",
|
|
95
|
+
doctype: "ToDo",
|
|
96
|
+
allocated_to: "jane@example.com",
|
|
97
|
+
status: "Open",
|
|
98
|
+
reference_type: "Todo",
|
|
99
|
+
reference_name: "TODO-001",
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
function makeFixtures() {
|
|
104
|
+
return new InMemoryFixtureProvider({
|
|
105
|
+
Todo: todoDocs,
|
|
106
|
+
DocShare: shareDocs,
|
|
107
|
+
ToDo: assignmentDocs,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const Default = () => (
|
|
112
|
+
<FrappeProvider baseURL={MOCK_HOST} fixtures={makeFixtures()}>
|
|
113
|
+
<YStack width={320} height={520}>
|
|
114
|
+
<DeskFormSidebar doctype="Todo" name="TODO-001" />
|
|
115
|
+
</YStack>
|
|
116
|
+
</FrappeProvider>
|
|
117
|
+
);
|
|
118
|
+
Default.storyName = "Main";
|
|
119
|
+
|
|
120
|
+
export const Loading = () => (
|
|
121
|
+
<FrappeProvider baseURL={MOCK_HOST} fixtures={makeFixtures()}>
|
|
122
|
+
<YStack width={320} height={200}>
|
|
123
|
+
<DeskFormSidebar doctype="Todo" name="TODO-MISSING" />
|
|
124
|
+
</YStack>
|
|
125
|
+
</FrappeProvider>
|
|
126
|
+
);
|
|
127
|
+
Loading.parameters = {
|
|
128
|
+
docs: {
|
|
129
|
+
description: {
|
|
130
|
+
story: "Name that is not in fixtures: the sidebar stays unmounted until a doc arrives.",
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
2
|
+
import { cleanup, screen } from "@testing-library/react";
|
|
3
|
+
import i18next from "i18next";
|
|
4
|
+
import { initReactI18next } from "react-i18next";
|
|
5
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { ComponentProps } from "react";
|
|
7
|
+
import { FrappeProvider } from "../FrappeProvider";
|
|
8
|
+
import { LinkTree } from "./LinkTree";
|
|
9
|
+
|
|
10
|
+
const mockUseLinkTitle = vi.fn();
|
|
11
|
+
const mockUseDocTypeMeta = vi.fn();
|
|
12
|
+
const mockUseFrappeCollection = vi.fn();
|
|
13
|
+
const mockUseLiveQuery = vi.fn();
|
|
14
|
+
|
|
15
|
+
vi.mock("@multiplatform.one/frappe", async (importOriginal) => {
|
|
16
|
+
const actual = await importOriginal<Record<string, unknown>>();
|
|
17
|
+
return {
|
|
18
|
+
...actual,
|
|
19
|
+
useLinkTitle: (...args: unknown[]) => mockUseLinkTitle(...args),
|
|
20
|
+
useDocTypeMeta: (...args: unknown[]) => mockUseDocTypeMeta(...args),
|
|
21
|
+
useFrappeCollection: (...args: unknown[]) => mockUseFrappeCollection(...args),
|
|
22
|
+
useLiveQuery: (...args: unknown[]) => mockUseLiveQuery(...args),
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
await i18next.use(initReactI18next).init({
|
|
28
|
+
lng: "en",
|
|
29
|
+
interpolation: { escapeValue: false },
|
|
30
|
+
resources: {
|
|
31
|
+
en: {
|
|
32
|
+
translation: {
|
|
33
|
+
"Select {{doctype}}": "Select {{doctype}}",
|
|
34
|
+
"Filter {{doctype}}": "Filter {{doctype}}",
|
|
35
|
+
"No matching {{doctype}}": "No matching {{doctype}}",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
vi.clearAllMocks();
|
|
44
|
+
mockUseLinkTitle.mockReturnValue({ title: undefined, isLoading: false });
|
|
45
|
+
mockUseDocTypeMeta.mockReturnValue({
|
|
46
|
+
meta: { is_tree: 1, title_field: "account_name" },
|
|
47
|
+
isLoading: false,
|
|
48
|
+
error: null,
|
|
49
|
+
});
|
|
50
|
+
mockUseFrappeCollection.mockReturnValue({});
|
|
51
|
+
mockUseLiveQuery.mockReturnValue({
|
|
52
|
+
data: [
|
|
53
|
+
{ name: "Assets", account_name: "Assets", parent_account: "", doctype: "Account" },
|
|
54
|
+
{
|
|
55
|
+
name: "HDFC Current",
|
|
56
|
+
account_name: "HDFC Current",
|
|
57
|
+
parent_account: "Assets",
|
|
58
|
+
doctype: "Account",
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
isLoading: false,
|
|
62
|
+
isError: false,
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(cleanup);
|
|
67
|
+
|
|
68
|
+
function renderTree(props: Partial<ComponentProps<typeof LinkTree>> = {}) {
|
|
69
|
+
return renderWithProviders(
|
|
70
|
+
<FrappeProvider baseURL="https://bench.example.com">
|
|
71
|
+
<LinkTree label="Account" doctype="Account" onChange={vi.fn()} {...props} />
|
|
72
|
+
</FrappeProvider>,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("LinkTree adapter (MPO-111)", () => {
|
|
77
|
+
it("renders TreeSelect, not the Link combobox", () => {
|
|
78
|
+
const result = renderTree({ value: "HDFC Current" });
|
|
79
|
+
expect(result.container.querySelector('[data-testid="tree-select"]')).toBeTruthy();
|
|
80
|
+
expect(result.container.querySelector('[data-testid="tree-select-trigger"]')).toBeTruthy();
|
|
81
|
+
expect(result.container.querySelector('[data-testid="combobox-trigger"]')).toBeNull();
|
|
82
|
+
expect(screen.getByText("HDFC Current")).toBeTruthy();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// TreeSelect resolves its trigger label as
|
|
86
|
+
// selected?.label ?? (value ? (valueLabel ?? value) : undefined)
|
|
87
|
+
// (components/src/TreeSelect/index.tsx). A node that IS in the loaded forest
|
|
88
|
+
// carries its own label, so useLinkTitle only shows for a value whose node
|
|
89
|
+
// has not been loaded -- which is the case the prop exists for, since the
|
|
90
|
+
// forest is paged and a stored link often points outside the current page.
|
|
91
|
+
it("shows the useLinkTitle title when the value is outside the loaded forest", () => {
|
|
92
|
+
mockUseLinkTitle.mockReturnValue({ title: "HDFC Current Account", isLoading: false });
|
|
93
|
+
renderTree({ value: "HDFC Savings" });
|
|
94
|
+
expect(screen.getByText("HDFC Current Account")).toBeTruthy();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("prefers the in-tree node label over useLinkTitle", () => {
|
|
98
|
+
mockUseLinkTitle.mockReturnValue({ title: "Should not win", isLoading: false });
|
|
99
|
+
renderTree({ value: "HDFC Current" });
|
|
100
|
+
expect(screen.getByText("HDFC Current")).toBeTruthy();
|
|
101
|
+
expect(screen.queryByText("Should not win")).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("does not call onChange when disabled", () => {
|
|
105
|
+
const onChange = vi.fn();
|
|
106
|
+
renderTree({ value: "Assets", onChange, disabled: true });
|
|
107
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { InMemoryFixtureProvider } from "@multiplatform.one/frappe";
|
|
2
|
+
import { action } from "@multiplatform.one/storybook";
|
|
3
|
+
import { useState, type ReactNode } from "react";
|
|
4
|
+
import { YStack } from "tamagui";
|
|
5
|
+
import { FrappeProvider } from "../FrappeProvider";
|
|
6
|
+
import { Link, type LinkProps } from "./Link";
|
|
7
|
+
import { LinkTree } from "./LinkTree";
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
title: "FrappeUI/LinkTree",
|
|
11
|
+
component: LinkTree,
|
|
12
|
+
tags: ["!test"],
|
|
13
|
+
parameters: {
|
|
14
|
+
status: { type: "beta" },
|
|
15
|
+
docs: {
|
|
16
|
+
description: {
|
|
17
|
+
component:
|
|
18
|
+
'Schema Link whose target DocType is a tree (Account, Item Group, Territory). Loads the forest the same way FrappeTreeView does, then renders TreeSelect. Link.tsx routes here when useDocTypeMeta().is_tree is set; control="combobox" ejects back to search_link.',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
argTypes: {
|
|
23
|
+
label: { control: "text" },
|
|
24
|
+
value: { control: "text" },
|
|
25
|
+
doctype: { control: "text" },
|
|
26
|
+
disabled: { control: "boolean" },
|
|
27
|
+
required: { control: "boolean" },
|
|
28
|
+
readOnly: { control: "boolean" },
|
|
29
|
+
error: { control: "text" },
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const MOCK_HOST = "https://frappe-linktree.mock.test";
|
|
34
|
+
|
|
35
|
+
interface Region {
|
|
36
|
+
name: string;
|
|
37
|
+
doctype: string;
|
|
38
|
+
region_name: string;
|
|
39
|
+
parent_region: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const regionDocs: Region[] = [
|
|
43
|
+
{ name: "North", doctype: "QaRegion", region_name: "Northern Region", parent_region: "" },
|
|
44
|
+
{ name: "South", doctype: "QaRegion", region_name: "Southern Region", parent_region: "" },
|
|
45
|
+
{ name: "West", doctype: "QaRegion", region_name: "Western Region", parent_region: "" },
|
|
46
|
+
{
|
|
47
|
+
name: "North A",
|
|
48
|
+
doctype: "QaRegion",
|
|
49
|
+
region_name: "North Sub-Region A",
|
|
50
|
+
parent_region: "North",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "North B",
|
|
54
|
+
doctype: "QaRegion",
|
|
55
|
+
region_name: "North Sub-Region B",
|
|
56
|
+
parent_region: "North",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "North A1",
|
|
60
|
+
doctype: "QaRegion",
|
|
61
|
+
region_name: "North Leaf A1",
|
|
62
|
+
parent_region: "North A",
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
67
|
+
return new Response(JSON.stringify(body), {
|
|
68
|
+
status,
|
|
69
|
+
headers: { "Content-Type": "application/json" },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function regionMeta(name: string) {
|
|
74
|
+
return {
|
|
75
|
+
name,
|
|
76
|
+
module: "QA",
|
|
77
|
+
title_field: "region_name",
|
|
78
|
+
is_tree: 1,
|
|
79
|
+
fields: [
|
|
80
|
+
{ fieldname: "region_name", fieldtype: "Data", label: "Region Name", in_list_view: 1 },
|
|
81
|
+
{ fieldname: "parent_region", fieldtype: "Link", label: "Parent Region", options: name },
|
|
82
|
+
],
|
|
83
|
+
permissions: [{ role: "All", read: 1, write: 1, create: 1 }],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
(() => {
|
|
88
|
+
if (typeof window === "undefined") return;
|
|
89
|
+
const w = window as unknown as Record<string, unknown>;
|
|
90
|
+
if (w.__frappeLinkTreeMockFetch) return;
|
|
91
|
+
w.__frappeLinkTreeMockFetch = true;
|
|
92
|
+
const realFetch = window.fetch.bind(window);
|
|
93
|
+
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
94
|
+
const urlStr =
|
|
95
|
+
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
96
|
+
if (urlStr.startsWith(MOCK_HOST)) {
|
|
97
|
+
const url = new URL(urlStr);
|
|
98
|
+
if (url.pathname.includes("frappe.desk.form.load.getdoctype")) {
|
|
99
|
+
return jsonResponse({ docs: [regionMeta("QaRegion")] });
|
|
100
|
+
}
|
|
101
|
+
if (url.pathname.includes("frappe.desk.search.get_link_title")) {
|
|
102
|
+
const docname = url.searchParams.get("docname") ?? "";
|
|
103
|
+
const hit = regionDocs.find((row) => row.name === docname);
|
|
104
|
+
return jsonResponse({ message: hit?.region_name ?? docname });
|
|
105
|
+
}
|
|
106
|
+
if (url.pathname.includes("frappe.sessions.get_csrf_token")) {
|
|
107
|
+
return jsonResponse({ message: "mock-csrf" });
|
|
108
|
+
}
|
|
109
|
+
const metaMatch = url.pathname.match(/^\/api\/resource\/DocType\/(.+)$/);
|
|
110
|
+
if (metaMatch) return jsonResponse({ data: regionMeta(decodeURIComponent(metaMatch[1])) });
|
|
111
|
+
return jsonResponse({ data: [], message: [] });
|
|
112
|
+
}
|
|
113
|
+
return realFetch(input as RequestInfo, init);
|
|
114
|
+
};
|
|
115
|
+
})();
|
|
116
|
+
|
|
117
|
+
function host(children: ReactNode) {
|
|
118
|
+
return (
|
|
119
|
+
<FrappeProvider
|
|
120
|
+
baseURL={MOCK_HOST}
|
|
121
|
+
fixtures={new InMemoryFixtureProvider({ QaRegion: regionDocs })}
|
|
122
|
+
>
|
|
123
|
+
{children}
|
|
124
|
+
</FrappeProvider>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function ControlledTree(props: Omit<LinkProps, "onChange" | "doctype"> & { doctype?: string }) {
|
|
129
|
+
const [value, setValue] = useState(props.value ?? "");
|
|
130
|
+
return (
|
|
131
|
+
<LinkTree
|
|
132
|
+
label="Region"
|
|
133
|
+
doctype="QaRegion"
|
|
134
|
+
{...props}
|
|
135
|
+
value={value}
|
|
136
|
+
onChange={(next) => {
|
|
137
|
+
setValue(next);
|
|
138
|
+
action("onChange")(next);
|
|
139
|
+
}}
|
|
140
|
+
/>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const Default = (args: Partial<LinkProps>) => host(<ControlledTree {...args} />);
|
|
145
|
+
Default.args = {
|
|
146
|
+
label: "Region",
|
|
147
|
+
placeholder: "Select a region",
|
|
148
|
+
};
|
|
149
|
+
Default.storyName = "Main";
|
|
150
|
+
|
|
151
|
+
export const AllStates = () =>
|
|
152
|
+
host(
|
|
153
|
+
<YStack gap="$4" width={400}>
|
|
154
|
+
<ControlledTree label="Default" />
|
|
155
|
+
<ControlledTree label="With Value" value="North A" />
|
|
156
|
+
<ControlledTree label="Required" required />
|
|
157
|
+
<ControlledTree label="With Error" error="Pick a leaf region" />
|
|
158
|
+
<ControlledTree label="Disabled" value="South" disabled />
|
|
159
|
+
<ControlledTree label="Read Only" value="West" readOnly />
|
|
160
|
+
</YStack>,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
export const ViaLink = () => {
|
|
164
|
+
const [value, setValue] = useState("North A");
|
|
165
|
+
return host(
|
|
166
|
+
<YStack gap="$4" width={400}>
|
|
167
|
+
<Link
|
|
168
|
+
label="Region (through Link, is_tree)"
|
|
169
|
+
doctype="QaRegion"
|
|
170
|
+
value={value}
|
|
171
|
+
onChange={(next) => {
|
|
172
|
+
setValue(next);
|
|
173
|
+
action("onChange")(next);
|
|
174
|
+
}}
|
|
175
|
+
/>
|
|
176
|
+
</YStack>,
|
|
177
|
+
);
|
|
178
|
+
};
|
|
179
|
+
ViaLink.storyName = "Via Link";
|
|
180
|
+
ViaLink.parameters = {
|
|
181
|
+
docs: {
|
|
182
|
+
description: {
|
|
183
|
+
story:
|
|
184
|
+
"Resolves through Link with a tree DocType so a lost LinkTree import shows up as a Combobox on this board instead of only in Link.tree.spec.tsx.",
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -152,3 +152,32 @@ describe("SchemaForm design-law (MPO-60 / R10)", () => {
|
|
|
152
152
|
expect(getComputedStyle(footer!).display).toBe("none");
|
|
153
153
|
});
|
|
154
154
|
});
|
|
155
|
+
|
|
156
|
+
describe("SchemaForm order (R2 / MPO-22)", () => {
|
|
157
|
+
it("reorders listed fields in the DOM; unlisted keep their meta positions", () => {
|
|
158
|
+
const fields: FieldSchema[] = [
|
|
159
|
+
{ type: "Data", name: "a", label: "Field A" },
|
|
160
|
+
{ type: "Data", name: "b", label: "Field B" },
|
|
161
|
+
{ type: "Data", name: "c", label: "Field C" },
|
|
162
|
+
{ type: "Data", name: "d", label: "Field D" },
|
|
163
|
+
{ type: "Data", name: "e", label: "Field E" },
|
|
164
|
+
];
|
|
165
|
+
const schema: FormSchema = {
|
|
166
|
+
fields,
|
|
167
|
+
sections: [{ label: "Order", columns: [{ fields }] }],
|
|
168
|
+
};
|
|
169
|
+
renderWithProviders(
|
|
170
|
+
<SchemaForm schema={schema} doc={{}} setValue={vi.fn()} order={["c", "a"]} />,
|
|
171
|
+
);
|
|
172
|
+
const a = screen.getByLabelText("Field A");
|
|
173
|
+
const b = screen.getByLabelText("Field B");
|
|
174
|
+
const c = screen.getByLabelText("Field C");
|
|
175
|
+
const d = screen.getByLabelText("Field D");
|
|
176
|
+
const e = screen.getByLabelText("Field E");
|
|
177
|
+
// c, b, a, d, e — b/d/e unlisted, stay put; listed slots fill as c then a.
|
|
178
|
+
expect(c.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
179
|
+
expect(b.compareDocumentPosition(a) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
180
|
+
expect(a.compareDocumentPosition(d) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
181
|
+
expect(d.compareDocumentPosition(e) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -13,7 +13,7 @@ export default {
|
|
|
13
13
|
docs: {
|
|
14
14
|
description: {
|
|
15
15
|
component:
|
|
16
|
-
"Schema-driven form renderer: sections → columns → fields resolved through the fieldRegistry. Supports depends_on, overrides, hidden and read-only fields.",
|
|
16
|
+
"Schema-driven form renderer: sections → columns → fields resolved through the fieldRegistry. Supports depends_on, overrides, hidden and read-only fields, and R2 `order` (listed names permute in place).",
|
|
17
17
|
},
|
|
18
18
|
},
|
|
19
19
|
},
|
|
@@ -209,6 +209,19 @@ export const HiddenFields = () => (
|
|
|
209
209
|
<StatefulForm initialDoc={{ first_name: "Jane" }} hideFields={["last_name", "age"]} />
|
|
210
210
|
);
|
|
211
211
|
|
|
212
|
+
/**
|
|
213
|
+
* R2 cheap path (MPO-22 / GL-64): `order` permutes listed fields in place.
|
|
214
|
+
* Here status then first_name swap among their slots; last_name / is_active /
|
|
215
|
+
* age / joined_on stay put. No `sections` tree required.
|
|
216
|
+
*/
|
|
217
|
+
export const OrderInPlace = () => (
|
|
218
|
+
<StatefulForm
|
|
219
|
+
initialDoc={{ first_name: "Jane", status: "Open", is_active: true }}
|
|
220
|
+
order={["status", "first_name"]}
|
|
221
|
+
/>
|
|
222
|
+
);
|
|
223
|
+
OrderInPlace.storyName = "Order in place (R2)";
|
|
224
|
+
|
|
212
225
|
/** Field overrides: render function, component and props-merge forms. */
|
|
213
226
|
export const Overrides = () => (
|
|
214
227
|
<StatefulForm
|