@gravitee/graphene-core 3.25.0 → 3.26.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.
@@ -0,0 +1,142 @@
1
+ // The entity list's data and state: the only file that calls the entities service.
2
+ //
3
+ // The list state lives in the URL, so a reload, a shared link and the Back button restore it: `q`,
4
+ // `status`, `type` (repeated for several types), `page`, `size` and `sort` (`name:asc`, `createdAt:desc`).
5
+ // Defaults are not written. A new criterion (search, filter, sort) returns to page 1 and replaces the
6
+ // history entry; changing the page or its size adds one.
7
+
8
+ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
9
+ import type { OnChangeFn, SortingState } from '@tanstack/react-table';
10
+ import { useEffect, useState } from 'react';
11
+ import { useSearchParams } from 'react-router-dom';
12
+ import {
13
+ deleteEntity,
14
+ ENTITY_STATUSES,
15
+ ENTITY_TYPES,
16
+ type EntityPage,
17
+ type EntitySort,
18
+ type EntityType,
19
+ listEntities,
20
+ type ListEntitiesParams,
21
+ } from './entities.service';
22
+
23
+ export const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
24
+ const DEFAULT_PAGE_SIZE = 10;
25
+ // Your API's default order: the table header shows it, and the URL leaves it out.
26
+ const DEFAULT_SORT: EntitySort = 'createdAt:desc';
27
+ const SORT_PATTERN = /^(name|createdAt):(asc|desc)$/;
28
+
29
+ type ListParamKey = 'q' | 'status' | 'type' | 'page' | 'size' | 'sort';
30
+
31
+ /** The list state the URL holds. `q` is the search as typed: the field gets it back unchanged. */
32
+ export function readListState(searchParams: URLSearchParams) {
33
+ const status = searchParams.get('status');
34
+ const types = searchParams.getAll('type');
35
+ const page = Number(searchParams.get('page'));
36
+ const size = Number(searchParams.get('size'));
37
+ const sort = searchParams.get('sort');
38
+ return {
39
+ q: searchParams.get('q') ?? '',
40
+ status: ENTITY_STATUSES.find((value) => value === status),
41
+ type: ENTITY_TYPES.filter((value) => types.includes(value)),
42
+ page: Number.isInteger(page) && page > 1 ? page : 1,
43
+ size: PAGE_SIZE_OPTIONS.includes(size) ? size : DEFAULT_PAGE_SIZE,
44
+ sort: sort && SORT_PATTERN.test(sort) ? (sort as EntitySort) : DEFAULT_SORT,
45
+ };
46
+ }
47
+
48
+ /** A copy of the URL with `changes` applied: an empty value removes its key, an array repeats it. */
49
+ export function withChanges(
50
+ current: URLSearchParams,
51
+ changes: Partial<Record<ListParamKey, string | string[] | undefined>>,
52
+ ) {
53
+ const next = new URLSearchParams(current);
54
+ for (const [key, value] of Object.entries(changes)) {
55
+ next.delete(key);
56
+ for (const item of [value ?? []].flat()) {
57
+ if (item !== '') next.append(key, item);
58
+ }
59
+ }
60
+ return next;
61
+ }
62
+
63
+ export function useEntityList() {
64
+ const [searchParams, setSearchParams] = useSearchParams();
65
+ const state = readListState(searchParams);
66
+ const params: ListEntitiesParams = { ...state, q: state.q.trim() || undefined };
67
+
68
+ const query = useQuery({
69
+ queryKey: ['entities', params],
70
+ queryFn: () => listEntities(params),
71
+ placeholderData: keepPreviousData,
72
+ });
73
+
74
+ // keepPreviousData holds the previous page only while the next one loads, and a retry with nothing
75
+ // loaded clears the error while it runs. Keep the last page that loaded and the last error, so that
76
+ // the rows, the toolbar and the alert stay on screen until the next answer: the search field never
77
+ // disappears while the user types, and the alert does not vanish while Retry runs.
78
+ const [lastLoaded, setLastLoaded] = useState<EntityPage>();
79
+ const [lastError, setLastError] = useState<Error | null>(null);
80
+ const answered = query.isSuccess && !query.isPlaceholderData;
81
+ if (answered && query.data !== lastLoaded) setLastLoaded(query.data);
82
+ if (answered && lastError) setLastError(null);
83
+ if (query.error && query.error !== lastError) setLastError(query.error);
84
+
85
+ // A page past the last one (its last row deleted, an old link) shows the last page instead.
86
+ const lastPage = Math.max(1, Math.ceil((query.data?.totalCount ?? 0) / state.size));
87
+ const pastTheEnd = answered && state.page > lastPage;
88
+ useEffect(() => {
89
+ if (pastTheEnd) {
90
+ setSearchParams((current) => withChanges(current, { page: lastPage > 1 ? String(lastPage) : undefined }), {
91
+ replace: true,
92
+ });
93
+ }
94
+ }, [pastTheEnd, lastPage, setSearchParams]);
95
+
96
+ const setCriteria = (changes: Parameters<typeof withChanges>[1]) =>
97
+ setSearchParams((current) => withChanges(current, { ...changes, page: undefined }), { replace: true });
98
+ const setPaging = (changes: Parameters<typeof withChanges>[1]) =>
99
+ setSearchParams((current) => withChanges(current, changes));
100
+
101
+ const [sortField, sortDirection] = state.sort.split(':');
102
+ const sorting: SortingState = [{ id: sortField, desc: sortDirection === 'desc' }];
103
+ const onSortingChange: OnChangeFn<SortingState> = (updater) => {
104
+ const [next] = typeof updater === 'function' ? updater(sorting) : updater;
105
+ const sort = next ? `${next.id}:${next.desc ? 'desc' : 'asc'}` : DEFAULT_SORT;
106
+ setCriteria({ sort: sort === DEFAULT_SORT ? undefined : sort });
107
+ };
108
+
109
+ return {
110
+ ...state,
111
+ sorting,
112
+ onSortingChange,
113
+ hasCriteria: params.q !== undefined || state.status !== undefined || state.type.length > 0,
114
+ // Decide the page's state only from a settled answer, never from the placeholder of the last one.
115
+ settled: answered && !pastTheEnd,
116
+ result: query.data ?? lastLoaded,
117
+ error: query.error ?? (query.isFetching ? lastError : null),
118
+ isFetching: query.isFetching || pastTheEnd,
119
+ refetch: query.refetch,
120
+ setQuery: (q: string) => setCriteria({ q }),
121
+ setStatus: (status: string | undefined) => setCriteria({ status }),
122
+ setTypes: (types: EntityType[]) => setCriteria({ type: types }),
123
+ setPage: (page: number) => setPaging({ page: page > 1 ? String(page) : undefined }),
124
+ setPageSize: (size: number) =>
125
+ setPaging({ size: size === DEFAULT_PAGE_SIZE ? undefined : String(size), page: undefined }),
126
+ reset: () => setCriteria({ q: undefined, status: undefined, type: undefined }),
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Deletes an entity, then refetches every page of the list without waiting for it: the caller hears the
132
+ * outcome as soon as the delete answers, while its row is still on screen.
133
+ */
134
+ export function useDeleteEntity() {
135
+ const queryClient = useQueryClient();
136
+ return useMutation({
137
+ mutationFn: deleteEntity,
138
+ onSuccess: () => {
139
+ void queryClient.invalidateQueries({ queryKey: ['entities'] });
140
+ },
141
+ });
142
+ }
@@ -1,316 +0,0 @@
1
- // Snippet: Entity List page with DataTable
2
- //
3
- // Use when building a list page for server-paginated entity data (APIs, Applications,
4
- // Policies, Agents, Plans, etc.) — any CRUD list where users manage entities.
5
- //
6
- // Do NOT use for:
7
- // - Static key-value detail panels (use raw Table)
8
- // - Time-series/triage explorers (logs, traces) — see Record Explorer pattern (deferred)
9
- // - Tables with fewer than 5 fixed rows
10
- //
11
- // Empty state pattern (page-level decision):
12
- // - totalCount === 0 && !hasFilters → DataTableEmptyState variant="first-use" (INSTEAD of DataTable)
13
- // - filteredCount === 0 && hasFilters → DataTableEmptyState variant="no-results" (INSIDE DataTable)
14
- //
15
- // Header button coordination:
16
- // - First-use → header hides "Add" button; empty state owns the primary CTA
17
- // - Has data → header shows "Add" button (primary); table is main content
18
- // - Both buttons trigger the same creation flow
19
- //
20
- // See Storybook "Composed/DataTableEmptyState → Integration" for interactive example.
21
- //
22
- // Column ordering (left to right):
23
- // 1. Name (link, always first, font-medium hover:underline)
24
- // 2. Status badge (lifecycle state)
25
- // 3. Category badges (type, version, security)
26
- // 4. Dates (relative format + full tooltip)
27
- // 5. Owner/actor (truncated text)
28
- // 6. Actions (always last, sr-only header)
29
- //
30
- // Minimum 3 data columns (excluding actions). Fewer → use Item list or card grid.
31
- // Target 4-7 visible columns. More than 7 → enable column visibility.
32
- // Do NOT show raw entity IDs as visible columns.
33
- //
34
- // Actions column: ALWAYS use MoreVerticalIcon (⋮) + DropdownMenu.
35
- // Never render a row of inline icon buttons. Single-action exception: exactly 1
36
- // action may be a lone icon button without a dropdown.
37
- //
38
- // See Storybook "Patterns/Data Table → ApiList" for interactive example.
39
-
40
- import {
41
- Badge,
42
- Button,
43
- DataTable,
44
- DataTableColumnHeader,
45
- DataTableEmptyState,
46
- DateCell,
47
- DropdownMenu,
48
- DropdownMenuContent,
49
- DropdownMenuItem,
50
- DropdownMenuSeparator,
51
- DropdownMenuTrigger,
52
- FacetedFilter,
53
- PageHeader,
54
- SearchField,
55
- } from '@gravitee/graphene-core';
56
- import {
57
- GlobeIcon,
58
- MoreVerticalIcon,
59
- PencilIcon,
60
- PlusIcon,
61
- SearchIcon,
62
- Trash2Icon,
63
- } from '@gravitee/graphene-core/icons';
64
- import type { ColumnDef, SortingState } from '@tanstack/react-table';
65
- import { useEffect, useReducer, useState } from 'react';
66
-
67
- // Replace with your entity type
68
- interface Entity {
69
- id: string;
70
- name: string;
71
- status: 'active' | 'inactive' | 'draft';
72
- createdAt: string;
73
- owner: string;
74
- }
75
-
76
- // Replace with your API fetch function
77
- declare function fetchEntities(params: {
78
- page: number;
79
- perPage: number;
80
- sortBy?: string;
81
- sortOrder?: 'asc' | 'desc';
82
- search?: string;
83
- status?: string[];
84
- }): Promise<{ data: Entity[]; totalCount: number }>;
85
-
86
- const STATUS_VARIANTS = {
87
- active: 'success',
88
- inactive: 'secondary',
89
- draft: 'outline',
90
- } as const;
91
-
92
- const statusOptions = [
93
- { value: 'active', label: 'Active' },
94
- { value: 'inactive', label: 'Inactive' },
95
- { value: 'draft', label: 'Draft' },
96
- ];
97
-
98
- const columns: ColumnDef<Entity, unknown>[] = [
99
- {
100
- accessorKey: 'name',
101
- header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
102
- cell: ({ row }) => (
103
- <a href={`#/entities/${row.original.id}`} className="font-medium hover:underline">
104
- {row.original.name}
105
- </a>
106
- ),
107
- },
108
- {
109
- accessorKey: 'status',
110
- header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
111
- cell: ({ row }) => <Badge variant={STATUS_VARIANTS[row.original.status]}>{row.original.status}</Badge>,
112
- },
113
- {
114
- accessorKey: 'createdAt',
115
- header: ({ column }) => <DataTableColumnHeader column={column} title="Created" />,
116
- cell: ({ row }) => <DateCell value={row.original.createdAt} />,
117
- },
118
- {
119
- accessorKey: 'owner',
120
- header: 'Owner',
121
- enableSorting: false,
122
- cell: ({ row }) => <span className="max-w-[150px] truncate text-muted-foreground">{row.original.owner}</span>,
123
- },
124
- {
125
- id: 'actions',
126
- header: () => <span className="sr-only">Actions</span>,
127
- enableSorting: false,
128
- enableHiding: false,
129
- cell: () => (
130
- <div className="flex justify-end">
131
- <DropdownMenu>
132
- <DropdownMenuTrigger asChild>
133
- <Button variant="ghost" size="icon-xs">
134
- <MoreVerticalIcon className="size-4" />
135
- </Button>
136
- </DropdownMenuTrigger>
137
- <DropdownMenuContent align="end">
138
- <DropdownMenuItem>
139
- <PencilIcon className="size-3.5" />
140
- Edit
141
- </DropdownMenuItem>
142
- <DropdownMenuSeparator />
143
- <DropdownMenuItem className="text-destructive">
144
- <Trash2Icon className="size-3.5" />
145
- Delete
146
- </DropdownMenuItem>
147
- </DropdownMenuContent>
148
- </DropdownMenu>
149
- </div>
150
- ),
151
- },
152
- ];
153
-
154
- interface FetchState {
155
- data: Entity[];
156
- totalCount: number;
157
- loading: boolean;
158
- }
159
-
160
- type FetchAction = { type: 'loading' } | { type: 'success'; data: Entity[]; totalCount: number };
161
-
162
- function fetchReducer(state: FetchState, action: FetchAction): FetchState {
163
- if (action.type === 'loading') return { ...state, loading: true };
164
- return { data: action.data, totalCount: action.totalCount, loading: false };
165
- }
166
-
167
- export function EntityListPage() {
168
- const [page, setPage] = useState(1);
169
- const [pageSize, setPageSize] = useState(10);
170
- const [query, setQuery] = useState('');
171
- const [sorting, setSorting] = useState<SortingState>([]);
172
- const [statusFilter, setStatusFilter] = useState<string[]>([]);
173
- const [state, dispatch] = useReducer(fetchReducer, { data: [], totalCount: 0, loading: true });
174
-
175
- useEffect(() => {
176
- let active = true;
177
- dispatch({ type: 'loading' });
178
- fetchEntities({
179
- page,
180
- perPage: pageSize,
181
- sortBy: sorting[0]?.id,
182
- sortOrder: sorting[0]?.desc ? 'desc' : 'asc',
183
- search: query || undefined,
184
- status: statusFilter.length ? statusFilter : undefined,
185
- }).then((res) => {
186
- if (active) dispatch({ type: 'success', data: res.data, totalCount: res.totalCount });
187
- });
188
- return () => {
189
- active = false;
190
- };
191
- }, [page, pageSize, sorting, query, statusFilter]);
192
-
193
- const handleSortingChange = (updater: SortingState | ((prev: SortingState) => SortingState)) => {
194
- setSorting(typeof updater === 'function' ? updater(sorting) : updater);
195
- setPage(1);
196
- };
197
-
198
- const handleStatusChange = (values: string[]) => {
199
- setStatusFilter(values);
200
- setPage(1);
201
- };
202
-
203
- const { data, totalCount, loading } = state;
204
- const hasFilters = query || statusFilter.length > 0;
205
- const isFirstUse = totalCount === 0 && !hasFilters && !loading;
206
-
207
- return (
208
- <div className="flex flex-col gap-6">
209
- <PageHeader
210
- title="Entities"
211
- description="Manage your entity catalog."
212
- actions={
213
- !isFirstUse && (
214
- <Button size="sm">
215
- <PlusIcon />
216
- Add entity
217
- </Button>
218
- )
219
- }
220
- />
221
-
222
- {isFirstUse ? (
223
- <div className="rounded-lg border">
224
- <DataTableEmptyState
225
- variant="first-use"
226
- icon={<GlobeIcon />}
227
- title="No entities yet"
228
- description="Get started by creating your first entity."
229
- primaryAction={
230
- <Button size="sm">
231
- <PlusIcon />
232
- Create entity
233
- </Button>
234
- }
235
- />
236
- </div>
237
- ) : (
238
- <DataTable
239
- columns={columns}
240
- data={data}
241
- loading={loading}
242
- skeletonCount={pageSize}
243
- sorting={sorting}
244
- onSortingChange={handleSortingChange}
245
- enableColumnVisibility
246
- serverSide
247
- pagination={{
248
- page,
249
- pageSize,
250
- totalCount,
251
- pageSizeOptions: [10, 25, 50, 100],
252
- onPageChange: setPage,
253
- onPageSizeChange: (size) => {
254
- setPageSize(size);
255
- setPage(1);
256
- },
257
- }}
258
- emptyMessage={
259
- <DataTableEmptyState
260
- variant="no-results"
261
- icon={<SearchIcon />}
262
- title="No entities match your search"
263
- description="Try adjusting your search terms or clearing filters."
264
- action={
265
- <Button
266
- size="sm"
267
- variant="outline"
268
- onClick={() => {
269
- setQuery('');
270
- setStatusFilter([]);
271
- setPage(1);
272
- }}
273
- >
274
- Clear filters
275
- </Button>
276
- }
277
- />
278
- }
279
- toolbar={
280
- <>
281
- <SearchField
282
- aria-label="Search entities"
283
- placeholder="Search..."
284
- value={query}
285
- onValueChange={(value) => {
286
- setQuery(value);
287
- setPage(1);
288
- }}
289
- className="w-64"
290
- />
291
- <FacetedFilter
292
- title="Status"
293
- options={statusOptions}
294
- selected={statusFilter}
295
- onChange={handleStatusChange}
296
- />
297
- {hasFilters && (
298
- <Button
299
- variant="ghost"
300
- size="sm"
301
- onClick={() => {
302
- setQuery('');
303
- setStatusFilter([]);
304
- setPage(1);
305
- }}
306
- >
307
- Reset
308
- </Button>
309
- )}
310
- </>
311
- }
312
- />
313
- )}
314
- </div>
315
- );
316
- }