@multiplatform.one/frappe-ui 7.77.2 → 7.79.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 +31 -13
- package/dist/cjs/FrappeListView.native.js +46 -13
- 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/RecordCard.native.js +4 -3
- package/dist/cjs/list/RecordCard.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 +31 -13
- package/dist/esm/FrappeListView.mjs.map +1 -1
- package/dist/esm/FrappeListView.native.js +46 -13
- 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/RecordCard.native.js +4 -3
- package/dist/esm/list/RecordCard.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 +31 -13
- package/dist/jsx/FrappeListView.mjs.map +1 -1
- package/dist/jsx/FrappeListView.native.js +46 -13
- 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/RecordCard.native.js +4 -3
- package/dist/jsx/list/RecordCard.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.spec.tsx +17 -0
- package/src/FrappeListView.successIsCallback.spec.tsx +143 -0
- package/src/FrappeListView.tsx +66 -28
- 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/RecordCard.native.tsx +3 -2
- 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/RecordCard.native.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
|
@@ -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");
|
|
@@ -149,6 +149,23 @@ describe("FrappeListView refresh press floor (LC-54 amendment)", () => {
|
|
|
149
149
|
expect(refresh.querySelector("svg")).not.toBeNull();
|
|
150
150
|
});
|
|
151
151
|
|
|
152
|
+
it("keeps refresh inside the toolbar: the switcher side shrinks, the tools side never does (FZ-8521)", async () => {
|
|
153
|
+
const { container } = renderListView();
|
|
154
|
+
const refresh = await refreshIn(container);
|
|
155
|
+
let tools = refresh.parentElement as HTMLElement;
|
|
156
|
+
while (tools && !/(?:^| )_fs-0(?: |$)/.test(String(tools.className))) {
|
|
157
|
+
tools = tools.parentElement as HTMLElement;
|
|
158
|
+
}
|
|
159
|
+
expect(tools).toBeTruthy();
|
|
160
|
+
const switcherSide = tools.previousElementSibling as HTMLElement;
|
|
161
|
+
const bar = tools.parentElement as HTMLElement;
|
|
162
|
+
expect(tools.className).toContain("_fs-0");
|
|
163
|
+
expect(switcherSide.className).toContain("_fs-1");
|
|
164
|
+
expect(switcherSide.className).toContain("_fg-1");
|
|
165
|
+
expect(switcherSide.className).toMatch(/_miw-0px/);
|
|
166
|
+
expect(bar.className).not.toContain("_jc-space-between");
|
|
167
|
+
});
|
|
168
|
+
|
|
152
169
|
it("the Add glyph still activates (house div[role=button], LC-260)", async () => {
|
|
153
170
|
const onAdd = vi.fn();
|
|
154
171
|
const { container } = renderListView({ onAdd });
|
|
@@ -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
|
);
|
|
@@ -1185,15 +1220,15 @@ function FrappeListViewBody<TDoc extends { name: string; doctype: string }>({
|
|
|
1185
1220
|
return (
|
|
1186
1221
|
<XStack
|
|
1187
1222
|
{...knobProps.panelPadding}
|
|
1223
|
+
{...knobProps.gap}
|
|
1188
1224
|
alignItems="center"
|
|
1189
|
-
justifyContent="space-between"
|
|
1190
1225
|
// Frame owns SF-CARD; this band is flattened (zero box border, one
|
|
1191
1226
|
// bottom hairline) so it does not paint a second surface.
|
|
1192
1227
|
borderWidth={0}
|
|
1193
1228
|
borderBottomWidth={1}
|
|
1194
1229
|
borderBottomColor="$borderColor"
|
|
1195
1230
|
>
|
|
1196
|
-
<XStack alignItems="center" {...knobProps.gap}>
|
|
1231
|
+
<XStack alignItems="center" flexGrow={1} flexShrink={1} minWidth={0} {...knobProps.gap}>
|
|
1197
1232
|
<ViewSwitcher
|
|
1198
1233
|
views={[
|
|
1199
1234
|
...(hasCards ? [{ type: "grid", label: t("Cards") }] : []),
|
|
@@ -1205,7 +1240,7 @@ function FrappeListViewBody<TDoc extends { name: string; doctype: string }>({
|
|
|
1205
1240
|
/>
|
|
1206
1241
|
</XStack>
|
|
1207
1242
|
|
|
1208
|
-
<XStack alignItems="center" {...knobProps.gap}>
|
|
1243
|
+
<XStack alignItems="center" flexShrink={0} {...knobProps.gap}>
|
|
1209
1244
|
{/* LC-54 (amendment 2026-08-13): house Button, own-box floor — the
|
|
1210
1245
|
raw tamagui Button + grown transparent target painted its hover
|
|
1211
1246
|
fill across the escaped 44px floor. `circular` makes the painted
|
|
@@ -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}
|