@multiplatform.one/frappe-ui 7.22.0 → 7.23.1

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 (222) hide show
  1. package/README.md +14 -0
  2. package/dist/cjs/FrappeForm.cjs +96 -52
  3. package/dist/cjs/FrappeForm.native.js +103 -52
  4. package/dist/cjs/FrappeForm.native.js.map +1 -1
  5. package/dist/cjs/FrappeFormActions.cjs +161 -0
  6. package/dist/cjs/FrappeFormActions.native.js +167 -0
  7. package/dist/cjs/FrappeFormActions.native.js.map +1 -0
  8. package/dist/cjs/FrappeTable.cjs +42 -13
  9. package/dist/cjs/FrappeTable.native.js +55 -19
  10. package/dist/cjs/FrappeTable.native.js.map +1 -1
  11. package/dist/cjs/desk/DeskFormPage.cjs +2 -1
  12. package/dist/cjs/desk/DeskFormPage.native.js +3 -0
  13. package/dist/cjs/desk/DeskFormPage.native.js.map +1 -1
  14. package/dist/cjs/filterFields.cjs +277 -34
  15. package/dist/cjs/filterFields.native.js +311 -36
  16. package/dist/cjs/filterFields.native.js.map +1 -1
  17. package/dist/cjs/formEngine/docLifecycle.cjs +98 -0
  18. package/dist/cjs/formEngine/docLifecycle.native.js +131 -0
  19. package/dist/cjs/formEngine/docLifecycle.native.js.map +1 -0
  20. package/dist/cjs/formEngine/useAmendedAs.cjs +48 -0
  21. package/dist/cjs/formEngine/useAmendedAs.native.js +60 -0
  22. package/dist/cjs/formEngine/useAmendedAs.native.js.map +1 -0
  23. package/dist/cjs/formEngine/useDocRights.cjs +2 -2
  24. package/dist/cjs/formEngine/useDocRights.native.js +2 -2
  25. package/dist/cjs/formEngine/useDocRights.native.js.map +1 -1
  26. package/dist/cjs/formEngine/useFormEngine.cjs +1 -0
  27. package/dist/cjs/formEngine/useFormEngine.native.js +1 -0
  28. package/dist/cjs/formEngine/useFormEngine.native.js.map +1 -1
  29. package/dist/cjs/index.cjs +30 -1
  30. package/dist/cjs/index.native.js +30 -1
  31. package/dist/cjs/index.native.js.map +1 -1
  32. package/dist/cjs/list/listColumns.cjs +397 -0
  33. package/dist/cjs/list/listColumns.native.js +447 -0
  34. package/dist/cjs/list/listColumns.native.js.map +1 -0
  35. package/dist/cjs/list/listFields.cjs +50 -0
  36. package/dist/cjs/list/listFields.native.js +79 -0
  37. package/dist/cjs/list/listFields.native.js.map +1 -0
  38. package/dist/cjs/list/listFormat.cjs +68 -0
  39. package/dist/cjs/list/listFormat.native.js +74 -0
  40. package/dist/cjs/list/listFormat.native.js.map +1 -0
  41. package/dist/cjs/list/statusColors.cjs +102 -0
  42. package/dist/cjs/list/statusColors.native.js +109 -0
  43. package/dist/cjs/list/statusColors.native.js.map +1 -0
  44. package/dist/cjs/useFrappeAggregate.cjs +1 -1
  45. package/dist/cjs/useFrappeAggregate.native.js +1 -1
  46. package/dist/cjs/useFrappeAggregate.native.js.map +1 -1
  47. package/dist/esm/FrappeForm.mjs +97 -53
  48. package/dist/esm/FrappeForm.mjs.map +1 -1
  49. package/dist/esm/FrappeForm.native.js +104 -53
  50. package/dist/esm/FrappeForm.native.js.map +1 -1
  51. package/dist/esm/FrappeFormActions.mjs +136 -0
  52. package/dist/esm/FrappeFormActions.mjs.map +1 -0
  53. package/dist/esm/FrappeFormActions.native.js +139 -0
  54. package/dist/esm/FrappeFormActions.native.js.map +1 -0
  55. package/dist/esm/FrappeTable.mjs +42 -13
  56. package/dist/esm/FrappeTable.mjs.map +1 -1
  57. package/dist/esm/FrappeTable.native.js +55 -19
  58. package/dist/esm/FrappeTable.native.js.map +1 -1
  59. package/dist/esm/desk/DeskFormPage.mjs +2 -1
  60. package/dist/esm/desk/DeskFormPage.mjs.map +1 -1
  61. package/dist/esm/desk/DeskFormPage.native.js +3 -0
  62. package/dist/esm/desk/DeskFormPage.native.js.map +1 -1
  63. package/dist/esm/filterFields.mjs +263 -34
  64. package/dist/esm/filterFields.mjs.map +1 -1
  65. package/dist/esm/filterFields.native.js +297 -36
  66. package/dist/esm/filterFields.native.js.map +1 -1
  67. package/dist/esm/formEngine/docLifecycle.mjs +68 -0
  68. package/dist/esm/formEngine/docLifecycle.mjs.map +1 -0
  69. package/dist/esm/formEngine/docLifecycle.native.js +98 -0
  70. package/dist/esm/formEngine/docLifecycle.native.js.map +1 -0
  71. package/dist/esm/formEngine/useAmendedAs.mjs +23 -0
  72. package/dist/esm/formEngine/useAmendedAs.mjs.map +1 -0
  73. package/dist/esm/formEngine/useAmendedAs.native.js +32 -0
  74. package/dist/esm/formEngine/useAmendedAs.native.js.map +1 -0
  75. package/dist/esm/formEngine/useDocRights.mjs +2 -2
  76. package/dist/esm/formEngine/useDocRights.mjs.map +1 -1
  77. package/dist/esm/formEngine/useDocRights.native.js +2 -2
  78. package/dist/esm/formEngine/useDocRights.native.js.map +1 -1
  79. package/dist/esm/formEngine/useFormEngine.mjs +1 -1
  80. package/dist/esm/formEngine/useFormEngine.mjs.map +1 -1
  81. package/dist/esm/formEngine/useFormEngine.native.js +1 -1
  82. package/dist/esm/formEngine/useFormEngine.native.js.map +1 -1
  83. package/dist/esm/index.mjs +6 -2
  84. package/dist/esm/index.mjs.map +1 -1
  85. package/dist/esm/index.native.js +6 -2
  86. package/dist/esm/index.native.js.map +1 -1
  87. package/dist/esm/list/listColumns.mjs +372 -0
  88. package/dist/esm/list/listColumns.mjs.map +1 -0
  89. package/dist/esm/list/listColumns.native.js +419 -0
  90. package/dist/esm/list/listColumns.native.js.map +1 -0
  91. package/dist/esm/list/listFields.mjs +23 -0
  92. package/dist/esm/list/listFields.mjs.map +1 -0
  93. package/dist/esm/list/listFields.native.js +49 -0
  94. package/dist/esm/list/listFields.native.js.map +1 -0
  95. package/dist/esm/list/listFormat.mjs +38 -0
  96. package/dist/esm/list/listFormat.mjs.map +1 -0
  97. package/dist/esm/list/listFormat.native.js +41 -0
  98. package/dist/esm/list/listFormat.native.js.map +1 -0
  99. package/dist/esm/list/statusColors.mjs +77 -0
  100. package/dist/esm/list/statusColors.mjs.map +1 -0
  101. package/dist/esm/list/statusColors.native.js +81 -0
  102. package/dist/esm/list/statusColors.native.js.map +1 -0
  103. package/dist/esm/useFrappeAggregate.mjs +1 -1
  104. package/dist/esm/useFrappeAggregate.mjs.map +1 -1
  105. package/dist/esm/useFrappeAggregate.native.js +1 -1
  106. package/dist/esm/useFrappeAggregate.native.js.map +1 -1
  107. package/dist/jsx/FrappeForm.mjs +97 -53
  108. package/dist/jsx/FrappeForm.mjs.map +1 -1
  109. package/dist/jsx/FrappeForm.native.js +103 -52
  110. package/dist/jsx/FrappeForm.native.js.map +1 -1
  111. package/dist/jsx/FrappeFormActions.mjs +136 -0
  112. package/dist/jsx/FrappeFormActions.mjs.map +1 -0
  113. package/dist/jsx/FrappeFormActions.native.js +167 -0
  114. package/dist/jsx/FrappeFormActions.native.js.map +1 -0
  115. package/dist/jsx/FrappeTable.mjs +42 -13
  116. package/dist/jsx/FrappeTable.mjs.map +1 -1
  117. package/dist/jsx/FrappeTable.native.js +55 -19
  118. package/dist/jsx/FrappeTable.native.js.map +1 -1
  119. package/dist/jsx/desk/DeskFormPage.mjs +2 -1
  120. package/dist/jsx/desk/DeskFormPage.mjs.map +1 -1
  121. package/dist/jsx/desk/DeskFormPage.native.js +3 -0
  122. package/dist/jsx/desk/DeskFormPage.native.js.map +1 -1
  123. package/dist/jsx/filterFields.mjs +263 -34
  124. package/dist/jsx/filterFields.mjs.map +1 -1
  125. package/dist/jsx/filterFields.native.js +311 -36
  126. package/dist/jsx/filterFields.native.js.map +1 -1
  127. package/dist/jsx/formEngine/docLifecycle.mjs +68 -0
  128. package/dist/jsx/formEngine/docLifecycle.mjs.map +1 -0
  129. package/dist/jsx/formEngine/docLifecycle.native.js +131 -0
  130. package/dist/jsx/formEngine/docLifecycle.native.js.map +1 -0
  131. package/dist/jsx/formEngine/useAmendedAs.mjs +23 -0
  132. package/dist/jsx/formEngine/useAmendedAs.mjs.map +1 -0
  133. package/dist/jsx/formEngine/useAmendedAs.native.js +60 -0
  134. package/dist/jsx/formEngine/useAmendedAs.native.js.map +1 -0
  135. package/dist/jsx/formEngine/useDocRights.mjs +2 -2
  136. package/dist/jsx/formEngine/useDocRights.mjs.map +1 -1
  137. package/dist/jsx/formEngine/useDocRights.native.js +2 -2
  138. package/dist/jsx/formEngine/useDocRights.native.js.map +1 -1
  139. package/dist/jsx/formEngine/useFormEngine.mjs +1 -1
  140. package/dist/jsx/formEngine/useFormEngine.mjs.map +1 -1
  141. package/dist/jsx/formEngine/useFormEngine.native.js +1 -0
  142. package/dist/jsx/formEngine/useFormEngine.native.js.map +1 -1
  143. package/dist/jsx/index.js +6 -2
  144. package/dist/jsx/index.js.map +1 -1
  145. package/dist/jsx/index.mjs +6 -2
  146. package/dist/jsx/index.mjs.map +1 -1
  147. package/dist/jsx/index.native.js +30 -1
  148. package/dist/jsx/index.native.js.map +1 -1
  149. package/dist/jsx/list/listColumns.mjs +372 -0
  150. package/dist/jsx/list/listColumns.mjs.map +1 -0
  151. package/dist/jsx/list/listColumns.native.js +447 -0
  152. package/dist/jsx/list/listColumns.native.js.map +1 -0
  153. package/dist/jsx/list/listFields.mjs +23 -0
  154. package/dist/jsx/list/listFields.mjs.map +1 -0
  155. package/dist/jsx/list/listFields.native.js +79 -0
  156. package/dist/jsx/list/listFields.native.js.map +1 -0
  157. package/dist/jsx/list/listFormat.mjs +38 -0
  158. package/dist/jsx/list/listFormat.mjs.map +1 -0
  159. package/dist/jsx/list/listFormat.native.js +74 -0
  160. package/dist/jsx/list/listFormat.native.js.map +1 -0
  161. package/dist/jsx/list/statusColors.mjs +77 -0
  162. package/dist/jsx/list/statusColors.mjs.map +1 -0
  163. package/dist/jsx/list/statusColors.native.js +109 -0
  164. package/dist/jsx/list/statusColors.native.js.map +1 -0
  165. package/dist/jsx/useFrappeAggregate.mjs +1 -1
  166. package/dist/jsx/useFrappeAggregate.mjs.map +1 -1
  167. package/dist/jsx/useFrappeAggregate.native.js +1 -1
  168. package/dist/jsx/useFrappeAggregate.native.js.map +1 -1
  169. package/package.json +22 -16
  170. package/src/FrappeForm.deskSemantics.spec.tsx +1 -1
  171. package/src/FrappeForm.lifecycle.spec.tsx +463 -0
  172. package/src/FrappeForm.lifecycle.stories.tsx +84 -0
  173. package/src/FrappeForm.tsx +124 -84
  174. package/src/FrappeFormActions.tsx +216 -0
  175. package/src/FrappeTable.defaultColumns.spec.tsx +229 -0
  176. package/src/FrappeTable.tsx +79 -32
  177. package/src/desk/DeskFormPage.tsx +1 -0
  178. package/src/filterFields.spec.ts +57 -2
  179. package/src/filterFields.ts +365 -38
  180. package/src/formEngine/docLifecycle.spec.ts +195 -0
  181. package/src/formEngine/docLifecycle.ts +148 -0
  182. package/src/formEngine/useAmendedAs.ts +38 -0
  183. package/src/formEngine/useDocRights.ts +11 -3
  184. package/src/formEngine/useFormEngine.ts +1 -1
  185. package/src/frappeViews.designLaw.spec.ts +6 -0
  186. package/src/index.ts +47 -1
  187. package/src/list/listColumns.tsx +488 -0
  188. package/src/list/listFields.spec.ts +147 -0
  189. package/src/list/listFields.ts +68 -0
  190. package/src/list/listFormat.spec.ts +151 -0
  191. package/src/list/listFormat.ts +80 -0
  192. package/src/list/seam.spec.ts +116 -0
  193. package/src/list/statusColors.spec.ts +100 -0
  194. package/src/list/statusColors.ts +94 -0
  195. package/src/useFrappeAggregate.ts +1 -1
  196. package/types/FrappeForm.d.ts +13 -3
  197. package/types/FrappeForm.d.ts.map +1 -1
  198. package/types/FrappeFormActions.d.ts +36 -0
  199. package/types/FrappeFormActions.d.ts.map +1 -0
  200. package/types/FrappeTable.d.ts +11 -5
  201. package/types/FrappeTable.d.ts.map +1 -1
  202. package/types/desk/DeskFormPage.d.ts.map +1 -1
  203. package/types/filterFields.d.ts +81 -10
  204. package/types/filterFields.d.ts.map +1 -1
  205. package/types/formEngine/docLifecycle.d.ts +59 -0
  206. package/types/formEngine/docLifecycle.d.ts.map +1 -0
  207. package/types/formEngine/useAmendedAs.d.ts +9 -0
  208. package/types/formEngine/useAmendedAs.d.ts.map +1 -0
  209. package/types/formEngine/useDocRights.d.ts +1 -1
  210. package/types/formEngine/useDocRights.d.ts.map +1 -1
  211. package/types/formEngine/useFormEngine.d.ts +1 -0
  212. package/types/formEngine/useFormEngine.d.ts.map +1 -1
  213. package/types/index.d.ts +5 -1
  214. package/types/index.d.ts.map +1 -1
  215. package/types/list/listColumns.d.ts +41 -0
  216. package/types/list/listColumns.d.ts.map +1 -0
  217. package/types/list/listFields.d.ts +16 -0
  218. package/types/list/listFields.d.ts.map +1 -0
  219. package/types/list/listFormat.d.ts +30 -0
  220. package/types/list/listFormat.d.ts.map +1 -0
  221. package/types/list/statusColors.d.ts +14 -0
  222. package/types/list/statusColors.d.ts.map +1 -0
@@ -0,0 +1,488 @@
1
+ // Meta-driven column builder for the doctype list view (desk ListView
2
+ // parity): subject-led document rows, not a report grid.
3
+ //
4
+ // Desk row anatomy (list_view.js):
5
+ // checkbox | subject <a> | indicator pills (filterable) | remaining
6
+ // in_list_view values (filterable) | activity cluster
7
+ // (assignee avatars · relative time · comment count · like)
8
+ // Title is a real permalink (middle-click / ⌘-click). Pills and values
9
+ // click-to-filter (data-filter) instead of navigating. Like is a sibling
10
+ // of the subject link (LC-74). Extra ID / Last Updated columns stay out —
11
+ // the timestamp lives in the activity cluster.
12
+ import type { ReactNode } from "react";
13
+ import { Avatar, Anchor, Chip, Text, XStack, type ChipColor } from "@multiplatform.one/components";
14
+ // House control for the like toggle — the icon rides the Button icon slot
15
+ // (LC-53 GLYPH-SLOT) and the sub-44 circular size resolves the press floor
16
+ // through the house channel (LC-54: 44px box + hitSlop).
17
+ import { Button } from "@multiplatform.one/forms";
18
+ import { ChatCircleIcon, HeartIcon } from "@phosphor-icons/react";
19
+ import type { DataTableColumn } from "@multiplatform.one/table";
20
+ import { formatAbsoluteDateTime } from "@multiplatform.one/theme";
21
+ import type { PermLevelMatrix } from "../formEngine/fieldPermissions";
22
+ import type { DocFieldMeta, DoctypeMeta } from "../formEngine/doctypeMeta";
23
+ // Field selection + permlevel pruning live in the pure listFields module
24
+ // (node-safe, unit-tested in listFields.spec.ts).
25
+ import { listViewFields } from "./listFields";
26
+ import { formatDateCell, formatTimeAgo, parseUserListField, stripHtmlPreview } from "./listFormat";
27
+ import { statusPillColors } from "./statusColors";
28
+
29
+ /** Minimal row shape for a doctype list (loose — server owns the schema). */
30
+ export interface ListDoc {
31
+ name: string;
32
+ doctype: string;
33
+ [key: string]: unknown;
34
+ }
35
+
36
+ export type ListRow = Partial<ListDoc>;
37
+
38
+ type Translate = (key: string, fallback: string) => string;
39
+
40
+ export interface BuildListColumnsArgs {
41
+ meta?: DoctypeMeta;
42
+ t: Translate;
43
+ /** Session user — hearts fill when `_liked_by` contains them. */
44
+ sessionUser?: string;
45
+ /** Toggle like for a document (omit to render hearts read-only). */
46
+ onToggleLike?: (name: string, nextLiked: boolean) => void;
47
+ /** Optimistic like overrides keyed by document name. */
48
+ likeOverrides?: Record<string, boolean>;
49
+ /**
50
+ * Permlevel role matrix (session roles × meta.permissions, built with
51
+ * `buildPermLevelMatrix`). Columns for fields the roles cannot READ at
52
+ * their permlevel are pruned — desk parity with base_list.js
53
+ * `get_fields_in_list_view` (`frappe.perm.has_perm(doctype, df.permlevel,
54
+ * "read")`). Undefined (fixtures / roles unavailable) prunes nothing —
55
+ * the server strips no-read values either way.
56
+ */
57
+ permMatrix?: PermLevelMatrix;
58
+ /** Document form href — subject renders as a real `<a>` when set (C1). */
59
+ getDocHref?: (name: string) => string;
60
+ /**
61
+ * Desk cell click-to-filter (C2). Status/priority pills, dates, and
62
+ * remaining values add (or toggle off) a tuple instead of opening the doc.
63
+ */
64
+ onFilterClick?: (fieldname: string, value: string) => void;
65
+ }
66
+
67
+ const NUMERIC_FIELDTYPES = new Set(["Currency", "Float", "Int", "Percent", "Duration"]);
68
+
69
+ // Fieldtypes whose stored value is markup — list cells render a stripped
70
+ // one-line plain-text preview (desk strip_html parity), never raw HTML.
71
+ const RICH_TEXT_FIELDTYPES = new Set([
72
+ "Text Editor",
73
+ "HTML Editor",
74
+ "HTML",
75
+ "Markdown Editor",
76
+ "Long Text",
77
+ "Text",
78
+ "Small Text",
79
+ ]);
80
+
81
+ /** Cell-safe one-line preview: rich fieldtypes stripped, newlines collapsed. */
82
+ function cellPreview(value: string, fieldtype?: string): string {
83
+ const text = fieldtype && RICH_TEXT_FIELDTYPES.has(fieldtype) ? stripHtmlPreview(value) : value;
84
+ return text.replace(/\s+/g, " ").trim();
85
+ }
86
+
87
+ function isStatusField(field: DocFieldMeta, meta?: DoctypeMeta): boolean {
88
+ if (field.fieldname === "status") return true;
89
+ if (field.fieldtype !== "Select") return false;
90
+ const stateTitles = new Set(
91
+ (meta?.states ?? []).map((state) => (state.title ?? "").trim().toLowerCase()),
92
+ );
93
+ if (stateTitles.size === 0) return false;
94
+ const options = (field.options ?? "").split("\n").map((option) => option.trim().toLowerCase());
95
+ return options.some((option) => option && stateTitles.has(option));
96
+ }
97
+
98
+ /** Desk indicator cells: every Select in the list, plus User.enabled. */
99
+ function isIndicatorField(field: DocFieldMeta, meta?: DoctypeMeta): boolean {
100
+ if (field.fieldtype === "Select") return true;
101
+ if (field.fieldtype === "Check" && field.fieldname === "enabled") return true;
102
+ return isStatusField(field, meta);
103
+ }
104
+
105
+ function stopRowPress(event?: { stopPropagation?: () => void }) {
106
+ event?.stopPropagation?.();
107
+ }
108
+
109
+ function chipColorFromStatus(value: string, meta?: DoctypeMeta): ChipColor {
110
+ const ramp = statusPillColors(value, meta).background.replace(/^\$/, "").replace(/\d+$/, "");
111
+ if (ramp === "pink") return "purple";
112
+ if (
113
+ ramp === "red" ||
114
+ ramp === "green" ||
115
+ ramp === "blue" ||
116
+ ramp === "yellow" ||
117
+ ramp === "orange" ||
118
+ ramp === "purple" ||
119
+ ramp === "gray"
120
+ ) {
121
+ return ramp;
122
+ }
123
+ return "gray";
124
+ }
125
+
126
+ function IndicatorPill({
127
+ value,
128
+ meta,
129
+ fieldname,
130
+ filterValue,
131
+ onFilterClick,
132
+ }: {
133
+ value: string;
134
+ meta?: DoctypeMeta;
135
+ fieldname: string;
136
+ /** Wire value when it differs from the label (Check enabled → 1/0). */
137
+ filterValue?: string;
138
+ onFilterClick?: (fieldname: string, value: string) => void;
139
+ }) {
140
+ const chipColor = chipColorFromStatus(value, meta);
141
+ const applied = filterValue ?? value;
142
+ return (
143
+ <XStack
144
+ alignSelf="flex-start"
145
+ {...(onFilterClick
146
+ ? {
147
+ "data-filter": `${fieldname},=,${applied}`,
148
+ onPress: (event?: { stopPropagation?: () => void }) => stopRowPress(event),
149
+ }
150
+ : {})}
151
+ >
152
+ <Chip
153
+ size="$2"
154
+ variant="subtle"
155
+ color={chipColor}
156
+ onPress={onFilterClick ? () => onFilterClick(fieldname, applied) : undefined}
157
+ >
158
+ {value}
159
+ </Chip>
160
+ </XStack>
161
+ );
162
+ }
163
+
164
+ function FilterableText({
165
+ fieldname,
166
+ value,
167
+ children,
168
+ onFilterClick,
169
+ }: {
170
+ fieldname: string;
171
+ value: string;
172
+ children: ReactNode;
173
+ onFilterClick?: (fieldname: string, value: string) => void;
174
+ }) {
175
+ if (!onFilterClick) return <>{children}</>;
176
+ return (
177
+ <XStack
178
+ role="button"
179
+ cursor="pointer"
180
+ minWidth={0}
181
+ flexShrink={1}
182
+ onPress={(event) => {
183
+ stopRowPress(event);
184
+ onFilterClick(fieldname, value);
185
+ }}
186
+ hoverStyle={{ opacity: 0.8 }}
187
+ {...{ "data-filter": `${fieldname},=,${value}` }}
188
+ >
189
+ {children}
190
+ </XStack>
191
+ );
192
+ }
193
+
194
+ function commentCount(doc: ListRow): number {
195
+ const raw = doc._comment_count;
196
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
197
+ if (typeof raw === "string" && raw.trim()) {
198
+ const parsed = Number(raw);
199
+ if (Number.isFinite(parsed)) return parsed;
200
+ }
201
+ const comments = doc._comments;
202
+ if (typeof comments === "string" && comments.trim()) {
203
+ try {
204
+ const parsed = JSON.parse(comments) as unknown;
205
+ if (Array.isArray(parsed)) return parsed.length;
206
+ } catch {
207
+ return 0;
208
+ }
209
+ }
210
+ return 0;
211
+ }
212
+
213
+ function LikeControl({
214
+ doc,
215
+ t,
216
+ sessionUser,
217
+ onToggleLike,
218
+ likeOverrides,
219
+ }: {
220
+ doc: ListRow;
221
+ t: Translate;
222
+ sessionUser?: string;
223
+ onToggleLike?: (name: string, nextLiked: boolean) => void;
224
+ likeOverrides?: Record<string, boolean>;
225
+ }) {
226
+ const name = doc.name ?? "";
227
+ const likedBy = parseUserListField(doc._liked_by);
228
+ const liked = likeOverrides?.[name] ?? (sessionUser ? likedBy.includes(sessionUser) : false);
229
+ const likeLabel = liked ? t("list.unlike", "Remove from liked") : t("list.like", "Add to liked");
230
+ if (!onToggleLike || !name) {
231
+ return (
232
+ <Text fontSize="$4" color={liked ? "$red10" : "$color8"} role="img" aria-label={likeLabel}>
233
+ {liked ? "♥" : "♡"}
234
+ </Text>
235
+ );
236
+ }
237
+ return (
238
+ <Button
239
+ circular
240
+ chromeless
241
+ size="$2"
242
+ col={(liked ? "$red10" : "$color8") as never}
243
+ icon={<HeartIcon size={16} weight={liked ? "fill" : "regular"} />}
244
+ aria-label={likeLabel}
245
+ aria-pressed={liked}
246
+ onPress={(event) => {
247
+ stopRowPress(event);
248
+ onToggleLike(name, !liked);
249
+ }}
250
+ />
251
+ );
252
+ }
253
+
254
+ function ActivityCluster({
255
+ doc,
256
+ t,
257
+ sessionUser,
258
+ onToggleLike,
259
+ likeOverrides,
260
+ }: {
261
+ doc: ListRow;
262
+ t: Translate;
263
+ sessionUser?: string;
264
+ onToggleLike?: (name: string, nextLiked: boolean) => void;
265
+ likeOverrides?: Record<string, boolean>;
266
+ }) {
267
+ const assignees = parseUserListField(doc._assign);
268
+ const comments = commentCount(doc);
269
+ const modified = doc.modified ? String(doc.modified) : "";
270
+ const absolute = modified ? formatAbsoluteDateTime(modified) : "";
271
+ return (
272
+ <XStack
273
+ alignItems="center"
274
+ justifyContent="flex-end"
275
+ gap="$2"
276
+ minWidth={0}
277
+ testID="list-activity"
278
+ >
279
+ {assignees.slice(0, 3).map((user) => (
280
+ <Avatar key={user} name={user} size={20} circular aria-label={user} />
281
+ ))}
282
+ {modified ? (
283
+ <Text
284
+ fontSize="$2"
285
+ color="$color9"
286
+ {...({ title: absolute || undefined } as Record<string, unknown>)}
287
+ >
288
+ {formatTimeAgo(modified)}
289
+ </Text>
290
+ ) : null}
291
+ <XStack
292
+ alignItems="center"
293
+ gap="$1"
294
+ {...({ "aria-label": t("list.comments", "Comments") } as Record<string, unknown>)}
295
+ >
296
+ <ChatCircleIcon size={14} />
297
+ <Text fontSize="$2" color="$color9">
298
+ {comments}
299
+ </Text>
300
+ </XStack>
301
+ <LikeControl
302
+ doc={doc}
303
+ t={t}
304
+ sessionUser={sessionUser}
305
+ onToggleLike={onToggleLike}
306
+ likeOverrides={likeOverrides}
307
+ />
308
+ </XStack>
309
+ );
310
+ }
311
+
312
+ function fieldCell(
313
+ field: DocFieldMeta,
314
+ meta: DoctypeMeta | undefined,
315
+ onFilterClick?: (fieldname: string, value: string) => void,
316
+ ) {
317
+ return ({ getValue }: { getValue: () => unknown }) => {
318
+ const raw = getValue();
319
+ if (raw === null || raw === undefined || raw === "")
320
+ return (
321
+ <Text fontSize="$3" color="$color8">
322
+ —
323
+ </Text>
324
+ );
325
+ if (field.fieldtype === "Check" && field.fieldname === "enabled") {
326
+ const on = raw === true || raw === 1 || raw === "1";
327
+ return (
328
+ <IndicatorPill
329
+ value={on ? "Active" : "Disabled"}
330
+ meta={meta}
331
+ fieldname={field.fieldname}
332
+ filterValue={on ? "1" : "0"}
333
+ onFilterClick={onFilterClick}
334
+ />
335
+ );
336
+ }
337
+ const value = String(raw);
338
+ if (isIndicatorField(field, meta)) {
339
+ return (
340
+ <IndicatorPill
341
+ value={value}
342
+ meta={meta}
343
+ fieldname={field.fieldname}
344
+ onFilterClick={onFilterClick}
345
+ />
346
+ );
347
+ }
348
+ switch (field.fieldtype) {
349
+ case "Check":
350
+ return (
351
+ <Text fontSize="$3" color={raw ? "$green11" : "$color8"}>
352
+ {raw ? "✓" : "—"}
353
+ </Text>
354
+ );
355
+ case "Date":
356
+ case "Datetime": {
357
+ const display = formatDateCell(value);
358
+ return (
359
+ <FilterableText fieldname={field.fieldname} value={value} onFilterClick={onFilterClick}>
360
+ <Text fontSize="$3" color="$color11">
361
+ {display}
362
+ </Text>
363
+ </FilterableText>
364
+ );
365
+ }
366
+ case "Currency":
367
+ case "Float":
368
+ case "Int":
369
+ case "Percent":
370
+ return (
371
+ <Text fontSize="$3" color="$color12" textAlign="right">
372
+ {value}
373
+ </Text>
374
+ );
375
+ case "Link":
376
+ return (
377
+ <FilterableText fieldname={field.fieldname} value={value} onFilterClick={onFilterClick}>
378
+ <Text fontSize="$3" color="$color11" numberOfLines={1} flexShrink={1} minWidth={0}>
379
+ {value}
380
+ </Text>
381
+ </FilterableText>
382
+ );
383
+ default:
384
+ return (
385
+ <Text fontSize="$3" color="$color11" numberOfLines={1} flexShrink={1} minWidth={0}>
386
+ {cellPreview(value, field.fieldtype)}
387
+ </Text>
388
+ );
389
+ }
390
+ };
391
+ }
392
+
393
+ /** Build the DataTable columns for a doctype list from its meta. */
394
+ export function buildListColumns({
395
+ meta,
396
+ t,
397
+ sessionUser,
398
+ onToggleLike,
399
+ likeOverrides,
400
+ permMatrix,
401
+ getDocHref,
402
+ onFilterClick,
403
+ }: BuildListColumnsArgs): DataTableColumn<ListRow>[] {
404
+ const titleField = meta?.title_field || "name";
405
+ const columns: DataTableColumn<ListRow>[] = [];
406
+
407
+ const titleMeta = meta?.fields?.find((field) => field.fieldname === titleField);
408
+ columns.push({
409
+ id: titleField,
410
+ header:
411
+ titleField === "name"
412
+ ? t("list.idColumn", "ID")
413
+ : titleMeta?.label || t("list.titleColumn", "Title"),
414
+ accessorKey: titleField,
415
+ minWidth: 180,
416
+ meta: { role: "title" },
417
+ cell: ({ row, getValue }) => {
418
+ const raw = getValue();
419
+ const name = row.original.name ?? "";
420
+ const value = raw === null || raw === undefined || raw === "" ? name : String(raw);
421
+ const href = name && getDocHref ? getDocHref(name) : undefined;
422
+ const label = cellPreview(value, titleMeta?.fieldtype);
423
+ const subject = (
424
+ <Text
425
+ fontSize="$3"
426
+ fontWeight="400"
427
+ color="$color12"
428
+ numberOfLines={1}
429
+ flexShrink={1}
430
+ minWidth={0}
431
+ >
432
+ {label}
433
+ </Text>
434
+ );
435
+ if (!href) return subject;
436
+ return (
437
+ <Anchor
438
+ href={href}
439
+ textDecorationLine="none"
440
+ minWidth={0}
441
+ flexShrink={1}
442
+ onPress={stopRowPress}
443
+ >
444
+ {subject}
445
+ </Anchor>
446
+ );
447
+ },
448
+ });
449
+
450
+ for (const field of listViewFields(meta, titleField, permMatrix)) {
451
+ columns.push({
452
+ id: field.fieldname,
453
+ header: field.label || field.fieldname,
454
+ accessorKey: field.fieldname,
455
+ ...(NUMERIC_FIELDTYPES.has(field.fieldtype) ? { align: "right" as const } : {}),
456
+ cell: fieldCell(field, meta, onFilterClick),
457
+ });
458
+ }
459
+
460
+ columns.push({
461
+ id: "_activity",
462
+ header: t("list.activity", "Activity"),
463
+ meta: { role: "actions", label: t("list.activity", "Activity") },
464
+ sortable: false,
465
+ filterable: false,
466
+ cell: ({ row }) => (
467
+ <ActivityCluster
468
+ doc={row.original}
469
+ t={t}
470
+ sessionUser={sessionUser}
471
+ onToggleLike={onToggleLike}
472
+ likeOverrides={likeOverrides}
473
+ />
474
+ ),
475
+ });
476
+
477
+ // Hidden sort key — desk default is Last Updated On. The timestamp paints
478
+ // in the activity cluster; the column exists so TanStack can sort on it
479
+ // without a second visible "Last Updated" slot.
480
+ columns.push({
481
+ id: "modified",
482
+ accessorKey: "modified",
483
+ header: t("list.lastUpdated", "Last Updated On"),
484
+ enableHiding: true,
485
+ });
486
+
487
+ return columns;
488
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * listFields — permlevel column pruning (desk parity with base_list.js
3
+ * `get_fields_in_list_view`: in_list_view fields above the session roles'
4
+ * READ permlevel never render as columns). The matrix itself is the form
5
+ * workstream's fieldPermissions module (perm.js parity) — reused, not forked.
6
+ * buildListColumns maps listViewFields 1:1 into column defs, so pruning is
7
+ * asserted here on the pure module.
8
+ */
9
+ import { describe, expect, it } from "vitest";
10
+ import { buildPermLevelMatrix, type PermLevelMatrix } from "../formEngine/fieldPermissions";
11
+ import type { DoctypeMeta } from "../formEngine/doctypeMeta";
12
+ import { listDocumentColumnIds, listFieldsToFetch, listViewFields } from "./listFields";
13
+
14
+ const meta: DoctypeMeta = {
15
+ name: "User",
16
+ title_field: "full_name",
17
+ fields: [
18
+ { fieldname: "full_name", fieldtype: "Data", label: "Full Name", in_list_view: 1 },
19
+ { fieldname: "enabled", fieldtype: "Check", label: "Enabled", in_list_view: 1 },
20
+ {
21
+ fieldname: "role_profile_name",
22
+ fieldtype: "Link",
23
+ label: "Role Profile",
24
+ in_list_view: 1,
25
+ permlevel: 1,
26
+ },
27
+ {
28
+ fieldname: "api_secret_hint",
29
+ fieldtype: "Data",
30
+ label: "API Secret Hint",
31
+ in_list_view: 1,
32
+ permlevel: 2,
33
+ },
34
+ { fieldname: "bio", fieldtype: "Small Text", label: "Bio" },
35
+ ],
36
+ permissions: [
37
+ { role: "System Manager", permlevel: 0, read: 1, write: 1 },
38
+ { role: "System Manager", permlevel: 1, read: 1, write: 1 },
39
+ // No rows at permlevel 2 — desk gap-fills {} and hides those fields.
40
+ ],
41
+ };
42
+
43
+ function viewFieldnames(matrix?: PermLevelMatrix): string[] {
44
+ return listViewFields(meta, meta.title_field ?? "name", matrix).map((field) => field.fieldname);
45
+ }
46
+
47
+ describe("listViewFields permlevel pruning", () => {
48
+ it("without a matrix (fixtures / roles unavailable) prunes nothing", () => {
49
+ const names = viewFieldnames(undefined);
50
+ expect(names).toContain("enabled");
51
+ expect(names).toContain("role_profile_name");
52
+ expect(names).toContain("api_secret_hint");
53
+ });
54
+
55
+ it("keeps fields whose permlevel the roles can read, prunes the rest", () => {
56
+ const matrix = buildPermLevelMatrix(meta.permissions, ["System Manager"]);
57
+ const names = viewFieldnames(matrix);
58
+ expect(names).toContain("enabled"); // level 0
59
+ expect(names).toContain("role_profile_name"); // level 1 granted
60
+ // Level 2 has no granting rows at all → hidden (desk gap-fill).
61
+ expect(names).not.toContain("api_secret_hint");
62
+ });
63
+
64
+ it("prunes every above-zero level for roles with no granting rows", () => {
65
+ const matrix = buildPermLevelMatrix(meta.permissions, ["Desk User"]);
66
+ const names = viewFieldnames(matrix);
67
+ expect(names).toContain("enabled");
68
+ expect(names).not.toContain("role_profile_name");
69
+ expect(names).not.toContain("api_secret_hint");
70
+ });
71
+
72
+ it("excludes the title field regardless of the matrix (title column owns it)", () => {
73
+ expect(viewFieldnames(undefined)).not.toContain("full_name");
74
+ const matrix = buildPermLevelMatrix(meta.permissions, ["Desk User"]);
75
+ expect(viewFieldnames(matrix)).not.toContain("full_name");
76
+ });
77
+
78
+ it("never includes fields that are not in_list_view", () => {
79
+ expect(viewFieldnames(undefined)).not.toContain("bio");
80
+ });
81
+ });
82
+
83
+ describe("listFieldsToFetch permlevel pruning", () => {
84
+ it("skips unreadable in_list_view fields but keeps bookkeeping fields", () => {
85
+ const matrix = buildPermLevelMatrix(meta.permissions, ["Desk User"]);
86
+ const fields = listFieldsToFetch(meta, matrix);
87
+ expect(fields).toEqual(
88
+ expect.arrayContaining(["name", "modified", "full_name", "enabled", "_comments"]),
89
+ );
90
+ expect(fields).not.toContain("role_profile_name");
91
+ expect(fields).not.toContain("api_secret_hint");
92
+ });
93
+
94
+ it("fetches everything in_list_view when no matrix is available", () => {
95
+ const fields = listFieldsToFetch(meta, undefined);
96
+ expect(fields).toContain("role_profile_name");
97
+ expect(fields).toContain("api_secret_hint");
98
+ });
99
+ });
100
+
101
+ // frappe/model/__init__.py default_fields and optional_fields: the only names
102
+ // frappe.desk.reportview.validate_fields lets through without a DocField.
103
+ // Anything else that isn't a field of the doctype answers 417 "Field not
104
+ // permitted in query" for every user without the query bypass (MPO-389).
105
+ const frappeStandardFields = new Set([
106
+ "doctype",
107
+ "name",
108
+ "owner",
109
+ "creation",
110
+ "modified",
111
+ "modified_by",
112
+ "docstatus",
113
+ "idx",
114
+ "_user_tags",
115
+ "_comments",
116
+ "_assign",
117
+ "_liked_by",
118
+ "_seen",
119
+ ]);
120
+
121
+ describe("listFieldsToFetch asks only for columns Frappe will select", () => {
122
+ it("names nothing but the doctype's fields and Frappe's standard columns", () => {
123
+ const fieldnames = new Set(meta.fields.map((field) => field.fieldname));
124
+ const rejected = listFieldsToFetch(meta, undefined).filter(
125
+ (name) => !fieldnames.has(name) && !frappeStandardFields.has(name),
126
+ );
127
+ expect(rejected).toEqual([]);
128
+ });
129
+
130
+ it("reads the comment count from _comments instead of the computed _comment_count", () => {
131
+ const fields = listFieldsToFetch(meta, undefined);
132
+ expect(fields).toContain("_comments");
133
+ expect(fields).not.toContain("_comment_count");
134
+ });
135
+ });
136
+
137
+ describe("listDocumentColumnIds desk list anatomy", () => {
138
+ it("is title, in_list_view values, then the activity cluster — no extra ID/modified", () => {
139
+ expect(listDocumentColumnIds(meta)).toEqual([
140
+ "full_name",
141
+ "enabled",
142
+ "role_profile_name",
143
+ "api_secret_hint",
144
+ "_activity",
145
+ ]);
146
+ });
147
+ });
@@ -0,0 +1,68 @@
1
+ // Pure (node-safe, no UI imports) field selection for the doctype list:
2
+ // which fields to fetch and which in_list_view fields render as columns.
3
+ // Kept out of listColumns.tsx so the pruning logic is unit-testable without
4
+ // the components barrel.
5
+ //
6
+ // Permlevel pruning reuses the form workstream's UI-free role-matrix module
7
+ // (desk perm.js parity) rather than forking it (schema conformance §10
8
+ // handoff).
9
+ import { canReadLevel, type PermLevelMatrix } from "../formEngine/fieldPermissions";
10
+ import type { DocFieldMeta, DoctypeMeta } from "../formEngine/doctypeMeta";
11
+
12
+ /** Read access for a field's permlevel (level 0 / no matrix always read). */
13
+ function fieldLevelReadable(field: DocFieldMeta, permMatrix?: PermLevelMatrix): boolean {
14
+ return canReadLevel(permMatrix, Number(field.permlevel ?? 0) || 0);
15
+ }
16
+
17
+ /** Fields the list view fetches for a doctype (columns + niceties). */
18
+ export function listFieldsToFetch(meta?: DoctypeMeta, permMatrix?: PermLevelMatrix): string[] {
19
+ const fields = new Set<string>([
20
+ "name",
21
+ "modified",
22
+ "owner",
23
+ "_liked_by",
24
+ "_assign",
25
+ "_user_tags",
26
+ "_comments",
27
+ ]);
28
+ if (meta?.title_field) fields.add(meta.title_field);
29
+ for (const field of meta?.fields ?? []) {
30
+ if (field.in_list_view && field.fieldname && fieldLevelReadable(field, permMatrix)) {
31
+ fields.add(field.fieldname);
32
+ }
33
+ }
34
+ return [...fields];
35
+ }
36
+
37
+ /**
38
+ * The in_list_view fields to render as columns (title field excluded).
39
+ * Desk parity: fields above the session's read permlevel never render as
40
+ * list columns (base_list.js `get_fields_in_list_view` →
41
+ * `frappe.perm.has_perm(doctype, df.permlevel, "read")`). An undefined
42
+ * matrix (fixtures / roles unavailable) prunes nothing — the server strips
43
+ * no-read values either way.
44
+ */
45
+ export function listViewFields(
46
+ meta: DoctypeMeta | undefined,
47
+ titleField: string,
48
+ permMatrix?: PermLevelMatrix,
49
+ ): DocFieldMeta[] {
50
+ return (meta?.fields ?? []).filter(
51
+ (field) =>
52
+ Boolean(field.in_list_view) &&
53
+ field.fieldname !== titleField &&
54
+ field.fieldtype !== "Image" &&
55
+ field.fieldtype !== "Attach Image" &&
56
+ fieldLevelReadable(field, permMatrix),
57
+ );
58
+ }
59
+
60
+ /** Column ids the list view paints, in order: title, in_list_view, activity. */
61
+ export function listDocumentColumnIds(meta?: DoctypeMeta, permMatrix?: PermLevelMatrix): string[] {
62
+ const titleField = meta?.title_field || "name";
63
+ return [
64
+ titleField,
65
+ ...listViewFields(meta, titleField, permMatrix).map((field) => field.fieldname),
66
+ "_activity",
67
+ ];
68
+ }