@multiplatform.one/frappe-ui 7.28.1 → 7.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/FrappeCalendar.cjs +3 -2
- package/dist/cjs/FrappeCalendar.native.js +3 -2
- package/dist/cjs/FrappeCalendar.native.js.map +1 -1
- package/dist/cjs/FrappeForm.cjs +148 -464
- package/dist/cjs/FrappeForm.native.js +152 -633
- package/dist/cjs/FrappeForm.native.js.map +1 -1
- package/dist/cjs/FrappeListView.cjs +5 -27
- package/dist/cjs/FrappeListView.native.js +11 -32
- package/dist/cjs/FrappeListView.native.js.map +1 -1
- package/dist/cjs/FrappeReportBuilder.cjs +523 -309
- package/dist/cjs/FrappeReportBuilder.native.js +658 -353
- package/dist/cjs/FrappeReportBuilder.native.js.map +1 -1
- package/dist/cjs/FrappeReportView.cjs +35 -234
- package/dist/cjs/FrappeReportView.native.js +39 -283
- package/dist/cjs/FrappeReportView.native.js.map +1 -1
- package/dist/cjs/dependsOn.cjs +2 -2
- package/dist/cjs/dependsOn.native.js +1 -2
- package/dist/cjs/dependsOn.native.js.map +1 -1
- package/dist/cjs/fields/shared/EditorPreviewPanes.cjs +2 -2
- package/dist/cjs/fields/shared/EditorPreviewPanes.native.js +2 -2
- package/dist/cjs/fields/shared/EditorPreviewPanes.native.js.map +1 -1
- package/dist/cjs/formEngine/docPermissions.cjs +20 -0
- package/dist/cjs/formEngine/docPermissions.native.js +69 -0
- package/dist/cjs/formEngine/docPermissions.native.js.map +1 -1
- package/dist/cjs/formEngine/index.cjs +2 -0
- package/dist/cjs/formEngine/index.native.js +2 -0
- package/dist/cjs/formEngine/index.native.js.map +1 -1
- package/dist/cjs/index.cjs +67 -7
- package/dist/cjs/index.native.js +67 -7
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/liveErrors.cjs +8 -2
- package/dist/cjs/liveErrors.native.js +8 -2
- package/dist/cjs/liveErrors.native.js.map +1 -1
- package/dist/cjs/measures.cjs +31 -0
- package/dist/cjs/measures.native.js +34 -0
- package/dist/cjs/measures.native.js.map +1 -0
- package/dist/cjs/recordShape.cjs +103 -0
- package/dist/cjs/recordShape.native.js +115 -0
- package/dist/cjs/recordShape.native.js.map +1 -0
- package/dist/cjs/report/ReportGrid.cjs +453 -0
- package/dist/cjs/report/ReportGrid.native.js +510 -0
- package/dist/cjs/report/ReportGrid.native.js.map +1 -0
- package/dist/cjs/report/ReportViewToolbar.cjs +253 -0
- package/dist/cjs/report/ReportViewToolbar.native.js +303 -0
- package/dist/cjs/report/ReportViewToolbar.native.js.map +1 -0
- package/dist/cjs/report/queryReport.cjs +444 -0
- package/dist/cjs/report/queryReport.native.js +588 -0
- package/dist/cjs/report/queryReport.native.js.map +1 -0
- package/dist/cjs/report/queryReportColumns.cjs +131 -0
- package/dist/cjs/report/queryReportColumns.native.js +159 -0
- package/dist/cjs/report/queryReportColumns.native.js.map +1 -0
- package/dist/cjs/report/reportViewModel.cjs +386 -0
- package/dist/cjs/report/reportViewModel.native.js +657 -0
- package/dist/cjs/report/reportViewModel.native.js.map +1 -0
- package/dist/cjs/report/savedReports.cjs +137 -0
- package/dist/cjs/report/savedReports.native.js +169 -0
- package/dist/cjs/report/savedReports.native.js.map +1 -0
- package/dist/cjs/report/useFrappeReport.cjs +184 -0
- package/dist/cjs/report/useFrappeReport.native.js +214 -0
- package/dist/cjs/report/useFrappeReport.native.js.map +1 -0
- package/dist/cjs/report/useQueryReport.cjs +201 -0
- package/dist/cjs/report/useQueryReport.native.js +233 -0
- package/dist/cjs/report/useQueryReport.native.js.map +1 -0
- package/dist/cjs/useFrappeForm.cjs +558 -0
- package/dist/cjs/useFrappeForm.native.js +714 -0
- package/dist/cjs/useFrappeForm.native.js.map +1 -0
- package/dist/esm/FrappeCalendar.mjs +4 -3
- package/dist/esm/FrappeCalendar.mjs.map +1 -1
- package/dist/esm/FrappeCalendar.native.js +4 -3
- package/dist/esm/FrappeCalendar.native.js.map +1 -1
- package/dist/esm/FrappeForm.mjs +151 -467
- package/dist/esm/FrappeForm.mjs.map +1 -1
- package/dist/esm/FrappeForm.native.js +155 -636
- package/dist/esm/FrappeForm.native.js.map +1 -1
- package/dist/esm/FrappeListView.mjs +6 -28
- package/dist/esm/FrappeListView.mjs.map +1 -1
- package/dist/esm/FrappeListView.native.js +12 -33
- package/dist/esm/FrappeListView.native.js.map +1 -1
- package/dist/esm/FrappeReportBuilder.mjs +526 -312
- package/dist/esm/FrappeReportBuilder.mjs.map +1 -1
- package/dist/esm/FrappeReportBuilder.native.js +661 -356
- package/dist/esm/FrappeReportBuilder.native.js.map +1 -1
- package/dist/esm/FrappeReportView.mjs +30 -229
- package/dist/esm/FrappeReportView.mjs.map +1 -1
- package/dist/esm/FrappeReportView.native.js +34 -278
- package/dist/esm/FrappeReportView.native.js.map +1 -1
- package/dist/esm/dependsOn.mjs +2 -2
- package/dist/esm/dependsOn.mjs.map +1 -1
- package/dist/esm/dependsOn.native.js +1 -2
- package/dist/esm/dependsOn.native.js.map +1 -1
- package/dist/esm/fields/shared/EditorPreviewPanes.mjs +2 -2
- package/dist/esm/fields/shared/EditorPreviewPanes.mjs.map +1 -1
- package/dist/esm/fields/shared/EditorPreviewPanes.native.js +2 -2
- package/dist/esm/fields/shared/EditorPreviewPanes.native.js.map +1 -1
- package/dist/esm/formEngine/docPermissions.mjs +19 -1
- package/dist/esm/formEngine/docPermissions.mjs.map +1 -1
- package/dist/esm/formEngine/docPermissions.native.js +68 -1
- package/dist/esm/formEngine/docPermissions.native.js.map +1 -1
- package/dist/esm/formEngine/index.mjs +2 -2
- package/dist/esm/formEngine/index.mjs.map +1 -1
- package/dist/esm/formEngine/index.native.js +2 -2
- package/dist/esm/formEngine/index.native.js.map +1 -1
- package/dist/esm/index.mjs +16 -4
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +16 -4
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/liveErrors.mjs +8 -3
- package/dist/esm/liveErrors.mjs.map +1 -1
- package/dist/esm/liveErrors.native.js +8 -3
- package/dist/esm/liveErrors.native.js.map +1 -1
- package/dist/esm/measures.mjs +5 -0
- package/dist/esm/measures.mjs.map +1 -0
- package/dist/esm/measures.native.js +5 -0
- package/dist/esm/measures.native.js.map +1 -0
- package/dist/esm/recordShape.mjs +74 -0
- package/dist/esm/recordShape.mjs.map +1 -0
- package/dist/esm/recordShape.native.js +83 -0
- package/dist/esm/recordShape.native.js.map +1 -0
- package/dist/esm/report/ReportGrid.mjs +428 -0
- package/dist/esm/report/ReportGrid.mjs.map +1 -0
- package/dist/esm/report/ReportGrid.native.js +471 -0
- package/dist/esm/report/ReportGrid.native.js.map +1 -0
- package/dist/esm/report/ReportViewToolbar.mjs +228 -0
- package/dist/esm/report/ReportViewToolbar.mjs.map +1 -0
- package/dist/esm/report/ReportViewToolbar.native.js +264 -0
- package/dist/esm/report/ReportViewToolbar.native.js.map +1 -0
- package/dist/esm/report/queryReport.mjs +404 -0
- package/dist/esm/report/queryReport.mjs.map +1 -0
- package/dist/esm/report/queryReport.native.js +545 -0
- package/dist/esm/report/queryReport.native.js.map +1 -0
- package/dist/esm/report/queryReportColumns.mjs +105 -0
- package/dist/esm/report/queryReportColumns.mjs.map +1 -0
- package/dist/esm/report/queryReportColumns.native.js +119 -0
- package/dist/esm/report/queryReportColumns.native.js.map +1 -0
- package/dist/esm/report/reportViewModel.mjs +343 -0
- package/dist/esm/report/reportViewModel.mjs.map +1 -0
- package/dist/esm/report/reportViewModel.native.js +611 -0
- package/dist/esm/report/reportViewModel.native.js.map +1 -0
- package/dist/esm/report/savedReports.mjs +112 -0
- package/dist/esm/report/savedReports.mjs.map +1 -0
- package/dist/esm/report/savedReports.native.js +141 -0
- package/dist/esm/report/savedReports.native.js.map +1 -0
- package/dist/esm/report/useFrappeReport.mjs +158 -0
- package/dist/esm/report/useFrappeReport.mjs.map +1 -0
- package/dist/esm/report/useFrappeReport.native.js +185 -0
- package/dist/esm/report/useFrappeReport.native.js.map +1 -0
- package/dist/esm/report/useQueryReport.mjs +176 -0
- package/dist/esm/report/useQueryReport.mjs.map +1 -0
- package/dist/esm/report/useQueryReport.native.js +205 -0
- package/dist/esm/report/useQueryReport.native.js.map +1 -0
- package/dist/esm/useFrappeForm.mjs +533 -0
- package/dist/esm/useFrappeForm.mjs.map +1 -0
- package/dist/esm/useFrappeForm.native.js +686 -0
- package/dist/esm/useFrappeForm.native.js.map +1 -0
- package/dist/jsx/FrappeCalendar.mjs +4 -3
- package/dist/jsx/FrappeCalendar.mjs.map +1 -1
- package/dist/jsx/FrappeCalendar.native.js +3 -2
- package/dist/jsx/FrappeCalendar.native.js.map +1 -1
- package/dist/jsx/FrappeForm.mjs +151 -467
- package/dist/jsx/FrappeForm.mjs.map +1 -1
- package/dist/jsx/FrappeForm.native.js +152 -633
- package/dist/jsx/FrappeForm.native.js.map +1 -1
- package/dist/jsx/FrappeListView.mjs +6 -28
- package/dist/jsx/FrappeListView.mjs.map +1 -1
- package/dist/jsx/FrappeListView.native.js +11 -32
- package/dist/jsx/FrappeListView.native.js.map +1 -1
- package/dist/jsx/FrappeReportBuilder.mjs +526 -312
- package/dist/jsx/FrappeReportBuilder.mjs.map +1 -1
- package/dist/jsx/FrappeReportBuilder.native.js +658 -353
- package/dist/jsx/FrappeReportBuilder.native.js.map +1 -1
- package/dist/jsx/FrappeReportView.mjs +30 -229
- package/dist/jsx/FrappeReportView.mjs.map +1 -1
- package/dist/jsx/FrappeReportView.native.js +39 -283
- package/dist/jsx/FrappeReportView.native.js.map +1 -1
- package/dist/jsx/dependsOn.mjs +2 -2
- package/dist/jsx/dependsOn.mjs.map +1 -1
- package/dist/jsx/dependsOn.native.js +1 -2
- package/dist/jsx/dependsOn.native.js.map +1 -1
- package/dist/jsx/fields/shared/EditorPreviewPanes.mjs +2 -2
- package/dist/jsx/fields/shared/EditorPreviewPanes.mjs.map +1 -1
- package/dist/jsx/fields/shared/EditorPreviewPanes.native.js +2 -2
- package/dist/jsx/fields/shared/EditorPreviewPanes.native.js.map +1 -1
- package/dist/jsx/formEngine/docPermissions.mjs +19 -1
- package/dist/jsx/formEngine/docPermissions.mjs.map +1 -1
- package/dist/jsx/formEngine/docPermissions.native.js +69 -0
- package/dist/jsx/formEngine/docPermissions.native.js.map +1 -1
- package/dist/jsx/formEngine/index.mjs +2 -2
- package/dist/jsx/formEngine/index.mjs.map +1 -1
- package/dist/jsx/formEngine/index.native.js +2 -0
- package/dist/jsx/formEngine/index.native.js.map +1 -1
- package/dist/jsx/index.js +16 -4
- package/dist/jsx/index.js.map +1 -1
- package/dist/jsx/index.mjs +16 -4
- package/dist/jsx/index.mjs.map +1 -1
- package/dist/jsx/index.native.js +67 -7
- package/dist/jsx/index.native.js.map +1 -1
- package/dist/jsx/liveErrors.mjs +8 -3
- package/dist/jsx/liveErrors.mjs.map +1 -1
- package/dist/jsx/liveErrors.native.js +8 -2
- package/dist/jsx/liveErrors.native.js.map +1 -1
- package/dist/jsx/measures.mjs +5 -0
- package/dist/jsx/measures.mjs.map +1 -0
- package/dist/jsx/measures.native.js +34 -0
- package/dist/jsx/measures.native.js.map +1 -0
- package/dist/jsx/recordShape.mjs +74 -0
- package/dist/jsx/recordShape.mjs.map +1 -0
- package/dist/jsx/recordShape.native.js +115 -0
- package/dist/jsx/recordShape.native.js.map +1 -0
- package/dist/jsx/report/ReportGrid.mjs +428 -0
- package/dist/jsx/report/ReportGrid.mjs.map +1 -0
- package/dist/jsx/report/ReportGrid.native.js +510 -0
- package/dist/jsx/report/ReportGrid.native.js.map +1 -0
- package/dist/jsx/report/ReportViewToolbar.mjs +228 -0
- package/dist/jsx/report/ReportViewToolbar.mjs.map +1 -0
- package/dist/jsx/report/ReportViewToolbar.native.js +303 -0
- package/dist/jsx/report/ReportViewToolbar.native.js.map +1 -0
- package/dist/jsx/report/queryReport.mjs +404 -0
- package/dist/jsx/report/queryReport.mjs.map +1 -0
- package/dist/jsx/report/queryReport.native.js +588 -0
- package/dist/jsx/report/queryReport.native.js.map +1 -0
- package/dist/jsx/report/queryReportColumns.mjs +105 -0
- package/dist/jsx/report/queryReportColumns.mjs.map +1 -0
- package/dist/jsx/report/queryReportColumns.native.js +159 -0
- package/dist/jsx/report/queryReportColumns.native.js.map +1 -0
- package/dist/jsx/report/reportViewModel.mjs +343 -0
- package/dist/jsx/report/reportViewModel.mjs.map +1 -0
- package/dist/jsx/report/reportViewModel.native.js +657 -0
- package/dist/jsx/report/reportViewModel.native.js.map +1 -0
- package/dist/jsx/report/savedReports.mjs +112 -0
- package/dist/jsx/report/savedReports.mjs.map +1 -0
- package/dist/jsx/report/savedReports.native.js +169 -0
- package/dist/jsx/report/savedReports.native.js.map +1 -0
- package/dist/jsx/report/useFrappeReport.mjs +158 -0
- package/dist/jsx/report/useFrappeReport.mjs.map +1 -0
- package/dist/jsx/report/useFrappeReport.native.js +214 -0
- package/dist/jsx/report/useFrappeReport.native.js.map +1 -0
- package/dist/jsx/report/useQueryReport.mjs +176 -0
- package/dist/jsx/report/useQueryReport.mjs.map +1 -0
- package/dist/jsx/report/useQueryReport.native.js +233 -0
- package/dist/jsx/report/useQueryReport.native.js.map +1 -0
- package/dist/jsx/useFrappeForm.mjs +533 -0
- package/dist/jsx/useFrappeForm.mjs.map +1 -0
- package/dist/jsx/useFrappeForm.native.js +714 -0
- package/dist/jsx/useFrappeForm.native.js.map +1 -0
- package/package.json +16 -16
- package/src/FrappeCalendar.tsx +8 -2
- package/src/FrappeForm.stories.tsx +52 -0
- package/src/FrappeForm.tsx +176 -565
- package/src/FrappeListView.cards.stories.tsx +3 -6
- package/src/FrappeListView.tsx +15 -55
- package/src/FrappeReportBuilder.stories.tsx +34 -7
- package/src/FrappeReportBuilder.tsx +790 -380
- package/src/FrappeReportView.spec.tsx +10 -6
- package/src/FrappeReportView.stories.tsx +20 -1
- package/src/FrappeReportView.tsx +89 -415
- package/src/dependsOn.ts +4 -1
- package/src/fields/shared/EditorPreviewPanes.tsx +2 -2
- package/src/formEngine/docPermissions.spec.ts +36 -1
- package/src/formEngine/docPermissions.ts +34 -0
- package/src/formEngine/index.ts +8 -1
- package/src/frappeRemainder.designLaw.spec.ts +9 -1
- package/src/frappeRoutes.spec.tsx +9 -0
- package/src/frappeViews.designLaw.spec.ts +15 -2
- package/src/guestFallback.spec.tsx +236 -0
- package/src/index.ts +117 -16
- package/src/liveErrors.spec.ts +10 -0
- package/src/liveErrors.ts +16 -3
- package/src/measures.ts +11 -0
- package/src/recordShape.spec.ts +100 -0
- package/src/recordShape.ts +133 -0
- package/src/report/ReportGrid.spec.tsx +175 -0
- package/src/report/ReportGrid.tsx +530 -0
- package/src/report/ReportViewToolbar.spec.tsx +90 -0
- package/src/report/ReportViewToolbar.tsx +314 -0
- package/src/report/queryReport.spec.ts +483 -0
- package/src/report/queryReport.ts +722 -0
- package/src/report/queryReportColumns.tsx +123 -0
- package/src/report/reportViewModel.spec.ts +393 -0
- package/src/report/reportViewModel.ts +617 -0
- package/src/report/savedReports.ts +172 -0
- package/src/report/seam.spec.ts +216 -0
- package/src/report/useFrappeReport.ts +241 -0
- package/src/report/useQueryReport.spec.tsx +131 -0
- package/src/report/useQueryReport.ts +309 -0
- package/src/useFrappeForm.spec.tsx +209 -0
- package/src/useFrappeForm.ts +778 -0
- package/types/FrappeCalendar.d.ts.map +1 -1
- package/types/FrappeForm.d.ts +21 -1
- package/types/FrappeForm.d.ts.map +1 -1
- package/types/FrappeListView.d.ts.map +1 -1
- package/types/FrappeReportBuilder.d.ts +66 -16
- package/types/FrappeReportBuilder.d.ts.map +1 -1
- package/types/FrappeReportView.d.ts +9 -127
- package/types/FrappeReportView.d.ts.map +1 -1
- package/types/dependsOn.d.ts.map +1 -1
- package/types/formEngine/docPermissions.d.ts +14 -0
- package/types/formEngine/docPermissions.d.ts.map +1 -1
- package/types/formEngine/index.d.ts +1 -1
- package/types/formEngine/index.d.ts.map +1 -1
- package/types/index.d.ts +15 -3
- package/types/index.d.ts.map +1 -1
- package/types/liveErrors.d.ts +9 -1
- package/types/liveErrors.d.ts.map +1 -1
- package/types/measures.d.ts +3 -0
- package/types/measures.d.ts.map +1 -0
- package/types/recordShape.d.ts +45 -0
- package/types/recordShape.d.ts.map +1 -0
- package/types/report/ReportGrid.d.ts +20 -0
- package/types/report/ReportGrid.d.ts.map +1 -0
- package/types/report/ReportViewToolbar.d.ts +31 -0
- package/types/report/ReportViewToolbar.d.ts.map +1 -0
- package/types/report/queryReport.d.ts +181 -0
- package/types/report/queryReport.d.ts.map +1 -0
- package/types/report/queryReportColumns.d.ts +12 -0
- package/types/report/queryReportColumns.d.ts.map +1 -0
- package/types/report/reportViewModel.d.ts +146 -0
- package/types/report/reportViewModel.d.ts.map +1 -0
- package/types/report/savedReports.d.ts +27 -0
- package/types/report/savedReports.d.ts.map +1 -0
- package/types/report/useFrappeReport.d.ts +60 -0
- package/types/report/useFrappeReport.d.ts.map +1 -0
- package/types/report/useQueryReport.d.ts +37 -0
- package/types/report/useQueryReport.d.ts.map +1 -0
- package/types/useFrappeForm.d.ts +142 -0
- package/types/useFrappeForm.d.ts.map +1 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
// Desk-parity report spreadsheet: row numbers, sortable column headers, a
|
|
2
|
+
// per-column FILTER ROW under the headers (desk's killer report feature —
|
|
3
|
+
// inputs drive the standard frappe filter pipeline via reportViewModel's
|
|
4
|
+
// kind→operator mapping), group header rows, and a totals row.
|
|
5
|
+
//
|
|
6
|
+
// Composed from @multiplatform.one/table's Table primitives (exported for
|
|
7
|
+
// standalone use) rather than DataTable: DataTable owns a single internal
|
|
8
|
+
// scrollport and wraps header content in Text, so it cannot host an input
|
|
9
|
+
// row under its headers today — capability gap flagged to the table
|
|
10
|
+
// workstream; this grid is the interim desk-parity surface.
|
|
11
|
+
import { ScrollView, Text, Tooltip, View, XStack, isWeb } from "@multiplatform.one/components";
|
|
12
|
+
import { Input } from "@multiplatform.one/forms";
|
|
13
|
+
import { Table } from "@multiplatform.one/table";
|
|
14
|
+
import {
|
|
15
|
+
ensureFocusVisibleRing,
|
|
16
|
+
formatCurrency,
|
|
17
|
+
formatNumber,
|
|
18
|
+
formatPercent,
|
|
19
|
+
} from "@multiplatform.one/theme";
|
|
20
|
+
import { CaretDownIcon, CaretUpDownIcon } from "@phosphor-icons/react";
|
|
21
|
+
import React, { useCallback, useState } from "react";
|
|
22
|
+
import { useTranslation } from "react-i18next";
|
|
23
|
+
import { formatDateCell } from "../list/listFormat";
|
|
24
|
+
import {
|
|
25
|
+
REPORT_NUMERIC_FIELDTYPES,
|
|
26
|
+
type ReportColumnDef,
|
|
27
|
+
type ReportGridRow,
|
|
28
|
+
type ReportRow,
|
|
29
|
+
type ReportSortState,
|
|
30
|
+
type ReportTotalsCell,
|
|
31
|
+
} from "./reportViewModel";
|
|
32
|
+
|
|
33
|
+
const ROW_NUMBER_WIDTH = 56;
|
|
34
|
+
|
|
35
|
+
// ── LC-49/LC-50 row keyboard contract ───────────────────────────────
|
|
36
|
+
// Local mirror of the table package's `isInteractiveInnerTarget`
|
|
37
|
+
// (public/table/src/utils/rowPress.ts — internal util, not on the
|
|
38
|
+
// barrel): when the whole row is one pressable unit, presses and keys
|
|
39
|
+
// that originate on inner interactive content (Link cells, future
|
|
40
|
+
// editors) must keep working without also firing the row press.
|
|
41
|
+
const INTERACTIVE_SELECTOR = [
|
|
42
|
+
"a[href]",
|
|
43
|
+
"button",
|
|
44
|
+
"input",
|
|
45
|
+
"select",
|
|
46
|
+
"textarea",
|
|
47
|
+
"label",
|
|
48
|
+
'[contenteditable="true"]',
|
|
49
|
+
'[role="button"]',
|
|
50
|
+
'[role="checkbox"]',
|
|
51
|
+
'[role="combobox"]',
|
|
52
|
+
'[role="link"]',
|
|
53
|
+
'[role="menuitem"]',
|
|
54
|
+
'[role="option"]',
|
|
55
|
+
'[role="radio"]',
|
|
56
|
+
'[role="slider"]',
|
|
57
|
+
'[role="spinbutton"]',
|
|
58
|
+
'[role="switch"]',
|
|
59
|
+
'[role="textbox"]',
|
|
60
|
+
"[aria-expanded]",
|
|
61
|
+
].join(", ");
|
|
62
|
+
|
|
63
|
+
function isInteractiveInnerTarget(target: unknown, boundary: unknown): boolean {
|
|
64
|
+
if (typeof Element === "undefined") return false;
|
|
65
|
+
if (!(target instanceof Element)) return false;
|
|
66
|
+
const boundaryEl = boundary instanceof Element ? boundary : null;
|
|
67
|
+
let node: Element | null = target;
|
|
68
|
+
while (node && node !== boundaryEl) {
|
|
69
|
+
if (node.matches(INTERACTIVE_SELECTOR)) return true;
|
|
70
|
+
const tabIndex = node.getAttribute("tabindex");
|
|
71
|
+
// A tab stop of its own (0 or positive) marks a focusable control; the
|
|
72
|
+
// row's roving -1/0 tabindex lives on the boundary, not inside it.
|
|
73
|
+
if (tabIndex != null && Number(tabIndex) >= 0) return true;
|
|
74
|
+
node = node.parentElement;
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ReportGridProps {
|
|
80
|
+
columns: readonly ReportColumnDef[];
|
|
81
|
+
rows: readonly ReportGridRow[];
|
|
82
|
+
sort: ReportSortState;
|
|
83
|
+
onSortChange: (sort: ReportSortState) => void;
|
|
84
|
+
/** Filter-row DRAFT values by fieldname (uncommitted text). */
|
|
85
|
+
columnFilters: Readonly<Record<string, string>>;
|
|
86
|
+
onColumnFilterChange: (fieldname: string, value: string) => void;
|
|
87
|
+
/** Open the row's document form. Absent, rows are not pressable. */
|
|
88
|
+
onOpenRow?: (name: string) => void;
|
|
89
|
+
/** Open a Link cell's target document. */
|
|
90
|
+
onOpenLink?: (doctype: string, name: string) => void;
|
|
91
|
+
/** Totals over the full filtered result — rendered as a bottom row. */
|
|
92
|
+
totals: readonly ReportTotalsCell[];
|
|
93
|
+
/** Group-by column (group header rows span the grid). */
|
|
94
|
+
groupByColumn?: ReportColumnDef;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function alignToJustify(align: ReportColumnDef["align"]) {
|
|
98
|
+
return align === "right" ? "flex-end" : align === "center" ? "center" : "flex-start";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function formatNumericCell(value: number, fieldtype: string): string {
|
|
102
|
+
switch (fieldtype) {
|
|
103
|
+
case "Currency":
|
|
104
|
+
return formatCurrency(value);
|
|
105
|
+
case "Int":
|
|
106
|
+
return formatNumber(Math.round(value), { maximumFractionDigits: 0 });
|
|
107
|
+
case "Percent":
|
|
108
|
+
return formatPercent(value, { precision: 1 });
|
|
109
|
+
default:
|
|
110
|
+
return formatNumber(value);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Fieldtype-aware cell content (query-report cell channel, D-04 numbers). */
|
|
115
|
+
function ReportCellValue({
|
|
116
|
+
column,
|
|
117
|
+
row,
|
|
118
|
+
onOpenLink,
|
|
119
|
+
}: {
|
|
120
|
+
column: ReportColumnDef;
|
|
121
|
+
row: ReportRow;
|
|
122
|
+
onOpenLink?: (doctype: string, name: string) => void;
|
|
123
|
+
}) {
|
|
124
|
+
const raw = row[column.fieldname];
|
|
125
|
+
if (raw === null || raw === undefined || raw === "") {
|
|
126
|
+
return (
|
|
127
|
+
<Text fontSize="$3" color="$color8">
|
|
128
|
+
—
|
|
129
|
+
</Text>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (column.fieldtype === "Check") {
|
|
133
|
+
return (
|
|
134
|
+
<Text fontSize="$3" color={raw ? "$green11" : "$color8"}>
|
|
135
|
+
{raw ? "✓" : "—"}
|
|
136
|
+
</Text>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (REPORT_NUMERIC_FIELDTYPES.has(column.fieldtype)) {
|
|
140
|
+
const numeric = Number(raw);
|
|
141
|
+
return (
|
|
142
|
+
<Text fontSize="$3" color="$color12" fontFamily="$mono">
|
|
143
|
+
{Number.isFinite(numeric) ? formatNumericCell(numeric, column.fieldtype) : String(raw)}
|
|
144
|
+
</Text>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
const value = String(raw);
|
|
148
|
+
switch (column.fieldtype) {
|
|
149
|
+
case "Date":
|
|
150
|
+
case "Datetime":
|
|
151
|
+
return (
|
|
152
|
+
<Text fontSize="$3" color="$color11" numberOfLines={1}>
|
|
153
|
+
{formatDateCell(value)}
|
|
154
|
+
</Text>
|
|
155
|
+
);
|
|
156
|
+
case "Link": {
|
|
157
|
+
const target = column.options;
|
|
158
|
+
if (target && onOpenLink) {
|
|
159
|
+
return (
|
|
160
|
+
<Text
|
|
161
|
+
fontSize="$3"
|
|
162
|
+
color="$color11"
|
|
163
|
+
textDecorationLine="underline"
|
|
164
|
+
cursor="pointer"
|
|
165
|
+
numberOfLines={1}
|
|
166
|
+
role="link"
|
|
167
|
+
onPress={(event) => {
|
|
168
|
+
event?.stopPropagation?.();
|
|
169
|
+
onOpenLink(target, value);
|
|
170
|
+
}}
|
|
171
|
+
>
|
|
172
|
+
{value}
|
|
173
|
+
</Text>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return (
|
|
177
|
+
<Text fontSize="$3" color="$color11" numberOfLines={1}>
|
|
178
|
+
{value}
|
|
179
|
+
</Text>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
default:
|
|
183
|
+
return (
|
|
184
|
+
<Text fontSize="$3" color="$color11" numberOfLines={1}>
|
|
185
|
+
{value}
|
|
186
|
+
</Text>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function ReportGrid({
|
|
192
|
+
columns,
|
|
193
|
+
rows,
|
|
194
|
+
sort,
|
|
195
|
+
onSortChange,
|
|
196
|
+
columnFilters,
|
|
197
|
+
onColumnFilterChange,
|
|
198
|
+
onOpenRow,
|
|
199
|
+
onOpenLink,
|
|
200
|
+
totals,
|
|
201
|
+
groupByColumn,
|
|
202
|
+
}: ReportGridProps) {
|
|
203
|
+
const { t } = useTranslation();
|
|
204
|
+
|
|
205
|
+
const handleSortPress = useCallback(
|
|
206
|
+
(fieldname: string) => {
|
|
207
|
+
if (sort.field === fieldname) {
|
|
208
|
+
onSortChange({ field: fieldname, order: sort.order === "asc" ? "desc" : "asc" });
|
|
209
|
+
} else {
|
|
210
|
+
onSortChange({ field: fieldname, order: "asc" });
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
[sort, onSortChange],
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
// ── Row press + keyboard (LC-49 ONE-TAB-STOP, LC-50 KEY-ACTIVATION) ──
|
|
217
|
+
// Mirrors DataTable's getRowPressProps contract (DataTableRoot): the
|
|
218
|
+
// DATA-row set exposes exactly one tab stop (roving anchor — the focused
|
|
219
|
+
// row while focus is inside the set, else the first row); Enter AND
|
|
220
|
+
// Space each fire exactly one activation; arrows/Home/End rove focus;
|
|
221
|
+
// inner interactive content (Link cells) wins over the row press.
|
|
222
|
+
const isSSR = typeof window === "undefined";
|
|
223
|
+
const [rowAnchor, setRowAnchor] = useState(0);
|
|
224
|
+
const dataRowCount = rows.reduce((count, row) => (row.kind === "row" ? count + 1 : count), 0);
|
|
225
|
+
const effectiveRowAnchor = Math.min(rowAnchor, Math.max(dataRowCount - 1, 0));
|
|
226
|
+
|
|
227
|
+
const getRowPressProps = useCallback(
|
|
228
|
+
(row: ReportRow, pressIndex: number) => {
|
|
229
|
+
const handlePress = (
|
|
230
|
+
event:
|
|
231
|
+
| { defaultPrevented?: boolean; target?: unknown; nativeEvent?: { target?: unknown } }
|
|
232
|
+
| undefined,
|
|
233
|
+
) => {
|
|
234
|
+
if (event?.defaultPrevented) return;
|
|
235
|
+
const targetEl = event?.target ?? event?.nativeEvent?.target;
|
|
236
|
+
if (isWeb && typeof Element !== "undefined" && targetEl instanceof Element) {
|
|
237
|
+
const boundary = targetEl.closest('[data-report-row="true"]');
|
|
238
|
+
if (isInteractiveInnerTarget(targetEl, boundary)) return;
|
|
239
|
+
}
|
|
240
|
+
onOpenRow?.(row.name);
|
|
241
|
+
};
|
|
242
|
+
return {
|
|
243
|
+
// Accessible row name from doc identity (DG-A11Y-06); positional
|
|
244
|
+
// fallback for the defensive empty-name case.
|
|
245
|
+
"aria-label":
|
|
246
|
+
row.name || t("report.rowLabel", { defaultValue: "Row {{row}}", row: pressIndex + 1 }),
|
|
247
|
+
onPress: handlePress,
|
|
248
|
+
cursor: "pointer" as const,
|
|
249
|
+
hoverStyle: { backgroundColor: "$color2" },
|
|
250
|
+
pressStyle: { backgroundColor: "$color4" },
|
|
251
|
+
...(isWeb && !isSSR
|
|
252
|
+
? {
|
|
253
|
+
tabIndex: pressIndex === effectiveRowAnchor ? 0 : -1,
|
|
254
|
+
outlineWidth: 0,
|
|
255
|
+
focusStyle: { outlineWidth: 0 },
|
|
256
|
+
focusVisibleStyle: ensureFocusVisibleRing({ outlineOffset: -2 }),
|
|
257
|
+
"data-report-row": "true",
|
|
258
|
+
onFocus: (event: React.FocusEvent) => {
|
|
259
|
+
// Only a focused ROW moves the anchor; focus on an inner
|
|
260
|
+
// control (own tab stop) leaves the roving state alone.
|
|
261
|
+
if (event.target === event.currentTarget) setRowAnchor(pressIndex);
|
|
262
|
+
},
|
|
263
|
+
onBlur: (event: React.FocusEvent) => {
|
|
264
|
+
const next = event.relatedTarget;
|
|
265
|
+
if (!(next instanceof Element) || !next.closest('[data-report-row="true"]')) {
|
|
266
|
+
setRowAnchor(0);
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
onKeyDown: (event: React.KeyboardEvent) => {
|
|
270
|
+
if (event.defaultPrevented) return;
|
|
271
|
+
// Keys on focused inner controls belong to those controls.
|
|
272
|
+
if (event.target !== event.currentTarget) return;
|
|
273
|
+
const key = event.key;
|
|
274
|
+
if (key === "Enter" || key === " " || key === "Spacebar") {
|
|
275
|
+
// preventDefault stops Space page-scroll; one physical
|
|
276
|
+
// key press = one activation (auto-repeat guarded).
|
|
277
|
+
event.preventDefault();
|
|
278
|
+
event.stopPropagation();
|
|
279
|
+
if (event.repeat) return;
|
|
280
|
+
onOpenRow?.(row.name);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (key === "ArrowDown" || key === "ArrowUp" || key === "Home" || key === "End") {
|
|
284
|
+
event.preventDefault();
|
|
285
|
+
event.stopPropagation();
|
|
286
|
+
const rowEl = event.currentTarget as HTMLElement;
|
|
287
|
+
const scope = rowEl.closest('[data-report-grid="true"]') ?? rowEl.ownerDocument;
|
|
288
|
+
const pressRows = Array.from(
|
|
289
|
+
scope.querySelectorAll('[data-report-row="true"]'),
|
|
290
|
+
) as HTMLElement[];
|
|
291
|
+
const currentIdx = pressRows.indexOf(rowEl);
|
|
292
|
+
if (currentIdx < 0) return;
|
|
293
|
+
const nextIdx =
|
|
294
|
+
key === "Home"
|
|
295
|
+
? 0
|
|
296
|
+
: key === "End"
|
|
297
|
+
? pressRows.length - 1
|
|
298
|
+
: Math.min(
|
|
299
|
+
Math.max(currentIdx + (key === "ArrowDown" ? 1 : -1), 0),
|
|
300
|
+
pressRows.length - 1,
|
|
301
|
+
);
|
|
302
|
+
pressRows[nextIdx]?.focus();
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
: {}),
|
|
307
|
+
};
|
|
308
|
+
},
|
|
309
|
+
[onOpenRow, effectiveRowAnchor, isSSR, t],
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
const totalsByField = new Map(totals.map((total) => [total.fieldname, total]));
|
|
313
|
+
const gridWidth = ROW_NUMBER_WIDTH + columns.reduce((sum, column) => sum + column.width, 0);
|
|
314
|
+
|
|
315
|
+
return (
|
|
316
|
+
<ScrollView horizontal showsHorizontalScrollIndicator flexGrow={0} flexShrink={1}>
|
|
317
|
+
<Table
|
|
318
|
+
minWidth={gridWidth}
|
|
319
|
+
width={gridWidth}
|
|
320
|
+
displayMode="flex"
|
|
321
|
+
cellHeight="$3"
|
|
322
|
+
// Roving scope marker: arrow-key navigation queries pressable rows
|
|
323
|
+
// within this container (getRowPressProps).
|
|
324
|
+
{...({ "data-report-grid": "true" } as Record<string, unknown>)}
|
|
325
|
+
>
|
|
326
|
+
<Table.Head>
|
|
327
|
+
{/* Header row: label + sort affordance (aria-sort, Enter/Space). */}
|
|
328
|
+
<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>
|
|
337
|
+
{columns.map((column) => {
|
|
338
|
+
const sorted = sort.field === column.fieldname ? sort.order : undefined;
|
|
339
|
+
return (
|
|
340
|
+
<Table.HeaderCell
|
|
341
|
+
key={column.fieldname}
|
|
342
|
+
cellWidth={column.width}
|
|
343
|
+
justifyContent={alignToJustify(column.align)}
|
|
344
|
+
aria-sort={
|
|
345
|
+
sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : "none"
|
|
346
|
+
}
|
|
347
|
+
>
|
|
348
|
+
<Tooltip
|
|
349
|
+
content={
|
|
350
|
+
sorted === "asc"
|
|
351
|
+
? t("report.sortDesc", "Sorted ascending (press to sort descending)")
|
|
352
|
+
: sorted === "desc"
|
|
353
|
+
? t("report.sortAsc", "Sorted descending (press to sort ascending)")
|
|
354
|
+
: t("report.sortColumn", "Sort by this column")
|
|
355
|
+
}
|
|
356
|
+
>
|
|
357
|
+
<XStack
|
|
358
|
+
gap="$1.5"
|
|
359
|
+
alignItems="center"
|
|
360
|
+
cursor="pointer"
|
|
361
|
+
role="button"
|
|
362
|
+
tabIndex={0}
|
|
363
|
+
outlineWidth={0}
|
|
364
|
+
focusStyle={{ outlineWidth: 0 }}
|
|
365
|
+
focusVisibleStyle={ensureFocusVisibleRing({ outlineOffset: -2 })}
|
|
366
|
+
aria-label={t("report.sortBy", {
|
|
367
|
+
defaultValue: "Sort by {{column}}",
|
|
368
|
+
column: column.label,
|
|
369
|
+
})}
|
|
370
|
+
onPress={() => handleSortPress(column.fieldname)}
|
|
371
|
+
onKeyDown={(event: { key?: string; preventDefault?: () => void }) => {
|
|
372
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
373
|
+
event.preventDefault?.();
|
|
374
|
+
handleSortPress(column.fieldname);
|
|
375
|
+
}
|
|
376
|
+
}}
|
|
377
|
+
hoverStyle={{ opacity: 0.8 }}
|
|
378
|
+
>
|
|
379
|
+
<Text fontSize="$3" fontWeight="400" numberOfLines={1}>
|
|
380
|
+
{column.label}
|
|
381
|
+
</Text>
|
|
382
|
+
{/* Caret orientation mirrors DataTable's headers:
|
|
383
|
+
asc = caret-down, desc = rotated 180. */}
|
|
384
|
+
<View aria-hidden role="presentation">
|
|
385
|
+
{sorted ? (
|
|
386
|
+
<CaretDownIcon
|
|
387
|
+
size={12}
|
|
388
|
+
color="var(--color10)"
|
|
389
|
+
style={sorted === "desc" ? { transform: "rotate(180deg)" } : undefined}
|
|
390
|
+
/>
|
|
391
|
+
) : (
|
|
392
|
+
<CaretUpDownIcon size={12} color="var(--color8)" />
|
|
393
|
+
)}
|
|
394
|
+
</View>
|
|
395
|
+
</XStack>
|
|
396
|
+
</Tooltip>
|
|
397
|
+
</Table.HeaderCell>
|
|
398
|
+
);
|
|
399
|
+
})}
|
|
400
|
+
</Table.Row>
|
|
401
|
+
{/* Filter row — desk parity: one input per column, standard filter
|
|
402
|
+
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
|
+
compact
|
|
423
|
+
value={columnFilters[column.fieldname] ?? ""}
|
|
424
|
+
onChangeText={(next) => onColumnFilterChange(column.fieldname, next)}
|
|
425
|
+
placeholder={placeholder}
|
|
426
|
+
// inputProps is the eject that reaches the real input
|
|
427
|
+
// element — a top-level aria-label stays on the wrapper.
|
|
428
|
+
inputProps={{
|
|
429
|
+
"aria-label": t("report.filterColumn", {
|
|
430
|
+
defaultValue: "Filter {{column}}",
|
|
431
|
+
column: column.label,
|
|
432
|
+
}),
|
|
433
|
+
}}
|
|
434
|
+
/>
|
|
435
|
+
</View>
|
|
436
|
+
</Table.HeaderCell>
|
|
437
|
+
);
|
|
438
|
+
})}
|
|
439
|
+
</Table.Row>
|
|
440
|
+
</Table.Head>
|
|
441
|
+
<Table.Body>
|
|
442
|
+
{(() => {
|
|
443
|
+
// Position within the pressable DATA-row set (group headers are
|
|
444
|
+
// not pressable and hold no tab stop).
|
|
445
|
+
let pressCursor = -1;
|
|
446
|
+
return rows.map((gridRow) => {
|
|
447
|
+
if (gridRow.kind === "group") {
|
|
448
|
+
const label =
|
|
449
|
+
gridRow.value === null || gridRow.value === undefined || gridRow.value === ""
|
|
450
|
+
? t("report.groupEmpty", "(blank)")
|
|
451
|
+
: String(gridRow.value);
|
|
452
|
+
return (
|
|
453
|
+
<Table.Row key={gridRow.key} backgroundColor="$color2">
|
|
454
|
+
<Table.Cell cellWidth={gridWidth} paddingVertical="$1.5" role="rowheader">
|
|
455
|
+
<Text fontSize="$2" fontWeight="400" color="$color11" numberOfLines={1}>
|
|
456
|
+
{groupByColumn
|
|
457
|
+
? t("report.groupHeader", {
|
|
458
|
+
defaultValue: "{{field}}: {{value}} · {{count}}",
|
|
459
|
+
field: groupByColumn.label,
|
|
460
|
+
value: label,
|
|
461
|
+
count: gridRow.count,
|
|
462
|
+
})
|
|
463
|
+
: label}
|
|
464
|
+
</Text>
|
|
465
|
+
</Table.Cell>
|
|
466
|
+
</Table.Row>
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
pressCursor += 1;
|
|
470
|
+
return (
|
|
471
|
+
// Spread LAST so the roving tabIndex/handlers stay in
|
|
472
|
+
// charge of this row's interactivity (DataTable precedent).
|
|
473
|
+
<Table.Row
|
|
474
|
+
key={gridRow.key}
|
|
475
|
+
{...(onOpenRow
|
|
476
|
+
? (getRowPressProps(gridRow.row, pressCursor) as Record<string, unknown>)
|
|
477
|
+
: { "aria-label": gridRow.row.name })}
|
|
478
|
+
>
|
|
479
|
+
<Table.Cell cellWidth={ROW_NUMBER_WIDTH} paddingVertical="$2">
|
|
480
|
+
<Text fontSize="$2" color="$color9" fontFamily="$mono">
|
|
481
|
+
{gridRow.index}
|
|
482
|
+
</Text>
|
|
483
|
+
</Table.Cell>
|
|
484
|
+
{columns.map((column) => (
|
|
485
|
+
<Table.Cell
|
|
486
|
+
key={column.fieldname}
|
|
487
|
+
cellWidth={column.width}
|
|
488
|
+
paddingVertical="$2"
|
|
489
|
+
justifyContent={alignToJustify(column.align)}
|
|
490
|
+
>
|
|
491
|
+
<ReportCellValue column={column} row={gridRow.row} onOpenLink={onOpenLink} />
|
|
492
|
+
</Table.Cell>
|
|
493
|
+
))}
|
|
494
|
+
</Table.Row>
|
|
495
|
+
);
|
|
496
|
+
});
|
|
497
|
+
})()}
|
|
498
|
+
</Table.Body>
|
|
499
|
+
{totals.length > 0 && (
|
|
500
|
+
<Table.Foot borderTopWidth={1} borderTopColor="$color6">
|
|
501
|
+
<Table.Row backgroundColor="$color2">
|
|
502
|
+
<Table.Cell cellWidth={ROW_NUMBER_WIDTH} paddingVertical="$2">
|
|
503
|
+
<Text fontSize="$2" fontWeight="400" color="$color10">
|
|
504
|
+
{t("report.totals", "Totals")}
|
|
505
|
+
</Text>
|
|
506
|
+
</Table.Cell>
|
|
507
|
+
{columns.map((column) => {
|
|
508
|
+
const total = totalsByField.get(column.fieldname);
|
|
509
|
+
return (
|
|
510
|
+
<Table.Cell
|
|
511
|
+
key={column.fieldname}
|
|
512
|
+
cellWidth={column.width}
|
|
513
|
+
paddingVertical="$2"
|
|
514
|
+
justifyContent={alignToJustify(column.align)}
|
|
515
|
+
>
|
|
516
|
+
{total ? (
|
|
517
|
+
<Text fontSize="$3" fontWeight="400" color="$color12" fontFamily="$mono">
|
|
518
|
+
{formatNumericCell(total.value, total.fieldtype)}
|
|
519
|
+
</Text>
|
|
520
|
+
) : null}
|
|
521
|
+
</Table.Cell>
|
|
522
|
+
);
|
|
523
|
+
})}
|
|
524
|
+
</Table.Row>
|
|
525
|
+
</Table.Foot>
|
|
526
|
+
)}
|
|
527
|
+
</Table>
|
|
528
|
+
</ScrollView>
|
|
529
|
+
);
|
|
530
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReportToolbar DOM prop hygiene (LC-46 class).
|
|
3
|
+
*
|
|
4
|
+
* The Save As popover previously passed `elevate` to raw tamagui
|
|
5
|
+
* Popover.Content, which has no such variant in tamagui 2.0 — the bare
|
|
6
|
+
* boolean fell through to the DOM element and fired React's
|
|
7
|
+
* non-boolean-attribute console.error on every popover open (and painted
|
|
8
|
+
* no shadow: the "styling" was dead). The surface now rides the house
|
|
9
|
+
* PopoverContent (elevatedSurface knob recipe). Render + open → zero
|
|
10
|
+
* unknown-prop / non-boolean-attribute console errors.
|
|
11
|
+
*/
|
|
12
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
13
|
+
import { fireEvent, waitFor } from "@testing-library/react";
|
|
14
|
+
import React from "react";
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
16
|
+
import type { ReportColumnDef } from "./reportViewModel";
|
|
17
|
+
import { ReportViewToolbar as ReportToolbar } from "./ReportViewToolbar";
|
|
18
|
+
|
|
19
|
+
const DOM_PROP_LEAK =
|
|
20
|
+
/does not recognize the `.+` prop on a DOM element|non-boolean attribute|Invalid DOM property/i;
|
|
21
|
+
|
|
22
|
+
const columns: ReportColumnDef[] = [
|
|
23
|
+
{ fieldname: "name", label: "ID", fieldtype: "Data", kind: "text", width: 160, align: "left" },
|
|
24
|
+
{
|
|
25
|
+
fieldname: "status",
|
|
26
|
+
label: "Status",
|
|
27
|
+
fieldtype: "Select",
|
|
28
|
+
kind: "select",
|
|
29
|
+
options: "Open\nClosed",
|
|
30
|
+
width: 120,
|
|
31
|
+
align: "left",
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
function renderToolbar() {
|
|
36
|
+
return renderWithProviders(
|
|
37
|
+
<ReportToolbar
|
|
38
|
+
columns={columns}
|
|
39
|
+
selected={["name", "status"]}
|
|
40
|
+
onToggleColumn={vi.fn()}
|
|
41
|
+
onResetColumns={vi.fn()}
|
|
42
|
+
groupBy={null}
|
|
43
|
+
onGroupByChange={vi.fn()}
|
|
44
|
+
baseFilters={[]}
|
|
45
|
+
columnFilterCount={0}
|
|
46
|
+
onClearColumnFilters={vi.fn()}
|
|
47
|
+
savedReports={[]}
|
|
48
|
+
currentReport={null}
|
|
49
|
+
onSelectReport={vi.fn()}
|
|
50
|
+
onSaveReport={vi.fn()}
|
|
51
|
+
onDeleteReport={vi.fn()}
|
|
52
|
+
onExport={vi.fn()}
|
|
53
|
+
/>,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let errorSpy: ReturnType<typeof vi.spyOn> | null = null;
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
// Strict local spy: the shared test setup filters known leak warnings
|
|
60
|
+
// globally, but this arm must stay falsifiable — capture everything.
|
|
61
|
+
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
62
|
+
});
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
errorSpy?.mockRestore();
|
|
65
|
+
errorSpy = null;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
function leakMessages(): string[] {
|
|
69
|
+
return (errorSpy?.mock.calls ?? [])
|
|
70
|
+
.map((args: unknown[]) => args.map(String).join(" "))
|
|
71
|
+
.filter((message) => DOM_PROP_LEAK.test(message));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe("ReportToolbar Save As popover (LC-46 DOM prop hygiene)", () => {
|
|
75
|
+
it("opens the Save As popover without leaking non-DOM props", async () => {
|
|
76
|
+
const result = renderToolbar();
|
|
77
|
+
|
|
78
|
+
const saveAs = result.findTextElement("Save As…");
|
|
79
|
+
expect(saveAs).toBeDefined();
|
|
80
|
+
fireEvent.click(saveAs as Element);
|
|
81
|
+
|
|
82
|
+
await waitFor(() => {
|
|
83
|
+
expect(document.querySelector('input[placeholder="New report name"]')).toBeTruthy();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(leakMessages()).toEqual([]);
|
|
87
|
+
// Belt-and-braces: no leaked attribute rendered anywhere either.
|
|
88
|
+
expect(document.querySelector("[elevate]")).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
});
|