@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.
Files changed (136) hide show
  1. package/dist/cjs/FrappeForm.cjs +20 -26
  2. package/dist/cjs/FrappeForm.native.js +26 -26
  3. package/dist/cjs/FrappeForm.native.js.map +1 -1
  4. package/dist/cjs/FrappeListView.cjs +31 -13
  5. package/dist/cjs/FrappeListView.native.js +46 -13
  6. package/dist/cjs/FrappeListView.native.js.map +1 -1
  7. package/dist/cjs/fields/Password/index.cjs +125 -36
  8. package/dist/cjs/fields/Password/index.native.js +101 -13
  9. package/dist/cjs/fields/Password/index.native.js.map +1 -1
  10. package/dist/cjs/list/ListControlsSheet.cjs +5 -11
  11. package/dist/cjs/list/ListControlsSheet.native.js +5 -11
  12. package/dist/cjs/list/ListControlsSheet.native.js.map +1 -1
  13. package/dist/cjs/list/ListFiltersButton.cjs +56 -0
  14. package/dist/cjs/list/ListFiltersButton.native.js +60 -0
  15. package/dist/cjs/list/ListFiltersButton.native.js.map +1 -0
  16. package/dist/cjs/list/ListSidebarToolbar.cjs +5 -17
  17. package/dist/cjs/list/ListSidebarToolbar.native.js +7 -19
  18. package/dist/cjs/list/ListSidebarToolbar.native.js.map +1 -1
  19. package/dist/cjs/list/ListToolsSheet.cjs +5 -18
  20. package/dist/cjs/list/ListToolsSheet.native.js +7 -20
  21. package/dist/cjs/list/ListToolsSheet.native.js.map +1 -1
  22. package/dist/cjs/list/RecordCard.native.js +4 -3
  23. package/dist/cjs/list/RecordCard.native.js.map +1 -1
  24. package/dist/cjs/list/defaultProjection.cjs +56 -0
  25. package/dist/cjs/list/defaultProjection.native.js +107 -0
  26. package/dist/cjs/list/defaultProjection.native.js.map +1 -0
  27. package/dist/cjs/shared/devWarn.native.js.map +1 -1
  28. package/dist/esm/FrappeForm.mjs +21 -27
  29. package/dist/esm/FrappeForm.mjs.map +1 -1
  30. package/dist/esm/FrappeForm.native.js +27 -27
  31. package/dist/esm/FrappeForm.native.js.map +1 -1
  32. package/dist/esm/FrappeListView.mjs +31 -13
  33. package/dist/esm/FrappeListView.mjs.map +1 -1
  34. package/dist/esm/FrappeListView.native.js +46 -13
  35. package/dist/esm/FrappeListView.native.js.map +1 -1
  36. package/dist/esm/fields/Password/index.mjs +125 -36
  37. package/dist/esm/fields/Password/index.mjs.map +1 -1
  38. package/dist/esm/fields/Password/index.native.js +101 -13
  39. package/dist/esm/fields/Password/index.native.js.map +1 -1
  40. package/dist/esm/list/ListControlsSheet.mjs +7 -13
  41. package/dist/esm/list/ListControlsSheet.mjs.map +1 -1
  42. package/dist/esm/list/ListControlsSheet.native.js +7 -13
  43. package/dist/esm/list/ListControlsSheet.native.js.map +1 -1
  44. package/dist/esm/list/ListFiltersButton.mjs +31 -0
  45. package/dist/esm/list/ListFiltersButton.mjs.map +1 -0
  46. package/dist/esm/list/ListFiltersButton.native.js +32 -0
  47. package/dist/esm/list/ListFiltersButton.native.js.map +1 -0
  48. package/dist/esm/list/ListSidebarToolbar.mjs +7 -19
  49. package/dist/esm/list/ListSidebarToolbar.mjs.map +1 -1
  50. package/dist/esm/list/ListSidebarToolbar.native.js +9 -21
  51. package/dist/esm/list/ListSidebarToolbar.native.js.map +1 -1
  52. package/dist/esm/list/ListToolsSheet.mjs +7 -20
  53. package/dist/esm/list/ListToolsSheet.mjs.map +1 -1
  54. package/dist/esm/list/ListToolsSheet.native.js +9 -22
  55. package/dist/esm/list/ListToolsSheet.native.js.map +1 -1
  56. package/dist/esm/list/RecordCard.native.js +4 -3
  57. package/dist/esm/list/RecordCard.native.js.map +1 -1
  58. package/dist/esm/list/defaultProjection.mjs +29 -0
  59. package/dist/esm/list/defaultProjection.mjs.map +1 -0
  60. package/dist/esm/list/defaultProjection.native.js +77 -0
  61. package/dist/esm/list/defaultProjection.native.js.map +1 -0
  62. package/dist/esm/shared/devWarn.mjs.map +1 -1
  63. package/dist/esm/shared/devWarn.native.js.map +1 -1
  64. package/dist/jsx/FrappeForm.mjs +21 -27
  65. package/dist/jsx/FrappeForm.mjs.map +1 -1
  66. package/dist/jsx/FrappeForm.native.js +26 -26
  67. package/dist/jsx/FrappeForm.native.js.map +1 -1
  68. package/dist/jsx/FrappeListView.mjs +31 -13
  69. package/dist/jsx/FrappeListView.mjs.map +1 -1
  70. package/dist/jsx/FrappeListView.native.js +46 -13
  71. package/dist/jsx/FrappeListView.native.js.map +1 -1
  72. package/dist/jsx/fields/Password/index.mjs +125 -36
  73. package/dist/jsx/fields/Password/index.mjs.map +1 -1
  74. package/dist/jsx/fields/Password/index.native.js +101 -13
  75. package/dist/jsx/fields/Password/index.native.js.map +1 -1
  76. package/dist/jsx/list/ListControlsSheet.mjs +7 -13
  77. package/dist/jsx/list/ListControlsSheet.mjs.map +1 -1
  78. package/dist/jsx/list/ListControlsSheet.native.js +5 -11
  79. package/dist/jsx/list/ListControlsSheet.native.js.map +1 -1
  80. package/dist/jsx/list/ListFiltersButton.mjs +31 -0
  81. package/dist/jsx/list/ListFiltersButton.mjs.map +1 -0
  82. package/dist/jsx/list/ListFiltersButton.native.js +60 -0
  83. package/dist/jsx/list/ListFiltersButton.native.js.map +1 -0
  84. package/dist/jsx/list/ListSidebarToolbar.mjs +7 -19
  85. package/dist/jsx/list/ListSidebarToolbar.mjs.map +1 -1
  86. package/dist/jsx/list/ListSidebarToolbar.native.js +7 -19
  87. package/dist/jsx/list/ListSidebarToolbar.native.js.map +1 -1
  88. package/dist/jsx/list/ListToolsSheet.mjs +7 -20
  89. package/dist/jsx/list/ListToolsSheet.mjs.map +1 -1
  90. package/dist/jsx/list/ListToolsSheet.native.js +7 -20
  91. package/dist/jsx/list/ListToolsSheet.native.js.map +1 -1
  92. package/dist/jsx/list/RecordCard.native.js +4 -3
  93. package/dist/jsx/list/RecordCard.native.js.map +1 -1
  94. package/dist/jsx/list/defaultProjection.mjs +29 -0
  95. package/dist/jsx/list/defaultProjection.mjs.map +1 -0
  96. package/dist/jsx/list/defaultProjection.native.js +107 -0
  97. package/dist/jsx/list/defaultProjection.native.js.map +1 -0
  98. package/dist/jsx/shared/devWarn.mjs.map +1 -1
  99. package/dist/jsx/shared/devWarn.native.js.map +1 -1
  100. package/package.json +16 -16
  101. package/src/FrappeForm.recordChanged.spec.tsx +179 -0
  102. package/src/FrappeForm.recordChanged.stories.tsx +83 -0
  103. package/src/FrappeForm.successIsCallback.spec.tsx +154 -0
  104. package/src/FrappeForm.tsx +34 -33
  105. package/src/FrappeListView.projection.spec.tsx +193 -0
  106. package/src/FrappeListView.sidebar.spec.tsx +20 -2
  107. package/src/FrappeListView.spec.tsx +17 -0
  108. package/src/FrappeListView.successIsCallback.spec.tsx +143 -0
  109. package/src/FrappeListView.tsx +66 -28
  110. package/src/fields/Password/Password.secret.spec.tsx +194 -0
  111. package/src/fields/Password/Password.spec.tsx +15 -8
  112. package/src/fields/Password/index.tsx +134 -35
  113. package/src/list/ListControls.spec.tsx +10 -1
  114. package/src/list/ListControlsSheet.spec.tsx +4 -2
  115. package/src/list/ListControlsSheet.tsx +5 -9
  116. package/src/list/ListFiltersButton.tsx +33 -0
  117. package/src/list/ListSidebarToolbar.tsx +13 -20
  118. package/src/list/ListToolsSheet.tsx +10 -18
  119. package/src/list/RecordCard.native.tsx +3 -2
  120. package/src/list/defaultProjection.ts +66 -0
  121. package/src/shared/devWarn.ts +2 -1
  122. package/types/FrappeForm.d.ts.map +1 -1
  123. package/types/FrappeListView.d.ts.map +1 -1
  124. package/types/fields/Password/index.d.ts +6 -0
  125. package/types/fields/Password/index.d.ts.map +1 -1
  126. package/types/list/ListControlsSheet.d.ts.map +1 -1
  127. package/types/list/ListFiltersButton.d.ts +11 -0
  128. package/types/list/ListFiltersButton.d.ts.map +1 -0
  129. package/types/list/ListSidebarToolbar.d.ts +5 -3
  130. package/types/list/ListSidebarToolbar.d.ts.map +1 -1
  131. package/types/list/ListToolsSheet.d.ts.map +1 -1
  132. package/types/list/RecordCard.native.d.ts.map +1 -1
  133. package/types/list/defaultProjection.d.ts +26 -0
  134. package/types/list/defaultProjection.d.ts.map +1 -0
  135. package/types/shared/devWarn.d.ts +1 -1
  136. 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 Filters button wearing the
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("holds the secondary actions in a ⋯ menu beside the Filters button", async () => {
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
+ });
@@ -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 metaListFields.map((f) => ({
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.map(
534
- (f) =>
535
- ({
536
- accessorKey: f.fieldname as string,
537
- header: f.label || f.fieldname,
538
- }) as DataTableColumn<Partial<TDoc>>,
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
- starred
779
- ? {
780
- id: `${slug}-favorite-${name}`,
781
- title: t("{{name}} removed from favorites", { name: label }),
782
- intent: "accent",
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}