@open-mercato/ui 0.7.1-develop.7193.1.910a5b0a1e → 0.7.1-develop.7194.1.ab4fc81f82
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/.turbo/turbo-build.log +2 -2
- package/dist/backend/AppShell.js +66 -7
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +14 -8
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +108 -11
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/forms/ActionsDropdown.js +35 -5
- package/dist/backend/forms/ActionsDropdown.js.map +2 -2
- package/dist/backend/forms/index.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +6 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/spotIds.js +2 -0
- package/dist/backend/injection/spotIds.js.map +2 -2
- package/dist/backend/inputs/DurationInput.js +182 -0
- package/dist/backend/inputs/DurationInput.js.map +7 -0
- package/dist/backend/inputs/EventSelect.js.map +2 -2
- package/dist/backend/inputs/index.js +1 -0
- package/dist/backend/inputs/index.js.map +2 -2
- package/dist/backend/nav/navBadges.js +38 -0
- package/dist/backend/nav/navBadges.js.map +7 -0
- package/dist/primitives/segmented-control.js +4 -4
- package/dist/primitives/segmented-control.js.map +1 -1
- package/dist/primitives/sheet.js +21 -11
- package/dist/primitives/sheet.js.map +2 -2
- package/dist/primitives/textarea.js +54 -4
- package/dist/primitives/textarea.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +106 -2
- package/src/backend/CrudForm.tsx +28 -9
- package/src/backend/DataTable.tsx +176 -22
- package/src/backend/__tests__/CrudForm.density.test.tsx +113 -0
- package/src/backend/__tests__/DataTable.propBulkActions.test.tsx +200 -0
- package/src/backend/__tests__/DataTable.render.test.tsx +76 -0
- package/src/backend/__tests__/navBadges.test.ts +57 -0
- package/src/backend/forms/ActionsDropdown.tsx +73 -6
- package/src/backend/forms/index.ts +1 -1
- package/src/backend/icons/lucideRegistry.generated.tsx +6 -0
- package/src/backend/injection/spotIds.ts +8 -0
- package/src/backend/inputs/DurationInput.tsx +207 -0
- package/src/backend/inputs/EventSelect.tsx +2 -0
- package/src/backend/inputs/__tests__/DurationInput.test.tsx +220 -0
- package/src/backend/inputs/index.ts +1 -0
- package/src/backend/nav/navBadges.ts +61 -0
- package/src/primitives/__tests__/segmented-control.test.tsx +2 -2
- package/src/primitives/__tests__/textarea.test.tsx +50 -0
- package/src/primitives/segmented-control.tsx +7 -7
- package/src/primitives/sheet.tsx +7 -1
- package/src/primitives/textarea.tsx +77 -4
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -378,6 +378,17 @@ export type CrudFormProps<TValues extends Record<string, unknown>> = {
|
|
|
378
378
|
embedded?: boolean
|
|
379
379
|
// Hide the footer action bar (Save/Cancel/Delete) when embedding in a custom layout
|
|
380
380
|
hideFooterActions?: boolean
|
|
381
|
+
/**
|
|
382
|
+
* Vertical rhythm of the form body.
|
|
383
|
+
*
|
|
384
|
+
* `default` is the page-width layout every existing host renders today and is
|
|
385
|
+
* unchanged byte-for-byte. `compact` tightens the between-group and
|
|
386
|
+
* between-field spacing and the group-card padding for narrow hosts — a docked
|
|
387
|
+
* inspector rail, a side panel — where the page rhythm reads as airy and costs
|
|
388
|
+
* the host a third of its column. It changes SPACING only: no label, helper
|
|
389
|
+
* text, control size or copy differs between the two.
|
|
390
|
+
*/
|
|
391
|
+
density?: 'default' | 'compact'
|
|
381
392
|
/**
|
|
382
393
|
* Opt-in: track dirty state even when `embedded` is true, AND enable the form's built-in
|
|
383
394
|
* navigation protection (beforeunload, link-click intercept, pushState/replaceState/popstate).
|
|
@@ -738,6 +749,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
738
749
|
loadingMessage,
|
|
739
750
|
customEntity = false,
|
|
740
751
|
embedded = false,
|
|
752
|
+
density = 'default',
|
|
741
753
|
hideFooterActions = false,
|
|
742
754
|
trackDirtyWhenEmbedded = false,
|
|
743
755
|
onDirtyChange,
|
|
@@ -1330,6 +1342,13 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
1330
1342
|
// handles the overflow case on its own: when content scrolls, the footer stays pinned
|
|
1331
1343
|
// to the dialog's bottom and the user can scroll fields above it.
|
|
1332
1344
|
const dialogFormPadding = ''
|
|
1345
|
+
// One step down the DS scale each, never an arbitrary value. `default` returns
|
|
1346
|
+
// the exact strings that were inline before this prop existed.
|
|
1347
|
+
const isCompactDensity = density === 'compact'
|
|
1348
|
+
const densityStackLg = isCompactDensity ? 'space-y-3' : 'space-y-4'
|
|
1349
|
+
const densityStackMd = isCompactDensity ? 'space-y-2' : 'space-y-3'
|
|
1350
|
+
const densityCardPadding = isCompactDensity ? 'p-3' : 'p-4'
|
|
1351
|
+
const densityGroupCardPadding = isCompactDensity ? 'px-3 py-2' : 'px-4 py-3'
|
|
1333
1352
|
|
|
1334
1353
|
const buildCustomFieldsManageHref = React.useCallback(
|
|
1335
1354
|
(targetEntityId: string | null) => {
|
|
@@ -3345,7 +3364,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
3345
3364
|
const sectionKey = `${entityLayout.entityId}:${section.fieldsetCode ?? 'default'}`
|
|
3346
3365
|
const manageDisabled = !manageHref
|
|
3347
3366
|
nodes.push(
|
|
3348
|
-
<div key={sectionKey} className=
|
|
3367
|
+
<div key={sectionKey} className={`rounded-lg border bg-card ${densityCardPadding} ${densityStackLg}`}>
|
|
3349
3368
|
<div className="flex items-start justify-between gap-3">
|
|
3350
3369
|
<div className="flex items-start gap-2">
|
|
3351
3370
|
{FieldsetIcon ? (
|
|
@@ -3587,7 +3606,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
3587
3606
|
fieldCount={customFieldCount}
|
|
3588
3607
|
chevronPosition={collapsibleChevronPosition}
|
|
3589
3608
|
>
|
|
3590
|
-
<div className=
|
|
3609
|
+
<div className={densityStackMd}>
|
|
3591
3610
|
{customFieldsInnerNodes}
|
|
3592
3611
|
</div>
|
|
3593
3612
|
</CollapsibleGroup>,
|
|
@@ -3641,14 +3660,14 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
3641
3660
|
fieldCount={groupFields.length}
|
|
3642
3661
|
chevronPosition={collapsibleChevronPosition}
|
|
3643
3662
|
>
|
|
3644
|
-
<div className=
|
|
3663
|
+
<div className={densityStackMd}>
|
|
3645
3664
|
{groupContent}
|
|
3646
3665
|
</div>
|
|
3647
3666
|
</CollapsibleGroup>,
|
|
3648
3667
|
)
|
|
3649
3668
|
} else {
|
|
3650
3669
|
nodes.push(
|
|
3651
|
-
<div key={g.id} className=
|
|
3670
|
+
<div key={g.id} className={`rounded-lg border bg-card ${densityGroupCardPadding} ${densityStackMd}`}>
|
|
3652
3671
|
{g.title ? (
|
|
3653
3672
|
<div className="text-sm font-medium">{t(g.title, g.title)}</div>
|
|
3654
3673
|
) : null}
|
|
@@ -3703,7 +3722,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
3703
3722
|
className={embedded ? 'min-h-[1px]' : 'min-h-[400px]'}
|
|
3704
3723
|
>
|
|
3705
3724
|
{wrapFormBody(
|
|
3706
|
-
<form id={formId} onSubmit={handleSubmit} className={
|
|
3725
|
+
<form id={formId} onSubmit={handleSubmit} className={`${densityStackLg} ${dialogFormPadding}`}>
|
|
3707
3726
|
{resolvedInjectionSpotId ? (
|
|
3708
3727
|
<InjectionSpot
|
|
3709
3728
|
spotId={resolvedInjectionSpotId}
|
|
@@ -3722,13 +3741,13 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
3722
3741
|
{sortableGroupsEnabled ? (
|
|
3723
3742
|
<DndContext sensors={sortableSensors} collisionDetection={closestCenter} onDragEnd={handleGroupDragEnd}>
|
|
3724
3743
|
<SortableContext items={col1Ids} strategy={verticalListSortingStrategy}>
|
|
3725
|
-
<div className=
|
|
3744
|
+
<div className={densityStackMd}>{col1Content}</div>
|
|
3726
3745
|
</SortableContext>
|
|
3727
3746
|
</DndContext>
|
|
3728
3747
|
) : (
|
|
3729
|
-
<div className=
|
|
3748
|
+
<div className={densityStackMd}>{col1Content}</div>
|
|
3730
3749
|
)}
|
|
3731
|
-
{hasSecondaryColumn ? <div className=
|
|
3750
|
+
{hasSecondaryColumn ? <div className={densityStackMd} data-crud-injection-region>{col2Content}</div> : null}
|
|
3732
3751
|
</div>
|
|
3733
3752
|
{formError && !Object.keys(errors).length ? <div className="text-sm text-status-error-text">{formError}</div> : null}
|
|
3734
3753
|
{hideFooterActions || formReadOnly ? null : (
|
|
@@ -3790,7 +3809,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
3790
3809
|
<form
|
|
3791
3810
|
id={formId}
|
|
3792
3811
|
onSubmit={handleSubmit}
|
|
3793
|
-
className={`${embedded ?
|
|
3812
|
+
className={`${embedded ? densityStackLg : `rounded-lg border bg-card ${densityCardPadding} ${densityStackLg}`} ${dialogFormPadding}`}
|
|
3794
3813
|
>
|
|
3795
3814
|
{resolvedInjectionSpotId ? (
|
|
3796
3815
|
<InjectionSpot
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import { useRouter } from 'next/navigation'
|
|
4
|
-
import { flexRender, type RowData, type SortingState, type ColumnVisibilityState as VisibilityState, type RowSelectionState } from '@tanstack/react-table'
|
|
5
|
-
import { useLegacyTable, getCoreRowModel, getSortedRowModel, type LegacyColumnDef as ColumnDef, type LegacyColumn as TableColumn } from '@tanstack/react-table/legacy'
|
|
4
|
+
import { flexRender, type RowData, type SortingState, type ColumnVisibilityState as VisibilityState, type RowSelectionState, type ExpandedState, type OnChangeFn } from '@tanstack/react-table'
|
|
5
|
+
import { useLegacyTable, getCoreRowModel, getSortedRowModel, getExpandedRowModel, type LegacyColumnDef as ColumnDef, type LegacyColumn as TableColumn } from '@tanstack/react-table/legacy'
|
|
6
6
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
7
|
-
import { RefreshCw, Loader2, SlidersHorizontal, MoreHorizontal, Circle, Filter, Columns3, ChevronUp, ChevronDown, ChevronsUpDown, Check, Inbox, Save } from 'lucide-react'
|
|
7
|
+
import { RefreshCw, Loader2, SlidersHorizontal, MoreHorizontal, Circle, Filter, Columns3, ChevronUp, ChevronDown, ChevronRight, ChevronsUpDown, Check, Inbox, Save } from 'lucide-react'
|
|
8
8
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../primitives/table'
|
|
9
9
|
import { Button } from '../primitives/button'
|
|
10
|
+
import { IconButton } from '../primitives/icon-button'
|
|
10
11
|
import { Checkbox } from '../primitives/checkbox'
|
|
11
12
|
import {
|
|
12
13
|
Select,
|
|
@@ -222,6 +223,13 @@ export type DataTableExportConfig = {
|
|
|
222
223
|
|
|
223
224
|
export type DataTablePerspectiveConfig = {
|
|
224
225
|
tableId: string
|
|
226
|
+
/**
|
|
227
|
+
* Where to render the "Views" perspective switcher. Defaults to 'left'
|
|
228
|
+
* (inside the filter toolbar). When 'right', the switcher moves to the
|
|
229
|
+
* right-hand actions area and the redundant standalone "Customize columns"
|
|
230
|
+
* (•••) button is suppressed — the "Views" control already opens that panel.
|
|
231
|
+
*/
|
|
232
|
+
align?: 'left' | 'right'
|
|
225
233
|
initialState?: {
|
|
226
234
|
response?: PerspectivesIndexResponse
|
|
227
235
|
activePerspectiveId?: string | null
|
|
@@ -456,6 +464,23 @@ export type DataTableProps<T extends RowData> = {
|
|
|
456
464
|
onClearAll: () => void
|
|
457
465
|
onRemoveLast: () => void
|
|
458
466
|
}
|
|
467
|
+
/**
|
|
468
|
+
* Opt-in expandable/nested rows (accordion). All expansion props are
|
|
469
|
+
* additive and default-off — when none are passed, the table renders and
|
|
470
|
+
* behaves exactly as before (no toggle column, no row model change).
|
|
471
|
+
*
|
|
472
|
+
* - `getSubRows`: derive child rows for a given row (TanStack hierarchy source).
|
|
473
|
+
* - `expandable`: whether a row shows an expand toggle. Pass a predicate to
|
|
474
|
+
* show the toggle for rows whose children are not loaded yet (lazy loading);
|
|
475
|
+
* defaults to "has sub-rows" when omitted but `getSubRows` is set.
|
|
476
|
+
* - `expanded` / `onExpandedChange`: controlled expansion state. Provide both
|
|
477
|
+
* for lazy-loading hosts that fetch children when a row is expanded; omit
|
|
478
|
+
* for self-managed expansion.
|
|
479
|
+
*/
|
|
480
|
+
getSubRows?: (row: T) => T[] | undefined
|
|
481
|
+
expandable?: boolean | ((row: T) => boolean)
|
|
482
|
+
expanded?: ExpandedState
|
|
483
|
+
onExpandedChange?: OnChangeFn<ExpandedState>
|
|
459
484
|
}
|
|
460
485
|
|
|
461
486
|
const DEFAULT_EXPORT_FORMATS: DataTableExportFormat[] = ['csv', 'json', 'xml', 'markdown']
|
|
@@ -485,7 +510,7 @@ const STICKY_RIGHT_SHADOW_CLASS =
|
|
|
485
510
|
const STICKY_LEFT_SHADOW_CLASS =
|
|
486
511
|
'md:after:absolute md:after:inset-y-0 md:after:-right-2 md:after:w-2 md:after:bg-gradient-to-r md:after:from-foreground/8 md:after:to-transparent md:after:pointer-events-none'
|
|
487
512
|
|
|
488
|
-
type BulkActionExecuteResult = {
|
|
513
|
+
export type BulkActionExecuteResult = {
|
|
489
514
|
ok: boolean
|
|
490
515
|
message?: string
|
|
491
516
|
affectedCount?: number
|
|
@@ -1282,6 +1307,10 @@ export function DataTable<T extends RowData>({
|
|
|
1282
1307
|
columnChooser,
|
|
1283
1308
|
activeFilterChips,
|
|
1284
1309
|
filterAwareEmptyState,
|
|
1310
|
+
getSubRows,
|
|
1311
|
+
expandable,
|
|
1312
|
+
expanded: expandedProp,
|
|
1313
|
+
onExpandedChange,
|
|
1285
1314
|
}: DataTableProps<T>) {
|
|
1286
1315
|
const t = useT()
|
|
1287
1316
|
const { confirm, ConfirmDialogElement } = useConfirmDialog()
|
|
@@ -1353,6 +1382,7 @@ export function DataTable<T extends RowData>({
|
|
|
1353
1382
|
const queryClient = useQueryClient()
|
|
1354
1383
|
const perspectiveConfig = perspective ?? null
|
|
1355
1384
|
const perspectiveTableId = perspectiveConfig?.tableId ?? null
|
|
1385
|
+
const perspectiveAlign: 'left' | 'right' = perspectiveConfig?.align ?? 'left'
|
|
1356
1386
|
const perspectiveEnabled = Boolean(perspectiveTableId)
|
|
1357
1387
|
// Snapshot from localStorage is read post-mount via useLayoutEffect to avoid SSR/CSR
|
|
1358
1388
|
// hydration mismatch. Initial render uses only props-derived state (identical on both sides).
|
|
@@ -1783,14 +1813,36 @@ export function DataTable<T extends RowData>({
|
|
|
1783
1813
|
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
|
1784
1814
|
const selectionScopeKeyRef = React.useRef<string | undefined>(selectionScopeKey)
|
|
1785
1815
|
const enableClientSorting = sortable && !manualSorting
|
|
1816
|
+
// Opt-in expansion: enabled only when the host passes any expansion prop, so
|
|
1817
|
+
// existing tables keep their exact row model and rendering.
|
|
1818
|
+
const expansionEnabled = getSubRows !== undefined || expandable !== undefined
|
|
1819
|
+
const [internalExpanded, setInternalExpanded] = React.useState<ExpandedState>({})
|
|
1820
|
+
const expandedState = expandedProp !== undefined ? expandedProp : internalExpanded
|
|
1821
|
+
const handleExpandedChange = React.useCallback<OnChangeFn<ExpandedState>>((updater) => {
|
|
1822
|
+
if (onExpandedChange) {
|
|
1823
|
+
onExpandedChange(updater)
|
|
1824
|
+
return
|
|
1825
|
+
}
|
|
1826
|
+
setInternalExpanded((prev) => (typeof updater === 'function' ? updater(prev) : updater))
|
|
1827
|
+
}, [onExpandedChange])
|
|
1786
1828
|
const table = useLegacyTable<T>({
|
|
1787
1829
|
data: clientFilteredData,
|
|
1788
1830
|
columns: mergedColumns,
|
|
1789
1831
|
getCoreRowModel: getCoreRowModel(),
|
|
1790
1832
|
...(enableClientSorting ? { getSortedRowModel: getSortedRowModel() } : {}),
|
|
1833
|
+
...(expansionEnabled ? {
|
|
1834
|
+
getSubRows: getSubRows as ((row: T) => T[] | undefined) | undefined,
|
|
1835
|
+
getExpandedRowModel: getExpandedRowModel(),
|
|
1836
|
+
getRowCanExpand: (row) => {
|
|
1837
|
+
if (typeof expandable === 'function') return expandable(row.original as T)
|
|
1838
|
+
if (expandable === true) return true
|
|
1839
|
+
return (row.subRows?.length ?? 0) > 0
|
|
1840
|
+
},
|
|
1841
|
+
onExpandedChange: handleExpandedChange,
|
|
1842
|
+
} : {}),
|
|
1791
1843
|
manualSorting: manualSorting === true,
|
|
1792
1844
|
getRowId: resolveDataTableRowId,
|
|
1793
|
-
state: { sorting, columnVisibility, columnOrder, rowSelection },
|
|
1845
|
+
state: { sorting, columnVisibility, columnOrder, rowSelection, ...(expansionEnabled ? { expanded: expandedState } : {}) },
|
|
1794
1846
|
enableRowSelection: hasInjectedBulkActions,
|
|
1795
1847
|
onSortingChange: (updater) => {
|
|
1796
1848
|
const next = typeof updater === 'function' ? updater(sorting) : updater
|
|
@@ -3074,14 +3126,61 @@ export function DataTable<T extends RowData>({
|
|
|
3074
3126
|
[confirm, extensionTableId, refreshButton, resolvedInjectionContext, router, selectedRows, t],
|
|
3075
3127
|
)
|
|
3076
3128
|
|
|
3129
|
+
/**
|
|
3130
|
+
* Host-owned bulk actions (the `bulkActions` prop) used to DISCARD whatever
|
|
3131
|
+
* they returned. An action that queued server-side work and returned
|
|
3132
|
+
* `{ ok: true, progressJobId }` — the contract `progress/AGENTS.md` requires
|
|
3133
|
+
* — was answered with silence: no toast, no top-bar tracking, and a rejected
|
|
3134
|
+
* promise surfaced as an unhandled rejection instead of an error flash.
|
|
3135
|
+
*
|
|
3136
|
+
* The `void` / `true` / `false` returns keep their exact previous behaviour,
|
|
3137
|
+
* because the callers that use them (customers, messages) already flash from
|
|
3138
|
+
* their own `runBulkMutation` and a second toast would be a regression. Only
|
|
3139
|
+
* a RESULT OBJECT is now acted on.
|
|
3140
|
+
*/
|
|
3077
3141
|
const runPropBulkAction = React.useCallback(
|
|
3078
3142
|
async (action: BulkAction<T>) => {
|
|
3079
|
-
|
|
3080
|
-
|
|
3143
|
+
try {
|
|
3144
|
+
const result = await action.onExecute(selectedRows)
|
|
3145
|
+
if (result === false) return
|
|
3146
|
+
if (!result || typeof result !== 'object') {
|
|
3147
|
+
setRowSelection({})
|
|
3148
|
+
return
|
|
3149
|
+
}
|
|
3150
|
+
|
|
3151
|
+
if (result.ok === false) {
|
|
3152
|
+
if (result.message === undefined) return
|
|
3153
|
+
flash(result.message, 'error')
|
|
3154
|
+
return
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3081
3157
|
setRowSelection({})
|
|
3158
|
+
if (result.progressJobId) {
|
|
3159
|
+
trackedBulkProgressJobIdsRef.current.add(result.progressJobId)
|
|
3160
|
+
flash(
|
|
3161
|
+
result.message
|
|
3162
|
+
?? t('ui.dataTable.bulkAction.started', 'Bulk action started. Track progress in the top bar.'),
|
|
3163
|
+
'success',
|
|
3164
|
+
)
|
|
3165
|
+
return
|
|
3166
|
+
}
|
|
3167
|
+
flash(
|
|
3168
|
+
result.message ?? t('ui.dataTable.bulkAction.success', 'Bulk action completed.'),
|
|
3169
|
+
'success',
|
|
3170
|
+
)
|
|
3171
|
+
if (refreshButton?.onRefresh) {
|
|
3172
|
+
refreshButton.onRefresh()
|
|
3173
|
+
} else {
|
|
3174
|
+
scheduleRouterRefresh(router)
|
|
3175
|
+
}
|
|
3176
|
+
} catch (error) {
|
|
3177
|
+
flash(
|
|
3178
|
+
error instanceof Error ? error.message : t('ui.dataTable.bulkAction.error', 'Bulk action failed.'),
|
|
3179
|
+
'error',
|
|
3180
|
+
)
|
|
3082
3181
|
}
|
|
3083
3182
|
},
|
|
3084
|
-
[selectedRows],
|
|
3183
|
+
[refreshButton, router, selectedRows, t],
|
|
3085
3184
|
)
|
|
3086
3185
|
|
|
3087
3186
|
const builtToolbar = React.useMemo(() => {
|
|
@@ -3180,6 +3279,7 @@ export function DataTable<T extends RowData>({
|
|
|
3180
3279
|
</div>
|
|
3181
3280
|
)
|
|
3182
3281
|
: null
|
|
3282
|
+
const perspectiveButtonLeading = perspectiveAlign === 'right' ? null : perspectiveButton
|
|
3183
3283
|
const saveViewButton = showSaveViewButton && canUsePerspectives ? (
|
|
3184
3284
|
<Button
|
|
3185
3285
|
type="button"
|
|
@@ -3203,10 +3303,10 @@ export function DataTable<T extends RowData>({
|
|
|
3203
3303
|
) : null}
|
|
3204
3304
|
</Button>
|
|
3205
3305
|
) : null
|
|
3206
|
-
const leadingItems = advancedFilterButton ||
|
|
3306
|
+
const leadingItems = advancedFilterButton || perspectiveButtonLeading || saveViewButton ? (
|
|
3207
3307
|
<div className="flex items-center gap-2">
|
|
3208
3308
|
{advancedFilterButton}
|
|
3209
|
-
{
|
|
3309
|
+
{perspectiveButtonLeading}
|
|
3210
3310
|
{saveViewButton}
|
|
3211
3311
|
</div>
|
|
3212
3312
|
) : null
|
|
@@ -3255,6 +3355,9 @@ export function DataTable<T extends RowData>({
|
|
|
3255
3355
|
const searchTrailingNode = searchTrailingInjectionSpotId && onSearchChange ? (
|
|
3256
3356
|
<InjectionSpot spotId={searchTrailingInjectionSpotId} context={resolvedInjectionContext} />
|
|
3257
3357
|
) : null
|
|
3358
|
+
// With the Views switcher moved to the right-hand actions area, the filter
|
|
3359
|
+
// toolbar has nothing left to render — drop it so no empty band appears.
|
|
3360
|
+
if (perspectiveAlign === 'right' && !anySearch && combined.length === 0 && !advancedFilterButton && !saveViewButton && !trailingItems && !fieldsetSelector) return null
|
|
3258
3361
|
return (
|
|
3259
3362
|
<FilterBar
|
|
3260
3363
|
searchValue={searchValue}
|
|
@@ -3286,6 +3389,7 @@ export function DataTable<T extends RowData>({
|
|
|
3286
3389
|
onFiltersApply,
|
|
3287
3390
|
onFiltersClear,
|
|
3288
3391
|
canUsePerspectives,
|
|
3392
|
+
perspectiveAlign,
|
|
3289
3393
|
embedded,
|
|
3290
3394
|
supportsCustomFieldFilterFieldsets,
|
|
3291
3395
|
resolvedEntityIds,
|
|
@@ -3415,17 +3519,29 @@ export function DataTable<T extends RowData>({
|
|
|
3415
3519
|
</Button>
|
|
3416
3520
|
) : null}
|
|
3417
3521
|
{canUsePerspectives ? (
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3522
|
+
perspectiveAlign === 'right' ? (
|
|
3523
|
+
<ViewSwitcherDropdown
|
|
3524
|
+
activePerspectiveId={activePerspectiveId}
|
|
3525
|
+
perspectives={perspectiveData?.perspectives ?? []}
|
|
3526
|
+
rolePerspectives={perspectiveData?.rolePerspectives ?? []}
|
|
3527
|
+
onClear={() => applyPerspectiveSettings({}, null)}
|
|
3528
|
+
onActivate={handlePerspectiveActivate}
|
|
3529
|
+
onOpenSidebar={() => setPerspectiveOpen(true)}
|
|
3530
|
+
t={t}
|
|
3531
|
+
/>
|
|
3532
|
+
) : (
|
|
3533
|
+
<Button
|
|
3534
|
+
type="button"
|
|
3535
|
+
variant="ghost"
|
|
3536
|
+
size="icon"
|
|
3537
|
+
onClick={() => setPerspectiveOpen(true)}
|
|
3538
|
+
aria-label={t('ui.dataTable.customizeColumns.ariaLabel', 'Customize columns')}
|
|
3539
|
+
title={t('ui.dataTable.customizeColumns.title', 'Customize columns')}
|
|
3540
|
+
>
|
|
3541
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
3542
|
+
<span className="sr-only">{t('ui.dataTable.customizeColumns.srOnly', 'Customize columns')}</span>
|
|
3543
|
+
</Button>
|
|
3544
|
+
)
|
|
3429
3545
|
) : null}
|
|
3430
3546
|
{exportConfig && hasExport ? <ExportMenu config={exportConfig} sections={resolvedExportSections} /> : null}
|
|
3431
3547
|
{toolbarInjectionSpotId ? (
|
|
@@ -3677,13 +3793,51 @@ export function DataTable<T extends RowData>({
|
|
|
3677
3793
|
</TruncatedCell>
|
|
3678
3794
|
) : content
|
|
3679
3795
|
|
|
3796
|
+
// Expansion affordance: only on the first data cell when
|
|
3797
|
+
// expansion is enabled. Indent by nesting depth so children
|
|
3798
|
+
// read as nested under their parent; render a fixed-width
|
|
3799
|
+
// spacer for non-expandable rows to keep columns aligned.
|
|
3800
|
+
const expandAffordance = expansionEnabled && cellIndex === 0 ? (
|
|
3801
|
+
<span
|
|
3802
|
+
className="inline-flex shrink-0 items-center"
|
|
3803
|
+
style={{ paddingLeft: `${row.depth * 1.25}rem` }}
|
|
3804
|
+
>
|
|
3805
|
+
{row.getCanExpand() ? (
|
|
3806
|
+
<IconButton
|
|
3807
|
+
type="button"
|
|
3808
|
+
variant="ghost"
|
|
3809
|
+
size="xs"
|
|
3810
|
+
aria-label={row.getIsExpanded()
|
|
3811
|
+
? t('ui.dataTable.expand.collapseRow', 'Collapse row')
|
|
3812
|
+
: t('ui.dataTable.expand.expandRow', 'Expand row')}
|
|
3813
|
+
aria-expanded={row.getIsExpanded()}
|
|
3814
|
+
onClick={(event) => {
|
|
3815
|
+
event.stopPropagation()
|
|
3816
|
+
row.toggleExpanded()
|
|
3817
|
+
}}
|
|
3818
|
+
>
|
|
3819
|
+
{row.getIsExpanded()
|
|
3820
|
+
? <ChevronDown className="size-4" aria-hidden="true" />
|
|
3821
|
+
: <ChevronRight className="size-4" aria-hidden="true" />}
|
|
3822
|
+
</IconButton>
|
|
3823
|
+
) : (
|
|
3824
|
+
<span className="inline-block size-6" aria-hidden="true" />
|
|
3825
|
+
)}
|
|
3826
|
+
</span>
|
|
3827
|
+
) : null
|
|
3828
|
+
|
|
3680
3829
|
return (
|
|
3681
3830
|
<TableCell
|
|
3682
3831
|
key={cell.id}
|
|
3683
3832
|
className={responsiveClass(priority, columnMeta?.hidden) + (isStickyCell ? ` md:sticky md:left-0 md:z-10 md:bg-background ${STICKY_LEFT_SHADOW_CLASS}` : '')}
|
|
3684
3833
|
style={typeof sizedWidth === 'number' ? { width: sizedWidth, minWidth: sizedWidth, maxWidth: sizedWidth } : undefined}
|
|
3685
3834
|
>
|
|
3686
|
-
{
|
|
3835
|
+
{expandAffordance ? (
|
|
3836
|
+
<span className="flex items-center gap-1.5">
|
|
3837
|
+
{expandAffordance}
|
|
3838
|
+
<span className="min-w-0 flex-1">{wrappedContent}</span>
|
|
3839
|
+
</span>
|
|
3840
|
+
) : wrappedContent}
|
|
3687
3841
|
</TableCell>
|
|
3688
3842
|
)
|
|
3689
3843
|
})}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `density` exists for narrow hosts — a docked inspector rail, a side panel —
|
|
5
|
+
* where the page-width rhythm reads as airy and costs the host a third of its
|
|
6
|
+
* column.
|
|
7
|
+
*
|
|
8
|
+
* The contract under test is deliberately narrow, because this prop lands on a
|
|
9
|
+
* component every form in the product renders through:
|
|
10
|
+
*
|
|
11
|
+
* 1. omitting it changes NOTHING (the default renders the same class strings
|
|
12
|
+
* that were inline before the prop existed), and
|
|
13
|
+
* 2. `compact` changes SPACING only — the same fields, labels, helper text and
|
|
14
|
+
* controls, one step down the DS scale.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
jest.mock('next/navigation', () => ({
|
|
18
|
+
useRouter: () => ({ push: jest.fn() }),
|
|
19
|
+
usePathname: () => '/',
|
|
20
|
+
useSearchParams: () => new URLSearchParams(),
|
|
21
|
+
}))
|
|
22
|
+
jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
|
|
23
|
+
jest.mock('../confirm-dialog', () => ({
|
|
24
|
+
useConfirmDialog: () => ({ confirm: jest.fn().mockResolvedValue(true), ConfirmDialogElement: null }),
|
|
25
|
+
}))
|
|
26
|
+
jest.mock('../custom-fields/FieldDefinitionsManager', () => {
|
|
27
|
+
const React = require('react')
|
|
28
|
+
return { __esModule: true, FieldDefinitionsManager: React.forwardRef(() => <div />) }
|
|
29
|
+
})
|
|
30
|
+
jest.mock('../utils/customFieldForms', () => ({
|
|
31
|
+
__esModule: true,
|
|
32
|
+
buildFormFieldFromCustomFieldDef: jest.fn(),
|
|
33
|
+
buildFormFieldsFromCustomFields: jest.fn(() => []),
|
|
34
|
+
fetchCustomFieldFormStructure: jest.fn(),
|
|
35
|
+
}))
|
|
36
|
+
|
|
37
|
+
import * as React from 'react'
|
|
38
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
39
|
+
import { CrudForm, type CrudField, type CrudFormGroup } from '../CrudForm'
|
|
40
|
+
|
|
41
|
+
const fields: CrudField[] = [
|
|
42
|
+
{ id: 'name', label: 'Name', type: 'text', description: 'What to call it' },
|
|
43
|
+
{ id: 'notes', label: 'Notes', type: 'text' },
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
const groups: CrudFormGroup[] = [{ id: 'basic', title: 'Basic', column: 1, fields: ['name', 'notes'] }]
|
|
47
|
+
|
|
48
|
+
function renderForm(density?: 'default' | 'compact') {
|
|
49
|
+
const { container } = renderWithProviders(
|
|
50
|
+
<CrudForm
|
|
51
|
+
fields={fields}
|
|
52
|
+
groups={groups}
|
|
53
|
+
initialValues={{ name: 'x' }}
|
|
54
|
+
onSubmit={jest.fn()}
|
|
55
|
+
embedded
|
|
56
|
+
{...(density ? { density } : {})}
|
|
57
|
+
/>,
|
|
58
|
+
)
|
|
59
|
+
return container
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function classesOf(container: HTMLElement): string[] {
|
|
63
|
+
return Array.from(container.querySelectorAll<HTMLElement>('*')).map((node) => node.className.toString())
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('CrudForm density', () => {
|
|
67
|
+
it('renders identically whether the prop is omitted or set to its default', () => {
|
|
68
|
+
// React's `useId` counter advances between renders in one file, so the
|
|
69
|
+
// generated form id is normalised away; everything else must match exactly.
|
|
70
|
+
const normalise = (html: string) => html.replace(/_r_[0-9a-z]+_/g, '_r_id_')
|
|
71
|
+
const omitted = normalise(renderForm().innerHTML)
|
|
72
|
+
const explicit = normalise(renderForm('default').innerHTML)
|
|
73
|
+
|
|
74
|
+
expect(explicit).toBe(omitted)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('steps the form rhythm down one DS step when compact', () => {
|
|
78
|
+
const form = renderForm().querySelector('form')
|
|
79
|
+
const compactForm = renderForm('compact').querySelector('form')
|
|
80
|
+
|
|
81
|
+
expect(form?.className).toContain('space-y-4')
|
|
82
|
+
expect(compactForm?.className).toContain('space-y-3')
|
|
83
|
+
expect(compactForm?.className).not.toContain('space-y-4')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('tightens the group card padding when compact', () => {
|
|
87
|
+
const defaultCard = renderForm().querySelector('.rounded-lg.border')
|
|
88
|
+
const compactCard = renderForm('compact').querySelector('.rounded-lg.border')
|
|
89
|
+
|
|
90
|
+
expect(defaultCard?.className).toContain('px-4')
|
|
91
|
+
expect(defaultCard?.className).toContain('py-3')
|
|
92
|
+
expect(compactCard?.className).toContain('px-3')
|
|
93
|
+
expect(compactCard?.className).toContain('py-2')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('never reaches for an arbitrary Tailwind value', () => {
|
|
97
|
+
// The DS ban is on values like `p-[13px]`; density must stay on the scale.
|
|
98
|
+
for (const className of classesOf(renderForm('compact'))) {
|
|
99
|
+
expect(className).not.toMatch(/\b(?:p|px|py|m|mx|my|space-[xy])-\[/)
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('changes spacing only — the same fields, labels and helper text survive', () => {
|
|
104
|
+
const compact = renderForm('compact')
|
|
105
|
+
|
|
106
|
+
expect(compact.textContent).toContain('Name')
|
|
107
|
+
expect(compact.textContent).toContain('Notes')
|
|
108
|
+
expect(compact.textContent).toContain('What to call it')
|
|
109
|
+
expect(compact.querySelectorAll('[data-crud-field-id]')).toHaveLength(
|
|
110
|
+
renderForm().querySelectorAll('[data-crud-field-id]').length,
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
})
|