@multiplatform.one/frappe-ui 7.22.0 → 7.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/README.md +14 -0
  2. package/dist/cjs/FrappeForm.cjs +96 -52
  3. package/dist/cjs/FrappeForm.native.js +103 -52
  4. package/dist/cjs/FrappeForm.native.js.map +1 -1
  5. package/dist/cjs/FrappeFormActions.cjs +161 -0
  6. package/dist/cjs/FrappeFormActions.native.js +167 -0
  7. package/dist/cjs/FrappeFormActions.native.js.map +1 -0
  8. package/dist/cjs/FrappeTable.cjs +42 -13
  9. package/dist/cjs/FrappeTable.native.js +55 -19
  10. package/dist/cjs/FrappeTable.native.js.map +1 -1
  11. package/dist/cjs/desk/DeskFormPage.cjs +2 -1
  12. package/dist/cjs/desk/DeskFormPage.native.js +3 -0
  13. package/dist/cjs/desk/DeskFormPage.native.js.map +1 -1
  14. package/dist/cjs/filterFields.cjs +277 -34
  15. package/dist/cjs/filterFields.native.js +311 -36
  16. package/dist/cjs/filterFields.native.js.map +1 -1
  17. package/dist/cjs/formEngine/docLifecycle.cjs +98 -0
  18. package/dist/cjs/formEngine/docLifecycle.native.js +131 -0
  19. package/dist/cjs/formEngine/docLifecycle.native.js.map +1 -0
  20. package/dist/cjs/formEngine/useAmendedAs.cjs +48 -0
  21. package/dist/cjs/formEngine/useAmendedAs.native.js +60 -0
  22. package/dist/cjs/formEngine/useAmendedAs.native.js.map +1 -0
  23. package/dist/cjs/formEngine/useDocRights.cjs +2 -2
  24. package/dist/cjs/formEngine/useDocRights.native.js +2 -2
  25. package/dist/cjs/formEngine/useDocRights.native.js.map +1 -1
  26. package/dist/cjs/formEngine/useFormEngine.cjs +1 -0
  27. package/dist/cjs/formEngine/useFormEngine.native.js +1 -0
  28. package/dist/cjs/formEngine/useFormEngine.native.js.map +1 -1
  29. package/dist/cjs/index.cjs +30 -1
  30. package/dist/cjs/index.native.js +30 -1
  31. package/dist/cjs/index.native.js.map +1 -1
  32. package/dist/cjs/list/listColumns.cjs +397 -0
  33. package/dist/cjs/list/listColumns.native.js +447 -0
  34. package/dist/cjs/list/listColumns.native.js.map +1 -0
  35. package/dist/cjs/list/listFields.cjs +50 -0
  36. package/dist/cjs/list/listFields.native.js +79 -0
  37. package/dist/cjs/list/listFields.native.js.map +1 -0
  38. package/dist/cjs/list/listFormat.cjs +68 -0
  39. package/dist/cjs/list/listFormat.native.js +74 -0
  40. package/dist/cjs/list/listFormat.native.js.map +1 -0
  41. package/dist/cjs/list/statusColors.cjs +102 -0
  42. package/dist/cjs/list/statusColors.native.js +109 -0
  43. package/dist/cjs/list/statusColors.native.js.map +1 -0
  44. package/dist/cjs/useFrappeAggregate.cjs +1 -1
  45. package/dist/cjs/useFrappeAggregate.native.js +1 -1
  46. package/dist/cjs/useFrappeAggregate.native.js.map +1 -1
  47. package/dist/esm/FrappeForm.mjs +97 -53
  48. package/dist/esm/FrappeForm.mjs.map +1 -1
  49. package/dist/esm/FrappeForm.native.js +104 -53
  50. package/dist/esm/FrappeForm.native.js.map +1 -1
  51. package/dist/esm/FrappeFormActions.mjs +136 -0
  52. package/dist/esm/FrappeFormActions.mjs.map +1 -0
  53. package/dist/esm/FrappeFormActions.native.js +139 -0
  54. package/dist/esm/FrappeFormActions.native.js.map +1 -0
  55. package/dist/esm/FrappeTable.mjs +42 -13
  56. package/dist/esm/FrappeTable.mjs.map +1 -1
  57. package/dist/esm/FrappeTable.native.js +55 -19
  58. package/dist/esm/FrappeTable.native.js.map +1 -1
  59. package/dist/esm/desk/DeskFormPage.mjs +2 -1
  60. package/dist/esm/desk/DeskFormPage.mjs.map +1 -1
  61. package/dist/esm/desk/DeskFormPage.native.js +3 -0
  62. package/dist/esm/desk/DeskFormPage.native.js.map +1 -1
  63. package/dist/esm/filterFields.mjs +263 -34
  64. package/dist/esm/filterFields.mjs.map +1 -1
  65. package/dist/esm/filterFields.native.js +297 -36
  66. package/dist/esm/filterFields.native.js.map +1 -1
  67. package/dist/esm/formEngine/docLifecycle.mjs +68 -0
  68. package/dist/esm/formEngine/docLifecycle.mjs.map +1 -0
  69. package/dist/esm/formEngine/docLifecycle.native.js +98 -0
  70. package/dist/esm/formEngine/docLifecycle.native.js.map +1 -0
  71. package/dist/esm/formEngine/useAmendedAs.mjs +23 -0
  72. package/dist/esm/formEngine/useAmendedAs.mjs.map +1 -0
  73. package/dist/esm/formEngine/useAmendedAs.native.js +32 -0
  74. package/dist/esm/formEngine/useAmendedAs.native.js.map +1 -0
  75. package/dist/esm/formEngine/useDocRights.mjs +2 -2
  76. package/dist/esm/formEngine/useDocRights.mjs.map +1 -1
  77. package/dist/esm/formEngine/useDocRights.native.js +2 -2
  78. package/dist/esm/formEngine/useDocRights.native.js.map +1 -1
  79. package/dist/esm/formEngine/useFormEngine.mjs +1 -1
  80. package/dist/esm/formEngine/useFormEngine.mjs.map +1 -1
  81. package/dist/esm/formEngine/useFormEngine.native.js +1 -1
  82. package/dist/esm/formEngine/useFormEngine.native.js.map +1 -1
  83. package/dist/esm/index.mjs +6 -2
  84. package/dist/esm/index.mjs.map +1 -1
  85. package/dist/esm/index.native.js +6 -2
  86. package/dist/esm/index.native.js.map +1 -1
  87. package/dist/esm/list/listColumns.mjs +372 -0
  88. package/dist/esm/list/listColumns.mjs.map +1 -0
  89. package/dist/esm/list/listColumns.native.js +419 -0
  90. package/dist/esm/list/listColumns.native.js.map +1 -0
  91. package/dist/esm/list/listFields.mjs +23 -0
  92. package/dist/esm/list/listFields.mjs.map +1 -0
  93. package/dist/esm/list/listFields.native.js +49 -0
  94. package/dist/esm/list/listFields.native.js.map +1 -0
  95. package/dist/esm/list/listFormat.mjs +38 -0
  96. package/dist/esm/list/listFormat.mjs.map +1 -0
  97. package/dist/esm/list/listFormat.native.js +41 -0
  98. package/dist/esm/list/listFormat.native.js.map +1 -0
  99. package/dist/esm/list/statusColors.mjs +77 -0
  100. package/dist/esm/list/statusColors.mjs.map +1 -0
  101. package/dist/esm/list/statusColors.native.js +81 -0
  102. package/dist/esm/list/statusColors.native.js.map +1 -0
  103. package/dist/esm/useFrappeAggregate.mjs +1 -1
  104. package/dist/esm/useFrappeAggregate.mjs.map +1 -1
  105. package/dist/esm/useFrappeAggregate.native.js +1 -1
  106. package/dist/esm/useFrappeAggregate.native.js.map +1 -1
  107. package/dist/jsx/FrappeForm.mjs +97 -53
  108. package/dist/jsx/FrappeForm.mjs.map +1 -1
  109. package/dist/jsx/FrappeForm.native.js +103 -52
  110. package/dist/jsx/FrappeForm.native.js.map +1 -1
  111. package/dist/jsx/FrappeFormActions.mjs +136 -0
  112. package/dist/jsx/FrappeFormActions.mjs.map +1 -0
  113. package/dist/jsx/FrappeFormActions.native.js +167 -0
  114. package/dist/jsx/FrappeFormActions.native.js.map +1 -0
  115. package/dist/jsx/FrappeTable.mjs +42 -13
  116. package/dist/jsx/FrappeTable.mjs.map +1 -1
  117. package/dist/jsx/FrappeTable.native.js +55 -19
  118. package/dist/jsx/FrappeTable.native.js.map +1 -1
  119. package/dist/jsx/desk/DeskFormPage.mjs +2 -1
  120. package/dist/jsx/desk/DeskFormPage.mjs.map +1 -1
  121. package/dist/jsx/desk/DeskFormPage.native.js +3 -0
  122. package/dist/jsx/desk/DeskFormPage.native.js.map +1 -1
  123. package/dist/jsx/filterFields.mjs +263 -34
  124. package/dist/jsx/filterFields.mjs.map +1 -1
  125. package/dist/jsx/filterFields.native.js +311 -36
  126. package/dist/jsx/filterFields.native.js.map +1 -1
  127. package/dist/jsx/formEngine/docLifecycle.mjs +68 -0
  128. package/dist/jsx/formEngine/docLifecycle.mjs.map +1 -0
  129. package/dist/jsx/formEngine/docLifecycle.native.js +131 -0
  130. package/dist/jsx/formEngine/docLifecycle.native.js.map +1 -0
  131. package/dist/jsx/formEngine/useAmendedAs.mjs +23 -0
  132. package/dist/jsx/formEngine/useAmendedAs.mjs.map +1 -0
  133. package/dist/jsx/formEngine/useAmendedAs.native.js +60 -0
  134. package/dist/jsx/formEngine/useAmendedAs.native.js.map +1 -0
  135. package/dist/jsx/formEngine/useDocRights.mjs +2 -2
  136. package/dist/jsx/formEngine/useDocRights.mjs.map +1 -1
  137. package/dist/jsx/formEngine/useDocRights.native.js +2 -2
  138. package/dist/jsx/formEngine/useDocRights.native.js.map +1 -1
  139. package/dist/jsx/formEngine/useFormEngine.mjs +1 -1
  140. package/dist/jsx/formEngine/useFormEngine.mjs.map +1 -1
  141. package/dist/jsx/formEngine/useFormEngine.native.js +1 -0
  142. package/dist/jsx/formEngine/useFormEngine.native.js.map +1 -1
  143. package/dist/jsx/index.js +6 -2
  144. package/dist/jsx/index.js.map +1 -1
  145. package/dist/jsx/index.mjs +6 -2
  146. package/dist/jsx/index.mjs.map +1 -1
  147. package/dist/jsx/index.native.js +30 -1
  148. package/dist/jsx/index.native.js.map +1 -1
  149. package/dist/jsx/list/listColumns.mjs +372 -0
  150. package/dist/jsx/list/listColumns.mjs.map +1 -0
  151. package/dist/jsx/list/listColumns.native.js +447 -0
  152. package/dist/jsx/list/listColumns.native.js.map +1 -0
  153. package/dist/jsx/list/listFields.mjs +23 -0
  154. package/dist/jsx/list/listFields.mjs.map +1 -0
  155. package/dist/jsx/list/listFields.native.js +79 -0
  156. package/dist/jsx/list/listFields.native.js.map +1 -0
  157. package/dist/jsx/list/listFormat.mjs +38 -0
  158. package/dist/jsx/list/listFormat.mjs.map +1 -0
  159. package/dist/jsx/list/listFormat.native.js +74 -0
  160. package/dist/jsx/list/listFormat.native.js.map +1 -0
  161. package/dist/jsx/list/statusColors.mjs +77 -0
  162. package/dist/jsx/list/statusColors.mjs.map +1 -0
  163. package/dist/jsx/list/statusColors.native.js +109 -0
  164. package/dist/jsx/list/statusColors.native.js.map +1 -0
  165. package/dist/jsx/useFrappeAggregate.mjs +1 -1
  166. package/dist/jsx/useFrappeAggregate.mjs.map +1 -1
  167. package/dist/jsx/useFrappeAggregate.native.js +1 -1
  168. package/dist/jsx/useFrappeAggregate.native.js.map +1 -1
  169. package/package.json +22 -16
  170. package/src/FrappeForm.deskSemantics.spec.tsx +1 -1
  171. package/src/FrappeForm.lifecycle.spec.tsx +463 -0
  172. package/src/FrappeForm.lifecycle.stories.tsx +84 -0
  173. package/src/FrappeForm.tsx +124 -84
  174. package/src/FrappeFormActions.tsx +216 -0
  175. package/src/FrappeTable.defaultColumns.spec.tsx +229 -0
  176. package/src/FrappeTable.tsx +79 -32
  177. package/src/desk/DeskFormPage.tsx +1 -0
  178. package/src/filterFields.spec.ts +57 -2
  179. package/src/filterFields.ts +365 -38
  180. package/src/formEngine/docLifecycle.spec.ts +195 -0
  181. package/src/formEngine/docLifecycle.ts +148 -0
  182. package/src/formEngine/useAmendedAs.ts +38 -0
  183. package/src/formEngine/useDocRights.ts +11 -3
  184. package/src/formEngine/useFormEngine.ts +1 -1
  185. package/src/frappeViews.designLaw.spec.ts +6 -0
  186. package/src/index.ts +47 -1
  187. package/src/list/listColumns.tsx +488 -0
  188. package/src/list/listFields.spec.ts +147 -0
  189. package/src/list/listFields.ts +68 -0
  190. package/src/list/listFormat.spec.ts +151 -0
  191. package/src/list/listFormat.ts +80 -0
  192. package/src/list/seam.spec.ts +116 -0
  193. package/src/list/statusColors.spec.ts +100 -0
  194. package/src/list/statusColors.ts +94 -0
  195. package/src/useFrappeAggregate.ts +1 -1
  196. package/types/FrappeForm.d.ts +13 -3
  197. package/types/FrappeForm.d.ts.map +1 -1
  198. package/types/FrappeFormActions.d.ts +36 -0
  199. package/types/FrappeFormActions.d.ts.map +1 -0
  200. package/types/FrappeTable.d.ts +11 -5
  201. package/types/FrappeTable.d.ts.map +1 -1
  202. package/types/desk/DeskFormPage.d.ts.map +1 -1
  203. package/types/filterFields.d.ts +81 -10
  204. package/types/filterFields.d.ts.map +1 -1
  205. package/types/formEngine/docLifecycle.d.ts +59 -0
  206. package/types/formEngine/docLifecycle.d.ts.map +1 -0
  207. package/types/formEngine/useAmendedAs.d.ts +9 -0
  208. package/types/formEngine/useAmendedAs.d.ts.map +1 -0
  209. package/types/formEngine/useDocRights.d.ts +1 -1
  210. package/types/formEngine/useDocRights.d.ts.map +1 -1
  211. package/types/formEngine/useFormEngine.d.ts +1 -0
  212. package/types/formEngine/useFormEngine.d.ts.map +1 -1
  213. package/types/index.d.ts +5 -1
  214. package/types/index.d.ts.map +1 -1
  215. package/types/list/listColumns.d.ts +41 -0
  216. package/types/list/listColumns.d.ts.map +1 -0
  217. package/types/list/listFields.d.ts +16 -0
  218. package/types/list/listFields.d.ts.map +1 -0
  219. package/types/list/listFormat.d.ts +30 -0
  220. package/types/list/listFormat.d.ts.map +1 -0
  221. package/types/list/statusColors.d.ts +14 -0
  222. package/types/list/statusColors.d.ts.map +1 -0
@@ -0,0 +1,229 @@
1
+ /**
2
+ * FrappeTable without `columns` (MPO-15 AC4).
3
+ *
4
+ * The prop used to be required, so the doctype-aware table could not render
5
+ * a doctype unless its caller did the meta work. Omitted now, the columns
6
+ * come from meta through `buildListColumns`, the same derivation the desk
7
+ * list screen calls: title, the in_list_view fields the session may read,
8
+ * the activity cluster, and the `modified` sort key kept hidden. A bench read
9
+ * waits for meta and the roles, then asks once for `listFieldsToFetch`.
10
+ */
11
+
12
+ import { InMemoryFixtureProvider } from "@multiplatform.one/frappe";
13
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
14
+ import { cleanup, screen, waitFor } from "@testing-library/react";
15
+ import i18next from "i18next";
16
+ import { initReactI18next } from "react-i18next";
17
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
18
+ import { FrappeProvider } from "./FrappeProvider";
19
+ import { FrappeTable } from "./FrappeTable";
20
+ import { buildPermLevelMatrix } from "./formEngine/fieldPermissions";
21
+ import type { DoctypeMeta } from "./formEngine/doctypeMeta";
22
+ import { buildListColumns } from "./list/listColumns";
23
+ import { listFieldsToFetch } from "./list/listFields";
24
+
25
+ beforeAll(async () => {
26
+ await i18next.use(initReactI18next).init({
27
+ lng: "en",
28
+ interpolation: { escapeValue: false },
29
+ resources: { en: { translation: {} } },
30
+ });
31
+ });
32
+
33
+ afterEach(() => {
34
+ cleanup();
35
+ vi.restoreAllMocks();
36
+ vi.unstubAllGlobals();
37
+ });
38
+
39
+ interface Customer {
40
+ name: string;
41
+ doctype: string;
42
+ customer_name: string;
43
+ status: string;
44
+ territory: string;
45
+ credit_limit: number;
46
+ modified: string;
47
+ }
48
+
49
+ const metaDoc = {
50
+ name: "Customer",
51
+ doctype: "DocType",
52
+ module: "Selling",
53
+ title_field: "customer_name",
54
+ fields: [
55
+ { fieldname: "customer_name", fieldtype: "Data", label: "Customer Name", in_list_view: 1 },
56
+ {
57
+ fieldname: "status",
58
+ fieldtype: "Select",
59
+ label: "Status",
60
+ options: "Active\nLead",
61
+ in_list_view: 1,
62
+ },
63
+ {
64
+ fieldname: "territory",
65
+ fieldtype: "Link",
66
+ label: "Territory",
67
+ options: "Territory",
68
+ in_list_view: 1,
69
+ },
70
+ {
71
+ fieldname: "credit_limit",
72
+ fieldtype: "Currency",
73
+ label: "Credit Limit",
74
+ in_list_view: 1,
75
+ permlevel: 1,
76
+ },
77
+ { fieldname: "notes", fieldtype: "Text", label: "Notes" },
78
+ ],
79
+ permissions: [
80
+ { role: "Sales User", permlevel: 0, read: 1, write: 1, create: 1 },
81
+ { role: "Sales Manager", permlevel: 1, read: 1, write: 1 },
82
+ ],
83
+ };
84
+ const meta = metaDoc as unknown as DoctypeMeta;
85
+
86
+ const customers: Customer[] = [
87
+ {
88
+ name: "CUST-001",
89
+ doctype: "Customer",
90
+ customer_name: "Acme Corp",
91
+ status: "Active",
92
+ territory: "North",
93
+ credit_limit: 5000,
94
+ modified: "2026-09-20 10:00:00",
95
+ },
96
+ {
97
+ name: "CUST-002",
98
+ doctype: "Customer",
99
+ customer_name: "Globex",
100
+ status: "Lead",
101
+ territory: "South",
102
+ credit_limit: 900,
103
+ modified: "2026-09-21 10:00:00",
104
+ },
105
+ ];
106
+
107
+ function headerText(container: HTMLElement): string {
108
+ return container.querySelector('[aria-label="Table headers"]')?.textContent ?? "";
109
+ }
110
+
111
+ describe("FrappeTable without columns, over a fixture corpus", () => {
112
+ it("renders the desk list columns buildListColumns derives from meta", async () => {
113
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation((input) => {
114
+ throw new Error(`unexpected network request: ${String(input)}`);
115
+ });
116
+ const { container } = renderWithProviders(
117
+ <FrappeProvider
118
+ baseURL="https://frappetable-default-columns.spec.test"
119
+ fixtures={new InMemoryFixtureProvider({ DocType: [metaDoc], Customer: customers })}
120
+ >
121
+ <FrappeTable<Customer> doctype="Customer" layout="table" enablePagination={false} />
122
+ </FrappeProvider>,
123
+ );
124
+
125
+ expect(await screen.findByText("Acme Corp")).toBeTruthy();
126
+ expect(await screen.findByText("Lead")).toBeTruthy();
127
+ expect(screen.getByText("North")).toBeTruthy();
128
+
129
+ // Fixtures withhold nothing, so every in_list_view field paints.
130
+ const derived = buildListColumns({ meta, t: (_key, fallback) => fallback });
131
+ const visibleHeaders = derived
132
+ .filter((column) => column.id !== "modified")
133
+ .map((column) => String(column.header));
134
+ expect(visibleHeaders).toEqual([
135
+ "Customer Name",
136
+ "Status",
137
+ "Territory",
138
+ "Credit Limit",
139
+ "Activity",
140
+ ]);
141
+ await waitFor(() => {
142
+ const headers = headerText(container);
143
+ for (const header of visibleHeaders) expect(headers).toContain(header);
144
+ });
145
+ // The modified column is the hidden sort key; its time paints in the
146
+ // activity cluster, not as a second "Last Updated On" column.
147
+ expect(headerText(container)).not.toContain("Last Updated On");
148
+ expect(container.querySelectorAll('[data-testid="list-activity"]').length).toBe(2);
149
+ expect(fetchSpy).not.toHaveBeenCalled();
150
+ });
151
+
152
+ it("falls back to the document name when the corpus has no meta for the doctype", async () => {
153
+ renderWithProviders(
154
+ <FrappeProvider
155
+ baseURL="https://frappetable-default-columns-nometa.spec.test"
156
+ fixtures={new InMemoryFixtureProvider({ Customer: customers })}
157
+ >
158
+ <FrappeTable<Customer> doctype="Customer" layout="table" enablePagination={false} />
159
+ </FrappeProvider>,
160
+ );
161
+
162
+ expect(await screen.findByText("CUST-001")).toBeTruthy();
163
+ expect(await screen.findByText("CUST-002")).toBeTruthy();
164
+ expect(screen.getByText("ID")).toBeTruthy();
165
+ });
166
+ });
167
+
168
+ describe("FrappeTable without columns, serverSide against a bench", () => {
169
+ function json(body: unknown): Response {
170
+ return new Response(JSON.stringify(body), {
171
+ status: 200,
172
+ headers: { "content-type": "application/json" },
173
+ });
174
+ }
175
+
176
+ it("waits for meta and roles, then reads the derived fields once", async () => {
177
+ const calls: string[] = [];
178
+ const listRequests: URL[] = [];
179
+ vi.stubGlobal("fetch", async (input: string | URL | Request) => {
180
+ const url = new URL(String(input instanceof Request ? input.url : input));
181
+ const method = url.pathname.replace(/^\/api\/method\//, "");
182
+ calls.push(method);
183
+ if (method === "frappe.desk.form.load.getdoctype") return json({ docs: [metaDoc] });
184
+ if (method === "frappe.auth.get_logged_user") return json({ message: "clerk@sales.test" });
185
+ if (method === "frappe.core.doctype.user.user.get_roles") {
186
+ return json({ message: ["Sales User"] });
187
+ }
188
+ if (method === "frappe.client.get_count") return json({ message: customers.length });
189
+ if (url.pathname === "/api/resource/Customer") {
190
+ listRequests.push(url);
191
+ return json({ data: customers });
192
+ }
193
+ return json({ data: [], message: [] });
194
+ });
195
+
196
+ const { container } = renderWithProviders(
197
+ <FrappeProvider
198
+ baseURL="https://frappetable-default-columns-bench.spec.test"
199
+ realtime={false}
200
+ >
201
+ <FrappeTable<Customer>
202
+ doctype="Customer"
203
+ serverSide
204
+ layout="table"
205
+ initialState={{ pagination: { pageIndex: 0, pageSize: 10 } }}
206
+ />
207
+ </FrappeProvider>,
208
+ );
209
+
210
+ expect(await screen.findByText("Acme Corp")).toBeTruthy();
211
+ // Sales User reads permlevel 0 only: Credit Limit is neither a column
212
+ // nor asked for, the way desk's list prunes it.
213
+ const expected = listFieldsToFetch(
214
+ meta,
215
+ buildPermLevelMatrix(meta.permissions, ["Sales User"]),
216
+ );
217
+ expect(expected).not.toContain("credit_limit");
218
+ expect(listRequests.length).toBe(1);
219
+ expect(JSON.parse(listRequests[0].searchParams.get("fields") ?? "[]")).toEqual(expected);
220
+ expect(calls.indexOf("frappe.desk.form.load.getdoctype")).toBeLessThan(
221
+ calls.indexOf("/api/resource/Customer"),
222
+ );
223
+ await waitFor(() => expect(headerText(container)).toContain("Status"));
224
+ expect(headerText(container)).not.toContain("Credit Limit");
225
+
226
+ await new Promise((resolve) => setTimeout(resolve, 300));
227
+ expect(listRequests.length).toBe(1);
228
+ });
229
+ });
@@ -25,7 +25,10 @@ import { useFrappeConfig } from "./FrappeProvider";
25
25
  import { FrappeReconnectIndicator } from "./FrappeReconnectIndicator";
26
26
  import { docFieldsToFilterFields } from "./filterFields";
27
27
  import type { DocRights } from "./formEngine/docPermissions";
28
+ import { buildPermLevelMatrix } from "./formEngine/fieldPermissions";
28
29
  import { useDocRights } from "./formEngine/useDocRights";
30
+ import { buildListColumns } from "./list/listColumns";
31
+ import { listFieldsToFetch } from "./list/listFields";
29
32
  import { useFrappeRows } from "./useFrappeRows";
30
33
 
31
34
  /**
@@ -71,13 +74,19 @@ export interface FrappeTableProps<TDoc extends { name: string; doctype: string }
71
74
  syncOptions?: any;
72
75
  /** The Frappe DocType to display */
73
76
  doctype: string;
74
- /** Column definitions for the table */
75
- columns: DataTableColumn<Partial<TDoc>>[];
77
+ /**
78
+ * Column definitions. Omitted, they come from the doctype's meta through
79
+ * `buildListColumns`, the desk list's own anatomy (title, the
80
+ * in_list_view fields the session may read, activity), and the table
81
+ * waits for meta and the session's roles before its first read.
82
+ */
83
+ columns?: DataTableColumn<Partial<TDoc>>[];
76
84
  /**
77
85
  * Fields to fetch. Omitted, a read from the bench (`serverSide`, or client
78
86
  * mode under a provider without `fixtures`) requests `name` plus every
79
- * column's `accessorKey`, and client mode under a provider carrying
80
- * `fixtures` reads whole documents from the corpus. A column keyed on
87
+ * column's `accessorKey` (with `columns` omitted too, the fields the
88
+ * derived columns paint: `listFieldsToFetch`), and client mode under a
89
+ * provider carrying `fixtures` reads whole documents from the corpus. A column keyed on
81
90
  * something that is not a doctype field (a rowAccumulator's derived key,
82
91
  * say) makes the bench refuse that read, so name the list to leave such a
83
92
  * key out. Name it, too, to share one collection with another reader of
@@ -321,7 +330,7 @@ export function FrappeTable<TDoc extends { name: string; doctype: string }>({
321
330
  auth,
322
331
  syncOptions,
323
332
  doctype,
324
- columns,
333
+ columns: columnsProp,
325
334
  fields,
326
335
  filters: initialFilters,
327
336
  onRowClick,
@@ -338,6 +347,7 @@ export function FrappeTable<TDoc extends { name: string; doctype: string }>({
338
347
  filterableFields: filterableFieldsProp,
339
348
  rights: rightsProp,
340
349
  layout,
350
+ initialState,
341
351
  ...dataTableProps
342
352
  }: FrappeTableProps<TDoc>) {
343
353
  // Infinite + serverSide: DataTable's page cache drives fetching through a
@@ -406,14 +416,69 @@ export function FrappeTable<TDoc extends { name: string; doctype: string }>({
406
416
  return new FrappeApp(finalBaseURL, (finalAuth ?? {}) as Record<string, unknown>);
407
417
  }, [serverSide, finalBaseURL, finalAuth]);
408
418
 
419
+ // Full doctype field list → table filterableFields: hidden / non-column
420
+ // fields become filterable through the toolbar Filter surface. An explicit
421
+ // filterableFields prop overrides the metadata-derived list (fixtures,
422
+ // stories, custom subsets). A fixture corpus is the whole world for meta
423
+ // too: the read goes to its "DocType" collection with the baseURL a
424
+ // fixture host may still carry withheld, so a corpus without the entry
425
+ // yields no menu instead of dialling getdoctype (MPO-230). Columns left to
426
+ // meta need it whatever else is given.
427
+ const deriveColumns = !columnsProp;
428
+ const skipMeta =
429
+ !deriveColumns && !!filterableFieldsProp && (!!rightsProp || !!connectionConfig.fixtures);
430
+ const metaConnection = useMemo(() => {
431
+ if (skipMeta) return undefined;
432
+ if (!connectionConfig.fixtures) return connectionConfig;
433
+ return { ...connectionConfig, baseURL: undefined };
434
+ }, [skipMeta, connectionConfig]);
435
+ const { meta, isLoading: isMetaLoading } = useDocTypeMeta(metaConnection, doctype);
436
+ const readSession = !rightsProp || deriveColumns;
437
+ const {
438
+ rights: resolvedRights,
439
+ pending: rolesPending,
440
+ user: sessionUser,
441
+ roles: sessionRoles,
442
+ } = useDocRights(readSession ? connectionConfig : undefined, readSession ? meta : undefined);
443
+ const rights = rightsProp ?? resolvedRights;
444
+
445
+ const permMatrix = useMemo(
446
+ () => (deriveColumns ? buildPermLevelMatrix(meta?.permissions, sessionRoles) : undefined),
447
+ [deriveColumns, meta?.permissions, sessionRoles],
448
+ );
449
+ const derivedColumns = useMemo(
450
+ () =>
451
+ deriveColumns
452
+ ? (buildListColumns({
453
+ meta,
454
+ t: (_key, fallback) => t(fallback),
455
+ sessionUser,
456
+ permMatrix,
457
+ }) as DataTableColumn<Partial<TDoc>>[])
458
+ : undefined,
459
+ [deriveColumns, meta, t, sessionUser, permMatrix],
460
+ );
461
+ const columns = columnsProp ?? derivedColumns ?? [];
462
+ // Derived columns settle once meta and the roles that prune them land;
463
+ // reading before that would fetch one field list and then another.
464
+ const columnsPending = deriveColumns && (isMetaLoading || rolesPending);
465
+ const resolvedInitialState = deriveColumns
466
+ ? {
467
+ ...initialState,
468
+ columnVisibility: { modified: false, ...initialState?.columnVisibility },
469
+ }
470
+ : initialState;
471
+
409
472
  // Keyed on content, not on the columns array, so an inline `columns`
410
473
  // literal cannot re-key the fetch every render and refetch in a loop.
411
474
  const columnFieldsKey = fields
412
475
  ? undefined
413
476
  : JSON.stringify(
414
- columns
415
- .map((column): unknown => column.accessorKey)
416
- .filter((key): key is string => typeof key === "string"),
477
+ deriveColumns
478
+ ? listFieldsToFetch(meta, permMatrix)
479
+ : columns
480
+ .map((column): unknown => column.accessorKey)
481
+ .filter((key): key is string => typeof key === "string"),
417
482
  );
418
483
  const serverFields = useMemo<string[]>(
419
484
  () =>
@@ -428,25 +493,6 @@ export function FrappeTable<TDoc extends { name: string; doctype: string }>({
428
493
  const clientFields = (fields ??
429
494
  (connectionConfig.fixtures ? undefined : serverFields)) as typeof fields;
430
495
 
431
- // Full doctype field list → table filterableFields: hidden / non-column
432
- // fields become filterable through the toolbar Filter surface. An explicit
433
- // filterableFields prop overrides the metadata-derived list (fixtures,
434
- // stories, custom subsets). A fixture corpus is the whole world for meta
435
- // too: the read goes to its "DocType" collection with the baseURL a
436
- // fixture host may still carry withheld, so a corpus without the entry
437
- // yields no menu instead of dialling getdoctype (MPO-230).
438
- const skipMeta = !!filterableFieldsProp && (!!rightsProp || !!connectionConfig.fixtures);
439
- const metaConnection = useMemo(() => {
440
- if (skipMeta) return undefined;
441
- if (!connectionConfig.fixtures) return connectionConfig;
442
- return { ...connectionConfig, baseURL: undefined };
443
- }, [skipMeta, connectionConfig]);
444
- const { meta } = useDocTypeMeta(metaConnection, doctype);
445
- const { rights: resolvedRights } = useDocRights(
446
- rightsProp ? undefined : connectionConfig,
447
- rightsProp ? undefined : meta,
448
- );
449
- const rights = rightsProp ?? resolvedRights;
450
496
  const metaFilterFields = useMemo(
451
497
  () => (meta?.fields ? docFieldsToFilterFields(meta.fields) : undefined),
452
498
  [meta],
@@ -670,7 +716,7 @@ export function FrappeTable<TDoc extends { name: string; doctype: string }>({
670
716
  );
671
717
 
672
718
  useEffect(() => {
673
- if (serverSide && !infiniteServerMode && frappeApp) {
719
+ if (serverSide && !infiniteServerMode && frappeApp && !columnsPending) {
674
720
  lastFetchKey.current = "";
675
721
  fetchServerData(
676
722
  lastStateRef.current ??
@@ -687,7 +733,7 @@ export function FrappeTable<TDoc extends { name: string; doctype: string }>({
687
733
  } as DataTableState),
688
734
  );
689
735
  }
690
- }, [serverSide, infiniteServerMode, !!frappeApp, fetchServerData]);
736
+ }, [serverSide, infiniteServerMode, !!frappeApp, columnsPending, fetchServerData]);
691
737
 
692
738
  // Always call the hook — toggling `savedFilters` must not change hook order (SB-M-012).
693
739
  const savedFiltersHook = useSavedFilters({ userId });
@@ -714,7 +760,7 @@ export function FrappeTable<TDoc extends { name: string; doctype: string }>({
714
760
  fields: clientFields,
715
761
  filters: activeFilters,
716
762
  limit: null,
717
- enabled: !serverSide,
763
+ enabled: !serverSide && !columnsPending,
718
764
  messages: rowsMessages,
719
765
  });
720
766
 
@@ -724,7 +770,7 @@ export function FrappeTable<TDoc extends { name: string; doctype: string }>({
724
770
  // (optimistic local apply), until the new collection is ready. Only the
725
771
  // very first load may show the skeleton.
726
772
  const lastGoodClientRows = useRef<TDoc[] | null>(null);
727
- if (!serverSide && !isRowsLoading) lastGoodClientRows.current = clientRows;
773
+ if (!serverSide && !isRowsLoading && !columnsPending) lastGoodClientRows.current = clientRows;
728
774
  const clientRowsResolved = useMemo(() => {
729
775
  if (serverSide) return clientRows;
730
776
  if (!isRowsLoading || !lastGoodClientRows.current) return clientRows;
@@ -796,7 +842,7 @@ export function FrappeTable<TDoc extends { name: string; doctype: string }>({
796
842
  // while a refetch is in flight — that was the named blank-the-list defect.
797
843
  const boundaryState = resolvedError
798
844
  ? { error: resolvedError }
799
- : isRowsLoadingResolved
845
+ : isRowsLoadingResolved || columnsPending
800
846
  ? { loading: loading ?? true }
801
847
  : rowData.length === 0 && !infiniteServerMode && !isFetching
802
848
  ? { empty: true }
@@ -881,6 +927,7 @@ export function FrappeTable<TDoc extends { name: string; doctype: string }>({
881
927
  paginationMode={paginationMode}
882
928
  {...(resolvedLoadPage ? { loadPage: resolvedLoadPage } : {})}
883
929
  {...(filterableFields ? { filterableFields } : {})}
930
+ {...(resolvedInitialState ? { initialState: resolvedInitialState } : {})}
884
931
  isFetching={isFetching}
885
932
  {...dataTableProps}
886
933
  readOnly={dataTableProps.readOnly || !rights.write}
@@ -156,6 +156,7 @@ export function DeskFormPage({ doctype, name, onNavigate }: DeskFormPageProps) {
156
156
  }}
157
157
  onCancel={() => handleNavigate(`/desk/${doctype}`)}
158
158
  onDelete={() => handleNavigate(`/desk/${doctype}`)}
159
+ onAmend={() => handleNavigate(`/desk/${doctype}/new`)}
159
160
  />
160
161
  </DetailPageLayout>
161
162
  </DeskShell>
@@ -1,7 +1,13 @@
1
1
  import type { DocTypeField } from "@multiplatform.one/frappe";
2
2
  import { describe, expect, it } from "vitest";
3
3
  import { filterValueToFrappeTuple } from "./FrappeTable";
4
- import { docFieldsToFilterFields } from "./filterFields";
4
+ import {
5
+ docFieldsToFilterFields,
6
+ filterableFieldsFromMeta,
7
+ standardDocFilterFields,
8
+ standardFiltersFromMeta,
9
+ } from "./filterFields";
10
+ import type { DoctypeMeta } from "./formEngine/doctypeMeta";
5
11
 
6
12
  const docFields = [
7
13
  { fieldname: "customer_name", fieldtype: "Data", label: "Customer Name" },
@@ -66,11 +72,34 @@ describe("docFieldsToFilterFields", () => {
66
72
  expect(byId.photo).toBeUndefined();
67
73
  });
68
74
 
69
- it("appends the standard doc fields (name/owner/creation/modified)", () => {
75
+ it("appends the standard doc fields, the desk facet columns included", () => {
70
76
  expect(byId.name).toMatchObject({ label: "ID", type: "text" });
71
77
  expect(byId.owner?.type).toBe("text");
72
78
  expect(byId.creation?.type).toBe("date");
73
79
  expect(byId.modified?.type).toBe("date");
80
+ expect(byId.modified_by).toMatchObject({ label: "Last Updated By", type: "text" });
81
+ expect(byId._assign).toMatchObject({ label: "Assigned To", type: "text" });
82
+ expect(byId._user_tags).toMatchObject({ label: "Tags", type: "text" });
83
+ expect(byId._liked_by).toMatchObject({ label: "Liked By", type: "text" });
84
+ expect(byId._comments).toMatchObject({ label: "Comments", type: "text" });
85
+ });
86
+
87
+ it("offers exactly the fields and labels the desk filter picker offers (one vocabulary)", () => {
88
+ const desk = filterableFieldsFromMeta({ name: "Customer", fields: docFields } as DoctypeMeta);
89
+ expect(fields.map((field) => [field.id, field.label])).toEqual(
90
+ desk.map((field) => [field.fieldname, field.label]),
91
+ );
92
+ const standard = standardDocFilterFields().map((field) => field.fieldname);
93
+ expect(fields.slice(-standard.length).map((field) => field.id)).toEqual(standard);
94
+ });
95
+
96
+ it("does not repeat a standard field the doctype already declares", () => {
97
+ const out = docFieldsToFilterFields([
98
+ { fieldname: "owner", fieldtype: "Link", label: "Owner", options: "User" } as DocTypeField,
99
+ ]);
100
+ expect(out.filter((field) => field.id === "owner")).toEqual([
101
+ { id: "owner", label: "Owner", type: "text" },
102
+ ]);
74
103
  });
75
104
 
76
105
  it("uses the label when present and falls back to fieldname", () => {
@@ -157,3 +186,29 @@ describe("filterValueToFrappeTuple", () => {
157
186
  ).toBeNull();
158
187
  });
159
188
  });
189
+
190
+ describe("standardFiltersFromMeta", () => {
191
+ const todoMeta = {
192
+ name: "ToDo",
193
+ title_field: "description",
194
+ fields: [
195
+ {
196
+ fieldname: "status",
197
+ fieldtype: "Select",
198
+ label: "Status",
199
+ options: "Open\nClosed",
200
+ in_standard_filter: 1,
201
+ },
202
+ { fieldname: "description", fieldtype: "Small Text", label: "Description" },
203
+ { fieldname: "priority", fieldtype: "Select", label: "Priority", in_list_view: 1 },
204
+ ],
205
+ } as DoctypeMeta;
206
+
207
+ it("prepends ID and the title field when they are not already standard filters", () => {
208
+ const names = standardFiltersFromMeta(todoMeta).map((field) => field.fieldname);
209
+ expect(names[0]).toBe("name");
210
+ expect(names).toContain("description");
211
+ expect(names).toContain("status");
212
+ expect(names).not.toContain("priority");
213
+ });
214
+ });