@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.
Files changed (127) 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 +26 -12
  5. package/dist/cjs/FrappeListView.native.js +41 -12
  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/defaultProjection.cjs +56 -0
  23. package/dist/cjs/list/defaultProjection.native.js +107 -0
  24. package/dist/cjs/list/defaultProjection.native.js.map +1 -0
  25. package/dist/cjs/shared/devWarn.native.js.map +1 -1
  26. package/dist/esm/FrappeForm.mjs +21 -27
  27. package/dist/esm/FrappeForm.mjs.map +1 -1
  28. package/dist/esm/FrappeForm.native.js +27 -27
  29. package/dist/esm/FrappeForm.native.js.map +1 -1
  30. package/dist/esm/FrappeListView.mjs +26 -12
  31. package/dist/esm/FrappeListView.mjs.map +1 -1
  32. package/dist/esm/FrappeListView.native.js +41 -12
  33. package/dist/esm/FrappeListView.native.js.map +1 -1
  34. package/dist/esm/fields/Password/index.mjs +125 -36
  35. package/dist/esm/fields/Password/index.mjs.map +1 -1
  36. package/dist/esm/fields/Password/index.native.js +101 -13
  37. package/dist/esm/fields/Password/index.native.js.map +1 -1
  38. package/dist/esm/list/ListControlsSheet.mjs +7 -13
  39. package/dist/esm/list/ListControlsSheet.mjs.map +1 -1
  40. package/dist/esm/list/ListControlsSheet.native.js +7 -13
  41. package/dist/esm/list/ListControlsSheet.native.js.map +1 -1
  42. package/dist/esm/list/ListFiltersButton.mjs +31 -0
  43. package/dist/esm/list/ListFiltersButton.mjs.map +1 -0
  44. package/dist/esm/list/ListFiltersButton.native.js +32 -0
  45. package/dist/esm/list/ListFiltersButton.native.js.map +1 -0
  46. package/dist/esm/list/ListSidebarToolbar.mjs +7 -19
  47. package/dist/esm/list/ListSidebarToolbar.mjs.map +1 -1
  48. package/dist/esm/list/ListSidebarToolbar.native.js +9 -21
  49. package/dist/esm/list/ListSidebarToolbar.native.js.map +1 -1
  50. package/dist/esm/list/ListToolsSheet.mjs +7 -20
  51. package/dist/esm/list/ListToolsSheet.mjs.map +1 -1
  52. package/dist/esm/list/ListToolsSheet.native.js +9 -22
  53. package/dist/esm/list/ListToolsSheet.native.js.map +1 -1
  54. package/dist/esm/list/defaultProjection.mjs +29 -0
  55. package/dist/esm/list/defaultProjection.mjs.map +1 -0
  56. package/dist/esm/list/defaultProjection.native.js +77 -0
  57. package/dist/esm/list/defaultProjection.native.js.map +1 -0
  58. package/dist/esm/shared/devWarn.mjs.map +1 -1
  59. package/dist/esm/shared/devWarn.native.js.map +1 -1
  60. package/dist/jsx/FrappeForm.mjs +21 -27
  61. package/dist/jsx/FrappeForm.mjs.map +1 -1
  62. package/dist/jsx/FrappeForm.native.js +26 -26
  63. package/dist/jsx/FrappeForm.native.js.map +1 -1
  64. package/dist/jsx/FrappeListView.mjs +26 -12
  65. package/dist/jsx/FrappeListView.mjs.map +1 -1
  66. package/dist/jsx/FrappeListView.native.js +41 -12
  67. package/dist/jsx/FrappeListView.native.js.map +1 -1
  68. package/dist/jsx/fields/Password/index.mjs +125 -36
  69. package/dist/jsx/fields/Password/index.mjs.map +1 -1
  70. package/dist/jsx/fields/Password/index.native.js +101 -13
  71. package/dist/jsx/fields/Password/index.native.js.map +1 -1
  72. package/dist/jsx/list/ListControlsSheet.mjs +7 -13
  73. package/dist/jsx/list/ListControlsSheet.mjs.map +1 -1
  74. package/dist/jsx/list/ListControlsSheet.native.js +5 -11
  75. package/dist/jsx/list/ListControlsSheet.native.js.map +1 -1
  76. package/dist/jsx/list/ListFiltersButton.mjs +31 -0
  77. package/dist/jsx/list/ListFiltersButton.mjs.map +1 -0
  78. package/dist/jsx/list/ListFiltersButton.native.js +60 -0
  79. package/dist/jsx/list/ListFiltersButton.native.js.map +1 -0
  80. package/dist/jsx/list/ListSidebarToolbar.mjs +7 -19
  81. package/dist/jsx/list/ListSidebarToolbar.mjs.map +1 -1
  82. package/dist/jsx/list/ListSidebarToolbar.native.js +7 -19
  83. package/dist/jsx/list/ListSidebarToolbar.native.js.map +1 -1
  84. package/dist/jsx/list/ListToolsSheet.mjs +7 -20
  85. package/dist/jsx/list/ListToolsSheet.mjs.map +1 -1
  86. package/dist/jsx/list/ListToolsSheet.native.js +7 -20
  87. package/dist/jsx/list/ListToolsSheet.native.js.map +1 -1
  88. package/dist/jsx/list/defaultProjection.mjs +29 -0
  89. package/dist/jsx/list/defaultProjection.mjs.map +1 -0
  90. package/dist/jsx/list/defaultProjection.native.js +107 -0
  91. package/dist/jsx/list/defaultProjection.native.js.map +1 -0
  92. package/dist/jsx/shared/devWarn.mjs.map +1 -1
  93. package/dist/jsx/shared/devWarn.native.js.map +1 -1
  94. package/package.json +16 -16
  95. package/src/FrappeForm.recordChanged.spec.tsx +179 -0
  96. package/src/FrappeForm.recordChanged.stories.tsx +83 -0
  97. package/src/FrappeForm.successIsCallback.spec.tsx +154 -0
  98. package/src/FrappeForm.tsx +34 -33
  99. package/src/FrappeListView.projection.spec.tsx +193 -0
  100. package/src/FrappeListView.sidebar.spec.tsx +20 -2
  101. package/src/FrappeListView.successIsCallback.spec.tsx +143 -0
  102. package/src/FrappeListView.tsx +63 -25
  103. package/src/fields/Password/Password.secret.spec.tsx +194 -0
  104. package/src/fields/Password/Password.spec.tsx +15 -8
  105. package/src/fields/Password/index.tsx +134 -35
  106. package/src/list/ListControls.spec.tsx +10 -1
  107. package/src/list/ListControlsSheet.spec.tsx +4 -2
  108. package/src/list/ListControlsSheet.tsx +5 -9
  109. package/src/list/ListFiltersButton.tsx +33 -0
  110. package/src/list/ListSidebarToolbar.tsx +13 -20
  111. package/src/list/ListToolsSheet.tsx +10 -18
  112. package/src/list/defaultProjection.ts +66 -0
  113. package/src/shared/devWarn.ts +2 -1
  114. package/types/FrappeForm.d.ts.map +1 -1
  115. package/types/FrappeListView.d.ts.map +1 -1
  116. package/types/fields/Password/index.d.ts +6 -0
  117. package/types/fields/Password/index.d.ts.map +1 -1
  118. package/types/list/ListControlsSheet.d.ts.map +1 -1
  119. package/types/list/ListFiltersButton.d.ts +11 -0
  120. package/types/list/ListFiltersButton.d.ts.map +1 -0
  121. package/types/list/ListSidebarToolbar.d.ts +5 -3
  122. package/types/list/ListSidebarToolbar.d.ts.map +1 -1
  123. package/types/list/ListToolsSheet.d.ts.map +1 -1
  124. package/types/list/defaultProjection.d.ts +26 -0
  125. package/types/list/defaultProjection.d.ts.map +1 -0
  126. package/types/shared/devWarn.d.ts +1 -1
  127. package/types/shared/devWarn.d.ts.map +1 -1
@@ -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
- <XStack
973
- role="alert"
974
- alignItems="center"
975
- {...knobProps.containerRadius}
976
- {...knobProps.panelPadding}
977
- backgroundColor="$red3"
978
- testID="frappe-form-deleted-banner"
979
- >
980
- <TamaguiText color={formCommonColors.error} {...knobProps.body}>
981
- {hasUnsavedChanges
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 && conflictFields.length > 0 && (
994
- <XStack
995
- role="status"
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
- <TamaguiText color="$orange11" {...knobProps.body}>
1003
- {t(
1004
- "Changed elsewhere while you were editing: {{fields}}. Your edits are kept — saving will overwrite the newer server values.",
1005
- { fields: conflictLabels },
1006
- )}
1007
- </TamaguiText>
1008
- </XStack>
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 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");
@@ -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
  );
@@ -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}