@multiplatform.one/frappe-ui 7.78.0 → 7.80.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/dist/cjs/FrappeForm.cjs +20 -26
- package/dist/cjs/FrappeForm.native.js +26 -26
- package/dist/cjs/FrappeForm.native.js.map +1 -1
- package/dist/cjs/FrappeListView.cjs +26 -12
- package/dist/cjs/FrappeListView.native.js +41 -12
- package/dist/cjs/FrappeListView.native.js.map +1 -1
- package/dist/cjs/fields/Password/index.cjs +125 -36
- package/dist/cjs/fields/Password/index.native.js +101 -13
- package/dist/cjs/fields/Password/index.native.js.map +1 -1
- package/dist/cjs/list/ListControlsSheet.cjs +5 -11
- package/dist/cjs/list/ListControlsSheet.native.js +5 -11
- package/dist/cjs/list/ListControlsSheet.native.js.map +1 -1
- package/dist/cjs/list/ListFiltersButton.cjs +56 -0
- package/dist/cjs/list/ListFiltersButton.native.js +60 -0
- package/dist/cjs/list/ListFiltersButton.native.js.map +1 -0
- package/dist/cjs/list/ListSidebarToolbar.cjs +5 -17
- package/dist/cjs/list/ListSidebarToolbar.native.js +7 -19
- package/dist/cjs/list/ListSidebarToolbar.native.js.map +1 -1
- package/dist/cjs/list/ListToolsSheet.cjs +5 -18
- package/dist/cjs/list/ListToolsSheet.native.js +7 -20
- package/dist/cjs/list/ListToolsSheet.native.js.map +1 -1
- package/dist/cjs/list/defaultProjection.cjs +56 -0
- package/dist/cjs/list/defaultProjection.native.js +107 -0
- package/dist/cjs/list/defaultProjection.native.js.map +1 -0
- package/dist/cjs/shared/devWarn.native.js.map +1 -1
- package/dist/esm/FrappeForm.mjs +21 -27
- package/dist/esm/FrappeForm.mjs.map +1 -1
- package/dist/esm/FrappeForm.native.js +27 -27
- package/dist/esm/FrappeForm.native.js.map +1 -1
- package/dist/esm/FrappeListView.mjs +26 -12
- package/dist/esm/FrappeListView.mjs.map +1 -1
- package/dist/esm/FrappeListView.native.js +41 -12
- package/dist/esm/FrappeListView.native.js.map +1 -1
- package/dist/esm/fields/Password/index.mjs +125 -36
- package/dist/esm/fields/Password/index.mjs.map +1 -1
- package/dist/esm/fields/Password/index.native.js +101 -13
- package/dist/esm/fields/Password/index.native.js.map +1 -1
- package/dist/esm/list/ListControlsSheet.mjs +7 -13
- package/dist/esm/list/ListControlsSheet.mjs.map +1 -1
- package/dist/esm/list/ListControlsSheet.native.js +7 -13
- package/dist/esm/list/ListControlsSheet.native.js.map +1 -1
- package/dist/esm/list/ListFiltersButton.mjs +31 -0
- package/dist/esm/list/ListFiltersButton.mjs.map +1 -0
- package/dist/esm/list/ListFiltersButton.native.js +32 -0
- package/dist/esm/list/ListFiltersButton.native.js.map +1 -0
- package/dist/esm/list/ListSidebarToolbar.mjs +7 -19
- package/dist/esm/list/ListSidebarToolbar.mjs.map +1 -1
- package/dist/esm/list/ListSidebarToolbar.native.js +9 -21
- package/dist/esm/list/ListSidebarToolbar.native.js.map +1 -1
- package/dist/esm/list/ListToolsSheet.mjs +7 -20
- package/dist/esm/list/ListToolsSheet.mjs.map +1 -1
- package/dist/esm/list/ListToolsSheet.native.js +9 -22
- package/dist/esm/list/ListToolsSheet.native.js.map +1 -1
- package/dist/esm/list/defaultProjection.mjs +29 -0
- package/dist/esm/list/defaultProjection.mjs.map +1 -0
- package/dist/esm/list/defaultProjection.native.js +77 -0
- package/dist/esm/list/defaultProjection.native.js.map +1 -0
- package/dist/esm/shared/devWarn.mjs.map +1 -1
- package/dist/esm/shared/devWarn.native.js.map +1 -1
- package/dist/jsx/FrappeForm.mjs +21 -27
- package/dist/jsx/FrappeForm.mjs.map +1 -1
- package/dist/jsx/FrappeForm.native.js +26 -26
- package/dist/jsx/FrappeForm.native.js.map +1 -1
- package/dist/jsx/FrappeListView.mjs +26 -12
- package/dist/jsx/FrappeListView.mjs.map +1 -1
- package/dist/jsx/FrappeListView.native.js +41 -12
- package/dist/jsx/FrappeListView.native.js.map +1 -1
- package/dist/jsx/fields/Password/index.mjs +125 -36
- package/dist/jsx/fields/Password/index.mjs.map +1 -1
- package/dist/jsx/fields/Password/index.native.js +101 -13
- package/dist/jsx/fields/Password/index.native.js.map +1 -1
- package/dist/jsx/list/ListControlsSheet.mjs +7 -13
- package/dist/jsx/list/ListControlsSheet.mjs.map +1 -1
- package/dist/jsx/list/ListControlsSheet.native.js +5 -11
- package/dist/jsx/list/ListControlsSheet.native.js.map +1 -1
- package/dist/jsx/list/ListFiltersButton.mjs +31 -0
- package/dist/jsx/list/ListFiltersButton.mjs.map +1 -0
- package/dist/jsx/list/ListFiltersButton.native.js +60 -0
- package/dist/jsx/list/ListFiltersButton.native.js.map +1 -0
- package/dist/jsx/list/ListSidebarToolbar.mjs +7 -19
- package/dist/jsx/list/ListSidebarToolbar.mjs.map +1 -1
- package/dist/jsx/list/ListSidebarToolbar.native.js +7 -19
- package/dist/jsx/list/ListSidebarToolbar.native.js.map +1 -1
- package/dist/jsx/list/ListToolsSheet.mjs +7 -20
- package/dist/jsx/list/ListToolsSheet.mjs.map +1 -1
- package/dist/jsx/list/ListToolsSheet.native.js +7 -20
- package/dist/jsx/list/ListToolsSheet.native.js.map +1 -1
- package/dist/jsx/list/defaultProjection.mjs +29 -0
- package/dist/jsx/list/defaultProjection.mjs.map +1 -0
- package/dist/jsx/list/defaultProjection.native.js +107 -0
- package/dist/jsx/list/defaultProjection.native.js.map +1 -0
- package/dist/jsx/shared/devWarn.mjs.map +1 -1
- package/dist/jsx/shared/devWarn.native.js.map +1 -1
- package/package.json +16 -16
- package/src/FrappeForm.recordChanged.spec.tsx +179 -0
- package/src/FrappeForm.recordChanged.stories.tsx +83 -0
- package/src/FrappeForm.successIsCallback.spec.tsx +154 -0
- package/src/FrappeForm.tsx +34 -33
- package/src/FrappeListView.projection.spec.tsx +193 -0
- package/src/FrappeListView.sidebar.spec.tsx +20 -2
- package/src/FrappeListView.successIsCallback.spec.tsx +143 -0
- package/src/FrappeListView.tsx +63 -25
- package/src/fields/Password/Password.secret.spec.tsx +194 -0
- package/src/fields/Password/Password.spec.tsx +15 -8
- package/src/fields/Password/index.tsx +134 -35
- package/src/list/ListControls.spec.tsx +10 -1
- package/src/list/ListControlsSheet.spec.tsx +4 -2
- package/src/list/ListControlsSheet.tsx +5 -9
- package/src/list/ListFiltersButton.tsx +33 -0
- package/src/list/ListSidebarToolbar.tsx +13 -20
- package/src/list/ListToolsSheet.tsx +10 -18
- package/src/list/defaultProjection.ts +66 -0
- package/src/shared/devWarn.ts +2 -1
- package/types/FrappeForm.d.ts.map +1 -1
- package/types/FrappeListView.d.ts.map +1 -1
- package/types/fields/Password/index.d.ts +6 -0
- package/types/fields/Password/index.d.ts.map +1 -1
- package/types/list/ListControlsSheet.d.ts.map +1 -1
- package/types/list/ListFiltersButton.d.ts +11 -0
- package/types/list/ListFiltersButton.d.ts.map +1 -0
- package/types/list/ListSidebarToolbar.d.ts +5 -3
- package/types/list/ListSidebarToolbar.d.ts.map +1 -1
- package/types/list/ListToolsSheet.d.ts.map +1 -1
- package/types/list/defaultProjection.d.ts +26 -0
- package/types/list/defaultProjection.d.ts.map +1 -0
- package/types/shared/devWarn.d.ts +1 -1
- package/types/shared/devWarn.d.ts.map +1 -1
package/src/FrappeForm.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type FormSection,
|
|
6
6
|
} from "@multiplatform.one/frappe";
|
|
7
7
|
import {
|
|
8
|
+
Alert,
|
|
8
9
|
AsyncBoundary,
|
|
9
10
|
AsyncSkeleton,
|
|
10
11
|
DetailPageLayout,
|
|
@@ -516,6 +517,7 @@ function FrappeFormView<TDoc extends { name: string; doctype: string }>({
|
|
|
516
517
|
canWrite: canWriteRecord,
|
|
517
518
|
wasDeleted,
|
|
518
519
|
conflictFields,
|
|
520
|
+
conflict,
|
|
519
521
|
docError,
|
|
520
522
|
tabs: formTabs,
|
|
521
523
|
promptNamed,
|
|
@@ -968,44 +970,43 @@ function FrappeFormView<TDoc extends { name: string; doctype: string }>({
|
|
|
968
970
|
))
|
|
969
971
|
: null}
|
|
970
972
|
|
|
973
|
+
{/* LC-191: the record changed under the open view. Deleted keeps the
|
|
974
|
+
view mounted and read-only; a conflict keeps the edits, names the
|
|
975
|
+
fields and lets the reader pick reload or overwrite. */}
|
|
971
976
|
{showBanners && wasDeleted && (
|
|
972
|
-
<
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
? t(
|
|
983
|
-
"This {{doctype}} was deleted on the server. Fields are read-only and your unsaved edits can no longer be saved.",
|
|
984
|
-
{ doctype },
|
|
985
|
-
)
|
|
986
|
-
: t("This {{doctype}} was deleted on the server. Fields are read-only.", {
|
|
987
|
-
doctype,
|
|
988
|
-
})}
|
|
989
|
-
</TamaguiText>
|
|
990
|
-
</XStack>
|
|
977
|
+
<Alert intent="error" testID="frappe-form-deleted-banner">
|
|
978
|
+
{hasUnsavedChanges
|
|
979
|
+
? t(
|
|
980
|
+
"This {{doctype}} was deleted on the server. Fields are read-only and your unsaved edits can no longer be saved.",
|
|
981
|
+
{ doctype },
|
|
982
|
+
)
|
|
983
|
+
: t("This {{doctype}} was deleted on the server. Fields are read-only.", {
|
|
984
|
+
doctype,
|
|
985
|
+
})}
|
|
986
|
+
</Alert>
|
|
991
987
|
)}
|
|
992
988
|
|
|
993
|
-
{showBanners && !wasDeleted &&
|
|
994
|
-
<
|
|
995
|
-
|
|
996
|
-
alignItems="center"
|
|
997
|
-
{...knobProps.containerRadius}
|
|
998
|
-
{...knobProps.panelPadding}
|
|
999
|
-
backgroundColor="$orange3"
|
|
989
|
+
{showBanners && !wasDeleted && conflict && (
|
|
990
|
+
<Alert
|
|
991
|
+
intent="warning"
|
|
1000
992
|
testID="frappe-form-conflict-banner"
|
|
993
|
+
title={t("Changed on the server")}
|
|
994
|
+
actionLabel={t("Reload")}
|
|
995
|
+
onAction={() => form.reset()}
|
|
996
|
+
{...(canWriteRecord && !effectiveDisabled
|
|
997
|
+
? { secondaryActionLabel: t("Overwrite"), onSecondaryAction: handleSave }
|
|
998
|
+
: {})}
|
|
1001
999
|
>
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1000
|
+
{conflictLabels
|
|
1001
|
+
? t(
|
|
1002
|
+
"{{fields}} changed while you were editing. Your edits are kept until you reload or overwrite.",
|
|
1003
|
+
{ fields: conflictLabels },
|
|
1004
|
+
)
|
|
1005
|
+
: t(
|
|
1006
|
+
"This {{doctype}} changed after you opened it. Your edits are kept until you reload or overwrite.",
|
|
1007
|
+
{ doctype },
|
|
1008
|
+
)}
|
|
1009
|
+
</Alert>
|
|
1009
1010
|
)}
|
|
1010
1011
|
|
|
1011
1012
|
{/* flexBasis auto — see the root stack note (zero-height collapse in
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LC-183 DEFAULT-PROJECTION: a list opens on at most five declared data
|
|
3
|
+
* columns plus one encoding; the rest stay reachable, hidden in the column
|
|
4
|
+
* chooser. LC-184 SCOPE-HIDES-CONSTANT: a field the host's scope pins to one
|
|
5
|
+
* value draws no header cell and no row meta, and comes back when the scope
|
|
6
|
+
* clears. A filter the reader adds in the list's own controls hides nothing.
|
|
7
|
+
*/
|
|
8
|
+
import { InMemoryFixtureProvider } from "@multiplatform.one/frappe";
|
|
9
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
10
|
+
import { cleanup, screen, waitFor } from "@testing-library/react";
|
|
11
|
+
import i18next from "i18next";
|
|
12
|
+
import { initReactI18next } from "react-i18next";
|
|
13
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
14
|
+
import { FrappeListView } from "./FrappeListView";
|
|
15
|
+
import { FrappeProvider } from "./FrappeProvider";
|
|
16
|
+
import {
|
|
17
|
+
defaultProjection,
|
|
18
|
+
defaultProjectionCap,
|
|
19
|
+
scopePinnedFields,
|
|
20
|
+
} from "./list/defaultProjection";
|
|
21
|
+
import { __resetFrappeDevWarnSeen } from "./shared/devWarn";
|
|
22
|
+
|
|
23
|
+
interface Ticket {
|
|
24
|
+
name: string;
|
|
25
|
+
doctype: string;
|
|
26
|
+
[field: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const declared = [
|
|
30
|
+
["subject", "Subject"],
|
|
31
|
+
["status", "Status"],
|
|
32
|
+
["priority", "Priority"],
|
|
33
|
+
["company", "Company"],
|
|
34
|
+
["team", "Team"],
|
|
35
|
+
["area", "Area"],
|
|
36
|
+
["due", "Due Label"],
|
|
37
|
+
] as const;
|
|
38
|
+
|
|
39
|
+
const meta = {
|
|
40
|
+
name: "Help Ticket",
|
|
41
|
+
doctype: "DocType",
|
|
42
|
+
module: "Support",
|
|
43
|
+
title_field: "subject",
|
|
44
|
+
image_field: "photo",
|
|
45
|
+
fields: [
|
|
46
|
+
...declared.map(([fieldname, label]) => ({
|
|
47
|
+
fieldname,
|
|
48
|
+
label,
|
|
49
|
+
fieldtype: "Data",
|
|
50
|
+
in_list_view: 1,
|
|
51
|
+
})),
|
|
52
|
+
{ fieldname: "photo", label: "Photo", fieldtype: "Attach Image", in_list_view: 1 },
|
|
53
|
+
],
|
|
54
|
+
permissions: [{ role: "All", read: 1, write: 1, create: 1, delete: 1 }],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function fixtures() {
|
|
58
|
+
return new InMemoryFixtureProvider({
|
|
59
|
+
DocType: [meta],
|
|
60
|
+
"Help Ticket": [
|
|
61
|
+
{
|
|
62
|
+
name: "T-1",
|
|
63
|
+
doctype: "Help Ticket",
|
|
64
|
+
subject: "Printer jam",
|
|
65
|
+
status: "Open",
|
|
66
|
+
priority: "High",
|
|
67
|
+
company: "Acme Corp",
|
|
68
|
+
team: "Ops Crew",
|
|
69
|
+
area: "Floor Two",
|
|
70
|
+
due: "Friday",
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const settle = { timeout: 5000 };
|
|
77
|
+
|
|
78
|
+
beforeAll(async () => {
|
|
79
|
+
await i18next.use(initReactI18next).init({
|
|
80
|
+
lng: "en",
|
|
81
|
+
interpolation: { escapeValue: false },
|
|
82
|
+
resources: { en: { translation: {} } },
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
cleanup();
|
|
88
|
+
vi.restoreAllMocks();
|
|
89
|
+
__resetFrappeDevWarnSeen();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
function renderTable(provider: InMemoryFixtureProvider, filters?: Record<string, unknown>) {
|
|
93
|
+
return renderWithProviders(
|
|
94
|
+
<FrappeProvider baseURL="http://test" fixtures={provider}>
|
|
95
|
+
<FrappeListView<Ticket>
|
|
96
|
+
doctype="Help Ticket"
|
|
97
|
+
defaultViewMode="table"
|
|
98
|
+
filters={filters as never}
|
|
99
|
+
/>
|
|
100
|
+
</FrappeProvider>,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function shownHeaders() {
|
|
105
|
+
return declared.map(([, label]) => label).filter((label) => screen.queryByText(label) !== null);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe("the default projection (LC-183)", () => {
|
|
109
|
+
it("splits the declared fields at five and keeps the rest for the chooser", () => {
|
|
110
|
+
const fields = declared.map(([fieldname]) => ({ fieldname }));
|
|
111
|
+
const projection = defaultProjection(fields, new Set());
|
|
112
|
+
expect(defaultProjectionCap).toBe(5);
|
|
113
|
+
expect(projection.shown.map((field) => field.fieldname)).toEqual([
|
|
114
|
+
"subject",
|
|
115
|
+
"status",
|
|
116
|
+
"priority",
|
|
117
|
+
"company",
|
|
118
|
+
"team",
|
|
119
|
+
]);
|
|
120
|
+
expect(projection.optIn.map((field) => field.fieldname)).toEqual(["area", "due"]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("opens the table on five data columns, warns in DEV and drops none", async () => {
|
|
124
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
125
|
+
renderTable(fixtures());
|
|
126
|
+
await waitFor(
|
|
127
|
+
() => expect(screen.getAllByText("Printer jam").length).toBeGreaterThan(0),
|
|
128
|
+
settle,
|
|
129
|
+
);
|
|
130
|
+
await waitFor(
|
|
131
|
+
() => expect(shownHeaders()).toEqual(["Subject", "Status", "Priority", "Company", "Team"]),
|
|
132
|
+
settle,
|
|
133
|
+
);
|
|
134
|
+
expect(screen.queryByText("Floor Two")).toBeNull();
|
|
135
|
+
expect(
|
|
136
|
+
warn.mock.calls.some((call) => String(call[0]).includes("list-projection-over-cap")),
|
|
137
|
+
).toBe(true);
|
|
138
|
+
expect(warn.mock.calls.find((call) => String(call[0]).includes("over-cap"))?.[0]).toContain(
|
|
139
|
+
"area, due",
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("a scope pins a column (LC-184)", () => {
|
|
145
|
+
it("reads the pinned fields off the host's equality filters only", () => {
|
|
146
|
+
expect([...scopePinnedFields({ company: "Acme Corp" })]).toEqual(["company"]);
|
|
147
|
+
expect([...scopePinnedFields([["company", "=", "Acme Corp"]])]).toEqual(["company"]);
|
|
148
|
+
expect([...scopePinnedFields([["company", "in", ["A", "B"]]])]).toEqual([]);
|
|
149
|
+
expect([...scopePinnedFields([["company", "like", "%Ac%"]])]).toEqual([]);
|
|
150
|
+
expect([
|
|
151
|
+
...scopePinnedFields([["Help Ticket", "team", "=", "Ops Crew"]], "Help Ticket"),
|
|
152
|
+
]).toEqual(["team"]);
|
|
153
|
+
expect([...scopePinnedFields([["Other", "team", "=", "Ops Crew"]], "Help Ticket")]).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("hides the scoped column and brings it back when the scope clears", async () => {
|
|
157
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
158
|
+
const provider = fixtures();
|
|
159
|
+
const view = renderTable(provider, { company: "Acme Corp" });
|
|
160
|
+
await waitFor(
|
|
161
|
+
() => expect(screen.getAllByText("Printer jam").length).toBeGreaterThan(0),
|
|
162
|
+
settle,
|
|
163
|
+
);
|
|
164
|
+
await waitFor(() => expect(shownHeaders()).toContain("Area"), settle);
|
|
165
|
+
expect(shownHeaders()).not.toContain("Company");
|
|
166
|
+
expect(screen.queryByText("Acme Corp")).toBeNull();
|
|
167
|
+
expect(shownHeaders()).toHaveLength(defaultProjectionCap);
|
|
168
|
+
|
|
169
|
+
view.rerender(
|
|
170
|
+
<FrappeProvider baseURL="http://test" fixtures={provider}>
|
|
171
|
+
<FrappeListView<Ticket> doctype="Help Ticket" defaultViewMode="table" />
|
|
172
|
+
</FrappeProvider>,
|
|
173
|
+
);
|
|
174
|
+
await waitFor(() => expect(shownHeaders()).toContain("Company"), settle);
|
|
175
|
+
expect(shownHeaders()).not.toContain("Area");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("a list row's meta drops the scoped value", async () => {
|
|
179
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
180
|
+
renderWithProviders(
|
|
181
|
+
<FrappeProvider baseURL="http://test" fixtures={fixtures()}>
|
|
182
|
+
<FrappeListView<Ticket>
|
|
183
|
+
doctype="Help Ticket"
|
|
184
|
+
defaultViewMode="list"
|
|
185
|
+
filters={{ company: "Acme Corp" } as never}
|
|
186
|
+
/>
|
|
187
|
+
</FrappeProvider>,
|
|
188
|
+
);
|
|
189
|
+
await waitFor(() => expect(screen.getAllByText(/\bOpen\b/).length).toBeGreaterThan(0), settle);
|
|
190
|
+
expect(screen.queryByText(/Acme Corp/)).toBeNull();
|
|
191
|
+
expect(screen.getAllByText(/Ops Crew/).length).toBeGreaterThan(0);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// A routed list whose filters live in the page's one sidebar: type facets
|
|
2
2
|
// with counts, a generation band, a base-XP stock slider and favorites
|
|
3
|
-
// beside the results from md up; below md a
|
|
3
|
+
// beside the results from md up; below md a funnel glyph wearing the
|
|
4
4
|
// active-filter count opens the same filters (sort included) in a bottom
|
|
5
5
|
// sheet, and the header's secondary actions move to a ⋯ menu.
|
|
6
6
|
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
@@ -210,7 +210,25 @@ describe("FrappeListView phone filters", () => {
|
|
|
210
210
|
);
|
|
211
211
|
});
|
|
212
212
|
|
|
213
|
-
it("
|
|
213
|
+
it("leaves the row to the search: the phone tools are two glyphs and no word (FZ-8660)", async () => {
|
|
214
|
+
renderList();
|
|
215
|
+
await sidebar();
|
|
216
|
+
const tools = screen.getByTestId("pokemon-phone-tools");
|
|
217
|
+
const filters = within(tools).getByTestId("pokemon-filters-button");
|
|
218
|
+
expect(filters.getAttribute("data-mp-action")).toBe("filter");
|
|
219
|
+
expect(filters.querySelector("svg")).not.toBeNull();
|
|
220
|
+
expect(tools.textContent).toBe("");
|
|
221
|
+
expect(tools.querySelectorAll("[aria-label]")).toHaveLength(2);
|
|
222
|
+
expect(tools.className).toContain("_fs-0");
|
|
223
|
+
const search = screen.getByRole("searchbox");
|
|
224
|
+
let field = search.parentElement as HTMLElement;
|
|
225
|
+
while (field.nextElementSibling === null) field = field.parentElement as HTMLElement;
|
|
226
|
+
expect(field.parentElement?.contains(tools)).toBe(true);
|
|
227
|
+
expect(field.className).toMatch(/_fg-1/);
|
|
228
|
+
expect(field.className).toMatch(/_miw-0px/);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("holds the secondary actions in a ⋯ menu beside the filter glyph", async () => {
|
|
214
232
|
renderList();
|
|
215
233
|
await sidebar();
|
|
216
234
|
const tools = screen.getByTestId("pokemon-phone-tools");
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// LC-194 SUCCESS-IS-A-CALLBACK on the list: starring a record is its own
|
|
2
|
+
// signal (the filled heart), so the list raises no success toast and opens
|
|
3
|
+
// nothing. Only an unstar, which can drop the row out of a favorites filter,
|
|
4
|
+
// offers an Undo.
|
|
5
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
6
|
+
import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
|
|
7
|
+
import i18next from "i18next";
|
|
8
|
+
import { initReactI18next } from "react-i18next";
|
|
9
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
10
|
+
import {
|
|
11
|
+
createRosterFixtures,
|
|
12
|
+
rosterPokemonFields,
|
|
13
|
+
rosterPokemonMeta,
|
|
14
|
+
type RosterPokemon,
|
|
15
|
+
} from "../tests/fixtures/pokemonRoster";
|
|
16
|
+
import type { FrappeNavigation } from "./frappeNavigation";
|
|
17
|
+
import { FrappeListView, type FrappeListViewProps } from "./FrappeListView";
|
|
18
|
+
import { FrappeProvider } from "./FrappeProvider";
|
|
19
|
+
import { favoritesStore } from "./list/favorites";
|
|
20
|
+
|
|
21
|
+
const feedback = vi.hoisted(() => ({ notify: vi.fn(), showToast: vi.fn() }));
|
|
22
|
+
|
|
23
|
+
vi.mock("@multiplatform.one/components", async (importOriginal) => {
|
|
24
|
+
const actual = await importOriginal<typeof import("@multiplatform.one/components")>();
|
|
25
|
+
return {
|
|
26
|
+
...actual,
|
|
27
|
+
notify: (...args: Parameters<typeof actual.notify>) => {
|
|
28
|
+
feedback.notify(...args);
|
|
29
|
+
return actual.notify(...args);
|
|
30
|
+
},
|
|
31
|
+
showToast: (...args: Parameters<typeof actual.showToast>) => {
|
|
32
|
+
feedback.showToast(...args);
|
|
33
|
+
return actual.showToast(...args);
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const host = "https://listview-callback.spec.test";
|
|
39
|
+
const realFetch = globalThis.fetch;
|
|
40
|
+
const favoritesKey = "callback-spec-favorites";
|
|
41
|
+
|
|
42
|
+
beforeAll(async () => {
|
|
43
|
+
await i18next.use(initReactI18next).init({
|
|
44
|
+
lng: "en",
|
|
45
|
+
interpolation: { escapeValue: false },
|
|
46
|
+
resources: { en: { translation: {} } },
|
|
47
|
+
});
|
|
48
|
+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
49
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
50
|
+
if (url.startsWith(host)) {
|
|
51
|
+
const body = /\/api\/resource\/DocType\//.test(url)
|
|
52
|
+
? { data: rosterPokemonMeta }
|
|
53
|
+
: { data: [] };
|
|
54
|
+
return new Response(JSON.stringify(body), {
|
|
55
|
+
status: 200,
|
|
56
|
+
headers: { "Content-Type": "application/json" },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return realFetch(input as RequestInfo, init);
|
|
60
|
+
}) as typeof fetch;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterAll(() => {
|
|
64
|
+
globalThis.fetch = realFetch;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
cleanup();
|
|
69
|
+
vi.restoreAllMocks();
|
|
70
|
+
feedback.notify.mockClear();
|
|
71
|
+
feedback.showToast.mockClear();
|
|
72
|
+
favoritesStore(favoritesKey).setState(() => ({ names: [] }));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const look: FrappeListViewProps<RosterPokemon>["look"] = {
|
|
76
|
+
title: (doc) => (doc.pokemon_name ?? "").toUpperCase(),
|
|
77
|
+
code: (doc) => `#${String(doc.poke_id ?? 0).padStart(3, "0")}`,
|
|
78
|
+
subtitle: (doc) => doc.species,
|
|
79
|
+
images: (doc) => [`https://art.spec.test/${doc.poke_id}.png`, doc.sprite_front ?? ""],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function renderList(navigate = vi.fn()) {
|
|
83
|
+
const navigation: FrappeNavigation = {
|
|
84
|
+
usePathname: () => "/pokemon",
|
|
85
|
+
useNavigate: () => navigate,
|
|
86
|
+
};
|
|
87
|
+
return renderWithProviders(
|
|
88
|
+
<FrappeProvider baseURL={host} fixtures={createRosterFixtures()} navigation={navigation}>
|
|
89
|
+
<FrappeListView<RosterPokemon>
|
|
90
|
+
doctype="Pokemon"
|
|
91
|
+
route="/pokemon"
|
|
92
|
+
fields={rosterPokemonFields}
|
|
93
|
+
defaultViewMode="grid"
|
|
94
|
+
look={look}
|
|
95
|
+
favorites={favoritesKey}
|
|
96
|
+
facets={[
|
|
97
|
+
{
|
|
98
|
+
param: "type",
|
|
99
|
+
doctype: "Pokemon Poke Type",
|
|
100
|
+
field: "poke_type",
|
|
101
|
+
orderBy: "slot",
|
|
102
|
+
label: "Type",
|
|
103
|
+
options: ["grass", "poison", "fire", "water", "electric", "ghost", "normal"],
|
|
104
|
+
optionLabel: (value) => `type:${value}`,
|
|
105
|
+
tint: (value) => (value === "grass" ? "green" : "purple"),
|
|
106
|
+
},
|
|
107
|
+
]}
|
|
108
|
+
/>
|
|
109
|
+
</FrappeProvider>,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
describe("FrappeListView reports a favorite without its own success feedback (LC-194)", () => {
|
|
114
|
+
it("a star fills the heart, raises no toast and navigates nowhere", async () => {
|
|
115
|
+
const navigate = vi.fn();
|
|
116
|
+
renderList(navigate);
|
|
117
|
+
const heart = await screen.findByTestId("pokemon-favorite-bulbasaur", {}, { timeout: 5000 });
|
|
118
|
+
fireEvent.click(heart);
|
|
119
|
+
await waitFor(() => expect(favoritesStore(favoritesKey).state.names).toEqual(["bulbasaur"]));
|
|
120
|
+
await waitFor(() =>
|
|
121
|
+
expect(screen.getByTestId("pokemon-favorite-bulbasaur").getAttribute("aria-pressed")).toBe(
|
|
122
|
+
"true",
|
|
123
|
+
),
|
|
124
|
+
);
|
|
125
|
+
expect(feedback.showToast).not.toHaveBeenCalled();
|
|
126
|
+
expect(feedback.notify).not.toHaveBeenCalled();
|
|
127
|
+
expect(navigate).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("an unstar offers Undo, never a success toast", async () => {
|
|
131
|
+
favoritesStore(favoritesKey).setState(() => ({ names: ["bulbasaur"] }));
|
|
132
|
+
const navigate = vi.fn();
|
|
133
|
+
renderList(navigate);
|
|
134
|
+
const heart = await screen.findByTestId("pokemon-favorite-bulbasaur", {}, { timeout: 5000 });
|
|
135
|
+
fireEvent.click(heart);
|
|
136
|
+
await waitFor(() => expect(favoritesStore(favoritesKey).state.names).toEqual([]));
|
|
137
|
+
expect(feedback.showToast).toHaveBeenCalledTimes(1);
|
|
138
|
+
const toast = feedback.showToast.mock.calls[0]?.[0] as { intent?: string; action?: unknown };
|
|
139
|
+
expect(toast.intent).not.toBe("success");
|
|
140
|
+
expect(toast.action).toMatchObject({ label: "Undo" });
|
|
141
|
+
expect(navigate).not.toHaveBeenCalled();
|
|
142
|
+
});
|
|
143
|
+
});
|
package/src/FrappeListView.tsx
CHANGED
|
@@ -61,6 +61,12 @@ import {
|
|
|
61
61
|
import { ListFilterPanel } from "./list/ListFilterPanel";
|
|
62
62
|
import { ListSidebarToolbar } from "./list/ListSidebarToolbar";
|
|
63
63
|
import { ListToastHost } from "./list/ListToastHost";
|
|
64
|
+
import {
|
|
65
|
+
defaultProjection,
|
|
66
|
+
defaultProjectionCap,
|
|
67
|
+
scopePinnedFields,
|
|
68
|
+
} from "./list/defaultProjection";
|
|
69
|
+
import { frappeDevWarn } from "./shared/devWarn";
|
|
64
70
|
import { ShufflePanel } from "./list/ShufflePanel";
|
|
65
71
|
import { useListControls } from "./list/useListControls";
|
|
66
72
|
import { recordKeyLabel, useRecordShape } from "./recordShape";
|
|
@@ -436,6 +442,30 @@ function FrappeListViewBody<TDoc extends { name: string; doctype: string }>({
|
|
|
436
442
|
return peekedFields?.find((key) => /sprite|image|photo|avatar|logo/i.test(key));
|
|
437
443
|
}, [meta, peekedFields]);
|
|
438
444
|
|
|
445
|
+
// LC-184: a column the host's scope pins to one value is not drawn; LC-183:
|
|
446
|
+
// the rest open on at most five, the others wait in the column chooser.
|
|
447
|
+
const scopeKey = JSON.stringify(filters ?? null);
|
|
448
|
+
const scopePinned = useMemo(
|
|
449
|
+
() => scopePinnedFields(JSON.parse(scopeKey) ?? undefined, doctype),
|
|
450
|
+
[scopeKey, doctype],
|
|
451
|
+
);
|
|
452
|
+
const projection = useMemo(
|
|
453
|
+
() =>
|
|
454
|
+
defaultProjection(
|
|
455
|
+
metaListFields.filter((field) => field.fieldname !== imageFieldName),
|
|
456
|
+
scopePinned,
|
|
457
|
+
),
|
|
458
|
+
[metaListFields, imageFieldName, scopePinned],
|
|
459
|
+
);
|
|
460
|
+
useEffect(() => {
|
|
461
|
+
if (projection.optIn.length === 0) return;
|
|
462
|
+
frappeDevWarn("list-projection-over-cap", {
|
|
463
|
+
component: "FrappeListView",
|
|
464
|
+
doctype,
|
|
465
|
+
suggest: `flag at most ${defaultProjectionCap} in_list_view fields; ${projection.optIn.map((field) => field.fieldname).join(", ")} open hidden`,
|
|
466
|
+
});
|
|
467
|
+
}, [projection, doctype]);
|
|
468
|
+
|
|
439
469
|
const effectiveFields = useMemo(() => {
|
|
440
470
|
if (fields) return fields;
|
|
441
471
|
const listFields = meta
|
|
@@ -451,7 +481,7 @@ function FrappeListViewBody<TDoc extends { name: string; doctype: string }>({
|
|
|
451
481
|
const effectiveDisplayFields = useMemo((): ListFieldConfig[] => {
|
|
452
482
|
if (displayFields) return displayFields;
|
|
453
483
|
if (meta && metaListFields.length > 0) {
|
|
454
|
-
return
|
|
484
|
+
return projection.shown.map((f) => ({
|
|
455
485
|
key: f.fieldname,
|
|
456
486
|
label: f.label || f.fieldname,
|
|
457
487
|
}));
|
|
@@ -463,7 +493,7 @@ function FrappeListViewBody<TDoc extends { name: string; doctype: string }>({
|
|
|
463
493
|
.map((key) => ({ key, label: recordKeyLabel(key) }));
|
|
464
494
|
}
|
|
465
495
|
return [{ key: "name", label: t("Name") }];
|
|
466
|
-
}, [displayFields, meta, metaListFields, peekedFields, imageFieldName, t]);
|
|
496
|
+
}, [displayFields, meta, metaListFields, projection, peekedFields, imageFieldName, t]);
|
|
467
497
|
|
|
468
498
|
const renderListItem = useCallback(
|
|
469
499
|
(doc: Partial<TDoc>) => {
|
|
@@ -530,13 +560,15 @@ function FrappeListViewBody<TDoc extends { name: string; doctype: string }>({
|
|
|
530
560
|
const effectiveColumns = useMemo((): DataTableColumn<Partial<TDoc>>[] => {
|
|
531
561
|
if (columns) return columns;
|
|
532
562
|
if (meta && metaListFields.length > 0) {
|
|
533
|
-
return metaListFields
|
|
534
|
-
(f) =>
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
563
|
+
return metaListFields
|
|
564
|
+
.filter((f) => !scopePinned.has(f.fieldname))
|
|
565
|
+
.map(
|
|
566
|
+
(f) =>
|
|
567
|
+
({
|
|
568
|
+
accessorKey: f.fieldname as string,
|
|
569
|
+
header: f.label || f.fieldname,
|
|
570
|
+
}) as DataTableColumn<Partial<TDoc>>,
|
|
571
|
+
);
|
|
540
572
|
}
|
|
541
573
|
if (peekedFields && peekedFields.length > 0) {
|
|
542
574
|
return peekedFields
|
|
@@ -550,7 +582,15 @@ function FrappeListViewBody<TDoc extends { name: string; doctype: string }>({
|
|
|
550
582
|
return [{ accessorKey: "name" as string, header: t("Name") }] as DataTableColumn<
|
|
551
583
|
Partial<TDoc>
|
|
552
584
|
>[];
|
|
553
|
-
}, [columns, meta, metaListFields, peekedFields, imageFieldName, t]);
|
|
585
|
+
}, [columns, meta, metaListFields, scopePinned, peekedFields, imageFieldName, t]);
|
|
586
|
+
|
|
587
|
+
const projectionVisibility = useMemo(
|
|
588
|
+
() =>
|
|
589
|
+
columns || projection.optIn.length === 0
|
|
590
|
+
? undefined
|
|
591
|
+
: Object.fromEntries(projection.optIn.map((field) => [field.fieldname, false])),
|
|
592
|
+
[columns, projection],
|
|
593
|
+
);
|
|
554
594
|
|
|
555
595
|
const standardFilterFields = useMemo(() => {
|
|
556
596
|
if (!meta) return [];
|
|
@@ -772,22 +812,17 @@ function FrappeListViewBody<TDoc extends { name: string; doctype: string }>({
|
|
|
772
812
|
if (!name) return;
|
|
773
813
|
const starred = isFavorite(name);
|
|
774
814
|
toggleStar(name);
|
|
815
|
+
// LC-194: the filled heart is the add's own signal; only a removal,
|
|
816
|
+
// which can drop the record out of a favorites filter, offers an undo.
|
|
817
|
+
if (!starred) return;
|
|
775
818
|
const titleValue = nameField ? (doc as Record<string, unknown>)[nameField] : undefined;
|
|
776
819
|
const label = typeof titleValue === "string" && titleValue ? titleValue : name;
|
|
777
|
-
showToast(
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
action: { label: t("Undo"), onPress: () => toggleStar(name) },
|
|
784
|
-
}
|
|
785
|
-
: {
|
|
786
|
-
id: `${slug}-favorite-${name}`,
|
|
787
|
-
title: t("{{name}} added to favorites", { name: label }),
|
|
788
|
-
intent: "success",
|
|
789
|
-
},
|
|
790
|
-
);
|
|
820
|
+
showToast({
|
|
821
|
+
id: `${slug}-favorite-${name}`,
|
|
822
|
+
title: t("{{name}} removed from favorites", { name: label }),
|
|
823
|
+
intent: "accent",
|
|
824
|
+
action: { label: t("Undo"), onPress: () => toggleStar(name) },
|
|
825
|
+
});
|
|
791
826
|
},
|
|
792
827
|
[isFavorite, toggleStar, nameField, slug, t],
|
|
793
828
|
);
|
|
@@ -1358,11 +1393,14 @@ function FrappeListViewBody<TDoc extends { name: string; doctype: string }>({
|
|
|
1358
1393
|
|
|
1359
1394
|
return (
|
|
1360
1395
|
<FrappeTable
|
|
1361
|
-
key={`table-${refreshKey}`}
|
|
1396
|
+
key={`table-${refreshKey}-${scopeKey}`}
|
|
1362
1397
|
doctype={doctype}
|
|
1363
1398
|
fields={effectiveFields}
|
|
1364
1399
|
filters={resolvedFilters as Parameters<typeof FrappeTable<TDoc>>[0]["filters"]}
|
|
1365
1400
|
columns={effectiveColumns}
|
|
1401
|
+
{...(projectionVisibility
|
|
1402
|
+
? { initialState: { columnVisibility: projectionVisibility } }
|
|
1403
|
+
: {})}
|
|
1366
1404
|
onRowClick={onItemClick}
|
|
1367
1405
|
rights={rights}
|
|
1368
1406
|
baseURL={finalBaseURL}
|