@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,467 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { EmptyState } from "../EmptyState";
|
|
5
|
+
import { ErrorState } from "../ErrorState";
|
|
6
|
+
import type { UiText } from "../uiText";
|
|
7
|
+
import { DataViewCardList } from "./DataViewCardList";
|
|
8
|
+
import { DataViewPagination } from "./DataViewPagination";
|
|
9
|
+
import type { DataViewRowActionsLayout } from "./DataViewRowActions";
|
|
10
|
+
import { DataViewTable } from "./DataViewTable";
|
|
11
|
+
import { DataViewToolbar } from "./DataViewToolbar";
|
|
12
|
+
import { useDataViewQuery } from "./hooks/useDataViewQuery";
|
|
13
|
+
import { useDataViewState } from "./hooks/useDataViewState";
|
|
14
|
+
import type { DataViewControlledQuery } from "./hooks/useDataViewState";
|
|
15
|
+
import { DataViewTextProvider, useDataViewText } from "./internal";
|
|
16
|
+
import type {
|
|
17
|
+
DataViewBatchAction,
|
|
18
|
+
DataViewColumn,
|
|
19
|
+
DataViewQuery,
|
|
20
|
+
DataViewRepository,
|
|
21
|
+
DataViewRowAction,
|
|
22
|
+
DataViewSearchScope,
|
|
23
|
+
DataViewStrings,
|
|
24
|
+
ViewStateRepository,
|
|
25
|
+
} from "./types";
|
|
26
|
+
|
|
27
|
+
const MOBILE_BREAKPOINT = "(max-width: 768px)";
|
|
28
|
+
/** Embedded views render all rows; the parent owns paging. */
|
|
29
|
+
const EMBEDDED_PAGE_SIZE = 10_000;
|
|
30
|
+
|
|
31
|
+
interface DataViewBaseProps<T> {
|
|
32
|
+
/** Stable key for persisted view preferences; omit to keep them in memory only. */
|
|
33
|
+
viewId?: string;
|
|
34
|
+
/** The data access seam — DataView never fetches on its own. */
|
|
35
|
+
repository: DataViewRepository<T>;
|
|
36
|
+
/** The preference persistence seam (e.g. `LocalStorageViewStateRepository`). */
|
|
37
|
+
viewStateRepository?: ViewStateRepository;
|
|
38
|
+
columns: DataViewColumn<T>[];
|
|
39
|
+
/** "embedded" renders a plain compact view: no view toggle, no page-size selector, no footer. */
|
|
40
|
+
variant?: "full" | "embedded";
|
|
41
|
+
enableSelection?: boolean;
|
|
42
|
+
batchActions?: DataViewBatchAction<T>[];
|
|
43
|
+
rowActions?: (row: T) => DataViewRowAction<T>[];
|
|
44
|
+
rowActionsLayout?: DataViewRowActionsLayout;
|
|
45
|
+
searchPlaceholder?: UiText;
|
|
46
|
+
searchDebounceMs?: number;
|
|
47
|
+
/** Caller-owned "search everything" toggle (see {@link DataViewSearchScope}). */
|
|
48
|
+
searchScope?: DataViewSearchScope;
|
|
49
|
+
pageSizeOptions?: number[];
|
|
50
|
+
initialPageSize?: number;
|
|
51
|
+
renderExpanded?: (row: T) => ReactNode;
|
|
52
|
+
/** Fully custom cards in the responsive card layout. */
|
|
53
|
+
renderCard?: (row: T) => ReactNode;
|
|
54
|
+
/** Custom filter controls, rendered in the toolbar. */
|
|
55
|
+
toolbarContent?: ReactNode;
|
|
56
|
+
/** Trailing toolbar slot (e.g. a "New" button). */
|
|
57
|
+
toolbarTrailing?: ReactNode;
|
|
58
|
+
/** Rendered inside the empty state (e.g. a "clear filters" button). */
|
|
59
|
+
emptyActions?: ReactNode;
|
|
60
|
+
emptyMessage?: UiText;
|
|
61
|
+
/** Custom error state; defaults to {@link ErrorState}. */
|
|
62
|
+
renderError?: (error: unknown) => ReactNode;
|
|
63
|
+
/** Shows the toolbar "clear filters" action and wires it to this reset. */
|
|
64
|
+
onClearFilters?: () => void;
|
|
65
|
+
/** URL-backed query state for server-side views (from {@link useServerDataView}). */
|
|
66
|
+
serverQuery?: DataViewControlledQuery;
|
|
67
|
+
/** Per-instance overrides of the DataView's user-facing strings. */
|
|
68
|
+
strings?: Partial<DataViewStrings>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type DataViewProps<T> = DataViewBaseProps<T> & (
|
|
72
|
+
| {
|
|
73
|
+
onRowClick?: undefined;
|
|
74
|
+
getRowLabel?: undefined;
|
|
75
|
+
}
|
|
76
|
+
| {
|
|
77
|
+
/** Pointer activation for overview-to-detail navigation. */
|
|
78
|
+
onRowClick: (row: T) => void;
|
|
79
|
+
/** Accessible record name used by the row's native activation button. */
|
|
80
|
+
getRowLabel: (row: T) => UiText;
|
|
81
|
+
}
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The single sanctioned surface for rendering data collections: a repository-driven
|
|
86
|
+
* table/card view with search, sorting, pagination, column management, selection,
|
|
87
|
+
* batch actions, row actions, expandable rows and persisted view preferences.
|
|
88
|
+
*
|
|
89
|
+
* DataView never fetches and never touches `localStorage`: all data access goes
|
|
90
|
+
* through the {@link DataViewRepository} and all preference persistence through the
|
|
91
|
+
* {@link ViewStateRepository} — swapping either implementation requires zero changes
|
|
92
|
+
* to any component file.
|
|
93
|
+
*/
|
|
94
|
+
export function DataView<T>(props: DataViewProps<T>) {
|
|
95
|
+
return (
|
|
96
|
+
<DataViewTextProvider overrides={props.strings}>
|
|
97
|
+
<DataViewInner {...props} />
|
|
98
|
+
</DataViewTextProvider>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function useIsMobile(): boolean {
|
|
103
|
+
const [isMobile, setIsMobile] = useState(
|
|
104
|
+
() =>
|
|
105
|
+
typeof window !== "undefined" &&
|
|
106
|
+
typeof window.matchMedia === "function" &&
|
|
107
|
+
window.matchMedia(MOBILE_BREAKPOINT).matches,
|
|
108
|
+
);
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const media = window.matchMedia(MOBILE_BREAKPOINT);
|
|
114
|
+
const onChange = () => setIsMobile(media.matches);
|
|
115
|
+
media.addEventListener("change", onChange);
|
|
116
|
+
return () => media.removeEventListener("change", onChange);
|
|
117
|
+
}, []);
|
|
118
|
+
return isMobile;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function DataViewInner<T>(props: DataViewProps<T>) {
|
|
122
|
+
const { strings, resolve } = useDataViewText();
|
|
123
|
+
const embedded = props.variant === "embedded";
|
|
124
|
+
const serverSide = props.repository.capabilities.serverSide;
|
|
125
|
+
const initialPageSize = embedded
|
|
126
|
+
? EMBEDDED_PAGE_SIZE
|
|
127
|
+
: (props.initialPageSize ?? props.pageSizeOptions?.[0] ?? 10);
|
|
128
|
+
|
|
129
|
+
const state = useDataViewState<T>({
|
|
130
|
+
viewId: props.viewId,
|
|
131
|
+
columns: props.columns,
|
|
132
|
+
viewStateRepository: props.viewStateRepository,
|
|
133
|
+
serverSide,
|
|
134
|
+
initialPageSize,
|
|
135
|
+
controlledQuery: props.serverQuery,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const query: DataViewQuery = useMemo(
|
|
139
|
+
() => ({
|
|
140
|
+
pagination: state.pagination,
|
|
141
|
+
sorting: state.sorting,
|
|
142
|
+
filters: state.filters,
|
|
143
|
+
search: state.search,
|
|
144
|
+
searchBroadened: props.searchScope?.broadened ?? false,
|
|
145
|
+
}),
|
|
146
|
+
[state.pagination, state.sorting, state.filters, state.search, props.searchScope?.broadened],
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const { rows, totalCount, isLoading, isFetching, error } = useDataViewQuery(
|
|
150
|
+
props.repository,
|
|
151
|
+
query,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// An out-of-range page (stale deep link, shrunk result set) snaps back to the
|
|
155
|
+
// last valid page, so the emitted query and the footer never disagree.
|
|
156
|
+
const setPagination = state.setPagination;
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (isLoading || isFetching || (error !== null && error !== undefined)) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const { pageIndex, pageSize } = query.pagination;
|
|
162
|
+
const lastPageIndex = Math.max(0, Math.ceil(totalCount / pageSize) - 1);
|
|
163
|
+
if (pageIndex > lastPageIndex) {
|
|
164
|
+
setPagination({ pageIndex: lastPageIndex, pageSize });
|
|
165
|
+
}
|
|
166
|
+
}, [isLoading, isFetching, error, totalCount, query.pagination, setPagination]);
|
|
167
|
+
|
|
168
|
+
// ----- selection (keyed by getRowId so it survives re-sorts and refetches) -----
|
|
169
|
+
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set());
|
|
170
|
+
const [selectAllAcrossPages, setSelectAllAcrossPages] = useState(false);
|
|
171
|
+
// Row objects for every id ever selected, so batch actions receive rows even after paging.
|
|
172
|
+
const selectedRowsRef = useRef(new Map<string, T>());
|
|
173
|
+
|
|
174
|
+
const getRowId = useCallback(
|
|
175
|
+
(row: T) => props.repository.getRowId(row),
|
|
176
|
+
[props.repository],
|
|
177
|
+
);
|
|
178
|
+
const pageRowIds = useMemo(() => rows.map(getRowId), [rows, getRowId]);
|
|
179
|
+
const allPageSelected = pageRowIds.length > 0 && pageRowIds.every((id) => selectedIds.has(id));
|
|
180
|
+
const somePageSelected = pageRowIds.some((id) => selectedIds.has(id));
|
|
181
|
+
|
|
182
|
+
const toggleSelected = useCallback(
|
|
183
|
+
(rowId: string) => {
|
|
184
|
+
setSelectedIds((current) => {
|
|
185
|
+
const next = new Set(current);
|
|
186
|
+
if (next.has(rowId)) {
|
|
187
|
+
next.delete(rowId);
|
|
188
|
+
selectedRowsRef.current.delete(rowId);
|
|
189
|
+
// Breaking the page selection always exits select-all-across-pages mode.
|
|
190
|
+
setSelectAllAcrossPages(false);
|
|
191
|
+
} else {
|
|
192
|
+
next.add(rowId);
|
|
193
|
+
const row = rows.find((candidate) => getRowId(candidate) === rowId);
|
|
194
|
+
if (row !== undefined) {
|
|
195
|
+
selectedRowsRef.current.set(rowId, row);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return next;
|
|
199
|
+
});
|
|
200
|
+
},
|
|
201
|
+
[rows, getRowId],
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const toggleSelectPage = useCallback(() => {
|
|
205
|
+
setSelectedIds((current) => {
|
|
206
|
+
const next = new Set(current);
|
|
207
|
+
if (pageRowIds.length > 0 && pageRowIds.every((id) => next.has(id))) {
|
|
208
|
+
for (const id of pageRowIds) {
|
|
209
|
+
next.delete(id);
|
|
210
|
+
selectedRowsRef.current.delete(id);
|
|
211
|
+
}
|
|
212
|
+
setSelectAllAcrossPages(false);
|
|
213
|
+
} else {
|
|
214
|
+
for (const row of rows) {
|
|
215
|
+
const id = getRowId(row);
|
|
216
|
+
next.add(id);
|
|
217
|
+
selectedRowsRef.current.set(id, row);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return next;
|
|
221
|
+
});
|
|
222
|
+
}, [pageRowIds, rows, getRowId]);
|
|
223
|
+
|
|
224
|
+
const clearSelection = useCallback(() => {
|
|
225
|
+
setSelectedIds((current) => (current.size === 0 ? current : new Set()));
|
|
226
|
+
setSelectAllAcrossPages(false);
|
|
227
|
+
selectedRowsRef.current.clear();
|
|
228
|
+
}, []);
|
|
229
|
+
|
|
230
|
+
// Changing what the result set *is* (filters, search, scope) invalidates the
|
|
231
|
+
// selection: "select all N results" must never carry over to a different query,
|
|
232
|
+
// or batch onSelectAll would act on records the user never confirmed.
|
|
233
|
+
const selectionScopeKey = JSON.stringify({
|
|
234
|
+
filters: query.filters,
|
|
235
|
+
search: query.search,
|
|
236
|
+
broadened: query.searchBroadened,
|
|
237
|
+
});
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
clearSelection();
|
|
240
|
+
}, [selectionScopeKey, clearSelection]);
|
|
241
|
+
|
|
242
|
+
const onBatchAction = useCallback(
|
|
243
|
+
(action: DataViewBatchAction<T>) => {
|
|
244
|
+
const selectedRows = [...selectedIds]
|
|
245
|
+
.map((id) => selectedRowsRef.current.get(id))
|
|
246
|
+
.filter((row): row is T => row !== undefined);
|
|
247
|
+
if (selectAllAcrossPages && action.onSelectAll !== undefined) {
|
|
248
|
+
action.onSelectAll(selectedRows);
|
|
249
|
+
} else {
|
|
250
|
+
action.onClick(selectedRows);
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
[selectedIds, selectAllAcrossPages],
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
// ----- expansion (keyed by row id) -----
|
|
257
|
+
const [expandedIds, setExpandedIds] = useState<ReadonlySet<string>>(new Set());
|
|
258
|
+
const toggleExpanded = useCallback((rowId: string) => {
|
|
259
|
+
setExpandedIds((current) => {
|
|
260
|
+
const next = new Set(current);
|
|
261
|
+
if (next.has(rowId)) {
|
|
262
|
+
next.delete(rowId);
|
|
263
|
+
} else {
|
|
264
|
+
next.add(rowId);
|
|
265
|
+
}
|
|
266
|
+
return next;
|
|
267
|
+
});
|
|
268
|
+
}, []);
|
|
269
|
+
|
|
270
|
+
// ----- responsive layout: auto until the user toggles, manual choice wins -----
|
|
271
|
+
const isMobile = useIsMobile();
|
|
272
|
+
const [manualLayout, setManualLayout] = useState<"table" | "cards" | null>(null);
|
|
273
|
+
const layout: "table" | "cards" = manualLayout ?? (isMobile ? "cards" : "table");
|
|
274
|
+
|
|
275
|
+
const hasActiveFilters = state.filters.length > 0 || state.search !== "";
|
|
276
|
+
const clearFilters = () => {
|
|
277
|
+
state.clearFilters();
|
|
278
|
+
state.setSearch("");
|
|
279
|
+
props.onClearFilters?.();
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const content = (() => {
|
|
283
|
+
if (isLoading) {
|
|
284
|
+
return <DataViewSkeleton label={resolve(strings.loading)} />;
|
|
285
|
+
}
|
|
286
|
+
if (error !== null && error !== undefined) {
|
|
287
|
+
return props.renderError !== undefined ? (
|
|
288
|
+
<>{props.renderError(error)}</>
|
|
289
|
+
) : (
|
|
290
|
+
<div style={{ padding: "var(--space-4)" }}>
|
|
291
|
+
<ErrorState title={strings.errorTitle} error={error} />
|
|
292
|
+
</div>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (rows.length === 0) {
|
|
296
|
+
return <EmptyState title={props.emptyMessage ?? strings.empty} action={props.emptyActions} />;
|
|
297
|
+
}
|
|
298
|
+
if (layout === "cards") {
|
|
299
|
+
return (
|
|
300
|
+
<DataViewCardList
|
|
301
|
+
rows={rows}
|
|
302
|
+
columns={state.orderedColumns}
|
|
303
|
+
getRowId={getRowId}
|
|
304
|
+
onRowClick={props.onRowClick}
|
|
305
|
+
getRowLabel={props.getRowLabel}
|
|
306
|
+
renderCard={props.renderCard}
|
|
307
|
+
selectionEnabled={props.enableSelection === true}
|
|
308
|
+
isSelected={(id) => selectedIds.has(id)}
|
|
309
|
+
onToggleSelected={toggleSelected}
|
|
310
|
+
renderExpanded={props.renderExpanded}
|
|
311
|
+
isExpanded={(id) => expandedIds.has(id)}
|
|
312
|
+
onToggleExpanded={toggleExpanded}
|
|
313
|
+
rowActions={props.rowActions}
|
|
314
|
+
/>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
return (
|
|
318
|
+
<div style={{ overflowX: "auto" }}>
|
|
319
|
+
<DataViewTable
|
|
320
|
+
rows={rows}
|
|
321
|
+
columns={state.visibleColumns}
|
|
322
|
+
getRowId={getRowId}
|
|
323
|
+
onRowClick={props.onRowClick}
|
|
324
|
+
getRowLabel={props.getRowLabel}
|
|
325
|
+
isMobile={isMobile}
|
|
326
|
+
sorting={state.sorting}
|
|
327
|
+
onToggleSort={state.toggleSort}
|
|
328
|
+
columnSizing={state.columnSizing}
|
|
329
|
+
onCommitColumnSizing={state.commitColumnSizing}
|
|
330
|
+
selectionEnabled={props.enableSelection === true}
|
|
331
|
+
isSelected={(id) => selectedIds.has(id)}
|
|
332
|
+
onToggleSelected={toggleSelected}
|
|
333
|
+
allPageSelected={allPageSelected}
|
|
334
|
+
somePageSelected={somePageSelected}
|
|
335
|
+
onToggleSelectPage={toggleSelectPage}
|
|
336
|
+
renderExpanded={props.renderExpanded}
|
|
337
|
+
isExpanded={(id) => expandedIds.has(id)}
|
|
338
|
+
onToggleExpanded={toggleExpanded}
|
|
339
|
+
rowActions={props.rowActions}
|
|
340
|
+
rowActionsLayout={props.rowActionsLayout ?? "menu"}
|
|
341
|
+
/>
|
|
342
|
+
</div>
|
|
343
|
+
);
|
|
344
|
+
})();
|
|
345
|
+
|
|
346
|
+
if (embedded) {
|
|
347
|
+
// Stable inputs only: keying this on transient fetch state would pop the
|
|
348
|
+
// whole band in and out on mount and on every background refetch.
|
|
349
|
+
const showsEmbeddedToolbar =
|
|
350
|
+
props.repository.capabilities.search ||
|
|
351
|
+
props.searchScope !== undefined ||
|
|
352
|
+
props.onClearFilters !== undefined ||
|
|
353
|
+
props.enableSelection === true ||
|
|
354
|
+
(props.batchActions?.length ?? 0) > 0 ||
|
|
355
|
+
props.toolbarContent !== undefined ||
|
|
356
|
+
props.toolbarTrailing !== undefined;
|
|
357
|
+
return (
|
|
358
|
+
<div data-terp="dataview" style={{ display: "grid" }}>
|
|
359
|
+
{showsEmbeddedToolbar && (
|
|
360
|
+
<DataViewToolbar<T>
|
|
361
|
+
searchEnabled={props.repository.capabilities.search}
|
|
362
|
+
search={state.search}
|
|
363
|
+
onSearchChange={state.setSearch}
|
|
364
|
+
searchPlaceholder={props.searchPlaceholder}
|
|
365
|
+
searchDebounceMs={props.searchDebounceMs}
|
|
366
|
+
searchScope={props.repository.capabilities.searchScope ? props.searchScope : undefined}
|
|
367
|
+
onClearFilters={props.onClearFilters !== undefined ? clearFilters : undefined}
|
|
368
|
+
hasActiveFilters={hasActiveFilters}
|
|
369
|
+
layout={layout}
|
|
370
|
+
selectedCount={selectedIds.size}
|
|
371
|
+
totalCount={totalCount}
|
|
372
|
+
selectAllAcrossPages={selectAllAcrossPages}
|
|
373
|
+
onSelectAllAcrossPages={
|
|
374
|
+
allPageSelected && totalCount > selectedIds.size
|
|
375
|
+
? () => setSelectAllAcrossPages(true)
|
|
376
|
+
: undefined
|
|
377
|
+
}
|
|
378
|
+
onClearSelection={clearSelection}
|
|
379
|
+
batchActions={props.batchActions}
|
|
380
|
+
onBatchAction={onBatchAction}
|
|
381
|
+
isFetching={isFetching}
|
|
382
|
+
trailing={props.toolbarTrailing}
|
|
383
|
+
>
|
|
384
|
+
{props.toolbarContent}
|
|
385
|
+
</DataViewToolbar>
|
|
386
|
+
)}
|
|
387
|
+
{content}
|
|
388
|
+
</div>
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return (
|
|
393
|
+
<div
|
|
394
|
+
data-terp="dataview"
|
|
395
|
+
style={{
|
|
396
|
+
display: "grid",
|
|
397
|
+
background: "var(--color-neutral-0)",
|
|
398
|
+
border: "1px solid var(--color-neutral-200)",
|
|
399
|
+
borderRadius: "var(--radius-lg)",
|
|
400
|
+
}}
|
|
401
|
+
>
|
|
402
|
+
<DataViewToolbar<T>
|
|
403
|
+
searchEnabled={props.repository.capabilities.search}
|
|
404
|
+
search={state.search}
|
|
405
|
+
onSearchChange={state.setSearch}
|
|
406
|
+
searchPlaceholder={props.searchPlaceholder}
|
|
407
|
+
searchDebounceMs={props.searchDebounceMs}
|
|
408
|
+
searchScope={props.repository.capabilities.searchScope ? props.searchScope : undefined}
|
|
409
|
+
onClearFilters={props.onClearFilters !== undefined ? clearFilters : undefined}
|
|
410
|
+
hasActiveFilters={hasActiveFilters}
|
|
411
|
+
columnSettings={{
|
|
412
|
+
columns: state.orderedColumns,
|
|
413
|
+
columnVisibility: state.columnVisibility,
|
|
414
|
+
onColumnVisibleChange: state.setColumnVisible,
|
|
415
|
+
onMoveColumn: state.moveColumn,
|
|
416
|
+
}}
|
|
417
|
+
layout={layout}
|
|
418
|
+
onLayoutChange={setManualLayout}
|
|
419
|
+
pageSize={state.pagination.pageSize}
|
|
420
|
+
pageSizeOptions={props.pageSizeOptions}
|
|
421
|
+
onPageSizeChange={(pageSize) =>
|
|
422
|
+
state.setPagination({ pageIndex: 0, pageSize })
|
|
423
|
+
}
|
|
424
|
+
selectedCount={selectedIds.size}
|
|
425
|
+
totalCount={totalCount}
|
|
426
|
+
selectAllAcrossPages={selectAllAcrossPages}
|
|
427
|
+
onSelectAllAcrossPages={
|
|
428
|
+
allPageSelected && totalCount > selectedIds.size
|
|
429
|
+
? () => setSelectAllAcrossPages(true)
|
|
430
|
+
: undefined
|
|
431
|
+
}
|
|
432
|
+
onClearSelection={clearSelection}
|
|
433
|
+
batchActions={props.batchActions}
|
|
434
|
+
onBatchAction={onBatchAction}
|
|
435
|
+
isFetching={isFetching}
|
|
436
|
+
trailing={props.toolbarTrailing}
|
|
437
|
+
>
|
|
438
|
+
{props.toolbarContent}
|
|
439
|
+
</DataViewToolbar>
|
|
440
|
+
{content}
|
|
441
|
+
<DataViewPagination
|
|
442
|
+
pagination={state.pagination}
|
|
443
|
+
totalCount={totalCount}
|
|
444
|
+
onPaginationChange={state.setPagination}
|
|
445
|
+
/>
|
|
446
|
+
</div>
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Fixed-height placeholder rows so the initial load never causes a layout jump. */
|
|
451
|
+
function DataViewSkeleton({ label }: { label: string }) {
|
|
452
|
+
return (
|
|
453
|
+
<div role="status" aria-label={label} style={{ display: "grid", gap: "var(--space-2)", padding: "var(--space-3)" }}>
|
|
454
|
+
{Array.from({ length: 5 }, (_, index) => (
|
|
455
|
+
<div
|
|
456
|
+
key={index}
|
|
457
|
+
aria-hidden
|
|
458
|
+
style={{
|
|
459
|
+
height: "2.75rem",
|
|
460
|
+
background: "var(--color-neutral-100)",
|
|
461
|
+
borderRadius: "var(--radius-md)",
|
|
462
|
+
}}
|
|
463
|
+
/>
|
|
464
|
+
))}
|
|
465
|
+
</div>
|
|
466
|
+
);
|
|
467
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import type { UiText } from "../uiText";
|
|
4
|
+
|
|
5
|
+
import { DataViewExpandToggle } from "./DataViewExpandableRow";
|
|
6
|
+
import { DataViewRowActions } from "./DataViewRowActions";
|
|
7
|
+
import { useDataViewText } from "./internal";
|
|
8
|
+
import type { DataViewColumn, DataViewRowAction } from "./types";
|
|
9
|
+
|
|
10
|
+
export interface DataViewCardListProps<T> {
|
|
11
|
+
rows: T[];
|
|
12
|
+
columns: DataViewColumn<T>[];
|
|
13
|
+
getRowId: (row: T) => string;
|
|
14
|
+
onRowClick?: (row: T) => void;
|
|
15
|
+
getRowLabel?: (row: T) => UiText;
|
|
16
|
+
/** Escape hatch for fully custom cards. */
|
|
17
|
+
renderCard?: (row: T) => ReactNode;
|
|
18
|
+
// Selection
|
|
19
|
+
selectionEnabled: boolean;
|
|
20
|
+
isSelected: (rowId: string) => boolean;
|
|
21
|
+
onToggleSelected: (rowId: string) => void;
|
|
22
|
+
// Expansion
|
|
23
|
+
renderExpanded?: (row: T) => ReactNode;
|
|
24
|
+
isExpanded: (rowId: string) => boolean;
|
|
25
|
+
onToggleExpanded: (rowId: string) => void;
|
|
26
|
+
// Actions
|
|
27
|
+
rowActions?: (row: T) => DataViewRowAction<T>[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const recordButtonStyle: CSSProperties = {
|
|
31
|
+
position: "absolute",
|
|
32
|
+
width: 1,
|
|
33
|
+
height: 1,
|
|
34
|
+
padding: 0,
|
|
35
|
+
margin: -1,
|
|
36
|
+
overflow: "hidden",
|
|
37
|
+
clip: "rect(0 0 0 0)",
|
|
38
|
+
whiteSpace: "nowrap",
|
|
39
|
+
border: 0,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function slotValue<T>(
|
|
43
|
+
columns: DataViewColumn<T>[],
|
|
44
|
+
row: T,
|
|
45
|
+
slot: "title" | "subtitle" | "status" | "date",
|
|
46
|
+
): ReactNode {
|
|
47
|
+
const column = columns.find((candidate) => candidate.meta?.mobileSlot === slot);
|
|
48
|
+
if (column === undefined) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
if (column.cell !== undefined) {
|
|
52
|
+
return column.cell(row);
|
|
53
|
+
}
|
|
54
|
+
const value = column.accessor?.(row);
|
|
55
|
+
return value === null || value === undefined ? null : String(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The stacked card layout: each row becomes a card auto-composed from the columns'
|
|
60
|
+
* `mobileSlot` meta (title, subtitle, status, date), with `renderCard` as a full
|
|
61
|
+
* escape hatch. Selection, row actions and expansion keep working in card view
|
|
62
|
+
* (standard actions collapse into the ellipsis menu).
|
|
63
|
+
*/
|
|
64
|
+
export function DataViewCardList<T>(props: DataViewCardListProps<T>) {
|
|
65
|
+
const { strings, resolve, format } = useDataViewText();
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<ul style={{ listStyle: "none", margin: 0, padding: "var(--space-2)", display: "grid", gap: "var(--space-2)" }}>
|
|
69
|
+
{props.rows.map((row) => {
|
|
70
|
+
const rowId = props.getRowId(row);
|
|
71
|
+
const expanded = props.isExpanded(rowId);
|
|
72
|
+
const clickable = props.onRowClick !== undefined;
|
|
73
|
+
return (
|
|
74
|
+
<li key={rowId}>
|
|
75
|
+
<div
|
|
76
|
+
onClick={clickable ? () => props.onRowClick?.(row) : undefined}
|
|
77
|
+
data-terp={clickable ? "dataview-card" : undefined}
|
|
78
|
+
style={{
|
|
79
|
+
display: "grid",
|
|
80
|
+
gap: "var(--space-2)",
|
|
81
|
+
padding: "var(--space-3)",
|
|
82
|
+
background: "var(--color-neutral-0)",
|
|
83
|
+
border: "1px solid var(--color-neutral-200)",
|
|
84
|
+
borderRadius: "var(--radius-lg)",
|
|
85
|
+
boxShadow: "var(--shadow-sm)",
|
|
86
|
+
cursor: clickable ? "pointer" : undefined,
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
{clickable && (
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
data-terp="dataview-row-open"
|
|
93
|
+
aria-label={format(strings.openRow, {
|
|
94
|
+
label: resolve(props.getRowLabel?.(row) ?? ""),
|
|
95
|
+
})}
|
|
96
|
+
onClick={(event) => {
|
|
97
|
+
event.stopPropagation();
|
|
98
|
+
props.onRowClick?.(row);
|
|
99
|
+
}}
|
|
100
|
+
style={recordButtonStyle}
|
|
101
|
+
/>
|
|
102
|
+
)}
|
|
103
|
+
<div style={{ display: "flex", alignItems: "flex-start", gap: "var(--space-2)" }}>
|
|
104
|
+
{props.renderExpanded !== undefined && (
|
|
105
|
+
<DataViewExpandToggle
|
|
106
|
+
expanded={expanded}
|
|
107
|
+
onToggle={() => props.onToggleExpanded(rowId)}
|
|
108
|
+
/>
|
|
109
|
+
)}
|
|
110
|
+
{props.selectionEnabled && (
|
|
111
|
+
<span onClick={(event) => event.stopPropagation()} style={{ display: "inline-flex" }}>
|
|
112
|
+
<input
|
|
113
|
+
type="checkbox"
|
|
114
|
+
aria-label={resolve(strings.selectRow)}
|
|
115
|
+
checked={props.isSelected(rowId)}
|
|
116
|
+
onChange={() => props.onToggleSelected(rowId)}
|
|
117
|
+
/>
|
|
118
|
+
</span>
|
|
119
|
+
)}
|
|
120
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
121
|
+
{props.renderCard !== undefined ? (
|
|
122
|
+
props.renderCard(row)
|
|
123
|
+
) : (
|
|
124
|
+
<DefaultCardBody row={row} columns={props.columns} />
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
{props.rowActions !== undefined && (
|
|
128
|
+
<DataViewRowActions
|
|
129
|
+
row={row}
|
|
130
|
+
actions={props.rowActions(row)}
|
|
131
|
+
layout="menu"
|
|
132
|
+
isMobile
|
|
133
|
+
/>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
{expanded && props.renderExpanded !== undefined && (
|
|
137
|
+
<div
|
|
138
|
+
style={{
|
|
139
|
+
borderTop: "1px solid var(--color-neutral-200)",
|
|
140
|
+
paddingTop: "var(--space-2)",
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
{props.renderExpanded(row)}
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
</li>
|
|
148
|
+
);
|
|
149
|
+
})}
|
|
150
|
+
</ul>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function DefaultCardBody<T>({ row, columns }: { row: T; columns: DataViewColumn<T>[] }) {
|
|
155
|
+
const title = slotValue(columns, row, "title");
|
|
156
|
+
const subtitle = slotValue(columns, row, "subtitle");
|
|
157
|
+
const status = slotValue(columns, row, "status");
|
|
158
|
+
const date = slotValue(columns, row, "date");
|
|
159
|
+
return (
|
|
160
|
+
<div style={{ display: "grid", gap: "var(--space-1)" }}>
|
|
161
|
+
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-2)", flexWrap: "wrap" }}>
|
|
162
|
+
<span style={{ fontWeight: "var(--font-weight-medium)" as never }}>{title}</span>
|
|
163
|
+
{status !== null && (
|
|
164
|
+
<span
|
|
165
|
+
style={{
|
|
166
|
+
fontSize: "var(--font-size-sm)",
|
|
167
|
+
padding: "0 var(--space-2)",
|
|
168
|
+
background: "var(--color-neutral-100)",
|
|
169
|
+
borderRadius: "var(--radius-full)",
|
|
170
|
+
color: "var(--color-neutral-700)",
|
|
171
|
+
}}
|
|
172
|
+
>
|
|
173
|
+
{status}
|
|
174
|
+
</span>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
{subtitle !== null && (
|
|
178
|
+
<span style={{ fontSize: "var(--font-size-sm)", color: "var(--color-neutral-500)" }}>
|
|
179
|
+
{subtitle}
|
|
180
|
+
</span>
|
|
181
|
+
)}
|
|
182
|
+
{date !== null && (
|
|
183
|
+
<span style={{ fontSize: "var(--font-size-sm)", color: "var(--color-neutral-500)" }}>
|
|
184
|
+
{date}
|
|
185
|
+
</span>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|