@multiplatform.one/frappe-ui 7.21.0 → 7.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/dist/cjs/FrappeForm.cjs +96 -52
- package/dist/cjs/FrappeForm.native.js +103 -52
- package/dist/cjs/FrappeForm.native.js.map +1 -1
- package/dist/cjs/FrappeFormActions.cjs +161 -0
- package/dist/cjs/FrappeFormActions.native.js +167 -0
- package/dist/cjs/FrappeFormActions.native.js.map +1 -0
- package/dist/cjs/FrappeTable.cjs +42 -13
- package/dist/cjs/FrappeTable.native.js +55 -19
- package/dist/cjs/FrappeTable.native.js.map +1 -1
- package/dist/cjs/desk/DeskFormPage.cjs +2 -1
- package/dist/cjs/desk/DeskFormPage.native.js +3 -0
- package/dist/cjs/desk/DeskFormPage.native.js.map +1 -1
- package/dist/cjs/filterFields.cjs +277 -34
- package/dist/cjs/filterFields.native.js +311 -36
- package/dist/cjs/filterFields.native.js.map +1 -1
- package/dist/cjs/formEngine/docLifecycle.cjs +98 -0
- package/dist/cjs/formEngine/docLifecycle.native.js +131 -0
- package/dist/cjs/formEngine/docLifecycle.native.js.map +1 -0
- package/dist/cjs/formEngine/useAmendedAs.cjs +48 -0
- package/dist/cjs/formEngine/useAmendedAs.native.js +60 -0
- package/dist/cjs/formEngine/useAmendedAs.native.js.map +1 -0
- package/dist/cjs/formEngine/useDocRights.cjs +2 -2
- package/dist/cjs/formEngine/useDocRights.native.js +2 -2
- package/dist/cjs/formEngine/useDocRights.native.js.map +1 -1
- package/dist/cjs/formEngine/useFormEngine.cjs +1 -0
- package/dist/cjs/formEngine/useFormEngine.native.js +1 -0
- package/dist/cjs/formEngine/useFormEngine.native.js.map +1 -1
- package/dist/cjs/index.cjs +30 -1
- package/dist/cjs/index.native.js +30 -1
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/list/listColumns.cjs +397 -0
- package/dist/cjs/list/listColumns.native.js +447 -0
- package/dist/cjs/list/listColumns.native.js.map +1 -0
- package/dist/cjs/list/listFields.cjs +50 -0
- package/dist/cjs/list/listFields.native.js +79 -0
- package/dist/cjs/list/listFields.native.js.map +1 -0
- package/dist/cjs/list/listFormat.cjs +68 -0
- package/dist/cjs/list/listFormat.native.js +74 -0
- package/dist/cjs/list/listFormat.native.js.map +1 -0
- package/dist/cjs/list/statusColors.cjs +102 -0
- package/dist/cjs/list/statusColors.native.js +109 -0
- package/dist/cjs/list/statusColors.native.js.map +1 -0
- package/dist/esm/FrappeForm.mjs +97 -53
- package/dist/esm/FrappeForm.mjs.map +1 -1
- package/dist/esm/FrappeForm.native.js +104 -53
- package/dist/esm/FrappeForm.native.js.map +1 -1
- package/dist/esm/FrappeFormActions.mjs +136 -0
- package/dist/esm/FrappeFormActions.mjs.map +1 -0
- package/dist/esm/FrappeFormActions.native.js +139 -0
- package/dist/esm/FrappeFormActions.native.js.map +1 -0
- package/dist/esm/FrappeTable.mjs +42 -13
- package/dist/esm/FrappeTable.mjs.map +1 -1
- package/dist/esm/FrappeTable.native.js +55 -19
- package/dist/esm/FrappeTable.native.js.map +1 -1
- package/dist/esm/desk/DeskFormPage.mjs +2 -1
- package/dist/esm/desk/DeskFormPage.mjs.map +1 -1
- package/dist/esm/desk/DeskFormPage.native.js +3 -0
- package/dist/esm/desk/DeskFormPage.native.js.map +1 -1
- package/dist/esm/filterFields.mjs +263 -34
- package/dist/esm/filterFields.mjs.map +1 -1
- package/dist/esm/filterFields.native.js +297 -36
- package/dist/esm/filterFields.native.js.map +1 -1
- package/dist/esm/formEngine/docLifecycle.mjs +68 -0
- package/dist/esm/formEngine/docLifecycle.mjs.map +1 -0
- package/dist/esm/formEngine/docLifecycle.native.js +98 -0
- package/dist/esm/formEngine/docLifecycle.native.js.map +1 -0
- package/dist/esm/formEngine/useAmendedAs.mjs +23 -0
- package/dist/esm/formEngine/useAmendedAs.mjs.map +1 -0
- package/dist/esm/formEngine/useAmendedAs.native.js +32 -0
- package/dist/esm/formEngine/useAmendedAs.native.js.map +1 -0
- package/dist/esm/formEngine/useDocRights.mjs +2 -2
- package/dist/esm/formEngine/useDocRights.mjs.map +1 -1
- package/dist/esm/formEngine/useDocRights.native.js +2 -2
- package/dist/esm/formEngine/useDocRights.native.js.map +1 -1
- package/dist/esm/formEngine/useFormEngine.mjs +1 -1
- package/dist/esm/formEngine/useFormEngine.mjs.map +1 -1
- package/dist/esm/formEngine/useFormEngine.native.js +1 -1
- package/dist/esm/formEngine/useFormEngine.native.js.map +1 -1
- package/dist/esm/index.mjs +6 -2
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +6 -2
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/list/listColumns.mjs +372 -0
- package/dist/esm/list/listColumns.mjs.map +1 -0
- package/dist/esm/list/listColumns.native.js +419 -0
- package/dist/esm/list/listColumns.native.js.map +1 -0
- package/dist/esm/list/listFields.mjs +23 -0
- package/dist/esm/list/listFields.mjs.map +1 -0
- package/dist/esm/list/listFields.native.js +49 -0
- package/dist/esm/list/listFields.native.js.map +1 -0
- package/dist/esm/list/listFormat.mjs +38 -0
- package/dist/esm/list/listFormat.mjs.map +1 -0
- package/dist/esm/list/listFormat.native.js +41 -0
- package/dist/esm/list/listFormat.native.js.map +1 -0
- package/dist/esm/list/statusColors.mjs +77 -0
- package/dist/esm/list/statusColors.mjs.map +1 -0
- package/dist/esm/list/statusColors.native.js +81 -0
- package/dist/esm/list/statusColors.native.js.map +1 -0
- package/dist/jsx/FrappeForm.mjs +97 -53
- package/dist/jsx/FrappeForm.mjs.map +1 -1
- package/dist/jsx/FrappeForm.native.js +103 -52
- package/dist/jsx/FrappeForm.native.js.map +1 -1
- package/dist/jsx/FrappeFormActions.mjs +136 -0
- package/dist/jsx/FrappeFormActions.mjs.map +1 -0
- package/dist/jsx/FrappeFormActions.native.js +167 -0
- package/dist/jsx/FrappeFormActions.native.js.map +1 -0
- package/dist/jsx/FrappeTable.mjs +42 -13
- package/dist/jsx/FrappeTable.mjs.map +1 -1
- package/dist/jsx/FrappeTable.native.js +55 -19
- package/dist/jsx/FrappeTable.native.js.map +1 -1
- package/dist/jsx/desk/DeskFormPage.mjs +2 -1
- package/dist/jsx/desk/DeskFormPage.mjs.map +1 -1
- package/dist/jsx/desk/DeskFormPage.native.js +3 -0
- package/dist/jsx/desk/DeskFormPage.native.js.map +1 -1
- package/dist/jsx/filterFields.mjs +263 -34
- package/dist/jsx/filterFields.mjs.map +1 -1
- package/dist/jsx/filterFields.native.js +311 -36
- package/dist/jsx/filterFields.native.js.map +1 -1
- package/dist/jsx/formEngine/docLifecycle.mjs +68 -0
- package/dist/jsx/formEngine/docLifecycle.mjs.map +1 -0
- package/dist/jsx/formEngine/docLifecycle.native.js +131 -0
- package/dist/jsx/formEngine/docLifecycle.native.js.map +1 -0
- package/dist/jsx/formEngine/useAmendedAs.mjs +23 -0
- package/dist/jsx/formEngine/useAmendedAs.mjs.map +1 -0
- package/dist/jsx/formEngine/useAmendedAs.native.js +60 -0
- package/dist/jsx/formEngine/useAmendedAs.native.js.map +1 -0
- package/dist/jsx/formEngine/useDocRights.mjs +2 -2
- package/dist/jsx/formEngine/useDocRights.mjs.map +1 -1
- package/dist/jsx/formEngine/useDocRights.native.js +2 -2
- package/dist/jsx/formEngine/useDocRights.native.js.map +1 -1
- package/dist/jsx/formEngine/useFormEngine.mjs +1 -1
- package/dist/jsx/formEngine/useFormEngine.mjs.map +1 -1
- package/dist/jsx/formEngine/useFormEngine.native.js +1 -0
- package/dist/jsx/formEngine/useFormEngine.native.js.map +1 -1
- package/dist/jsx/index.js +6 -2
- package/dist/jsx/index.js.map +1 -1
- package/dist/jsx/index.mjs +6 -2
- package/dist/jsx/index.mjs.map +1 -1
- package/dist/jsx/index.native.js +30 -1
- package/dist/jsx/index.native.js.map +1 -1
- package/dist/jsx/list/listColumns.mjs +372 -0
- package/dist/jsx/list/listColumns.mjs.map +1 -0
- package/dist/jsx/list/listColumns.native.js +447 -0
- package/dist/jsx/list/listColumns.native.js.map +1 -0
- package/dist/jsx/list/listFields.mjs +23 -0
- package/dist/jsx/list/listFields.mjs.map +1 -0
- package/dist/jsx/list/listFields.native.js +79 -0
- package/dist/jsx/list/listFields.native.js.map +1 -0
- package/dist/jsx/list/listFormat.mjs +38 -0
- package/dist/jsx/list/listFormat.mjs.map +1 -0
- package/dist/jsx/list/listFormat.native.js +74 -0
- package/dist/jsx/list/listFormat.native.js.map +1 -0
- package/dist/jsx/list/statusColors.mjs +77 -0
- package/dist/jsx/list/statusColors.mjs.map +1 -0
- package/dist/jsx/list/statusColors.native.js +109 -0
- package/dist/jsx/list/statusColors.native.js.map +1 -0
- package/package.json +22 -16
- package/src/FrappeForm.deskSemantics.spec.tsx +1 -1
- package/src/FrappeForm.lifecycle.spec.tsx +463 -0
- package/src/FrappeForm.lifecycle.stories.tsx +84 -0
- package/src/FrappeForm.tsx +124 -84
- package/src/FrappeFormActions.tsx +216 -0
- package/src/FrappeTable.defaultColumns.spec.tsx +229 -0
- package/src/FrappeTable.tsx +79 -32
- package/src/desk/DeskFormPage.tsx +1 -0
- package/src/filterFields.spec.ts +57 -2
- package/src/filterFields.ts +365 -38
- package/src/formEngine/docLifecycle.spec.ts +195 -0
- package/src/formEngine/docLifecycle.ts +148 -0
- package/src/formEngine/useAmendedAs.ts +38 -0
- package/src/formEngine/useDocRights.ts +11 -3
- package/src/formEngine/useFormEngine.ts +1 -1
- package/src/frappeViews.designLaw.spec.ts +6 -0
- package/src/index.ts +47 -1
- package/src/list/listColumns.tsx +488 -0
- package/src/list/listFields.spec.ts +147 -0
- package/src/list/listFields.ts +68 -0
- package/src/list/listFormat.spec.ts +151 -0
- package/src/list/listFormat.ts +80 -0
- package/src/list/seam.spec.ts +116 -0
- package/src/list/statusColors.spec.ts +100 -0
- package/src/list/statusColors.ts +94 -0
- package/types/FrappeForm.d.ts +13 -3
- package/types/FrappeForm.d.ts.map +1 -1
- package/types/FrappeFormActions.d.ts +36 -0
- package/types/FrappeFormActions.d.ts.map +1 -0
- package/types/FrappeTable.d.ts +11 -5
- package/types/FrappeTable.d.ts.map +1 -1
- package/types/desk/DeskFormPage.d.ts.map +1 -1
- package/types/filterFields.d.ts +81 -10
- package/types/filterFields.d.ts.map +1 -1
- package/types/formEngine/docLifecycle.d.ts +59 -0
- package/types/formEngine/docLifecycle.d.ts.map +1 -0
- package/types/formEngine/useAmendedAs.d.ts +9 -0
- package/types/formEngine/useAmendedAs.d.ts.map +1 -0
- package/types/formEngine/useDocRights.d.ts +1 -1
- package/types/formEngine/useDocRights.d.ts.map +1 -1
- package/types/formEngine/useFormEngine.d.ts +1 -0
- package/types/formEngine/useFormEngine.d.ts.map +1 -1
- package/types/index.d.ts +5 -1
- package/types/index.d.ts.map +1 -1
- package/types/list/listColumns.d.ts +41 -0
- package/types/list/listColumns.d.ts.map +1 -0
- package/types/list/listFields.d.ts +16 -0
- package/types/list/listFields.d.ts.map +1 -0
- package/types/list/listFormat.d.ts +30 -0
- package/types/list/listFormat.d.ts.map +1 -0
- package/types/list/statusColors.d.ts +14 -0
- package/types/list/statusColors.d.ts.map +1 -0
|
@@ -0,0 +1,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
|
+
});
|
package/src/FrappeTable.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
75
|
-
|
|
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
|
|
80
|
-
*
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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>
|
package/src/filterFields.spec.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
+
});
|