noecosystem-design 0.2.3 → 0.3.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/README.md +2 -2
- package/apps/storybook/stories/catalog/checkbox.stories.tsx +7 -2
- package/apps/storybook/stories/catalog/data-table.stories.tsx +226 -3
- package/docs/design-system/components.md +32 -0
- package/docs/design-system/inventory.md +1 -0
- package/docs/design-system/visual-qa.md +6 -0
- package/package.json +1 -1
- package/packages/data-ui/package.json +6 -1
- package/packages/data-ui/src/data-table.tsx +2 -99
- package/packages/data-ui/src/pagination.tsx +2 -2
- package/packages/data-ui/src/table/adapters/tanstack.test.ts +116 -0
- package/packages/data-ui/src/table/adapters/tanstack.ts +179 -0
- package/packages/data-ui/src/table/contracts.ts +133 -0
- package/packages/data-ui/src/table/data-table-visual.evidence.browser.test.tsx +157 -0
- package/packages/data-ui/src/table/data-table.browser.test.tsx +180 -0
- package/packages/data-ui/src/table/data-table.tsx +524 -0
- package/packages/design-tokens/src/tokens.css +10 -4
- package/packages/design-tokens/src/tokens.json +8 -4
- package/packages/design-tokens/src/tokens.mjs +8 -4
- package/packages/registry/catalog-index.json +2 -2
- package/packages/registry/manifest.json +60 -18
- package/packages/registry/r/activity-feed.json +1 -1
- package/packages/registry/r/agent-composer.json +7 -1
- package/packages/registry/r/agent-message.json +1 -1
- package/packages/registry/r/agent-plan.json +1 -1
- package/packages/registry/r/agent-workspace.json +1 -1
- package/packages/registry/r/approval-request.json +7 -1
- package/packages/registry/r/calendar.json +6 -5
- package/packages/registry/r/chart-frame.json +1 -1
- package/packages/registry/r/chart.json +1 -1
- package/packages/registry/r/dashboard-01.json +22 -2
- package/packages/registry/r/dashboard-02.json +22 -2
- package/packages/registry/r/dashboard-shell.json +1 -1
- package/packages/registry/r/data-table.json +22 -2
- package/packages/registry/r/date-picker.json +6 -5
- package/packages/registry/r/filter-bar.json +1 -1
- package/packages/registry/r/inline-cta.json +6 -6
- package/packages/registry/r/metric-card.json +1 -1
- package/packages/registry/r/pagination.json +7 -5
- package/packages/registry/r/run-inspector.json +1 -1
- package/packages/registry/r/tool-call.json +1 -1
- package/packages/registry/r/workflow-canvas.json +1 -1
- package/packages/registry/r/workflow-node.json +1 -1
- package/packages/registry/registry.json +12 -3
- package/packages/registry/search-index.json +23 -8
- package/packages/ui/package.json +5 -1
- package/packages/ui/src/checkbox.tsx +1 -1
- package/scripts/generate-manifest.mjs +71 -0
- package/vitest.config.ts +5 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import {
|
|
2
|
+
columnFilteringFeature,
|
|
3
|
+
createFilteredRowModel,
|
|
4
|
+
createPaginatedRowModel,
|
|
5
|
+
createSortedRowModel,
|
|
6
|
+
rowPaginationFeature,
|
|
7
|
+
rowSelectionFeature,
|
|
8
|
+
rowSortingFeature,
|
|
9
|
+
sortFn_alphanumeric,
|
|
10
|
+
sortFn_basic,
|
|
11
|
+
sortFn_datetime,
|
|
12
|
+
sortFn_text,
|
|
13
|
+
tableFeatures,
|
|
14
|
+
} from '@tanstack/react-table';
|
|
15
|
+
import type {
|
|
16
|
+
ColumnDef,
|
|
17
|
+
ColumnFiltersState,
|
|
18
|
+
PaginationState,
|
|
19
|
+
Row,
|
|
20
|
+
RowData,
|
|
21
|
+
RowSelectionState,
|
|
22
|
+
SortingState,
|
|
23
|
+
TableFeatures,
|
|
24
|
+
} from '@tanstack/react-table';
|
|
25
|
+
|
|
26
|
+
import type { DataColumn, DataSort, FilterSpec, PageSpec, RowId } from '../contracts';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Internal TanStack Table v9 adapter.
|
|
30
|
+
*
|
|
31
|
+
* This module is the only place in @noe/data-ui that knows about TanStack.
|
|
32
|
+
* Public NOE types (contracts.ts) never reference TanStack, and TanStack types
|
|
33
|
+
* must never be re-exported from the package entry point.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Client-side evaluator for NOE FilterSpec operators. Registered in the
|
|
38
|
+
* feature set as `noe` and attached to every filterable column.
|
|
39
|
+
*/
|
|
40
|
+
function noeFilterFn<TFeatures extends TableFeatures, TData extends RowData>(
|
|
41
|
+
row: Row<TFeatures, TData>,
|
|
42
|
+
columnId: string,
|
|
43
|
+
filterValue: unknown,
|
|
44
|
+
): boolean {
|
|
45
|
+
const spec = filterValue as { operator: FilterSpec['operator']; value: unknown };
|
|
46
|
+
const cell = row.getValue<unknown>(columnId);
|
|
47
|
+
const target = spec.value;
|
|
48
|
+
|
|
49
|
+
switch (spec.operator) {
|
|
50
|
+
case 'contains':
|
|
51
|
+
return String(cell ?? '')
|
|
52
|
+
.toLowerCase()
|
|
53
|
+
.includes(String(target ?? '').toLowerCase());
|
|
54
|
+
case 'equals':
|
|
55
|
+
return cell === target;
|
|
56
|
+
case 'startsWith':
|
|
57
|
+
return String(cell ?? '')
|
|
58
|
+
.toLowerCase()
|
|
59
|
+
.startsWith(String(target ?? '').toLowerCase());
|
|
60
|
+
case 'endsWith':
|
|
61
|
+
return String(cell ?? '')
|
|
62
|
+
.toLowerCase()
|
|
63
|
+
.endsWith(String(target ?? '').toLowerCase());
|
|
64
|
+
case 'gt':
|
|
65
|
+
return compareValues(cell, target) > 0;
|
|
66
|
+
case 'gte':
|
|
67
|
+
return compareValues(cell, target) >= 0;
|
|
68
|
+
case 'lt':
|
|
69
|
+
return compareValues(cell, target) < 0;
|
|
70
|
+
case 'lte':
|
|
71
|
+
return compareValues(cell, target) <= 0;
|
|
72
|
+
case 'in':
|
|
73
|
+
return Array.isArray(target) && target.includes(cell);
|
|
74
|
+
case 'between': {
|
|
75
|
+
const [min, max] = Array.isArray(target) ? target : [undefined, undefined];
|
|
76
|
+
return compareValues(cell, min) >= 0 && compareValues(cell, max) <= 0;
|
|
77
|
+
}
|
|
78
|
+
default:
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function compareValues(a: unknown, b: unknown): number {
|
|
84
|
+
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
|
85
|
+
return String(a ?? '').localeCompare(String(b ?? ''));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The NOE feature set: sorting, filtering, pagination and selection are
|
|
90
|
+
* registered explicitly (never `stockFeatures`), so unsupported behavior
|
|
91
|
+
* stays out of the bundle while DataTable keeps one static feature identity.
|
|
92
|
+
*/
|
|
93
|
+
export const noeTableFeatures = tableFeatures({
|
|
94
|
+
columnFilteringFeature,
|
|
95
|
+
filteredRowModel: createFilteredRowModel(),
|
|
96
|
+
filterFns: { noe: noeFilterFn },
|
|
97
|
+
rowPaginationFeature,
|
|
98
|
+
paginatedRowModel: createPaginatedRowModel(),
|
|
99
|
+
rowSelectionFeature,
|
|
100
|
+
rowSortingFeature,
|
|
101
|
+
sortedRowModel: createSortedRowModel(),
|
|
102
|
+
sortFns: {
|
|
103
|
+
text: sortFn_text,
|
|
104
|
+
alphanumeric: sortFn_alphanumeric,
|
|
105
|
+
basic: sortFn_basic,
|
|
106
|
+
datetime: sortFn_datetime,
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
export type NoeTableFeatures = typeof noeTableFeatures;
|
|
111
|
+
|
|
112
|
+
// --- Sorting -----------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
export function toTanStackSorting(sorts: readonly DataSort[]): SortingState {
|
|
115
|
+
return sorts.map((sort) => ({ id: sort.field, desc: sort.direction === 'desc' }));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function fromTanStackSorting(sorting: SortingState): DataSort[] {
|
|
119
|
+
return sorting.map((sort) => ({
|
|
120
|
+
field: sort.id,
|
|
121
|
+
direction: sort.desc ? 'desc' : 'asc',
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- Filtering ---------------------------------------------------------------
|
|
126
|
+
// One TanStack column filter per field; the NOE operator travels inside the
|
|
127
|
+
// filter value so `fromTanStackFilters` can reconstruct the NOE contract.
|
|
128
|
+
|
|
129
|
+
export function toTanStackFilters(filters: readonly FilterSpec[]): ColumnFiltersState {
|
|
130
|
+
const byField = new Map<string, FilterSpec>();
|
|
131
|
+
for (const filter of filters) byField.set(filter.field, filter);
|
|
132
|
+
return [...byField.values()].map((filter) => ({
|
|
133
|
+
id: filter.field,
|
|
134
|
+
value: { operator: filter.operator, value: filter.value },
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function fromTanStackFilters(filters: ColumnFiltersState): FilterSpec[] {
|
|
139
|
+
return filters.map((filter) => {
|
|
140
|
+
const value = filter.value as { operator: FilterSpec['operator']; value: unknown };
|
|
141
|
+
return { field: filter.id, operator: value.operator, value: value.value };
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// --- Pagination ---------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
export function toTanStackPagination(page: PageSpec | undefined): PaginationState {
|
|
148
|
+
return { pageIndex: page?.index ?? 0, pageSize: page?.size ?? Number.MAX_SAFE_INTEGER };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function fromTanStackPagination(pagination: PaginationState): PageSpec {
|
|
152
|
+
return { index: pagination.pageIndex, size: pagination.pageSize };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- Selection -----------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
export function toTanStackSelection(ids: readonly RowId[]): RowSelectionState {
|
|
158
|
+
const state: RowSelectionState = {};
|
|
159
|
+
for (const id of ids) state[id] = true;
|
|
160
|
+
return state;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function fromTanStackSelection(selection: RowSelectionState): RowId[] {
|
|
164
|
+
return Object.keys(selection);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// --- Columns --------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
export function toTanStackColumns<Row extends { id: string }>(
|
|
170
|
+
columns: readonly DataColumn<Row>[],
|
|
171
|
+
): ColumnDef<NoeTableFeatures, Row, unknown>[] {
|
|
172
|
+
return columns.map((column) => ({
|
|
173
|
+
id: column.key,
|
|
174
|
+
accessorKey: column.key,
|
|
175
|
+
enableSorting: column.sortable !== false,
|
|
176
|
+
...(column.sortFn ? { sortFn: column.sortFn } : {}),
|
|
177
|
+
filterFn: 'noe' as const,
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* NOE data-table public contracts.
|
|
5
|
+
*
|
|
6
|
+
* These types are the stable public API of the NOE DataTable. Internal engines
|
|
7
|
+
* (currently TanStack Table v9) adapt to these contracts and must never leak
|
|
8
|
+
* through them. See ADR 0008 for the capability-library boundary.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Stable application-level row identifier. Never a positional index. */
|
|
12
|
+
export type RowId = string;
|
|
13
|
+
|
|
14
|
+
/** NOE sort direction. Logical, not visual: RTL rendering stays start/end based. */
|
|
15
|
+
export type DataSortDirection = 'asc' | 'desc';
|
|
16
|
+
|
|
17
|
+
/** A single sort entry over a data field. */
|
|
18
|
+
export interface DataSort {
|
|
19
|
+
field: string;
|
|
20
|
+
direction: DataSortDirection;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* NOE filter operators. In server mode these travel to the backend verbatim;
|
|
25
|
+
* in client mode they are evaluated by the internal engine adapter.
|
|
26
|
+
*/
|
|
27
|
+
export type FilterOperator =
|
|
28
|
+
| 'contains'
|
|
29
|
+
| 'equals'
|
|
30
|
+
| 'startsWith'
|
|
31
|
+
| 'endsWith'
|
|
32
|
+
| 'gt'
|
|
33
|
+
| 'gte'
|
|
34
|
+
| 'lt'
|
|
35
|
+
| 'lte'
|
|
36
|
+
| 'in'
|
|
37
|
+
| 'between';
|
|
38
|
+
|
|
39
|
+
/** A single filter entry over a data field. */
|
|
40
|
+
export interface FilterSpec {
|
|
41
|
+
field: string;
|
|
42
|
+
operator: FilterOperator;
|
|
43
|
+
value: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Zero-based page index plus page size. */
|
|
47
|
+
export interface PageSpec {
|
|
48
|
+
/** Zero-based page index. */
|
|
49
|
+
index: number;
|
|
50
|
+
/** Number of rows per page. */
|
|
51
|
+
size: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A serializable description of the current table query state. */
|
|
55
|
+
export interface DataQuery {
|
|
56
|
+
search?: string;
|
|
57
|
+
filters: FilterSpec[];
|
|
58
|
+
sorts: DataSort[];
|
|
59
|
+
page?: PageSpec;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface DataQueryInput {
|
|
63
|
+
search?: string;
|
|
64
|
+
filters?: readonly FilterSpec[];
|
|
65
|
+
sorts?: readonly DataSort[];
|
|
66
|
+
page?: PageSpec;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Builds a serializable DataQuery from table state pieces. */
|
|
70
|
+
export function toDataQuery(input: DataQueryInput): DataQuery {
|
|
71
|
+
return {
|
|
72
|
+
...(input.search !== undefined ? { search: input.search } : {}),
|
|
73
|
+
filters: [...(input.filters ?? [])],
|
|
74
|
+
sorts: [...(input.sorts ?? [])],
|
|
75
|
+
...(input.page !== undefined ? { page: { ...input.page } } : {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Row selection behavior: one row at a time, or many. */
|
|
80
|
+
export type SelectionMode = 'single' | 'multiple';
|
|
81
|
+
|
|
82
|
+
/** Selected row ids as stable application identifiers. */
|
|
83
|
+
export type SelectionState = readonly RowId[];
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Store-agnostic state ownership contract (OpenStatus-inspired BYOS pattern).
|
|
87
|
+
* Products can back table state with URL, React state, a query cache, or any
|
|
88
|
+
* external store without coupling NOE to a specific transport or framework.
|
|
89
|
+
*/
|
|
90
|
+
export interface TableStateAdapter<TState> {
|
|
91
|
+
get(): TState;
|
|
92
|
+
set(next: TState): void;
|
|
93
|
+
subscribe(listener: () => void): () => void;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Minimal in-memory TableStateAdapter for non-persisted table state. */
|
|
97
|
+
export function createMemoryTableStateAdapter<TState>(initial: TState): TableStateAdapter<TState> {
|
|
98
|
+
let value = initial;
|
|
99
|
+
const listeners = new Set<() => void>();
|
|
100
|
+
return {
|
|
101
|
+
get: () => value,
|
|
102
|
+
set: (next: TState) => {
|
|
103
|
+
value = next;
|
|
104
|
+
for (const listener of listeners) listener();
|
|
105
|
+
},
|
|
106
|
+
subscribe: (listener: () => void) => {
|
|
107
|
+
listeners.add(listener);
|
|
108
|
+
return () => listeners.delete(listener);
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type DataColumnSortFn = 'text' | 'alphanumeric' | 'basic' | 'datetime';
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* NOE column contract. `key` addresses a row field; presentation stays with
|
|
117
|
+
* NOE (semantic markup, alignment, bidi handling), while the internal engine
|
|
118
|
+
* uses the key as a stable accessor id for sorting and filtering.
|
|
119
|
+
*/
|
|
120
|
+
export type DataColumn<Row> = {
|
|
121
|
+
/** Row field this column reads and sorts/filters by. */
|
|
122
|
+
key: keyof Row & string;
|
|
123
|
+
header: React.ReactNode;
|
|
124
|
+
/** Logical alignment: `start`/`end`, never left/right. */
|
|
125
|
+
align?: 'start' | 'end';
|
|
126
|
+
/** Render with LTR isolation for technical identifiers. */
|
|
127
|
+
technical?: boolean;
|
|
128
|
+
render?: (row: Row) => React.ReactNode;
|
|
129
|
+
/** Opt out of sorting for this column. Default: sortable. */
|
|
130
|
+
sortable?: boolean;
|
|
131
|
+
/** Client-side sort strategy. Default: engine auto-detection. */
|
|
132
|
+
sortFn?: DataColumnSortFn;
|
|
133
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type * as React from 'react';
|
|
2
|
+
import { expect, test } from 'vitest';
|
|
3
|
+
import { render } from 'vitest-browser-react';
|
|
4
|
+
|
|
5
|
+
import '../../../design-tokens/src/tailwind.css';
|
|
6
|
+
|
|
7
|
+
import type { DataColumn, DataSort } from './contracts';
|
|
8
|
+
import { DataTable } from './data-table';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Deterministic PNG evidence for the NOE DataTable (docs/design-system/visual-qa.md).
|
|
12
|
+
* Captures regular/compact density, RTL Persian, selection and loading states
|
|
13
|
+
* into evidence/data-table/. Pure layout evidence: no mock data leaves the repo.
|
|
14
|
+
*
|
|
15
|
+
* All variants render once; per-variant wrappers are captured individually
|
|
16
|
+
* because repeated render/unmount cycles invalidate the shared mount wrapper.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// Resolved relative to this test file by the browser screenshot driver.
|
|
20
|
+
const OUT = '../../../../evidence/data-table';
|
|
21
|
+
|
|
22
|
+
type Row = { id: string; service: string; owner: string; status: string; updated: string };
|
|
23
|
+
|
|
24
|
+
const columns: DataColumn<Row>[] = [
|
|
25
|
+
{ key: 'service', header: 'Service', technical: true },
|
|
26
|
+
{ key: 'owner', header: 'Owner' },
|
|
27
|
+
{ key: 'status', header: 'Status' },
|
|
28
|
+
{ key: 'updated', header: 'Updated', technical: true, align: 'end' },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const rows: Row[] = [
|
|
32
|
+
{ id: '1', service: 'catalog-api', owner: 'Platform', status: 'Healthy', updated: '2m ago' },
|
|
33
|
+
{
|
|
34
|
+
id: '2',
|
|
35
|
+
service: 'agent-runner',
|
|
36
|
+
owner: 'AI Platform',
|
|
37
|
+
status: 'Degraded',
|
|
38
|
+
updated: '11m ago',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: '3',
|
|
42
|
+
service: 'registry-sync',
|
|
43
|
+
owner: 'Design Systems',
|
|
44
|
+
status: 'Healthy',
|
|
45
|
+
updated: '1h ago',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: '4',
|
|
49
|
+
service: 'registry-api',
|
|
50
|
+
owner: 'Design Systems',
|
|
51
|
+
status: 'Healthy',
|
|
52
|
+
updated: '3h ago',
|
|
53
|
+
},
|
|
54
|
+
{ id: '5', service: 'agent-gateway', owner: 'AI Platform', status: 'Healthy', updated: '5h ago' },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const faColumns: DataColumn<Row>[] = [
|
|
58
|
+
{ key: 'service', header: 'سرویس', technical: true },
|
|
59
|
+
{ key: 'owner', header: 'مالک' },
|
|
60
|
+
{ key: 'status', header: 'وضعیت' },
|
|
61
|
+
];
|
|
62
|
+
const faSorting: DataSort[] = [{ field: 'service', direction: 'asc' }];
|
|
63
|
+
|
|
64
|
+
const captures: Array<{ name: string; ui: React.ReactElement }> = [
|
|
65
|
+
{
|
|
66
|
+
name: 'data-table-regular-en-light',
|
|
67
|
+
ui: (
|
|
68
|
+
<DataTable columns={columns} rows={rows} caption="Service health" empty="No services yet." />
|
|
69
|
+
),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'data-table-compact-fa-rtl',
|
|
73
|
+
ui: (
|
|
74
|
+
<div dir="rtl" lang="fa">
|
|
75
|
+
<DataTable
|
|
76
|
+
columns={faColumns}
|
|
77
|
+
rows={rows}
|
|
78
|
+
caption="وضعیت سرویسها"
|
|
79
|
+
density="compact"
|
|
80
|
+
empty="هنوز سرویسی ثبت نشده است."
|
|
81
|
+
sorting={faSorting}
|
|
82
|
+
onSortingChange={() => {}}
|
|
83
|
+
/>
|
|
84
|
+
</div>
|
|
85
|
+
),
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'data-table-selection',
|
|
89
|
+
ui: (
|
|
90
|
+
<DataTable
|
|
91
|
+
columns={columns}
|
|
92
|
+
rows={rows}
|
|
93
|
+
caption="Service health"
|
|
94
|
+
empty="No services yet."
|
|
95
|
+
defaultSelection={['2']}
|
|
96
|
+
selectAllLabel="Select all services"
|
|
97
|
+
selectedCountLabel={(count: number) => `${count} selected`}
|
|
98
|
+
renderSelectionActions={() => <button type="button">Restart</button>}
|
|
99
|
+
/>
|
|
100
|
+
),
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'data-table-loading',
|
|
104
|
+
ui: (
|
|
105
|
+
<DataTable
|
|
106
|
+
columns={columns}
|
|
107
|
+
rows={[]}
|
|
108
|
+
caption="Service health"
|
|
109
|
+
empty="No services yet."
|
|
110
|
+
loading
|
|
111
|
+
loadingRows={4}
|
|
112
|
+
/>
|
|
113
|
+
),
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: 'data-table-selection-fa-dark-compact',
|
|
117
|
+
ui: (
|
|
118
|
+
<div
|
|
119
|
+
data-theme="dark"
|
|
120
|
+
dir="rtl"
|
|
121
|
+
lang="fa"
|
|
122
|
+
className="rounded-lg bg-background p-3 text-foreground"
|
|
123
|
+
>
|
|
124
|
+
<DataTable
|
|
125
|
+
columns={faColumns}
|
|
126
|
+
rows={rows}
|
|
127
|
+
caption="وضعیت سرویسها"
|
|
128
|
+
density="compact"
|
|
129
|
+
empty="هنوز سرویسی ثبت نشده است."
|
|
130
|
+
defaultSelection={['2']}
|
|
131
|
+
selectAllLabel="انتخاب همه سرویسها"
|
|
132
|
+
selectedCountLabel={(count: number) => `${count} مورد انتخاب شده`}
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
),
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
test('captures DataTable visual evidence', async () => {
|
|
140
|
+
const screen = await render(
|
|
141
|
+
<div className="flex flex-col">
|
|
142
|
+
{captures.map(({ name, ui }) => (
|
|
143
|
+
<div data-testid={`capture-${name}`} key={name}>
|
|
144
|
+
{ui}
|
|
145
|
+
</div>
|
|
146
|
+
))}
|
|
147
|
+
</div>,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
for (const { name } of captures) {
|
|
151
|
+
await screen
|
|
152
|
+
.getByTestId(`capture-${name}`)
|
|
153
|
+
.screenshot({ animations: 'disabled', caret: 'hide', path: `${OUT}/${name}.png` });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
expect(captures).toHaveLength(5);
|
|
157
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { expect, test, vi } from 'vitest';
|
|
3
|
+
import { render } from 'vitest-browser-react';
|
|
4
|
+
|
|
5
|
+
import type { DataColumn } from './contracts';
|
|
6
|
+
import { DataTable } from './data-table';
|
|
7
|
+
|
|
8
|
+
type Row = { id: string; service: string; owner: string };
|
|
9
|
+
|
|
10
|
+
const columns: DataColumn<Row>[] = [
|
|
11
|
+
{ key: 'service', header: 'Service', technical: true },
|
|
12
|
+
{ key: 'owner', header: 'Owner' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const rows: Row[] = [
|
|
16
|
+
{ id: '1', service: 'catalog-api', owner: 'Platform' },
|
|
17
|
+
{ id: '2', service: 'agent-runner', owner: 'AI Platform' },
|
|
18
|
+
{ id: '3', service: 'registry-sync', owner: 'Design Systems' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
interface ScreenLike {
|
|
22
|
+
getByRole(
|
|
23
|
+
role: 'table' | 'button' | 'checkbox' | 'columnheader',
|
|
24
|
+
options?: { name?: string },
|
|
25
|
+
): {
|
|
26
|
+
element(): Element;
|
|
27
|
+
click(): Promise<void>;
|
|
28
|
+
};
|
|
29
|
+
getByText(text: string): { element(): Element };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const bodyRows = (screen: ScreenLike) =>
|
|
33
|
+
(screen.getByRole('table').element() as HTMLTableElement).querySelectorAll('tbody tr');
|
|
34
|
+
|
|
35
|
+
test('renders a semantic table with logical alignment metadata', async () => {
|
|
36
|
+
const screen = await render(
|
|
37
|
+
<DataTable columns={columns} rows={rows} caption="Service health" empty="No services yet." />,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const table = screen.getByRole('table').element() as HTMLTableElement;
|
|
41
|
+
expect(table.querySelector('caption')?.textContent).toBe('Service health');
|
|
42
|
+
const header = screen
|
|
43
|
+
.getByRole('columnheader', { name: 'Service' })
|
|
44
|
+
.element() as HTMLTableCellElement;
|
|
45
|
+
expect(header.getAttribute('data-align')).toBe('start');
|
|
46
|
+
expect(header.getAttribute('aria-sort')).toBe('none');
|
|
47
|
+
expect(bodyRows(screen)).toHaveLength(3);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('sorting cycles ascending, descending, then unsorted with aria-sort state', async () => {
|
|
51
|
+
const onSortingChange = vi.fn();
|
|
52
|
+
const screen = await render(
|
|
53
|
+
<DataTable
|
|
54
|
+
columns={columns}
|
|
55
|
+
rows={rows}
|
|
56
|
+
caption="Service health"
|
|
57
|
+
empty="No services yet."
|
|
58
|
+
onSortingChange={onSortingChange}
|
|
59
|
+
/>,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const header = () =>
|
|
63
|
+
screen.getByRole('columnheader', { name: 'Service' }).element() as HTMLTableCellElement;
|
|
64
|
+
const firstRowService = () =>
|
|
65
|
+
(bodyRows(screen)[0] as HTMLTableRowElement).querySelector('td')?.textContent;
|
|
66
|
+
|
|
67
|
+
await screen.getByRole('button', { name: 'Service' }).click();
|
|
68
|
+
expect(header().getAttribute('aria-sort')).toBe('ascending');
|
|
69
|
+
expect(firstRowService()).toBe('agent-runner');
|
|
70
|
+
expect(onSortingChange).toHaveBeenLastCalledWith([{ field: 'service', direction: 'asc' }]);
|
|
71
|
+
|
|
72
|
+
await screen.getByRole('button', { name: 'Service' }).click();
|
|
73
|
+
expect(header().getAttribute('aria-sort')).toBe('descending');
|
|
74
|
+
expect(firstRowService()).toBe('registry-sync');
|
|
75
|
+
expect(onSortingChange).toHaveBeenLastCalledWith([{ field: 'service', direction: 'desc' }]);
|
|
76
|
+
|
|
77
|
+
await screen.getByRole('button', { name: 'Service' }).click();
|
|
78
|
+
expect(header().getAttribute('aria-sort')).toBe('none');
|
|
79
|
+
expect(firstRowService()).toBe('catalog-api');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('selection keeps stable row ids and reflects indeterminate state', async () => {
|
|
83
|
+
const onSelectionChange = vi.fn();
|
|
84
|
+
const screen = await render(
|
|
85
|
+
<DataTable
|
|
86
|
+
columns={columns}
|
|
87
|
+
rows={rows}
|
|
88
|
+
caption="Service health"
|
|
89
|
+
empty="No services yet."
|
|
90
|
+
defaultSelection={['2']}
|
|
91
|
+
onSelectionChange={onSelectionChange}
|
|
92
|
+
selectAllLabel="Select all rows"
|
|
93
|
+
rowSelectionLabel={(row: Row) => `Select ${row.service}`}
|
|
94
|
+
selectedCountLabel={(count: number) => `${count} selected`}
|
|
95
|
+
/>,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
await screen.getByRole('checkbox', { name: 'Select catalog-api' }).click();
|
|
99
|
+
expect(onSelectionChange).toHaveBeenLastCalledWith(['1', '2']);
|
|
100
|
+
expect(
|
|
101
|
+
screen.getByRole('checkbox', { name: 'Select all rows' }).element() as HTMLInputElement,
|
|
102
|
+
).toBePartiallyChecked();
|
|
103
|
+
|
|
104
|
+
await screen.getByRole('checkbox', { name: 'Select all rows' }).click();
|
|
105
|
+
expect(onSelectionChange).toHaveBeenLastCalledWith(['1', '2', '3']);
|
|
106
|
+
expect(screen.getByText('3 selected').element()).toBeTruthy();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('client-side pagination slices rows and reports status', async () => {
|
|
110
|
+
function PaginationHost() {
|
|
111
|
+
const [page, setPage] = useState({ index: 0, size: 2 });
|
|
112
|
+
return (
|
|
113
|
+
<DataTable
|
|
114
|
+
columns={columns}
|
|
115
|
+
rows={rows}
|
|
116
|
+
caption="Service health"
|
|
117
|
+
empty="No services yet."
|
|
118
|
+
page={page}
|
|
119
|
+
onPageChange={setPage}
|
|
120
|
+
previousPageLabel="Previous page"
|
|
121
|
+
nextPageLabel="Next page"
|
|
122
|
+
/>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const screen = await render(<PaginationHost />);
|
|
126
|
+
|
|
127
|
+
expect(bodyRows(screen)).toHaveLength(2);
|
|
128
|
+
expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled();
|
|
129
|
+
expect(screen.getByText('1–2 / 3').element()).toBeTruthy();
|
|
130
|
+
|
|
131
|
+
await screen.getByRole('button', { name: 'Next page' }).click();
|
|
132
|
+
expect(bodyRows(screen)).toHaveLength(1);
|
|
133
|
+
expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled();
|
|
134
|
+
expect(screen.getByText('3–3 / 3').element()).toBeTruthy();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('renders logical alignment inside an RTL container without mirroring hacks', async () => {
|
|
138
|
+
const screen = await render(
|
|
139
|
+
<div dir="rtl">
|
|
140
|
+
<DataTable columns={columns} rows={rows} caption="Service health" empty="No services yet." />
|
|
141
|
+
</div>,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const header = screen
|
|
145
|
+
.getByRole('columnheader', { name: 'Owner' })
|
|
146
|
+
.element() as HTMLTableCellElement;
|
|
147
|
+
expect(header.getAttribute('data-align')).toBe('start');
|
|
148
|
+
expect(header.className).toContain('text-start');
|
|
149
|
+
const technicalCell = screen
|
|
150
|
+
.getByRole('cell', { name: 'catalog-api' })
|
|
151
|
+
.element() as HTMLTableCellElement;
|
|
152
|
+
expect(technicalCell.querySelector('[dir="ltr"]')).toBeTruthy();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('loading renders skeleton rows instead of data', async () => {
|
|
156
|
+
const screen = await render(
|
|
157
|
+
<DataTable
|
|
158
|
+
columns={columns}
|
|
159
|
+
rows={rows}
|
|
160
|
+
caption="Service health"
|
|
161
|
+
empty="No services yet."
|
|
162
|
+
loading
|
|
163
|
+
loadingRows={2}
|
|
164
|
+
/>,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
expect(
|
|
168
|
+
(screen.getByRole('table').element() as HTMLTableElement).querySelectorAll(
|
|
169
|
+
'[data-slot="data-table-loading-row"]',
|
|
170
|
+
),
|
|
171
|
+
).toHaveLength(2);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('empty model renders the empty affordance', async () => {
|
|
175
|
+
const screen = await render(
|
|
176
|
+
<DataTable columns={columns} rows={[]} caption="Service health" empty="No services yet." />,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
expect(screen.getByText('No services yet.').element()).toBeTruthy();
|
|
180
|
+
});
|