@multiplatform.one/frappe-ui 7.35.0 → 7.36.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 (52) hide show
  1. package/dist/cjs/FrappeReportBuilder.cjs +9 -3
  2. package/dist/cjs/FrappeReportBuilder.native.js +12 -4
  3. package/dist/cjs/FrappeReportBuilder.native.js.map +1 -1
  4. package/dist/cjs/report/ReportGrid.cjs +123 -23
  5. package/dist/cjs/report/ReportGrid.native.js +126 -16
  6. package/dist/cjs/report/ReportGrid.native.js.map +1 -1
  7. package/dist/cjs/report/ReportViewToolbar.cjs +1 -1
  8. package/dist/cjs/report/ReportViewToolbar.native.js +1 -1
  9. package/dist/cjs/report/ReportViewToolbar.native.js.map +1 -1
  10. package/dist/cjs/report/reportViewModel.cjs +20 -0
  11. package/dist/cjs/report/reportViewModel.native.js +89 -0
  12. package/dist/cjs/report/reportViewModel.native.js.map +1 -1
  13. package/dist/esm/FrappeReportBuilder.mjs +11 -5
  14. package/dist/esm/FrappeReportBuilder.mjs.map +1 -1
  15. package/dist/esm/FrappeReportBuilder.native.js +14 -6
  16. package/dist/esm/FrappeReportBuilder.native.js.map +1 -1
  17. package/dist/esm/report/ReportGrid.mjs +125 -26
  18. package/dist/esm/report/ReportGrid.mjs.map +1 -1
  19. package/dist/esm/report/ReportGrid.native.js +128 -19
  20. package/dist/esm/report/ReportGrid.native.js.map +1 -1
  21. package/dist/esm/report/ReportViewToolbar.mjs +1 -1
  22. package/dist/esm/report/ReportViewToolbar.native.js +1 -1
  23. package/dist/esm/report/ReportViewToolbar.native.js.map +1 -1
  24. package/dist/esm/report/reportViewModel.mjs +19 -1
  25. package/dist/esm/report/reportViewModel.mjs.map +1 -1
  26. package/dist/esm/report/reportViewModel.native.js +88 -1
  27. package/dist/esm/report/reportViewModel.native.js.map +1 -1
  28. package/dist/jsx/FrappeReportBuilder.mjs +11 -5
  29. package/dist/jsx/FrappeReportBuilder.mjs.map +1 -1
  30. package/dist/jsx/FrappeReportBuilder.native.js +12 -4
  31. package/dist/jsx/FrappeReportBuilder.native.js.map +1 -1
  32. package/dist/jsx/report/ReportGrid.mjs +125 -26
  33. package/dist/jsx/report/ReportGrid.mjs.map +1 -1
  34. package/dist/jsx/report/ReportGrid.native.js +126 -16
  35. package/dist/jsx/report/ReportGrid.native.js.map +1 -1
  36. package/dist/jsx/report/ReportViewToolbar.mjs +1 -1
  37. package/dist/jsx/report/ReportViewToolbar.native.js +1 -1
  38. package/dist/jsx/report/reportViewModel.mjs +19 -1
  39. package/dist/jsx/report/reportViewModel.mjs.map +1 -1
  40. package/dist/jsx/report/reportViewModel.native.js +89 -0
  41. package/dist/jsx/report/reportViewModel.native.js.map +1 -1
  42. package/package.json +16 -16
  43. package/src/FrappeReportBuilder.tsx +29 -4
  44. package/src/report/ReportGrid.phone.spec.tsx +140 -0
  45. package/src/report/ReportGrid.tsx +218 -71
  46. package/src/report/ReportViewToolbar.tsx +1 -1
  47. package/src/report/reportViewModel.ts +38 -0
  48. package/types/FrappeReportBuilder.d.ts.map +1 -1
  49. package/types/report/ReportGrid.d.ts +19 -1
  50. package/types/report/ReportGrid.d.ts.map +1 -1
  51. package/types/report/reportViewModel.d.ts +10 -0
  52. package/types/report/reportViewModel.d.ts.map +1 -1
@@ -0,0 +1,140 @@
1
+ // The phone report Clay picked (FZ-7684, option B): four columns that fit
2
+ // the screen, the record's image inside the title cell, units in the number
3
+ // cells, and no row-number column or per-column filter row.
4
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
5
+ import { screen } from "@testing-library/react";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { ReportGrid, reportColumnUnit } from "./ReportGrid";
8
+ import {
9
+ buildGridRows,
10
+ phoneReportFieldnames,
11
+ type ReportColumnDef,
12
+ type ReportRow,
13
+ } from "./reportViewModel";
14
+
15
+ const columns: ReportColumnDef[] = [
16
+ {
17
+ fieldname: "pokemon_name",
18
+ label: "Pokemon Name",
19
+ fieldtype: "Data",
20
+ kind: "text",
21
+ width: 160,
22
+ align: "left",
23
+ },
24
+ {
25
+ fieldname: "poke_id",
26
+ label: "Poke ID",
27
+ fieldtype: "Int",
28
+ kind: "number",
29
+ width: 110,
30
+ align: "right",
31
+ },
32
+ {
33
+ fieldname: "height",
34
+ label: "Height (dm)",
35
+ fieldtype: "Int",
36
+ kind: "number",
37
+ width: 110,
38
+ align: "right",
39
+ },
40
+ ];
41
+
42
+ const rows: ReportRow[] = [
43
+ {
44
+ name: "pikachu",
45
+ pokemon_name: "Pikachu",
46
+ poke_id: 25,
47
+ height: 4,
48
+ sprite_front: "/files/25.png",
49
+ },
50
+ ];
51
+
52
+ function renderGrid() {
53
+ return renderWithProviders(
54
+ <ReportGrid
55
+ columns={columns}
56
+ rows={buildGridRows(rows, 0, 20)}
57
+ sort={{ field: "modified", order: "desc" }}
58
+ onSortChange={vi.fn()}
59
+ columnFilters={{}}
60
+ onColumnFilterChange={vi.fn()}
61
+ totals={[]}
62
+ imageField="sprite_front"
63
+ titleField="pokemon_name"
64
+ fileUrl={(path) => `https://site.test${path}`}
65
+ />,
66
+ );
67
+ }
68
+
69
+ describe("ReportGrid on a phone", () => {
70
+ const width = window.innerWidth;
71
+ beforeEach(() => {
72
+ window.innerWidth = 390;
73
+ });
74
+ afterEach(() => {
75
+ window.innerWidth = width;
76
+ });
77
+
78
+ it("drops the row numbers and the per-column filter row", () => {
79
+ renderGrid();
80
+ expect(screen.queryByLabelText("Row number")).toBeNull();
81
+ expect(screen.queryByLabelText("Filter Pokemon Name")).toBeNull();
82
+ });
83
+
84
+ it("heads a number column without its unit and puts the unit in the cell", () => {
85
+ renderGrid();
86
+ expect(screen.getByText("Height")).toBeTruthy();
87
+ expect(screen.queryByText("Height (dm)")).toBeNull();
88
+ const cells = Array.from(document.querySelectorAll("span, div")).filter(
89
+ (node) => node.textContent === "4 dm" && node.children.length > 0,
90
+ );
91
+ expect(cells.length).toBeGreaterThan(0);
92
+ });
93
+
94
+ it("leads the title cell with the record's image", () => {
95
+ const { container } = renderGrid();
96
+ const image = container.querySelector('img[src="https://site.test/files/25.png"]');
97
+ expect(image).not.toBeNull();
98
+ });
99
+ });
100
+
101
+ describe("reportColumnUnit", () => {
102
+ it("splits a short trailing unit off a number label only", () => {
103
+ expect(reportColumnUnit(columns[2])).toEqual({ label: "Height", unit: "dm" });
104
+ expect(reportColumnUnit({ ...columns[0], label: "Name (EN)" })).toEqual({ label: "Name (EN)" });
105
+ expect(
106
+ reportColumnUnit({ ...columns[2], fieldtype: "Currency", label: "Price (USD)" }),
107
+ ).toEqual({ label: "Price (USD)" });
108
+ });
109
+ });
110
+
111
+ describe("phoneReportFieldnames", () => {
112
+ it("picks the title, then numbers, up to four columns", () => {
113
+ expect(
114
+ phoneReportFieldnames({
115
+ title_field: "pokemon_name",
116
+ image_field: "sprite_front",
117
+ fields: [
118
+ { fieldname: "pokemon_name", fieldtype: "Data", label: "Pokemon Name", in_list_view: 1 },
119
+ { fieldname: "poke_id", fieldtype: "Int", label: "Poke ID", in_list_view: 1 },
120
+ { fieldname: "species", fieldtype: "Data", label: "Species", in_list_view: 1 },
121
+ { fieldname: "height", fieldtype: "Int", label: "Height (dm)" },
122
+ { fieldname: "weight", fieldtype: "Int", label: "Weight (hg)" },
123
+ { fieldname: "base_experience", fieldtype: "Int", label: "Base Experience" },
124
+ { fieldname: "sprite_front", fieldtype: "Attach Image", label: "Sprite (Front)" },
125
+ ],
126
+ }),
127
+ ).toEqual(["pokemon_name", "poke_id", "height", "weight"]);
128
+ });
129
+
130
+ it("falls back to flagged text fields when there are too few numbers", () => {
131
+ expect(
132
+ phoneReportFieldnames({
133
+ fields: [
134
+ { fieldname: "subject", fieldtype: "Data", label: "Subject", in_list_view: 1 },
135
+ { fieldname: "status", fieldtype: "Select", label: "Status", in_list_view: 1 },
136
+ ],
137
+ }),
138
+ ).toEqual(["name", "subject", "status"]);
139
+ });
140
+ });
@@ -8,11 +8,21 @@
8
8
  // scrollport and wraps header content in Text, so it cannot host an input
9
9
  // row under its headers today — capability gap flagged to the table
10
10
  // workstream; this grid is the interim desk-parity surface.
11
- import { ScrollView, Text, Tooltip, View, XStack, isWeb } from "@multiplatform.one/components";
11
+ import {
12
+ Image,
13
+ ScrollView,
14
+ Text,
15
+ Tooltip,
16
+ View,
17
+ XStack,
18
+ getTokenValue,
19
+ isWeb,
20
+ } from "@multiplatform.one/components";
12
21
  import { Input } from "@multiplatform.one/forms";
13
- import { Table } from "@multiplatform.one/table";
22
+ import { Table, resolveSimpleTableColumns } from "@multiplatform.one/table";
14
23
  import {
15
24
  ensureFocusVisibleRing,
25
+ useLayoutSizeClass,
16
26
  formatCurrency,
17
27
  formatNumber,
18
28
  formatPercent,
@@ -20,6 +30,7 @@ import {
20
30
  import { CaretDownIcon, CaretUpDownIcon } from "@phosphor-icons/react";
21
31
  import React, { useCallback, useState } from "react";
22
32
  import { useTranslation } from "react-i18next";
33
+ import { splitUnitLabel } from "../fieldRegistry";
23
34
  import { formatDateCell } from "../list/listFormat";
24
35
  import {
25
36
  REPORT_NUMERIC_FIELDTYPES,
@@ -32,6 +43,42 @@ import {
32
43
 
33
44
  const ROW_NUMBER_WIDTH = 56;
34
45
 
46
+ /**
47
+ * Phone widths from the table package's column fitter (FZ-7698): numbers
48
+ * take a narrow token width, the title column takes what is left, so four
49
+ * columns fit without a sideways scroll.
50
+ */
51
+ function fitColumns(
52
+ columns: readonly ReportColumnDef[],
53
+ rows: readonly ReportGridRow[],
54
+ titleFieldname: string | undefined,
55
+ viewportWidth: number,
56
+ ): ReportColumnDef[] {
57
+ if (viewportWidth <= 0) return [...columns];
58
+ const data = rows.flatMap((row) => (row.kind === "row" ? [row.row] : []));
59
+ const fitted = resolveSimpleTableColumns(
60
+ columns.map((column) => ({
61
+ accessorKey: column.fieldname,
62
+ header: column.label,
63
+ type: REPORT_NUMERIC_FIELDTYPES.has(column.fieldtype)
64
+ ? ("number" as const)
65
+ : ("text" as const),
66
+ ...(column.fieldname === titleFieldname
67
+ ? { width: { flex: 1, min: "$10" as const } }
68
+ : REPORT_NUMERIC_FIELDTYPES.has(column.fieldtype)
69
+ ? { width: "$8" as const }
70
+ : { width: "$11" as const }),
71
+ })),
72
+ data as Array<Record<string, any>>,
73
+ viewportWidth,
74
+ (token) => Number(getTokenValue(token as Parameters<typeof getTokenValue>[0], "size")),
75
+ );
76
+ return columns.map((column, index) => ({
77
+ ...column,
78
+ width: fitted[index]?.width ?? column.width,
79
+ }));
80
+ }
81
+
35
82
  // ── LC-49/LC-50 row keyboard contract ───────────────────────────────
36
83
  // Local mirror of the table package's `isInteractiveInnerTarget`
37
84
  // (public/table/src/utils/rowPress.ts — internal util, not on the
@@ -92,6 +139,29 @@ export interface ReportGridProps {
92
139
  totals: readonly ReportTotalsCell[];
93
140
  /** Group-by column (group header rows span the grid). */
94
141
  groupByColumn?: ReportColumnDef;
142
+ /**
143
+ * The doctype's image field. On a phone the title cell leads with that
144
+ * image, so the record reads as one column instead of two.
145
+ */
146
+ imageField?: string;
147
+ /** The doctype's title field (defaults to the first column). */
148
+ titleField?: string;
149
+ /** Turns a stored file path into a URL the image can load. */
150
+ fileUrl?: (path: string) => string;
151
+ }
152
+
153
+ /**
154
+ * A number column's label and unit: "Height (dm)" heads the column as
155
+ * "Height" and each cell reads "3 dm", so the unit is never lost to a
156
+ * narrow header. Currency and percent carry their own symbol.
157
+ */
158
+ export function reportColumnUnit(column: ReportColumnDef): { label: string; unit?: string } {
159
+ if (!REPORT_NUMERIC_FIELDTYPES.has(column.fieldtype)) return { label: column.label };
160
+ if (column.fieldtype === "Currency" || column.fieldtype === "Percent") {
161
+ return { label: column.label };
162
+ }
163
+ const split = splitUnitLabel(column.label);
164
+ return { label: String(split.label), unit: split.unit };
95
165
  }
96
166
 
97
167
  function alignToJustify(align: ReportColumnDef["align"]) {
@@ -116,10 +186,12 @@ function ReportCellValue({
116
186
  column,
117
187
  row,
118
188
  onOpenLink,
189
+ fileUrl,
119
190
  }: {
120
191
  column: ReportColumnDef;
121
192
  row: ReportRow;
122
193
  onOpenLink?: (doctype: string, name: string) => void;
194
+ fileUrl?: (path: string) => string;
123
195
  }) {
124
196
  const raw = row[column.fieldname];
125
197
  if (raw === null || raw === undefined || raw === "") {
@@ -138,12 +210,18 @@ function ReportCellValue({
138
210
  }
139
211
  if (REPORT_NUMERIC_FIELDTYPES.has(column.fieldtype)) {
140
212
  const numeric = Number(raw);
213
+ const { unit } = reportColumnUnit(column);
141
214
  return (
142
- <Text fontSize="$3" color="$color12" fontFamily="$mono">
215
+ <Text fontSize="$3" color="$color12" fontFamily="$mono" numberOfLines={1}>
143
216
  {Number.isFinite(numeric) ? formatNumericCell(numeric, column.fieldtype) : String(raw)}
217
+ {unit ? <Text color="$color10">{` ${unit}`}</Text> : null}
144
218
  </Text>
145
219
  );
146
220
  }
221
+ if (column.fieldtype === "Attach Image" && typeof raw === "string") {
222
+ // Desk prints the path (formatters.js AttachImage); a report reads the image.
223
+ return <Image src={fileUrl ? fileUrl(raw) : raw} alt="" size="extraSmall" framed />;
224
+ }
147
225
  const value = String(raw);
148
226
  switch (column.fieldtype) {
149
227
  case "Date":
@@ -189,7 +267,7 @@ function ReportCellValue({
189
267
  }
190
268
 
191
269
  export function ReportGrid({
192
- columns,
270
+ columns: columnsIn,
193
271
  rows,
194
272
  sort,
195
273
  onSortChange,
@@ -199,8 +277,19 @@ export function ReportGrid({
199
277
  onOpenLink,
200
278
  totals,
201
279
  groupByColumn,
280
+ imageField,
281
+ titleField,
282
+ fileUrl,
202
283
  }: ReportGridProps) {
203
284
  const { t } = useTranslation();
285
+ // A phone report fits its columns to the screen: no row numbers, no
286
+ // per-column filter row (the filter bar above owns filtering there), the
287
+ // title column takes the room the numbers leave (FZ-7684).
288
+ const compact = useLayoutSizeClass() === "compact";
289
+ const [viewportWidth, setViewportWidth] = useState(0);
290
+ const titleFieldname = titleField ?? columnsIn[0]?.fieldname;
291
+ const columns = compact ? fitColumns(columnsIn, rows, titleFieldname, viewportWidth) : columnsIn;
292
+ const rowNumberWidth = compact ? 0 : ROW_NUMBER_WIDTH;
204
293
 
205
294
  const handleSortPress = useCallback(
206
295
  (fieldname: string) => {
@@ -310,10 +399,19 @@ export function ReportGrid({
310
399
  );
311
400
 
312
401
  const totalsByField = new Map(totals.map((total) => [total.fieldname, total]));
313
- const gridWidth = ROW_NUMBER_WIDTH + columns.reduce((sum, column) => sum + column.width, 0);
402
+ const gridWidth = rowNumberWidth + columns.reduce((sum, column) => sum + column.width, 0);
314
403
 
315
404
  return (
316
- <ScrollView horizontal showsHorizontalScrollIndicator flexGrow={0} flexShrink={1}>
405
+ <ScrollView
406
+ horizontal
407
+ showsHorizontalScrollIndicator
408
+ flexGrow={0}
409
+ flexShrink={1}
410
+ onLayout={(event) => {
411
+ const width = Math.floor(event.nativeEvent.layout.width);
412
+ setViewportWidth((previous) => (previous === width ? previous : width));
413
+ }}
414
+ >
317
415
  <Table
318
416
  minWidth={gridWidth}
319
417
  width={gridWidth}
@@ -326,14 +424,16 @@ export function ReportGrid({
326
424
  <Table.Head>
327
425
  {/* Header row: label + sort affordance (aria-sort, Enter/Space). */}
328
426
  <Table.Row>
329
- <Table.HeaderCell
330
- cellWidth={ROW_NUMBER_WIDTH}
331
- aria-label={t("report.rowNumber", "Row number")}
332
- >
333
- <Text fontSize="$2" color="$color9">
334
- #
335
- </Text>
336
- </Table.HeaderCell>
427
+ {compact ? null : (
428
+ <Table.HeaderCell
429
+ cellWidth={ROW_NUMBER_WIDTH}
430
+ aria-label={t("report.rowNumber", "Row number")}
431
+ >
432
+ <Text fontSize="$2" color="$color9">
433
+ #
434
+ </Text>
435
+ </Table.HeaderCell>
436
+ )}
337
437
  {columns.map((column) => {
338
438
  const sorted = sort.field === column.fieldname ? sort.order : undefined;
339
439
  return (
@@ -377,7 +477,7 @@ export function ReportGrid({
377
477
  hoverStyle={{ opacity: 0.8 }}
378
478
  >
379
479
  <Text fontSize="$3" fontWeight="400" numberOfLines={1}>
380
- {column.label}
480
+ {reportColumnUnit(column).label}
381
481
  </Text>
382
482
  {/* Caret orientation mirrors DataTable's headers:
383
483
  asc = caret-down, desc = rotated 180. */}
@@ -400,42 +500,44 @@ export function ReportGrid({
400
500
  </Table.Row>
401
501
  {/* Filter row — desk parity: one input per column, standard filter
402
502
  pipeline. Number/date accept comparison prefixes (>, <, >=…). */}
403
- <Table.Row>
404
- <Table.HeaderCell cellWidth={ROW_NUMBER_WIDTH} paddingVertical="$1.5" />
405
- {columns.map((column) => {
406
- const placeholder =
407
- column.kind === "number" || column.kind === "date"
408
- ? t("report.filterComparablePlaceholder", "= > <…")
409
- : column.kind === "check"
410
- ? t("report.filterCheckPlaceholder", "yes / no")
411
- : t("report.filterPlaceholder", "Filter…");
412
- return (
413
- <Table.HeaderCell
414
- key={column.fieldname}
415
- cellWidth={column.width}
416
- paddingVertical="$1.5"
417
- overflow="visible"
418
- >
419
- <View flex={1} minWidth={0} overflow="visible">
420
- <Input
421
- size="$2"
422
- value={columnFilters[column.fieldname] ?? ""}
423
- onChangeText={(next) => onColumnFilterChange(column.fieldname, next)}
424
- placeholder={placeholder}
425
- // inputProps is the eject that reaches the real input
426
- // element — a top-level aria-label stays on the wrapper.
427
- inputProps={{
428
- "aria-label": t("report.filterColumn", {
429
- defaultValue: "Filter {{column}}",
430
- column: column.label,
431
- }),
432
- }}
433
- />
434
- </View>
435
- </Table.HeaderCell>
436
- );
437
- })}
438
- </Table.Row>
503
+ {compact ? null : (
504
+ <Table.Row>
505
+ <Table.HeaderCell cellWidth={ROW_NUMBER_WIDTH} paddingVertical="$1.5" />
506
+ {columns.map((column) => {
507
+ const placeholder =
508
+ column.kind === "number" || column.kind === "date"
509
+ ? t("report.filterComparablePlaceholder", "= > <…")
510
+ : column.kind === "check"
511
+ ? t("report.filterCheckPlaceholder", "yes / no")
512
+ : t("report.filterPlaceholder", "Filter…");
513
+ return (
514
+ <Table.HeaderCell
515
+ key={column.fieldname}
516
+ cellWidth={column.width}
517
+ paddingVertical="$1.5"
518
+ overflow="visible"
519
+ >
520
+ <View flex={1} minWidth={0} overflow="visible">
521
+ <Input
522
+ size="$2"
523
+ value={columnFilters[column.fieldname] ?? ""}
524
+ onChangeText={(next) => onColumnFilterChange(column.fieldname, next)}
525
+ placeholder={placeholder}
526
+ // inputProps is the eject that reaches the real input
527
+ // element — a top-level aria-label stays on the wrapper.
528
+ inputProps={{
529
+ "aria-label": t("report.filterColumn", {
530
+ defaultValue: "Filter {{column}}",
531
+ column: column.label,
532
+ }),
533
+ }}
534
+ />
535
+ </View>
536
+ </Table.HeaderCell>
537
+ );
538
+ })}
539
+ </Table.Row>
540
+ )}
439
541
  </Table.Head>
440
542
  <Table.Body>
441
543
  {(() => {
@@ -475,21 +577,51 @@ export function ReportGrid({
475
577
  ? (getRowPressProps(gridRow.row, pressCursor) as Record<string, unknown>)
476
578
  : { "aria-label": gridRow.row.name })}
477
579
  >
478
- <Table.Cell cellWidth={ROW_NUMBER_WIDTH} paddingVertical="$2">
479
- <Text fontSize="$2" color="$color9" fontFamily="$mono">
480
- {gridRow.index}
481
- </Text>
482
- </Table.Cell>
483
- {columns.map((column) => (
484
- <Table.Cell
485
- key={column.fieldname}
486
- cellWidth={column.width}
487
- paddingVertical="$2"
488
- justifyContent={alignToJustify(column.align)}
489
- >
490
- <ReportCellValue column={column} row={gridRow.row} onOpenLink={onOpenLink} />
580
+ {compact ? null : (
581
+ <Table.Cell cellWidth={ROW_NUMBER_WIDTH} paddingVertical="$2">
582
+ <Text fontSize="$2" color="$color9" fontFamily="$mono">
583
+ {gridRow.index}
584
+ </Text>
491
585
  </Table.Cell>
492
- ))}
586
+ )}
587
+ {columns.map((column) => {
588
+ const image =
589
+ compact && imageField && column.fieldname === titleFieldname
590
+ ? gridRow.row[imageField]
591
+ : undefined;
592
+ return (
593
+ <Table.Cell
594
+ key={column.fieldname}
595
+ cellWidth={column.width}
596
+ paddingVertical="$2"
597
+ justifyContent={alignToJustify(column.align)}
598
+ >
599
+ {typeof image === "string" && image ? (
600
+ <XStack gap="$2" alignItems="center" minWidth="$0" flexShrink={1}>
601
+ <Image
602
+ src={fileUrl ? fileUrl(image) : image}
603
+ alt=""
604
+ size="extraSmall"
605
+ framed
606
+ />
607
+ <ReportCellValue
608
+ column={column}
609
+ row={gridRow.row}
610
+ onOpenLink={onOpenLink}
611
+ fileUrl={fileUrl}
612
+ />
613
+ </XStack>
614
+ ) : (
615
+ <ReportCellValue
616
+ column={column}
617
+ row={gridRow.row}
618
+ onOpenLink={onOpenLink}
619
+ fileUrl={fileUrl}
620
+ />
621
+ )}
622
+ </Table.Cell>
623
+ );
624
+ })}
493
625
  </Table.Row>
494
626
  );
495
627
  });
@@ -498,13 +630,28 @@ export function ReportGrid({
498
630
  {totals.length > 0 && (
499
631
  <Table.Foot borderTopWidth={1} borderTopColor="$color6">
500
632
  <Table.Row backgroundColor="$color2">
501
- <Table.Cell cellWidth={ROW_NUMBER_WIDTH} paddingVertical="$2">
502
- <Text fontSize="$2" fontWeight="400" color="$color10">
503
- {t("report.totals", "Totals")}
504
- </Text>
505
- </Table.Cell>
506
- {columns.map((column) => {
633
+ {compact ? null : (
634
+ <Table.Cell cellWidth={ROW_NUMBER_WIDTH} paddingVertical="$2">
635
+ <Text fontSize="$2" fontWeight="400" color="$color10">
636
+ {t("report.totals", "Totals")}
637
+ </Text>
638
+ </Table.Cell>
639
+ )}
640
+ {columns.map((column, columnIndex) => {
507
641
  const total = totalsByField.get(column.fieldname);
642
+ if (compact && columnIndex === 0 && !total) {
643
+ return (
644
+ <Table.Cell
645
+ key={column.fieldname}
646
+ cellWidth={column.width}
647
+ paddingVertical="$2"
648
+ >
649
+ <Text fontSize="$2" fontWeight="400" color="$color10">
650
+ {t("report.totals", "Totals")}
651
+ </Text>
652
+ </Table.Cell>
653
+ );
654
+ }
508
655
  return (
509
656
  <Table.Cell
510
657
  key={column.fieldname}
@@ -108,7 +108,7 @@ export function ReportViewToolbar({
108
108
  <DropdownMenu.Trigger asChild>
109
109
  <Button size="$3" chromeless icon={ColumnsIcon}>
110
110
  {t("report.columns", {
111
- defaultValue: "Columns ({{enabled}}/{{total}})",
111
+ defaultValue: "Columns · {{enabled}} of {{total}}",
112
112
  enabled: selected.length,
113
113
  total: columns.length,
114
114
  })}
@@ -224,6 +224,44 @@ export function defaultReportFieldnames(meta?: ReportMetaSource): string[] {
224
224
  return out;
225
225
  }
226
226
 
227
+ /** Columns a phone report shows before the reader picks more (FZ-7684). */
228
+ export const PHONE_REPORT_COLUMNS = 4;
229
+
230
+ /**
231
+ * A phone report's default columns: the title (the record, with its image
232
+ * drawn inside the title cell), then numbers, which stay narrow enough for
233
+ * four columns to fit a phone. Flagged list fields lead the numbers; text
234
+ * columns join only when there are too few numbers. Everything else is one
235
+ * tap away in the column picker.
236
+ */
237
+ export function phoneReportFieldnames(
238
+ meta?: ReportMetaSource,
239
+ max: number = PHONE_REPORT_COLUMNS,
240
+ ): string[] {
241
+ const out: string[] = [];
242
+ const push = (fieldname?: string) => {
243
+ if (fieldname && !out.includes(fieldname) && out.length < max) out.push(fieldname);
244
+ };
245
+ push(meta?.title_field || "name");
246
+ const values = (meta?.fields ?? []).filter((field) => {
247
+ const fieldtype = String(field.fieldtype);
248
+ return (
249
+ !VALUELESS_FIELDTYPES.has(fieldtype) &&
250
+ !fieldtype.startsWith("Table") &&
251
+ field.report_hide !== 1 &&
252
+ field.fieldname !== meta?.image_field &&
253
+ fieldtype !== "Attach Image"
254
+ );
255
+ });
256
+ const flagged = (field: DocFieldMeta) =>
257
+ field.in_list_view === 1 || field.in_standard_filter === 1;
258
+ const numeric = (field: DocFieldMeta) => REPORT_NUMERIC_FIELDTYPES.has(String(field.fieldtype));
259
+ for (const field of values) if (flagged(field) && numeric(field)) push(field.fieldname);
260
+ for (const field of values) if (numeric(field)) push(field.fieldname);
261
+ for (const field of values) if (flagged(field)) push(field.fieldname);
262
+ return out;
263
+ }
264
+
227
265
  /** Resolve selected fieldnames against the pickable set (drops unknowns). */
228
266
  export function resolveSelectedColumns(
229
267
  all: ReportColumnDef[],
@@ -1 +1 @@
1
- {"version":3,"file":"FrappeReportBuilder.d.ts","sourceRoot":"","sources":["../src/FrappeReportBuilder.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,OAAO,EAOL,KAAK,aAAa,EAClB,KAAK,YAAY,EAClB,MAAM,+BAA+B,CAAC;AAavC,OAAc,EAA6C,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAEzF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AA0B3C,KAAK,WAAW,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC;AAE7C,MAAM,WAAW,wBAAyB,SAAQ,IAAI,CAAC,WAAW,EAAE,UAAU,CAAC;IAC7E,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,WAAW,EAAE,CAAC;IAClD;;;OAGG;IACH,YAAY,CAAC,EAAE,WAAW,EAAE,CAAC;IAC7B,qFAAqF;IACrF,cAAc,CAAC,EAAE,MAAM,IAAI,CAAC;IAC5B;;;OAGG;IACH,cAAc,CAAC,EAAE,aAAa,CAAC,MAAM,GAAG,YAAY,CAAC,CAAC;IACtD,oFAAoF;IACpF,cAAc,CAAC,EAAE,MAAM,GAAG,aAAa,CAAC;IACxC,2EAA2E;IAC3E,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,6BAA6B;IAC7B,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,yEAAyE;IACzE,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnE,0CAA0C;IAC1C,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACrD;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IACjD,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,KAAK,IAAI,CAAC;IACrD,4CAA4C;IAC5C,OAAO,CAAC,EAAE,SAAS,CAAC;IACpB,oDAAoD;IACpD,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,SAAS,CAAC;IACpC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC7D,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACvC;AAqID,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,wBAAwB,2CAMlE;AAgoBD,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC"}
1
+ {"version":3,"file":"FrappeReportBuilder.d.ts","sourceRoot":"","sources":["../src/FrappeReportBuilder.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,OAAO,EAOL,KAAK,aAAa,EAClB,KAAK,YAAY,EAClB,MAAM,+BAA+B,CAAC;AAavC,OAAc,EAA6C,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAEzF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AA2B3C,KAAK,WAAW,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC;AAE7C,MAAM,WAAW,wBAAyB,SAAQ,IAAI,CAAC,WAAW,EAAE,UAAU,CAAC;IAC7E,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,WAAW,EAAE,CAAC;IAClD;;;OAGG;IACH,YAAY,CAAC,EAAE,WAAW,EAAE,CAAC;IAC7B,qFAAqF;IACrF,cAAc,CAAC,EAAE,MAAM,IAAI,CAAC;IAC5B;;;OAGG;IACH,cAAc,CAAC,EAAE,aAAa,CAAC,MAAM,GAAG,YAAY,CAAC,CAAC;IACtD,oFAAoF;IACpF,cAAc,CAAC,EAAE,MAAM,GAAG,aAAa,CAAC;IACxC,2EAA2E;IAC3E,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,6BAA6B;IAC7B,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,yEAAyE;IACzE,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnE,0CAA0C;IAC1C,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACrD;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IACjD,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,KAAK,IAAI,CAAC;IACrD,4CAA4C;IAC5C,OAAO,CAAC,EAAE,SAAS,CAAC;IACpB,oDAAoD;IACpD,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,SAAS,CAAC;IACpC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC7D,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACvC;AAqID,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,wBAAwB,2CAMlE;AAwpBD,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC"}
@@ -15,6 +15,24 @@ export interface ReportGridProps {
15
15
  totals: readonly ReportTotalsCell[];
16
16
  /** Group-by column (group header rows span the grid). */
17
17
  groupByColumn?: ReportColumnDef;
18
+ /**
19
+ * The doctype's image field. On a phone the title cell leads with that
20
+ * image, so the record reads as one column instead of two.
21
+ */
22
+ imageField?: string;
23
+ /** The doctype's title field (defaults to the first column). */
24
+ titleField?: string;
25
+ /** Turns a stored file path into a URL the image can load. */
26
+ fileUrl?: (path: string) => string;
18
27
  }
19
- export declare function ReportGrid({ columns, rows, sort, onSortChange, columnFilters, onColumnFilterChange, onOpenRow, onOpenLink, totals, groupByColumn, }: ReportGridProps): import("react/jsx-runtime").JSX.Element;
28
+ /**
29
+ * A number column's label and unit: "Height (dm)" heads the column as
30
+ * "Height" and each cell reads "3 dm", so the unit is never lost to a
31
+ * narrow header. Currency and percent carry their own symbol.
32
+ */
33
+ export declare function reportColumnUnit(column: ReportColumnDef): {
34
+ label: string;
35
+ unit?: string;
36
+ };
37
+ export declare function ReportGrid({ columns: columnsIn, rows, sort, onSortChange, columnFilters, onColumnFilterChange, onOpenRow, onOpenLink, totals, groupByColumn, imageField, titleField, fileUrl, }: ReportGridProps): import("react/jsx-runtime").JSX.Element;
20
38
  //# sourceMappingURL=ReportGrid.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ReportGrid.d.ts","sourceRoot":"","sources":["../../src/report/ReportGrid.tsx"],"names":[],"mappings":"AAuBA,OAAO,EAEL,KAAK,eAAe,EACpB,KAAK,aAAa,EAElB,KAAK,eAAe,EACpB,KAAK,gBAAgB,EACtB,MAAM,mBAAmB,CAAC;AAgD3B,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,SAAS,eAAe,EAAE,CAAC;IACpC,IAAI,EAAE,SAAS,aAAa,EAAE,CAAC;IAC/B,IAAI,EAAE,eAAe,CAAC;IACtB,YAAY,EAAE,CAAC,IAAI,EAAE,eAAe,KAAK,IAAI,CAAC;IAC9C,+DAA+D;IAC/D,aAAa,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAChD,oBAAoB,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACjE,oEAAoE;IACpE,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,0CAA0C;IAC1C,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACrD,uEAAuE;IACvE,MAAM,EAAE,SAAS,gBAAgB,EAAE,CAAC;IACpC,yDAAyD;IACzD,aAAa,CAAC,EAAE,eAAe,CAAC;CACjC;AAgGD,wBAAgB,UAAU,CAAC,EACzB,OAAO,EACP,IAAI,EACJ,IAAI,EACJ,YAAY,EACZ,aAAa,EACb,oBAAoB,EACpB,SAAS,EACT,UAAU,EACV,MAAM,EACN,aAAa,GACd,EAAE,eAAe,2CAuUjB"}
1
+ {"version":3,"file":"ReportGrid.d.ts","sourceRoot":"","sources":["../../src/report/ReportGrid.tsx"],"names":[],"mappings":"AAkCA,OAAO,EAEL,KAAK,eAAe,EACpB,KAAK,aAAa,EAElB,KAAK,eAAe,EACpB,KAAK,gBAAgB,EACtB,MAAM,mBAAmB,CAAC;AAoF3B,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,SAAS,eAAe,EAAE,CAAC;IACpC,IAAI,EAAE,SAAS,aAAa,EAAE,CAAC;IAC/B,IAAI,EAAE,eAAe,CAAC;IACtB,YAAY,EAAE,CAAC,IAAI,EAAE,eAAe,KAAK,IAAI,CAAC;IAC9C,+DAA+D;IAC/D,aAAa,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAChD,oBAAoB,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACjE,oEAAoE;IACpE,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,0CAA0C;IAC1C,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACrD,uEAAuE;IACvE,MAAM,EAAE,SAAS,gBAAgB,EAAE,CAAC;IACpC,yDAAyD;IACzD,aAAa,CAAC,EAAE,eAAe,CAAC;IAChC;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gEAAgE;IAChE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;CACpC;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,eAAe,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAO1F;AAwGD,wBAAgB,UAAU,CAAC,EACzB,OAAO,EAAE,SAAS,EAClB,IAAI,EACJ,IAAI,EACJ,YAAY,EACZ,aAAa,EACb,oBAAoB,EACpB,SAAS,EACT,UAAU,EACV,MAAM,EACN,aAAa,EACb,UAAU,EACV,UAAU,EACV,OAAO,GACR,EAAE,eAAe,2CAyYjB"}
@@ -52,6 +52,16 @@ export declare function reportColumnsFromMeta(meta?: ReportMetaSource): ReportCo
52
52
  * bundles and cannot be derived from the meta channel — honest subset.
53
53
  */
54
54
  export declare function defaultReportFieldnames(meta?: ReportMetaSource): string[];
55
+ /** Columns a phone report shows before the reader picks more (FZ-7684). */
56
+ export declare const PHONE_REPORT_COLUMNS = 4;
57
+ /**
58
+ * A phone report's default columns: the title (the record, with its image
59
+ * drawn inside the title cell), then numbers, which stay narrow enough for
60
+ * four columns to fit a phone. Flagged list fields lead the numbers; text
61
+ * columns join only when there are too few numbers. Everything else is one
62
+ * tap away in the column picker.
63
+ */
64
+ export declare function phoneReportFieldnames(meta?: ReportMetaSource, max?: number): string[];
55
65
  /** Resolve selected fieldnames against the pickable set (drops unknowns). */
56
66
  export declare function resolveSelectedColumns(all: ReportColumnDef[], fieldnames: readonly string[]): ReportColumnDef[];
57
67
  /**