@terpjs/react-core 0.1.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 +190 -0
- package/package.json +44 -0
- package/src/AppShell.test.tsx +152 -0
- package/src/AppShell.tsx +554 -0
- package/src/Authorized.test.tsx +60 -0
- package/src/Authorized.tsx +21 -0
- package/src/Breadcrumbs.test.tsx +45 -0
- package/src/Breadcrumbs.tsx +110 -0
- package/src/ConfirmDialog.tsx +170 -0
- package/src/DetailPage.tsx +28 -0
- package/src/EmptyState.tsx +74 -0
- package/src/ErrorState.tsx +108 -0
- package/src/Field.test.tsx +53 -0
- package/src/Field.tsx +51 -0
- package/src/HubPage.test.tsx +108 -0
- package/src/HubPage.tsx +204 -0
- package/src/LoadingState.test.tsx +40 -0
- package/src/LoadingState.tsx +96 -0
- package/src/LoginView.test.tsx +57 -0
- package/src/LoginView.tsx +203 -0
- package/src/ModuleNav.test.tsx +96 -0
- package/src/ModuleNav.tsx +88 -0
- package/src/OverviewPage.tsx +26 -0
- package/src/Page.test.tsx +147 -0
- package/src/Page.tsx +158 -0
- package/src/PageActions.test.tsx +104 -0
- package/src/PageActions.tsx +72 -0
- package/src/ProfileView.test.tsx +112 -0
- package/src/ProfileView.tsx +84 -0
- package/src/RequireAuth.test.tsx +89 -0
- package/src/RequireAuth.tsx +22 -0
- package/src/ResourceList.test.tsx +176 -0
- package/src/ResourceList.tsx +123 -0
- package/src/TerpProvider.tsx +320 -0
- package/src/UserMenu.test.tsx +166 -0
- package/src/UserMenu.tsx +125 -0
- package/src/admin/AdminHub.tsx +108 -0
- package/src/admin/AuditLogAdmin.tsx +116 -0
- package/src/admin/GroupCreate.tsx +90 -0
- package/src/admin/GroupDetail.tsx +446 -0
- package/src/admin/GroupsAdmin.tsx +109 -0
- package/src/admin/UserCreate.tsx +115 -0
- package/src/admin/UserDetail.tsx +228 -0
- package/src/admin/UsersAdmin.tsx +111 -0
- package/src/admin/admin.test.tsx +537 -0
- package/src/admin/crumbs.tsx +14 -0
- package/src/admin/module.tsx +51 -0
- package/src/admin/roles.ts +19 -0
- package/src/bootstrap.test.tsx +67 -0
- package/src/bootstrap.tsx +270 -0
- package/src/capabilities.test.ts +24 -0
- package/src/capabilities.ts +26 -0
- package/src/createAuthClient.test.ts +176 -0
- package/src/createAuthClient.ts +105 -0
- package/src/dataview/DataView.test.tsx +392 -0
- package/src/dataview/DataView.tsx +467 -0
- package/src/dataview/DataViewCardList.tsx +189 -0
- package/src/dataview/DataViewColumnSettings.tsx +118 -0
- package/src/dataview/DataViewExpandableRow.tsx +67 -0
- package/src/dataview/DataViewPagination.tsx +113 -0
- package/src/dataview/DataViewRowActions.tsx +131 -0
- package/src/dataview/DataViewTable.tsx +359 -0
- package/src/dataview/DataViewToolbar.tsx +260 -0
- package/src/dataview/README.md +138 -0
- package/src/dataview/glyphs.tsx +175 -0
- package/src/dataview/hooks/hooks.test.tsx +240 -0
- package/src/dataview/hooks/useDataViewQuery.ts +72 -0
- package/src/dataview/hooks/useDataViewState.ts +310 -0
- package/src/dataview/hooks/useServerDataView.ts +154 -0
- package/src/dataview/hooks/useViewSearch.ts +68 -0
- package/src/dataview/index.ts +62 -0
- package/src/dataview/internal.tsx +96 -0
- package/src/dataview/repositories/HttpDataViewRepository.ts +110 -0
- package/src/dataview/repositories/InMemoryDataViewRepository.ts +145 -0
- package/src/dataview/repositories/repositories.test.ts +158 -0
- package/src/dataview/repositories/viewState.test.ts +90 -0
- package/src/dataview/repositories/viewState.ts +128 -0
- package/src/dataview/types.ts +249 -0
- package/src/errorMessages.test.tsx +83 -0
- package/src/errorMessages.tsx +79 -0
- package/src/feedback.test.tsx +167 -0
- package/src/files.test.tsx +142 -0
- package/src/files.tsx +174 -0
- package/src/icons.test.tsx +46 -0
- package/src/icons.tsx +533 -0
- package/src/index.ts +155 -0
- package/src/layout.test.tsx +72 -0
- package/src/layout.tsx +90 -0
- package/src/layoutContract.test.tsx +179 -0
- package/src/layoutContract.ts +137 -0
- package/src/locale.test.tsx +97 -0
- package/src/locale.tsx +246 -0
- package/src/nav.test.ts +21 -0
- package/src/nav.ts +13 -0
- package/src/pageMarker.ts +15 -0
- package/src/raw.d.ts +7 -0
- package/src/realtime-hook.test.tsx +226 -0
- package/src/realtime.test.ts +44 -0
- package/src/realtime.ts +307 -0
- package/src/refresh-session.test.tsx +114 -0
- package/src/revocation.test.tsx +81 -0
- package/src/router.test.tsx +307 -0
- package/src/router.tsx +222 -0
- package/src/sso.test.tsx +128 -0
- package/src/sso.ts +142 -0
- package/src/ssr.test.tsx +45 -0
- package/src/styles.test.ts +21 -0
- package/src/styles.ts +302 -0
- package/src/theme.test.tsx +74 -0
- package/src/theme.tsx +143 -0
- package/src/toast.test.tsx +94 -0
- package/src/toast.tsx +214 -0
- package/src/tokens.guard.test.ts +51 -0
- package/src/ui/Alert.test.tsx +19 -0
- package/src/ui/Alert.tsx +115 -0
- package/src/ui/Badge.test.tsx +14 -0
- package/src/ui/Badge.tsx +48 -0
- package/src/ui/Button.test.tsx +36 -0
- package/src/ui/Button.tsx +95 -0
- package/src/ui/Card.test.tsx +40 -0
- package/src/ui/Card.tsx +92 -0
- package/src/ui/Checkbox.test.tsx +17 -0
- package/src/ui/Checkbox.tsx +51 -0
- package/src/ui/Combobox.test.tsx +58 -0
- package/src/ui/Combobox.tsx +313 -0
- package/src/ui/DatePicker.test.tsx +60 -0
- package/src/ui/DatePicker.tsx +421 -0
- package/src/ui/Input.tsx +30 -0
- package/src/ui/Markdown.test.tsx +32 -0
- package/src/ui/Markdown.tsx +213 -0
- package/src/ui/Menu.test.tsx +85 -0
- package/src/ui/Menu.tsx +216 -0
- package/src/ui/Popover.tsx +218 -0
- package/src/ui/Radio.test.tsx +29 -0
- package/src/ui/Radio.tsx +127 -0
- package/src/ui/Select.tsx +40 -0
- package/src/ui/Switch.test.tsx +17 -0
- package/src/ui/Switch.tsx +53 -0
- package/src/ui/Tabs.test.tsx +29 -0
- package/src/ui/Tabs.tsx +128 -0
- package/src/ui/Textarea.tsx +27 -0
- package/src/ui/Tooltip.test.tsx +28 -0
- package/src/ui/Tooltip.tsx +67 -0
- package/src/ui/controlStyles.ts +9 -0
- package/src/uiText.test.tsx +93 -0
- package/src/uiText.tsx +342 -0
- package/src/unwrap.test.ts +67 -0
- package/src/unwrap.ts +101 -0
- package/src/useResource.test.tsx +118 -0
- package/src/useResource.ts +110 -0
- package/src/useTerpClient.test.ts +35 -0
- package/tsconfig.json +17 -0
- package/vite.config.ts +14 -0
- package/vitest.setup.ts +58 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DataViewQuery,
|
|
3
|
+
DataViewRepository,
|
|
4
|
+
DataViewResult,
|
|
5
|
+
} from "../types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The request seam of {@link HttpDataViewRepository}: given already-mapped API
|
|
9
|
+
* parameters, perform the request (typically via the app's typed contract client)
|
|
10
|
+
* and return one raw page. Injecting it keeps the repository transport-agnostic —
|
|
11
|
+
* any REST/GraphQL backend plugs in without touching DataView.
|
|
12
|
+
*/
|
|
13
|
+
export type HttpRequestAdapter<T> = (
|
|
14
|
+
params: HttpDataViewParams,
|
|
15
|
+
signal?: AbortSignal,
|
|
16
|
+
) => Promise<{ items: T[]; total: number }>;
|
|
17
|
+
|
|
18
|
+
/** The flat API parameters an {@link HttpRequestAdapter} receives. */
|
|
19
|
+
export interface HttpDataViewParams {
|
|
20
|
+
/** `pageIndex * pageSize`. */
|
|
21
|
+
skip: number;
|
|
22
|
+
/** `pageSize`. */
|
|
23
|
+
limit: number;
|
|
24
|
+
/** e.g. `["title", "-created_at"]` (leading `-` = descending). */
|
|
25
|
+
sort: string[];
|
|
26
|
+
/** Column filters keyed by column id. */
|
|
27
|
+
filters: Record<string, unknown>;
|
|
28
|
+
/** Free-text search term ("" when inactive). */
|
|
29
|
+
search: string;
|
|
30
|
+
/** The broadened "search everything" flag. */
|
|
31
|
+
searchBroadened: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface HttpDataViewRepositoryOptions<T> {
|
|
35
|
+
/** Performs the actual request from mapped parameters. */
|
|
36
|
+
request: HttpRequestAdapter<T>;
|
|
37
|
+
/** Stable row identity. */
|
|
38
|
+
getRowId: (row: T) => string;
|
|
39
|
+
/** Whether the backend supports free-text search (default true). */
|
|
40
|
+
search?: boolean;
|
|
41
|
+
/** Whether the backend supports the broadened search scope (default false). */
|
|
42
|
+
searchScope?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* The largest `limit` ever sent (default 200 — Terp's `PAGINATION_MAX_LIMIT`
|
|
45
|
+
* default, which the backend enforces with a 422). An embedded DataView asks its
|
|
46
|
+
* repository for "everything"; against a server-side source that request is
|
|
47
|
+
* clamped here so it degrades to the first `maxLimit` rows instead of failing.
|
|
48
|
+
* Raise it only in lockstep with the backend setting.
|
|
49
|
+
*/
|
|
50
|
+
maxLimit?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A server-side {@link DataViewRepository}: maps every {@link DataViewQuery} to flat
|
|
55
|
+
* API parameters (`skip = pageIndex * pageSize`, `limit = pageSize`, sort/filter/search)
|
|
56
|
+
* and delegates the transport to an injectable {@link HttpRequestAdapter}.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```ts
|
|
60
|
+
* const repo = new HttpDataViewRepository<NoteRead>({
|
|
61
|
+
* getRowId: (n) => n.id,
|
|
62
|
+
* request: async ({ skip, limit }, signal) => {
|
|
63
|
+
* const page = unwrap(await client.GET("/api/v1/notes/", {
|
|
64
|
+
* params: { query: { skip, limit } }, signal,
|
|
65
|
+
* }));
|
|
66
|
+
* return { items: page.items, total: page.total };
|
|
67
|
+
* },
|
|
68
|
+
* });
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export class HttpDataViewRepository<T> implements DataViewRepository<T> {
|
|
72
|
+
readonly capabilities: DataViewRepository<T>["capabilities"];
|
|
73
|
+
|
|
74
|
+
private readonly options: HttpDataViewRepositoryOptions<T>;
|
|
75
|
+
|
|
76
|
+
constructor(options: HttpDataViewRepositoryOptions<T>) {
|
|
77
|
+
this.options = options;
|
|
78
|
+
this.capabilities = {
|
|
79
|
+
serverSide: true,
|
|
80
|
+
search: options.search ?? true,
|
|
81
|
+
searchScope: options.searchScope ?? false,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
getRowId(row: T): string {
|
|
86
|
+
return this.options.getRowId(row);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async query(q: DataViewQuery, signal?: AbortSignal): Promise<DataViewResult<T>> {
|
|
90
|
+
const filters: Record<string, unknown> = {};
|
|
91
|
+
for (const filter of q.filters) {
|
|
92
|
+
filters[filter.id] = filter.value;
|
|
93
|
+
}
|
|
94
|
+
// Clamp the page size first and window with the clamped value, so page N is
|
|
95
|
+
// always the N-th `pageSize` window — never a far-flung skip with a smaller limit.
|
|
96
|
+
const pageSize = Math.min(q.pagination.pageSize, this.options.maxLimit ?? 200);
|
|
97
|
+
const page = await this.options.request(
|
|
98
|
+
{
|
|
99
|
+
skip: q.pagination.pageIndex * pageSize,
|
|
100
|
+
limit: pageSize,
|
|
101
|
+
sort: q.sorting.map((sort) => (sort.desc ? `-${sort.id}` : sort.id)),
|
|
102
|
+
filters,
|
|
103
|
+
search: q.search,
|
|
104
|
+
searchBroadened: q.searchBroadened,
|
|
105
|
+
},
|
|
106
|
+
signal,
|
|
107
|
+
);
|
|
108
|
+
return { rows: page.items, totalCount: page.total };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DataViewQuery,
|
|
3
|
+
DataViewRepository,
|
|
4
|
+
DataViewResult,
|
|
5
|
+
} from "../types";
|
|
6
|
+
|
|
7
|
+
/** How {@link InMemoryDataViewRepository} reads and matches rows. */
|
|
8
|
+
export interface InMemoryDataViewRepositoryOptions<T> {
|
|
9
|
+
/** Stable row identity. */
|
|
10
|
+
getRowId: (row: T) => string;
|
|
11
|
+
/** The raw sortable/filterable value of a column for a row. */
|
|
12
|
+
getValue: (row: T, columnId: string) => unknown;
|
|
13
|
+
/**
|
|
14
|
+
* Column ids the free-text search matches against (case-insensitive substring).
|
|
15
|
+
* Omit to disable search (`capabilities.search` becomes false).
|
|
16
|
+
*/
|
|
17
|
+
searchFields?: string[];
|
|
18
|
+
/**
|
|
19
|
+
* Custom filter match; the default is faceted equality (`value` is the filter value or,
|
|
20
|
+
* when an array, any-of).
|
|
21
|
+
*/
|
|
22
|
+
matchesFilter?: (row: T, columnId: string, value: unknown) => boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function defaultMatchesFilter(cell: unknown, value: unknown): boolean {
|
|
26
|
+
if (Array.isArray(value)) {
|
|
27
|
+
return value.length === 0 || value.some((candidate) => candidate === cell);
|
|
28
|
+
}
|
|
29
|
+
return cell === value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function compareValues(a: unknown, b: unknown): number {
|
|
33
|
+
if (a === b) {
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
if (a === null || a === undefined) {
|
|
37
|
+
return -1;
|
|
38
|
+
}
|
|
39
|
+
if (b === null || b === undefined) {
|
|
40
|
+
return 1;
|
|
41
|
+
}
|
|
42
|
+
if (typeof a === "number" && typeof b === "number") {
|
|
43
|
+
return a - b;
|
|
44
|
+
}
|
|
45
|
+
if (a instanceof Date && b instanceof Date) {
|
|
46
|
+
return a.getTime() - b.getTime();
|
|
47
|
+
}
|
|
48
|
+
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A {@link DataViewRepository} over a plain array: filtering, searching, sorting and
|
|
53
|
+
* paging all happen client-side inside the repository, so the DataView stays a pure
|
|
54
|
+
* renderer of {@link DataViewResult} pages.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```ts
|
|
58
|
+
* const repo = new InMemoryDataViewRepository(tickets, {
|
|
59
|
+
* getRowId: (t) => t.id,
|
|
60
|
+
* getValue: (t, col) => t[col as keyof Ticket],
|
|
61
|
+
* searchFields: ["title", "assignee"],
|
|
62
|
+
* });
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export class InMemoryDataViewRepository<T> implements DataViewRepository<T> {
|
|
66
|
+
readonly capabilities: DataViewRepository<T>["capabilities"];
|
|
67
|
+
|
|
68
|
+
private rows: T[];
|
|
69
|
+
private readonly options: InMemoryDataViewRepositoryOptions<T>;
|
|
70
|
+
|
|
71
|
+
constructor(rows: T[], options: InMemoryDataViewRepositoryOptions<T>) {
|
|
72
|
+
this.rows = rows;
|
|
73
|
+
this.options = options;
|
|
74
|
+
this.capabilities = {
|
|
75
|
+
serverSide: false,
|
|
76
|
+
search: (options.searchFields ?? []).length > 0,
|
|
77
|
+
searchScope: false,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
getRowId(row: T): string {
|
|
82
|
+
return this.options.getRowId(row);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Replace the backing rows (e.g. after a caller-side refetch). */
|
|
86
|
+
setRows(rows: T[]): void {
|
|
87
|
+
this.rows = rows;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Distinct values of one column across the full (unfiltered) data set. */
|
|
91
|
+
getFacetedValues(columnId: string): unknown[] {
|
|
92
|
+
const seen = new Set<unknown>();
|
|
93
|
+
for (const row of this.rows) {
|
|
94
|
+
seen.add(this.options.getValue(row, columnId));
|
|
95
|
+
}
|
|
96
|
+
return [...seen];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
query(q: DataViewQuery): Promise<DataViewResult<T>> {
|
|
100
|
+
let result = this.rows;
|
|
101
|
+
|
|
102
|
+
for (const filter of q.filters) {
|
|
103
|
+
result = result.filter((row) => {
|
|
104
|
+
if (this.options.matchesFilter !== undefined) {
|
|
105
|
+
return this.options.matchesFilter(row, filter.id, filter.value);
|
|
106
|
+
}
|
|
107
|
+
return defaultMatchesFilter(this.options.getValue(row, filter.id), filter.value);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const search = q.search.trim().toLowerCase();
|
|
112
|
+
const searchFields = this.options.searchFields ?? [];
|
|
113
|
+
if (search !== "" && searchFields.length > 0) {
|
|
114
|
+
result = result.filter((row) =>
|
|
115
|
+
searchFields.some((field) =>
|
|
116
|
+
String(this.options.getValue(row, field) ?? "")
|
|
117
|
+
.toLowerCase()
|
|
118
|
+
.includes(search),
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (q.sorting.length > 0) {
|
|
124
|
+
result = [...result].sort((a, b) => {
|
|
125
|
+
for (const sort of q.sorting) {
|
|
126
|
+
const order = compareValues(
|
|
127
|
+
this.options.getValue(a, sort.id),
|
|
128
|
+
this.options.getValue(b, sort.id),
|
|
129
|
+
);
|
|
130
|
+
if (order !== 0) {
|
|
131
|
+
return sort.desc ? -order : order;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return 0;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const totalCount = result.length;
|
|
139
|
+
const start = q.pagination.pageIndex * q.pagination.pageSize;
|
|
140
|
+
return Promise.resolve({
|
|
141
|
+
rows: result.slice(start, start + q.pagination.pageSize),
|
|
142
|
+
totalCount,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { HttpDataViewRepository } from "./HttpDataViewRepository";
|
|
4
|
+
import { InMemoryDataViewRepository } from "./InMemoryDataViewRepository";
|
|
5
|
+
import type { DataViewQuery } from "../types";
|
|
6
|
+
|
|
7
|
+
interface Ticket {
|
|
8
|
+
id: string;
|
|
9
|
+
title: string;
|
|
10
|
+
priority: number;
|
|
11
|
+
status: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const TICKETS: Ticket[] = [
|
|
15
|
+
{ id: "1", title: "Broken printer", priority: 2, status: "open" },
|
|
16
|
+
{ id: "2", title: "VPN access", priority: 1, status: "closed" },
|
|
17
|
+
{ id: "3", title: "New laptop", priority: 3, status: "open" },
|
|
18
|
+
{ id: "4", title: "Password reset", priority: 1, status: "open" },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
function repo(rows: Ticket[] = TICKETS) {
|
|
22
|
+
return new InMemoryDataViewRepository(rows, {
|
|
23
|
+
getRowId: (t) => t.id,
|
|
24
|
+
getValue: (t, col) => t[col as keyof Ticket],
|
|
25
|
+
searchFields: ["title"],
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function query(patch: Partial<DataViewQuery> = {}): DataViewQuery {
|
|
30
|
+
return {
|
|
31
|
+
pagination: { pageIndex: 0, pageSize: 10 },
|
|
32
|
+
sorting: [],
|
|
33
|
+
filters: [],
|
|
34
|
+
search: "",
|
|
35
|
+
searchBroadened: false,
|
|
36
|
+
...patch,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("InMemoryDataViewRepository", () => {
|
|
41
|
+
it("returns all rows with the total count", async () => {
|
|
42
|
+
const result = await repo().query(query());
|
|
43
|
+
expect(result.rows).toHaveLength(4);
|
|
44
|
+
expect(result.totalCount).toBe(4);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("pages with pageIndex/pageSize and keeps the filtered total", async () => {
|
|
48
|
+
const result = await repo().query(query({ pagination: { pageIndex: 1, pageSize: 3 } }));
|
|
49
|
+
expect(result.rows).toHaveLength(1);
|
|
50
|
+
expect(result.totalCount).toBe(4);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("sorts ascending and descending", async () => {
|
|
54
|
+
const asc = await repo().query(query({ sorting: [{ id: "priority", desc: false }] }));
|
|
55
|
+
expect(asc.rows.map((t) => t.priority)).toEqual([1, 1, 2, 3]);
|
|
56
|
+
const desc = await repo().query(query({ sorting: [{ id: "title", desc: true }] }));
|
|
57
|
+
expect(desc.rows[0]?.title).toBe("VPN access");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("searches case-insensitively across the configured fields", async () => {
|
|
61
|
+
const result = await repo().query(query({ search: "LAPTOP" }));
|
|
62
|
+
expect(result.rows.map((t) => t.id)).toEqual(["3"]);
|
|
63
|
+
expect(result.totalCount).toBe(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("applies faceted-equality filters (single and any-of)", async () => {
|
|
67
|
+
const single = await repo().query(query({ filters: [{ id: "status", value: "closed" }] }));
|
|
68
|
+
expect(single.rows.map((t) => t.id)).toEqual(["2"]);
|
|
69
|
+
const anyOf = await repo().query(query({ filters: [{ id: "priority", value: [1, 3] }] }));
|
|
70
|
+
expect(anyOf.rows.map((t) => t.id)).toEqual(["2", "3", "4"]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("exposes faceted unique values across the full data set", () => {
|
|
74
|
+
expect(repo().getFacetedValues("status")).toEqual(["open", "closed"]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("advertises client-side capabilities (search only when fields are configured)", () => {
|
|
78
|
+
expect(repo().capabilities).toEqual({ serverSide: false, search: true, searchScope: false });
|
|
79
|
+
const noSearch = new InMemoryDataViewRepository(TICKETS, {
|
|
80
|
+
getRowId: (t) => t.id,
|
|
81
|
+
getValue: (t, col) => t[col as keyof Ticket],
|
|
82
|
+
});
|
|
83
|
+
expect(noSearch.capabilities.search).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("HttpDataViewRepository", () => {
|
|
88
|
+
it("maps the query to skip/limit/sort/filter/search params", async () => {
|
|
89
|
+
const request = vi.fn().mockResolvedValue({ items: [TICKETS[0]], total: 41 });
|
|
90
|
+
const http = new HttpDataViewRepository<Ticket>({ request, getRowId: (t) => t.id });
|
|
91
|
+
|
|
92
|
+
const result = await http.query(
|
|
93
|
+
query({
|
|
94
|
+
pagination: { pageIndex: 2, pageSize: 25 },
|
|
95
|
+
sorting: [
|
|
96
|
+
{ id: "title", desc: false },
|
|
97
|
+
{ id: "priority", desc: true },
|
|
98
|
+
],
|
|
99
|
+
filters: [{ id: "status", value: "open" }],
|
|
100
|
+
search: "printer",
|
|
101
|
+
searchBroadened: true,
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
expect(request).toHaveBeenCalledWith(
|
|
106
|
+
{
|
|
107
|
+
skip: 50,
|
|
108
|
+
limit: 25,
|
|
109
|
+
sort: ["title", "-priority"],
|
|
110
|
+
filters: { status: "open" },
|
|
111
|
+
search: "printer",
|
|
112
|
+
searchBroadened: true,
|
|
113
|
+
},
|
|
114
|
+
undefined,
|
|
115
|
+
);
|
|
116
|
+
expect(result).toEqual({ rows: [TICKETS[0]], totalCount: 41 });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("forwards the abort signal to the adapter", async () => {
|
|
120
|
+
const request = vi.fn().mockResolvedValue({ items: [], total: 0 });
|
|
121
|
+
const http = new HttpDataViewRepository<Ticket>({ request, getRowId: (t) => t.id });
|
|
122
|
+
const controller = new AbortController();
|
|
123
|
+
await http.query(query(), controller.signal);
|
|
124
|
+
expect(request.mock.calls[0]?.[1]).toBe(controller.signal);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("advertises server-side capabilities from its options", () => {
|
|
128
|
+
const http = new HttpDataViewRepository<Ticket>({
|
|
129
|
+
request: vi.fn(),
|
|
130
|
+
getRowId: (t) => t.id,
|
|
131
|
+
search: false,
|
|
132
|
+
searchScope: true,
|
|
133
|
+
});
|
|
134
|
+
expect(http.capabilities).toEqual({ serverSide: true, search: false, searchScope: true });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("clamps the limit at the platform pagination cap (embedded views ask for everything)", async () => {
|
|
138
|
+
// The backend refuses limit > PAGINATION_MAX_LIMIT (default 200) with a 422; an
|
|
139
|
+
// embedded DataView's render-all page size must degrade, not fail.
|
|
140
|
+
const request = vi.fn().mockResolvedValue({ items: [], total: 0 });
|
|
141
|
+
const http = new HttpDataViewRepository<Ticket>({ request, getRowId: (t) => t.id });
|
|
142
|
+
await http.query(query({ pagination: { pageIndex: 0, pageSize: 10_000 } }));
|
|
143
|
+
expect(request.mock.calls[0]?.[0].limit).toBe(200);
|
|
144
|
+
|
|
145
|
+
// Windows stay consistent past page 0: skip is computed from the CLAMPED page
|
|
146
|
+
// size, so page N is the N-th 200-row window (never skip=10000&limit=200).
|
|
147
|
+
await http.query(query({ pagination: { pageIndex: 1, pageSize: 10_000 } }));
|
|
148
|
+
expect(request.mock.calls[1]?.[0]).toMatchObject({ skip: 200, limit: 200 });
|
|
149
|
+
|
|
150
|
+
const widened = new HttpDataViewRepository<Ticket>({
|
|
151
|
+
request,
|
|
152
|
+
getRowId: (t) => t.id,
|
|
153
|
+
maxLimit: 500,
|
|
154
|
+
});
|
|
155
|
+
await widened.query(query({ pagination: { pageIndex: 0, pageSize: 10_000 } }));
|
|
156
|
+
expect(request.mock.calls[2]?.[0].limit).toBe(500);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
InMemoryViewStateRepository,
|
|
6
|
+
LocalStorageViewStateRepository,
|
|
7
|
+
} from "./viewState";
|
|
8
|
+
import { emptyDataViewState } from "../types";
|
|
9
|
+
import type { DataViewState } from "../types";
|
|
10
|
+
|
|
11
|
+
function sampleState(): DataViewState {
|
|
12
|
+
return {
|
|
13
|
+
columnVisibility: { status: false },
|
|
14
|
+
columnOrder: ["title", "status"],
|
|
15
|
+
columnSizing: { title: 240 },
|
|
16
|
+
sorting: [{ id: "title", desc: true }],
|
|
17
|
+
filters: [{ id: "status", value: "open" }],
|
|
18
|
+
search: "printer",
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("LocalStorageViewStateRepository", () => {
|
|
23
|
+
beforeEach(() => window.localStorage.clear());
|
|
24
|
+
afterEach(() => window.localStorage.clear());
|
|
25
|
+
|
|
26
|
+
it("round-trips a state per viewId", () => {
|
|
27
|
+
const repo = new LocalStorageViewStateRepository();
|
|
28
|
+
repo.save("tickets.list", sampleState());
|
|
29
|
+
expect(repo.load("tickets.list")).toEqual(sampleState());
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("isolates views from each other", () => {
|
|
33
|
+
const repo = new LocalStorageViewStateRepository();
|
|
34
|
+
repo.save("a", sampleState());
|
|
35
|
+
repo.save("b", { ...emptyDataViewState(), search: "other" });
|
|
36
|
+
expect(repo.load("a")?.search).toBe("printer");
|
|
37
|
+
expect(repo.load("b")?.search).toBe("other");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns undefined when nothing was persisted", () => {
|
|
41
|
+
expect(new LocalStorageViewStateRepository().load("missing")).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("falls back to defaults on corrupt JSON", () => {
|
|
45
|
+
window.localStorage.setItem("terp.dataview.bad", "{not json");
|
|
46
|
+
expect(new LocalStorageViewStateRepository().load("bad")).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("falls back to defaults on a wrong envelope version", () => {
|
|
50
|
+
window.localStorage.setItem(
|
|
51
|
+
"terp.dataview.old",
|
|
52
|
+
JSON.stringify({ version: 999, state: sampleState() }),
|
|
53
|
+
);
|
|
54
|
+
expect(new LocalStorageViewStateRepository().load("old")).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("falls back to defaults on schema-invalid state shapes", () => {
|
|
58
|
+
window.localStorage.setItem(
|
|
59
|
+
"terp.dataview.shape",
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
version: 1,
|
|
62
|
+
state: { ...sampleState(), columnSizing: { title: "wide" } },
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
expect(new LocalStorageViewStateRepository().load("shape")).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("supports a custom key prefix", () => {
|
|
69
|
+
const repo = new LocalStorageViewStateRepository("myapp.views");
|
|
70
|
+
repo.save("v", sampleState());
|
|
71
|
+
expect(window.localStorage.getItem("myapp.views.v")).not.toBeNull();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("InMemoryViewStateRepository", () => {
|
|
76
|
+
it("round-trips per viewId and isolates views", () => {
|
|
77
|
+
const repo = new InMemoryViewStateRepository();
|
|
78
|
+
repo.save("a", sampleState());
|
|
79
|
+
expect(repo.load("a")).toEqual(sampleState());
|
|
80
|
+
expect(repo.load("b")).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("returns copies so stored state cannot be mutated in place", () => {
|
|
84
|
+
const repo = new InMemoryViewStateRepository();
|
|
85
|
+
repo.save("a", sampleState());
|
|
86
|
+
const loaded = repo.load("a");
|
|
87
|
+
loaded!.columnSizing.title = 1;
|
|
88
|
+
expect(repo.load("a")?.columnSizing.title).toBe(240);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { DataViewState, ViewStateRepository } from "../types";
|
|
2
|
+
import { emptyDataViewState } from "../types";
|
|
3
|
+
|
|
4
|
+
/** Version of the persisted envelope; bump when {@link DataViewState}'s shape changes. */
|
|
5
|
+
const ENVELOPE_VERSION = 1;
|
|
6
|
+
|
|
7
|
+
interface Envelope {
|
|
8
|
+
version: number;
|
|
9
|
+
state: DataViewState;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
13
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isStringArray(value: unknown): value is string[] {
|
|
17
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isSorting(value: unknown): value is { id: string; desc: boolean }[] {
|
|
21
|
+
return (
|
|
22
|
+
Array.isArray(value) &&
|
|
23
|
+
value.every(
|
|
24
|
+
(entry) =>
|
|
25
|
+
isRecord(entry) && typeof entry.id === "string" && typeof entry.desc === "boolean",
|
|
26
|
+
)
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isFilters(value: unknown): value is { id: string; value: unknown }[] {
|
|
31
|
+
return (
|
|
32
|
+
Array.isArray(value) &&
|
|
33
|
+
value.every((entry) => isRecord(entry) && typeof entry.id === "string" && "value" in entry)
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isRecordOf(value: unknown, kind: "boolean" | "number"): boolean {
|
|
38
|
+
return isRecord(value) && Object.values(value).every((entry) => typeof entry === kind);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Validate a parsed envelope; anything malformed falls back to `undefined` (defaults). */
|
|
42
|
+
function parseEnvelope(raw: string): DataViewState | undefined {
|
|
43
|
+
let parsed: unknown;
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(raw);
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
if (!isRecord(parsed) || parsed.version !== ENVELOPE_VERSION || !isRecord(parsed.state)) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
const state = parsed.state;
|
|
53
|
+
if (
|
|
54
|
+
!isRecordOf(state.columnVisibility, "boolean") ||
|
|
55
|
+
!isStringArray(state.columnOrder) ||
|
|
56
|
+
!isRecordOf(state.columnSizing, "number") ||
|
|
57
|
+
!isSorting(state.sorting) ||
|
|
58
|
+
!isFilters(state.filters) ||
|
|
59
|
+
typeof state.search !== "string"
|
|
60
|
+
) {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
columnVisibility: state.columnVisibility as Record<string, boolean>,
|
|
65
|
+
columnOrder: state.columnOrder,
|
|
66
|
+
columnSizing: state.columnSizing as Record<string, number>,
|
|
67
|
+
sorting: state.sorting,
|
|
68
|
+
filters: state.filters,
|
|
69
|
+
search: state.search,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* {@link ViewStateRepository} over `localStorage`, storing a schema-validated, versioned
|
|
75
|
+
* envelope per `viewId`. Corrupt or outdated data (bad JSON, wrong version, wrong shapes)
|
|
76
|
+
* is treated as "nothing persisted" so the view falls back to its defaults; storage
|
|
77
|
+
* failures (quota, privacy mode) are swallowed — preferences are best-effort.
|
|
78
|
+
*/
|
|
79
|
+
export class LocalStorageViewStateRepository implements ViewStateRepository {
|
|
80
|
+
private readonly prefix: string;
|
|
81
|
+
|
|
82
|
+
constructor(prefix = "terp.dataview") {
|
|
83
|
+
this.prefix = prefix;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private key(viewId: string): string {
|
|
87
|
+
return `${this.prefix}.${viewId}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
load(viewId: string): DataViewState | undefined {
|
|
91
|
+
let raw: string | null;
|
|
92
|
+
try {
|
|
93
|
+
raw = window.localStorage.getItem(this.key(viewId));
|
|
94
|
+
} catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
return raw === null ? undefined : parseEnvelope(raw);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
save(viewId: string, state: DataViewState): void {
|
|
101
|
+
const envelope: Envelope = { version: ENVELOPE_VERSION, state };
|
|
102
|
+
try {
|
|
103
|
+
window.localStorage.setItem(this.key(viewId), JSON.stringify(envelope));
|
|
104
|
+
} catch {
|
|
105
|
+
// Best-effort persistence: never let a full/blocked storage break the view.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* {@link ViewStateRepository} in memory — for tests and for views without a `viewId`
|
|
112
|
+
* (preferences last for the component's lifetime only).
|
|
113
|
+
*/
|
|
114
|
+
export class InMemoryViewStateRepository implements ViewStateRepository {
|
|
115
|
+
private readonly store = new Map<string, DataViewState>();
|
|
116
|
+
|
|
117
|
+
load(viewId: string): DataViewState | undefined {
|
|
118
|
+
const state = this.store.get(viewId);
|
|
119
|
+
// Return a copy so callers can't mutate the stored state in place.
|
|
120
|
+
return state === undefined ? undefined : structuredClone(state);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
save(viewId: string, state: DataViewState): void {
|
|
124
|
+
this.store.set(viewId, structuredClone(state));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export { emptyDataViewState };
|