@gravitee/graphene-core 2.42.1 → 2.43.0-fix-improve-data-table.f0d5d2e

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gravitee/graphene-core",
3
- "version": "2.42.1",
3
+ "version": "2.43.0-fix-improve-data-table.f0d5d2e",
4
4
  "type": "module",
5
5
  "description": "Gravitee's Graphene design system: accessible React components, design tokens, icons, and shared ESLint/TypeScript configs. Built on shadcn/ui and Tailwind.",
6
6
  "keywords": [
@@ -91,6 +91,7 @@
91
91
  },
92
92
  "peerDependencies": {
93
93
  "@fontsource/dm-sans": ">=5.0.0",
94
+ "@gravitee/graphene-core": "2.43.0-fix-improve-data-table.f0d5d2e",
94
95
  "@tanstack/react-table": "^8.0.0",
95
96
  "@testing-library/dom": ">=10.0.0",
96
97
  "@testing-library/react": ">=16.0.0",
@@ -0,0 +1,321 @@
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 type { ColumnDef, SortingState } from '@tanstack/react-table';
41
+ import { useEffect, useReducer, useRef, useState } from 'react';
42
+ import {
43
+ Badge,
44
+ Button,
45
+ DataTable,
46
+ DataTableColumnHeader,
47
+ DataTableEmptyState,
48
+ DropdownMenu,
49
+ DropdownMenuContent,
50
+ DropdownMenuItem,
51
+ DropdownMenuSeparator,
52
+ DropdownMenuTrigger,
53
+ FacetedFilter,
54
+ Input,
55
+ } from '@gravitee/graphene-core';
56
+ import { DateCell } from '@gravitee/graphene-core/composed/DataTable';
57
+ import {
58
+ GlobeIcon,
59
+ MoreVerticalIcon,
60
+ PencilIcon,
61
+ PlusIcon,
62
+ SearchIcon,
63
+ Trash2Icon,
64
+ } from '@gravitee/graphene-core/icons';
65
+
66
+ // Replace with your entity type
67
+ interface Entity {
68
+ id: string;
69
+ name: string;
70
+ status: 'active' | 'inactive' | 'draft';
71
+ createdAt: string;
72
+ owner: string;
73
+ }
74
+
75
+ // Replace with your API fetch function
76
+ declare function fetchEntities(params: {
77
+ page: number;
78
+ perPage: number;
79
+ sortBy?: string;
80
+ sortOrder?: 'asc' | 'desc';
81
+ search?: string;
82
+ status?: string[];
83
+ }): Promise<{ data: Entity[]; totalCount: number }>;
84
+
85
+ const STATUS_VARIANTS = {
86
+ active: 'success',
87
+ inactive: 'secondary',
88
+ draft: 'outline',
89
+ } as const;
90
+
91
+ const statusOptions = [
92
+ { value: 'active', label: 'Active' },
93
+ { value: 'inactive', label: 'Inactive' },
94
+ { value: 'draft', label: 'Draft' },
95
+ ];
96
+
97
+ const columns: ColumnDef<Entity, unknown>[] = [
98
+ {
99
+ accessorKey: 'name',
100
+ header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
101
+ cell: ({ row }) => (
102
+ <a href={`#/entities/${row.original.id}`} className="font-medium hover:underline">
103
+ {row.original.name}
104
+ </a>
105
+ ),
106
+ },
107
+ {
108
+ accessorKey: 'status',
109
+ header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
110
+ cell: ({ row }) => <Badge variant={STATUS_VARIANTS[row.original.status]}>{row.original.status}</Badge>,
111
+ },
112
+ {
113
+ accessorKey: 'createdAt',
114
+ header: ({ column }) => <DataTableColumnHeader column={column} title="Created" />,
115
+ cell: ({ row }) => <DateCell value={row.original.createdAt} />,
116
+ },
117
+ {
118
+ accessorKey: 'owner',
119
+ header: 'Owner',
120
+ enableSorting: false,
121
+ cell: ({ row }) => <span className="max-w-[150px] truncate text-muted-foreground">{row.original.owner}</span>,
122
+ },
123
+ {
124
+ id: 'actions',
125
+ header: () => <span className="sr-only">Actions</span>,
126
+ enableSorting: false,
127
+ enableHiding: false,
128
+ cell: () => (
129
+ <div className="flex justify-end">
130
+ <DropdownMenu>
131
+ <DropdownMenuTrigger asChild>
132
+ <Button variant="ghost" size="icon-xs">
133
+ <MoreVerticalIcon className="size-4" />
134
+ </Button>
135
+ </DropdownMenuTrigger>
136
+ <DropdownMenuContent align="end">
137
+ <DropdownMenuItem>
138
+ <PencilIcon className="size-3.5" />
139
+ Edit
140
+ </DropdownMenuItem>
141
+ <DropdownMenuSeparator />
142
+ <DropdownMenuItem className="text-destructive">
143
+ <Trash2Icon className="size-3.5" />
144
+ Delete
145
+ </DropdownMenuItem>
146
+ </DropdownMenuContent>
147
+ </DropdownMenu>
148
+ </div>
149
+ ),
150
+ },
151
+ ];
152
+
153
+ interface FetchState {
154
+ data: Entity[];
155
+ totalCount: number;
156
+ loading: boolean;
157
+ }
158
+
159
+ type FetchAction = { type: 'loading' } | { type: 'success'; data: Entity[]; totalCount: number };
160
+
161
+ function fetchReducer(state: FetchState, action: FetchAction): FetchState {
162
+ if (action.type === 'loading') return { ...state, loading: true };
163
+ return { data: action.data, totalCount: action.totalCount, loading: false };
164
+ }
165
+
166
+ export function EntityListPage() {
167
+ const [page, setPage] = useState(1);
168
+ const [pageSize, setPageSize] = useState(10);
169
+ const [search, setSearch] = useState('');
170
+ const [debouncedSearch, setDebouncedSearch] = 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
+ const debounceRef = useRef<ReturnType<typeof setTimeout>>();
176
+ useEffect(() => {
177
+ debounceRef.current = setTimeout(() => {
178
+ setDebouncedSearch(search);
179
+ setPage(1);
180
+ }, 300);
181
+ return () => clearTimeout(debounceRef.current);
182
+ }, [search]);
183
+
184
+ useEffect(() => {
185
+ let active = true;
186
+ dispatch({ type: 'loading' });
187
+ fetchEntities({
188
+ page,
189
+ perPage: pageSize,
190
+ sortBy: sorting[0]?.id,
191
+ sortOrder: sorting[0]?.desc ? 'desc' : 'asc',
192
+ search: debouncedSearch || undefined,
193
+ status: statusFilter.length ? statusFilter : undefined,
194
+ }).then((res) => {
195
+ if (active) dispatch({ type: 'success', data: res.data, totalCount: res.totalCount });
196
+ });
197
+ return () => {
198
+ active = false;
199
+ };
200
+ }, [page, pageSize, sorting, debouncedSearch, statusFilter]);
201
+
202
+ const handleSortingChange = (updater: SortingState | ((prev: SortingState) => SortingState)) => {
203
+ setSorting(typeof updater === 'function' ? updater(sorting) : updater);
204
+ setPage(1);
205
+ };
206
+
207
+ const handleStatusChange = (values: string[]) => {
208
+ setStatusFilter(values);
209
+ setPage(1);
210
+ };
211
+
212
+ const { data, totalCount, loading } = state;
213
+ const hasFilters = search || statusFilter.length > 0;
214
+ const isFirstUse = totalCount === 0 && !hasFilters && !loading;
215
+
216
+ return (
217
+ <div className="space-y-4">
218
+ <div className="flex items-center justify-between">
219
+ <div>
220
+ <h2 className="text-lg font-semibold">Entities</h2>
221
+ <p className="text-sm text-muted-foreground">Manage your entity catalog.</p>
222
+ </div>
223
+ {!isFirstUse && (
224
+ <Button size="sm">
225
+ <PlusIcon />
226
+ Add entity
227
+ </Button>
228
+ )}
229
+ </div>
230
+
231
+ {isFirstUse ? (
232
+ <div className="rounded-lg border">
233
+ <DataTableEmptyState
234
+ variant="first-use"
235
+ icon={<GlobeIcon />}
236
+ title="No entities yet"
237
+ description="Get started by creating your first entity."
238
+ primaryAction={
239
+ <Button size="sm">
240
+ <PlusIcon />
241
+ Create entity
242
+ </Button>
243
+ }
244
+ />
245
+ </div>
246
+ ) : (
247
+ <DataTable
248
+ columns={columns}
249
+ data={data}
250
+ loading={loading}
251
+ skeletonCount={pageSize}
252
+ sorting={sorting}
253
+ onSortingChange={handleSortingChange}
254
+ enableColumnVisibility
255
+ serverSide
256
+ pagination={{
257
+ page,
258
+ pageSize,
259
+ totalCount,
260
+ pageSizeOptions: [10, 25, 50, 100],
261
+ onPageChange: setPage,
262
+ onPageSizeChange: (size) => {
263
+ setPageSize(size);
264
+ setPage(1);
265
+ },
266
+ }}
267
+ emptyMessage={
268
+ <DataTableEmptyState
269
+ variant="no-results"
270
+ icon={<SearchIcon />}
271
+ title="No entities match your search"
272
+ description="Try adjusting your search terms or clearing filters."
273
+ action={
274
+ <Button
275
+ size="sm"
276
+ variant="outline"
277
+ onClick={() => {
278
+ setSearch('');
279
+ setStatusFilter([]);
280
+ setPage(1);
281
+ }}
282
+ >
283
+ Clear filters
284
+ </Button>
285
+ }
286
+ />
287
+ }
288
+ toolbar={
289
+ <>
290
+ <Input
291
+ placeholder="Search..."
292
+ value={search}
293
+ onChange={(e) => setSearch(e.target.value)}
294
+ className="h-8 w-64"
295
+ />
296
+ <FacetedFilter
297
+ title="Status"
298
+ options={statusOptions}
299
+ selected={statusFilter}
300
+ onChange={handleStatusChange}
301
+ />
302
+ {hasFilters && (
303
+ <Button
304
+ variant="ghost"
305
+ size="sm"
306
+ onClick={() => {
307
+ setSearch('');
308
+ setStatusFilter([]);
309
+ setPage(1);
310
+ }}
311
+ >
312
+ Reset
313
+ </Button>
314
+ )}
315
+ </>
316
+ }
317
+ />
318
+ )}
319
+ </div>
320
+ );
321
+ }