@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.
Files changed (114) hide show
  1. package/dist/cjs/FileBrowser/AttachLibrary.cjs +51 -49
  2. package/dist/cjs/FileBrowser/AttachLibrary.native.js +58 -49
  3. package/dist/cjs/FileBrowser/AttachLibrary.native.js.map +1 -1
  4. package/dist/cjs/FrappeConnectionIndicator.cjs +5 -3
  5. package/dist/cjs/FrappeConnectionIndicator.native.js +5 -3
  6. package/dist/cjs/FrappeConnectionIndicator.native.js.map +1 -1
  7. package/dist/cjs/FrappeForm.cjs +3 -1
  8. package/dist/cjs/FrappeForm.native.js +3 -1
  9. package/dist/cjs/FrappeForm.native.js.map +1 -1
  10. package/dist/cjs/FrappeReconnectIndicator.cjs +4 -2
  11. package/dist/cjs/FrappeReconnectIndicator.native.js +4 -2
  12. package/dist/cjs/FrappeReconnectIndicator.native.js.map +1 -1
  13. package/dist/cjs/index.cjs +2 -0
  14. package/dist/cjs/index.native.js +2 -0
  15. package/dist/cjs/index.native.js.map +1 -1
  16. package/dist/cjs/schemaForm/applyFieldOrder.cjs +63 -0
  17. package/dist/cjs/schemaForm/applyFieldOrder.native.js +116 -0
  18. package/dist/cjs/schemaForm/applyFieldOrder.native.js.map +1 -0
  19. package/dist/cjs/schemaForm/index.cjs +13 -7
  20. package/dist/cjs/schemaForm/index.native.js +15 -7
  21. package/dist/cjs/schemaForm/index.native.js.map +1 -1
  22. package/dist/esm/FileBrowser/AttachLibrary.mjs +51 -49
  23. package/dist/esm/FileBrowser/AttachLibrary.mjs.map +1 -1
  24. package/dist/esm/FileBrowser/AttachLibrary.native.js +58 -49
  25. package/dist/esm/FileBrowser/AttachLibrary.native.js.map +1 -1
  26. package/dist/esm/FrappeConnectionIndicator.mjs +5 -3
  27. package/dist/esm/FrappeConnectionIndicator.mjs.map +1 -1
  28. package/dist/esm/FrappeConnectionIndicator.native.js +5 -3
  29. package/dist/esm/FrappeConnectionIndicator.native.js.map +1 -1
  30. package/dist/esm/FrappeForm.mjs +3 -1
  31. package/dist/esm/FrappeForm.mjs.map +1 -1
  32. package/dist/esm/FrappeForm.native.js +3 -1
  33. package/dist/esm/FrappeForm.native.js.map +1 -1
  34. package/dist/esm/FrappeReconnectIndicator.mjs +4 -2
  35. package/dist/esm/FrappeReconnectIndicator.mjs.map +1 -1
  36. package/dist/esm/FrappeReconnectIndicator.native.js +4 -2
  37. package/dist/esm/FrappeReconnectIndicator.native.js.map +1 -1
  38. package/dist/esm/index.mjs +2 -2
  39. package/dist/esm/index.mjs.map +1 -1
  40. package/dist/esm/index.native.js +2 -2
  41. package/dist/esm/index.native.js.map +1 -1
  42. package/dist/esm/schemaForm/applyFieldOrder.mjs +37 -0
  43. package/dist/esm/schemaForm/applyFieldOrder.mjs.map +1 -0
  44. package/dist/esm/schemaForm/applyFieldOrder.native.js +87 -0
  45. package/dist/esm/schemaForm/applyFieldOrder.native.js.map +1 -0
  46. package/dist/esm/schemaForm/index.mjs +11 -7
  47. package/dist/esm/schemaForm/index.mjs.map +1 -1
  48. package/dist/esm/schemaForm/index.native.js +13 -7
  49. package/dist/esm/schemaForm/index.native.js.map +1 -1
  50. package/dist/jsx/FileBrowser/AttachLibrary.mjs +51 -49
  51. package/dist/jsx/FileBrowser/AttachLibrary.mjs.map +1 -1
  52. package/dist/jsx/FileBrowser/AttachLibrary.native.js +58 -49
  53. package/dist/jsx/FileBrowser/AttachLibrary.native.js.map +1 -1
  54. package/dist/jsx/FrappeConnectionIndicator.mjs +5 -3
  55. package/dist/jsx/FrappeConnectionIndicator.mjs.map +1 -1
  56. package/dist/jsx/FrappeConnectionIndicator.native.js +5 -3
  57. package/dist/jsx/FrappeConnectionIndicator.native.js.map +1 -1
  58. package/dist/jsx/FrappeForm.mjs +3 -1
  59. package/dist/jsx/FrappeForm.mjs.map +1 -1
  60. package/dist/jsx/FrappeForm.native.js +3 -1
  61. package/dist/jsx/FrappeForm.native.js.map +1 -1
  62. package/dist/jsx/FrappeReconnectIndicator.mjs +4 -2
  63. package/dist/jsx/FrappeReconnectIndicator.mjs.map +1 -1
  64. package/dist/jsx/FrappeReconnectIndicator.native.js +4 -2
  65. package/dist/jsx/FrappeReconnectIndicator.native.js.map +1 -1
  66. package/dist/jsx/index.js +2 -2
  67. package/dist/jsx/index.js.map +1 -1
  68. package/dist/jsx/index.mjs +2 -2
  69. package/dist/jsx/index.mjs.map +1 -1
  70. package/dist/jsx/index.native.js +2 -0
  71. package/dist/jsx/index.native.js.map +1 -1
  72. package/dist/jsx/schemaForm/applyFieldOrder.mjs +37 -0
  73. package/dist/jsx/schemaForm/applyFieldOrder.mjs.map +1 -0
  74. package/dist/jsx/schemaForm/applyFieldOrder.native.js +116 -0
  75. package/dist/jsx/schemaForm/applyFieldOrder.native.js.map +1 -0
  76. package/dist/jsx/schemaForm/index.mjs +11 -7
  77. package/dist/jsx/schemaForm/index.mjs.map +1 -1
  78. package/dist/jsx/schemaForm/index.native.js +15 -7
  79. package/dist/jsx/schemaForm/index.native.js.map +1 -1
  80. package/package.json +16 -16
  81. package/src/FileBrowser/AttachLibrary.spec.tsx +74 -0
  82. package/src/FileBrowser/AttachLibrary.stories.tsx +67 -0
  83. package/src/FileBrowser/AttachLibrary.tsx +56 -49
  84. package/src/FrappeConnectionIndicator.spec.tsx +51 -0
  85. package/src/FrappeConnectionIndicator.stories.tsx +55 -0
  86. package/src/FrappeConnectionIndicator.tsx +15 -4
  87. package/src/FrappeForm.tsx +13 -3
  88. package/src/FrappeReconnectIndicator.spec.tsx +52 -0
  89. package/src/FrappeReconnectIndicator.stories.tsx +55 -0
  90. package/src/FrappeReconnectIndicator.tsx +13 -3
  91. package/src/SyncErrorNotice.stories.tsx +50 -0
  92. package/src/desk/DeskFormSidebar.spec.tsx +90 -0
  93. package/src/desk/DeskFormSidebar.stories.tsx +133 -0
  94. package/src/fields/LinkTree.spec.tsx +109 -0
  95. package/src/fields/LinkTree.stories.tsx +187 -0
  96. package/src/index.ts +1 -1
  97. package/src/schemaForm/SchemaForm.spec.tsx +29 -0
  98. package/src/schemaForm/SchemaForm.stories.tsx +14 -1
  99. package/src/schemaForm/applyFieldOrder.spec.ts +75 -0
  100. package/src/schemaForm/applyFieldOrder.ts +71 -0
  101. package/src/schemaForm/index.tsx +17 -5
  102. package/types/FileBrowser/AttachLibrary.d.ts.map +1 -1
  103. package/types/FrappeConnectionIndicator.d.ts +8 -2
  104. package/types/FrappeConnectionIndicator.d.ts.map +1 -1
  105. package/types/FrappeForm.d.ts +7 -1
  106. package/types/FrappeForm.d.ts.map +1 -1
  107. package/types/FrappeReconnectIndicator.d.ts +7 -2
  108. package/types/FrappeReconnectIndicator.d.ts.map +1 -1
  109. package/types/index.d.ts +1 -1
  110. package/types/index.d.ts.map +1 -1
  111. package/types/schemaForm/applyFieldOrder.d.ts +28 -0
  112. package/types/schemaForm/applyFieldOrder.d.ts.map +1 -0
  113. package/types/schemaForm/index.d.ts +8 -1
  114. 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
@@ -173,7 +173,7 @@ export {
173
173
  type DependsEvaluator,
174
174
  } from "./dependsOn";
175
175
 
176
- export { SchemaForm } from "./schemaForm";
176
+ export { SchemaForm, applyFieldOrder, applyOrderToSchema } from "./schemaForm";
177
177
  export type {
178
178
  SchemaFormProps,
179
179
  FormSchema,
@@ -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