@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.
- package/USAGE_GUIDE.md +60 -34
- package/dist/composed/DataTable/DataTable.d.ts +14 -1
- package/dist/composed/DataTable/DataTable.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/styles/globals.css +1 -1
- package/package.json +3 -1
- package/snippets/list-page/DeleteEntityDialog.tsx +71 -0
- package/snippets/list-page/EntitiesPage.tsx +221 -0
- package/snippets/list-page/EntityActionsMenu.tsx +69 -0
- package/snippets/list-page/entities.service.ts +108 -0
- package/snippets/list-page/entityColumns.tsx +79 -0
- package/snippets/list-page/snippet.json +6 -0
- package/snippets/list-page/useEntityList.ts +142 -0
- package/snippets/data-table-list-page.tsx +0 -316
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gravitee/graphene-core",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.26.0",
|
|
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": [
|
|
@@ -163,8 +163,10 @@
|
|
|
163
163
|
"devDependencies": {
|
|
164
164
|
"@hookform/resolvers": "5.4.0",
|
|
165
165
|
"@monaco-editor/react": "4.7.0",
|
|
166
|
+
"@tanstack/react-query": "5.100.14",
|
|
166
167
|
"monaco-editor": "0.52.2",
|
|
167
168
|
"react-hook-form": "7.78.0",
|
|
169
|
+
"react-router-dom": "7.15.1",
|
|
168
170
|
"shadcn": "4.11.0",
|
|
169
171
|
"vitest": "4.1.8",
|
|
170
172
|
"zod": "4.4.3"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Confirms the deletion of one entity, runs it and reports the outcome in a toast (the app mounts the
|
|
2
|
+
// Toaster once: the Gamma shell does). The row's ⋮ menu opens it. While the delete runs, the dialog
|
|
3
|
+
// cannot be dismissed; a failure keeps it open so the user can try again or cancel.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
Button,
|
|
7
|
+
Dialog,
|
|
8
|
+
DialogClose,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogDescription,
|
|
11
|
+
DialogFooter,
|
|
12
|
+
DialogHeader,
|
|
13
|
+
DialogTitle,
|
|
14
|
+
toast,
|
|
15
|
+
} from '@gravitee/graphene-core';
|
|
16
|
+
import { Trash2Icon } from '@gravitee/graphene-core/icons';
|
|
17
|
+
import type { Entity } from './entities.service';
|
|
18
|
+
import { useDeleteEntity } from './useEntityList';
|
|
19
|
+
|
|
20
|
+
interface DeleteEntityDialogProps {
|
|
21
|
+
readonly entity: Entity;
|
|
22
|
+
readonly open: boolean;
|
|
23
|
+
readonly onOpenChange: (open: boolean) => void;
|
|
24
|
+
/** Runs once the entity is deleted, before the dialog closes. */
|
|
25
|
+
readonly onDeleted: () => void;
|
|
26
|
+
/** Where the focus goes when the dialog closes: the element that opened it is often gone by then. */
|
|
27
|
+
readonly onCloseAutoFocus?: (event: Event) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function DeleteEntityDialog({
|
|
31
|
+
entity,
|
|
32
|
+
open,
|
|
33
|
+
onOpenChange,
|
|
34
|
+
onDeleted,
|
|
35
|
+
onCloseAutoFocus,
|
|
36
|
+
}: DeleteEntityDialogProps) {
|
|
37
|
+
const { mutate, isPending } = useDeleteEntity();
|
|
38
|
+
|
|
39
|
+
const confirm = () =>
|
|
40
|
+
mutate(entity.id, {
|
|
41
|
+
onSuccess: () => {
|
|
42
|
+
toast.success(`Deleted ${entity.name}`);
|
|
43
|
+
onDeleted();
|
|
44
|
+
onOpenChange(false);
|
|
45
|
+
},
|
|
46
|
+
onError: (error) => toast.error(`Could not delete ${entity.name}`, { description: error.message }),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<Dialog open={open} onOpenChange={(next) => !isPending && onOpenChange(next)}>
|
|
51
|
+
<DialogContent showCloseButton={false} onCloseAutoFocus={onCloseAutoFocus}>
|
|
52
|
+
<DialogHeader>
|
|
53
|
+
<DialogTitle>Delete {entity.name}?</DialogTitle>
|
|
54
|
+
{/* Say what stops working once it is gone, when something does. */}
|
|
55
|
+
<DialogDescription>This deletes the entity for good. It cannot be undone.</DialogDescription>
|
|
56
|
+
</DialogHeader>
|
|
57
|
+
<DialogFooter>
|
|
58
|
+
<DialogClose asChild>
|
|
59
|
+
<Button variant="outline" disabled={isPending}>
|
|
60
|
+
Cancel
|
|
61
|
+
</Button>
|
|
62
|
+
</DialogClose>
|
|
63
|
+
<Button variant="destructive" disabled={isPending} onClick={confirm}>
|
|
64
|
+
<Trash2Icon aria-hidden="true" />
|
|
65
|
+
{isPending ? 'Deleting...' : 'Delete entity'}
|
|
66
|
+
</Button>
|
|
67
|
+
</DialogFooter>
|
|
68
|
+
</DialogContent>
|
|
69
|
+
</Dialog>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Snippet: entity list page
|
|
2
|
+
//
|
|
3
|
+
// Use for the list page of an entity the user manages (APIs, applications, plans, agents...), fetched
|
|
4
|
+
// page by page from the server, with search, filters, sorting, row actions and creation.
|
|
5
|
+
//
|
|
6
|
+
// Do NOT use for:
|
|
7
|
+
// - Static key-value detail panels (use raw Table)
|
|
8
|
+
// - Time-series or triage explorers (logs, traces): no snippet yet
|
|
9
|
+
// - Tables with fewer than 5 fixed rows
|
|
10
|
+
//
|
|
11
|
+
// This folder: EntitiesPage.tsx (the page), useEntityList.ts (query and URL state), entityColumns.tsx
|
|
12
|
+
// (the columns), EntityActionsMenu.tsx (the row menu), DeleteEntityDialog.tsx (the delete confirmation)
|
|
13
|
+
// and entities.service.ts (sample data, to replace with your API client). Copy the folder, then rename
|
|
14
|
+
// Entity and entities after your resource.
|
|
15
|
+
//
|
|
16
|
+
// States, decided once the query has settled, never from the previous page kept while loading:
|
|
17
|
+
// - Loading: skeleton rows on the first load; later fetches keep the rows under DataTable's overlay.
|
|
18
|
+
// - Error: an alert with Retry above the last loaded rows, saying they may be out of date; the alert
|
|
19
|
+
// alone when nothing has loaded yet.
|
|
20
|
+
// - First use (no entity, no criterion): DataTableEmptyState "first-use" INSTEAD of the table, without
|
|
21
|
+
// a button.
|
|
22
|
+
// - No results (a criterion active): DataTableEmptyState "no-results" INSIDE the table, without a
|
|
23
|
+
// button: the search field's clear button and Reset clear the criteria.
|
|
24
|
+
// - Data: the rows.
|
|
25
|
+
// The header's Add button stays in every state: one creation CTA on screen.
|
|
26
|
+
//
|
|
27
|
+
// Column ordering (left to right):
|
|
28
|
+
// 1. Name (link, always first, font-medium hover:underline)
|
|
29
|
+
// 2. Status badge (lifecycle state)
|
|
30
|
+
// 3. Category badges (type, version, security)
|
|
31
|
+
// 4. Dates (relative format + full tooltip)
|
|
32
|
+
// 5. Owner/actor (truncated text)
|
|
33
|
+
// 6. Actions (always last, sr-only header)
|
|
34
|
+
//
|
|
35
|
+
// Minimum 3 data columns (excluding actions). Fewer → use Item list or card grid.
|
|
36
|
+
// Target 4-7 visible columns. The View menu (enableColumnVisibility) hides and shows them: keep the
|
|
37
|
+
// name and the actions always visible (enableHiding: false), and give a column whose header is a
|
|
38
|
+
// component a meta.label, or the menu shows its id.
|
|
39
|
+
// Do NOT show raw entity IDs as visible columns.
|
|
40
|
+
//
|
|
41
|
+
// Actions column: ALWAYS use MoreVerticalIcon (⋮) + DropdownMenu, named after the row.
|
|
42
|
+
// Never render a row of inline icon buttons. Single-action exception: exactly 1
|
|
43
|
+
// action may be a lone icon button without a dropdown. Delete comes last and asks for confirmation
|
|
44
|
+
// in a Dialog, then reports the outcome in a toast.
|
|
45
|
+
//
|
|
46
|
+
// See Storybook "Patterns/List Page" for the five states.
|
|
47
|
+
|
|
48
|
+
import {
|
|
49
|
+
Alert,
|
|
50
|
+
AlertAction,
|
|
51
|
+
AlertDescription,
|
|
52
|
+
AlertTitle,
|
|
53
|
+
Button,
|
|
54
|
+
DataTable,
|
|
55
|
+
DataTableEmptyState,
|
|
56
|
+
FacetedFilter,
|
|
57
|
+
Label,
|
|
58
|
+
PageHeader,
|
|
59
|
+
SearchField,
|
|
60
|
+
Select,
|
|
61
|
+
SelectContent,
|
|
62
|
+
SelectItem,
|
|
63
|
+
SelectTrigger,
|
|
64
|
+
SelectValue,
|
|
65
|
+
} from '@gravitee/graphene-core';
|
|
66
|
+
import { BoxesIcon, CircleXIcon, PlusIcon, SearchIcon } from '@gravitee/graphene-core/icons';
|
|
67
|
+
import { useId, useRef } from 'react';
|
|
68
|
+
import { Link } from 'react-router-dom';
|
|
69
|
+
import { EntityDeletedContext } from './EntityActionsMenu';
|
|
70
|
+
import { ENTITY_STATUSES, ENTITY_TYPES } from './entities.service';
|
|
71
|
+
import { entityColumns, STATUS_BADGES, TYPE_LABELS } from './entityColumns';
|
|
72
|
+
import { PAGE_SIZE_OPTIONS, useEntityList } from './useEntityList';
|
|
73
|
+
|
|
74
|
+
const ALL_STATUSES = 'all';
|
|
75
|
+
const TYPE_OPTIONS = ENTITY_TYPES.map((value) => ({ value, label: TYPE_LABELS[value] }));
|
|
76
|
+
|
|
77
|
+
export function EntitiesPage() {
|
|
78
|
+
// Your module's permission check. Gamma modules read it with `useHasPermission` from
|
|
79
|
+
// `@gravitee/gamma-modules-sdk`, for example `useHasPermission({ anyOf: ['{ENTITY_CREATE_PERMISSION}'] })`.
|
|
80
|
+
const canCreate = true;
|
|
81
|
+
const list = useEntityList();
|
|
82
|
+
const searchRef = useRef<HTMLInputElement>(null);
|
|
83
|
+
const statusId = useId();
|
|
84
|
+
|
|
85
|
+
const { result, error, settled } = list;
|
|
86
|
+
const totalCount = result?.totalCount ?? 0;
|
|
87
|
+
const isFirstUse = settled && totalCount === 0 && !list.hasCriteria;
|
|
88
|
+
const announcement =
|
|
89
|
+
!settled || isFirstUse ? '' : totalCount === 0 ? 'No results' : `${totalCount} result${totalCount > 1 ? 's' : ''}`;
|
|
90
|
+
|
|
91
|
+
const reset = () => {
|
|
92
|
+
list.reset();
|
|
93
|
+
searchRef.current?.focus();
|
|
94
|
+
};
|
|
95
|
+
// A deleted entity takes its row and ⋮ with it: the focus moves to the search field, as after Reset.
|
|
96
|
+
const focusSearch = () => searchRef.current?.focus();
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div className="flex flex-col gap-6">
|
|
100
|
+
<PageHeader
|
|
101
|
+
title="Entities"
|
|
102
|
+
// One sentence on what this resource is for, never a restatement of the title.
|
|
103
|
+
description="The entities your teams publish, with their status, type and owner."
|
|
104
|
+
actions={
|
|
105
|
+
canCreate && (
|
|
106
|
+
<Button asChild>
|
|
107
|
+
<Link to="new">
|
|
108
|
+
<PlusIcon />
|
|
109
|
+
Add entity
|
|
110
|
+
</Link>
|
|
111
|
+
</Button>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
/>
|
|
115
|
+
|
|
116
|
+
{/* Tells a screen reader the outcome of a search once the list settles. */}
|
|
117
|
+
<p role="status" className="sr-only">
|
|
118
|
+
{announcement}
|
|
119
|
+
</p>
|
|
120
|
+
|
|
121
|
+
{error && (
|
|
122
|
+
<Alert variant="destructive">
|
|
123
|
+
<CircleXIcon />
|
|
124
|
+
<AlertTitle>Could not load entities</AlertTitle>
|
|
125
|
+
<AlertDescription>{result ? 'The list below may be out of date.' : error.message}</AlertDescription>
|
|
126
|
+
<AlertAction align="center">
|
|
127
|
+
<Button variant="outline" size="xs" disabled={list.isFetching} onClick={() => list.refetch()}>
|
|
128
|
+
{list.isFetching ? 'Retrying...' : 'Retry'}
|
|
129
|
+
</Button>
|
|
130
|
+
</AlertAction>
|
|
131
|
+
</Alert>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{isFirstUse ? (
|
|
135
|
+
// A flagship list may replace this block with an educational landing.
|
|
136
|
+
<div className="rounded-lg border">
|
|
137
|
+
<DataTableEmptyState
|
|
138
|
+
variant="first-use"
|
|
139
|
+
icon={<BoxesIcon />}
|
|
140
|
+
title="No entities yet"
|
|
141
|
+
description="Add your first entity to follow its status, type and owner here."
|
|
142
|
+
/>
|
|
143
|
+
</div>
|
|
144
|
+
) : (
|
|
145
|
+
(result || !error) && (
|
|
146
|
+
<EntityDeletedContext value={focusSearch}>
|
|
147
|
+
<DataTable
|
|
148
|
+
aria-label="Entities"
|
|
149
|
+
columns={entityColumns}
|
|
150
|
+
data={result?.data ?? []}
|
|
151
|
+
serverSide
|
|
152
|
+
enableColumnVisibility
|
|
153
|
+
loading={list.isFetching}
|
|
154
|
+
skeletonCount={list.size}
|
|
155
|
+
sorting={list.sorting}
|
|
156
|
+
onSortingChange={list.onSortingChange}
|
|
157
|
+
// Rows keyed by entity: when a row goes, its ⋮ never passes to the next entity.
|
|
158
|
+
tableOptions={{ getRowId: (entity) => entity.id }}
|
|
159
|
+
pagination={{
|
|
160
|
+
page: list.page,
|
|
161
|
+
pageSize: list.size,
|
|
162
|
+
totalCount,
|
|
163
|
+
pageSizeOptions: PAGE_SIZE_OPTIONS,
|
|
164
|
+
onPageChange: list.setPage,
|
|
165
|
+
onPageSizeChange: list.setPageSize,
|
|
166
|
+
}}
|
|
167
|
+
emptyMessage={
|
|
168
|
+
<DataTableEmptyState
|
|
169
|
+
variant="no-results"
|
|
170
|
+
icon={<SearchIcon />}
|
|
171
|
+
title="No entities found"
|
|
172
|
+
description="Try another search, or adjust the filters."
|
|
173
|
+
/>
|
|
174
|
+
}
|
|
175
|
+
toolbar={
|
|
176
|
+
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
177
|
+
<SearchField
|
|
178
|
+
ref={searchRef}
|
|
179
|
+
aria-label="Search entities"
|
|
180
|
+
placeholder="Search entities..."
|
|
181
|
+
value={list.q}
|
|
182
|
+
onValueChange={list.setQuery}
|
|
183
|
+
className="w-64"
|
|
184
|
+
/>
|
|
185
|
+
{/* The filters and Reset wrap together, never Reset alone. */}
|
|
186
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
187
|
+
<Label htmlFor={statusId} className="sr-only">
|
|
188
|
+
Status
|
|
189
|
+
</Label>
|
|
190
|
+
<Select
|
|
191
|
+
value={list.status ?? ALL_STATUSES}
|
|
192
|
+
onValueChange={(value) => list.setStatus(value === ALL_STATUSES ? undefined : value)}
|
|
193
|
+
>
|
|
194
|
+
<SelectTrigger id={statusId} className="min-w-32">
|
|
195
|
+
<SelectValue />
|
|
196
|
+
</SelectTrigger>
|
|
197
|
+
<SelectContent>
|
|
198
|
+
<SelectItem value={ALL_STATUSES}>All statuses</SelectItem>
|
|
199
|
+
{ENTITY_STATUSES.map((status) => (
|
|
200
|
+
<SelectItem key={status} value={status}>
|
|
201
|
+
{STATUS_BADGES[status].value}
|
|
202
|
+
</SelectItem>
|
|
203
|
+
))}
|
|
204
|
+
</SelectContent>
|
|
205
|
+
</Select>
|
|
206
|
+
<FacetedFilter title="Type" options={TYPE_OPTIONS} selected={list.type} onChange={list.setTypes} />
|
|
207
|
+
{list.hasCriteria && (
|
|
208
|
+
<Button variant="ghost" size="sm" onClick={reset}>
|
|
209
|
+
Reset
|
|
210
|
+
</Button>
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
}
|
|
215
|
+
/>
|
|
216
|
+
</EntityDeletedContext>
|
|
217
|
+
)
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// The ⋮ menu of a row: named after the entity, Edit first, the destructive Delete last, which asks for
|
|
2
|
+
// confirmation before anything is deleted.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
Button,
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
DropdownMenuContent,
|
|
8
|
+
DropdownMenuItem,
|
|
9
|
+
DropdownMenuSeparator,
|
|
10
|
+
DropdownMenuTrigger,
|
|
11
|
+
} from '@gravitee/graphene-core';
|
|
12
|
+
import { MoreVerticalIcon, PencilIcon, Trash2Icon } from '@gravitee/graphene-core/icons';
|
|
13
|
+
import { createContext, use, useRef, useState } from 'react';
|
|
14
|
+
import { Link } from 'react-router-dom';
|
|
15
|
+
import { DeleteEntityDialog } from './DeleteEntityDialog';
|
|
16
|
+
import type { Entity } from './entities.service';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Where the focus goes once a row's entity is deleted, since its row and ⋮ go with it. The page provides it
|
|
20
|
+
* around the table; without it, the focus falls back on the page body.
|
|
21
|
+
*/
|
|
22
|
+
export const EntityDeletedContext = createContext<() => void>(() => {});
|
|
23
|
+
|
|
24
|
+
export function EntityActionsMenu({ entity }: { readonly entity: Entity }) {
|
|
25
|
+
const onDeleted = use(EntityDeletedContext);
|
|
26
|
+
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
|
27
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
28
|
+
const deleted = useRef(false);
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="flex justify-end">
|
|
32
|
+
<DropdownMenu>
|
|
33
|
+
<DropdownMenuTrigger asChild>
|
|
34
|
+
<Button ref={triggerRef} variant="ghost" size="icon-xs" aria-label={`Actions for ${entity.name}`}>
|
|
35
|
+
<MoreVerticalIcon />
|
|
36
|
+
</Button>
|
|
37
|
+
</DropdownMenuTrigger>
|
|
38
|
+
<DropdownMenuContent align="end">
|
|
39
|
+
<DropdownMenuItem asChild>
|
|
40
|
+
<Link to={`./${entity.id}/edit`}>
|
|
41
|
+
<PencilIcon />
|
|
42
|
+
Edit
|
|
43
|
+
</Link>
|
|
44
|
+
</DropdownMenuItem>
|
|
45
|
+
<DropdownMenuSeparator />
|
|
46
|
+
<DropdownMenuItem variant="destructive" onSelect={() => setConfirmingDelete(true)}>
|
|
47
|
+
<Trash2Icon />
|
|
48
|
+
Delete
|
|
49
|
+
</DropdownMenuItem>
|
|
50
|
+
</DropdownMenuContent>
|
|
51
|
+
</DropdownMenu>
|
|
52
|
+
<DeleteEntityDialog
|
|
53
|
+
entity={entity}
|
|
54
|
+
open={confirmingDelete}
|
|
55
|
+
onOpenChange={setConfirmingDelete}
|
|
56
|
+
onDeleted={() => {
|
|
57
|
+
deleted.current = true;
|
|
58
|
+
}}
|
|
59
|
+
// The menu that opened the dialog is closed by then. Cancelled: give the focus back to the ⋮.
|
|
60
|
+
// Deleted: the ⋮ goes with its row, so the focus goes where the page says.
|
|
61
|
+
onCloseAutoFocus={(event) => {
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
if (deleted.current) onDeleted();
|
|
64
|
+
else triggerRef.current?.focus();
|
|
65
|
+
}}
|
|
66
|
+
/>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Sample data service: replace it with your API client.
|
|
2
|
+
//
|
|
3
|
+
// `useEntityList.ts` is the only file that calls this service: point its query and its delete at your
|
|
4
|
+
// module's API client, then delete this file. The other files import only its types and value lists.
|
|
5
|
+
//
|
|
6
|
+
// Until then the snippet runs as-is: about 50 entities in memory, answered after 300 ms, filtered on the
|
|
7
|
+
// name, the status and the type, then sorted and paged the way a server endpoint does it.
|
|
8
|
+
|
|
9
|
+
export type EntityStatus = 'active' | 'inactive' | 'draft';
|
|
10
|
+
export type EntityType = 'internal' | 'external' | 'partner';
|
|
11
|
+
export type EntitySortField = 'name' | 'createdAt';
|
|
12
|
+
export type EntitySort = `${EntitySortField}:${'asc' | 'desc'}`;
|
|
13
|
+
|
|
14
|
+
export interface Entity {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
status: EntityStatus;
|
|
18
|
+
type: EntityType;
|
|
19
|
+
/** ISO 8601 date. */
|
|
20
|
+
createdAt: string;
|
|
21
|
+
owner: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ListEntitiesParams {
|
|
25
|
+
/** Part of the name, case-insensitive. */
|
|
26
|
+
q?: string;
|
|
27
|
+
status?: EntityStatus;
|
|
28
|
+
/** Entities of any of these types. */
|
|
29
|
+
type?: EntityType[];
|
|
30
|
+
sort?: EntitySort;
|
|
31
|
+
/** 1-based. */
|
|
32
|
+
page: number;
|
|
33
|
+
size: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface EntityPage {
|
|
37
|
+
data: Entity[];
|
|
38
|
+
totalCount: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const ENTITY_STATUSES: EntityStatus[] = ['active', 'inactive', 'draft'];
|
|
42
|
+
export const ENTITY_TYPES: EntityType[] = ['internal', 'external', 'partner'];
|
|
43
|
+
|
|
44
|
+
type ServiceMode = 'data' | 'empty' | 'error' | 'slow';
|
|
45
|
+
|
|
46
|
+
const ADJECTIVES = ['Amber', 'Blue', 'Coral', 'Crimson', 'Golden', 'Ivory', 'Jade', 'Onyx', 'Silver', 'Violet'];
|
|
47
|
+
const NOUNS = ['Falcon', 'Harbor', 'Meadow', 'River', 'Summit'];
|
|
48
|
+
const OWNERS = ['Alex Martin', 'Sam Rivera', 'Jordan Lee', 'Taylor Kim', 'Morgan Chen', 'Casey Dubois'];
|
|
49
|
+
const STATUS_CYCLE: EntityStatus[] = ['active', 'active', 'inactive', 'active', 'draft'];
|
|
50
|
+
const HOUR = 3_600_000;
|
|
51
|
+
|
|
52
|
+
let mode: ServiceMode = 'data';
|
|
53
|
+
let entities = sampleEntities();
|
|
54
|
+
|
|
55
|
+
/** 50 entities, newest first, created over the last four weeks. */
|
|
56
|
+
export function sampleEntities(): Entity[] {
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
return Array.from({ length: 50 }, (_, index) => ({
|
|
59
|
+
id: `entity-${index + 1}`,
|
|
60
|
+
name: `${ADJECTIVES[index % ADJECTIVES.length]} ${NOUNS[Math.floor(index / ADJECTIVES.length)]}`,
|
|
61
|
+
status: STATUS_CYCLE[index % STATUS_CYCLE.length],
|
|
62
|
+
type: ENTITY_TYPES[Math.floor(index / 2) % ENTITY_TYPES.length],
|
|
63
|
+
createdAt: new Date(now - (index * 13 + 2) * HOUR).toISOString(),
|
|
64
|
+
owner: OWNERS[index % OWNERS.length],
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* For the Storybook story and the tests: `data` (the default), `empty` (no entity at all), `error` (every
|
|
70
|
+
* call rejects) or `slow` (answers after 3 s). Also puts back the entities deleted so far.
|
|
71
|
+
*/
|
|
72
|
+
export function configureEntitiesService(options: { mode: ServiceMode }) {
|
|
73
|
+
mode = options.mode;
|
|
74
|
+
entities = sampleEntities();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function listEntities({ q, status, type, sort, page, size }: ListEntitiesParams): Promise<EntityPage> {
|
|
78
|
+
return respond(() => {
|
|
79
|
+
const search = q?.toLowerCase();
|
|
80
|
+
const matches = (mode === 'empty' ? [] : entities).filter(
|
|
81
|
+
(entity) =>
|
|
82
|
+
(!search || entity.name.toLowerCase().includes(search)) &&
|
|
83
|
+
(!status || entity.status === status) &&
|
|
84
|
+
(!type?.length || type.includes(entity.type)),
|
|
85
|
+
);
|
|
86
|
+
if (sort) {
|
|
87
|
+
const [field, direction] = sort.split(':') as [EntitySortField, 'asc' | 'desc'];
|
|
88
|
+
matches.sort((a, b) => a[field].localeCompare(b[field]) * (direction === 'asc' ? 1 : -1));
|
|
89
|
+
}
|
|
90
|
+
return { data: matches.slice((page - 1) * size, page * size), totalCount: matches.length };
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function deleteEntity(id: string): Promise<void> {
|
|
95
|
+
return respond(() => {
|
|
96
|
+
entities = entities.filter((entity) => entity.id !== id);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Answers like a server: after a delay, or with an error when the service is configured to fail. */
|
|
101
|
+
export function respond<T>(answer: () => T): Promise<T> {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
setTimeout(
|
|
104
|
+
() => (mode === 'error' ? reject(new Error('Service unavailable (503)')) : resolve(answer())),
|
|
105
|
+
mode === 'slow' ? 3000 : 300,
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The columns of the entity list, left to right: name, status, type, creation date, owner, actions.
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Badge,
|
|
5
|
+
BadgeCell,
|
|
6
|
+
type BadgeCellProps,
|
|
7
|
+
DataTableColumnHeader,
|
|
8
|
+
DateCell,
|
|
9
|
+
TruncatedCell,
|
|
10
|
+
} from '@gravitee/graphene-core';
|
|
11
|
+
import { CircleCheckIcon, CircleIcon, CircleMinusIcon } from '@gravitee/graphene-core/icons';
|
|
12
|
+
import type { ColumnDef } from '@tanstack/react-table';
|
|
13
|
+
import { Link } from 'react-router-dom';
|
|
14
|
+
import { EntityActionsMenu } from './EntityActionsMenu';
|
|
15
|
+
import type { Entity, EntityStatus, EntityType } from './entities.service';
|
|
16
|
+
|
|
17
|
+
// A status pairs its colour with an icon and a label: it never relies on colour alone.
|
|
18
|
+
export const STATUS_BADGES: Record<EntityStatus, BadgeCellProps> = {
|
|
19
|
+
active: { value: 'Active', variant: 'success', icon: <CircleCheckIcon aria-hidden="true" /> },
|
|
20
|
+
inactive: { value: 'Inactive', variant: 'secondary', icon: <CircleMinusIcon aria-hidden="true" /> },
|
|
21
|
+
draft: { value: 'Draft', variant: 'outline', icon: <CircleIcon aria-hidden="true" /> },
|
|
22
|
+
};
|
|
23
|
+
export const TYPE_LABELS: Record<EntityType, string> = {
|
|
24
|
+
internal: 'Internal',
|
|
25
|
+
external: 'External',
|
|
26
|
+
partner: 'Partner',
|
|
27
|
+
};
|
|
28
|
+
// Categories stay neutral. `highlight` (violet), the one accent that carries no state, sets apart the
|
|
29
|
+
// type to notice; never red, green or amber, which mean error, success and warning.
|
|
30
|
+
const TYPE_VARIANTS = { internal: 'highlight', external: 'secondary', partner: 'secondary' } as const;
|
|
31
|
+
|
|
32
|
+
/** Name and Created sort on the server, and name themselves in the View menu with `meta.label`. */
|
|
33
|
+
export const entityColumns: ColumnDef<Entity, unknown>[] = [
|
|
34
|
+
{
|
|
35
|
+
accessorKey: 'name',
|
|
36
|
+
header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
|
|
37
|
+
meta: { label: 'Name' },
|
|
38
|
+
enableHiding: false,
|
|
39
|
+
cell: ({ row }) => (
|
|
40
|
+
<Link
|
|
41
|
+
to={`./${row.original.id}`}
|
|
42
|
+
className="rounded-lg font-medium outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
43
|
+
>
|
|
44
|
+
{row.original.name}
|
|
45
|
+
</Link>
|
|
46
|
+
),
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
accessorKey: 'status',
|
|
50
|
+
header: 'Status',
|
|
51
|
+
enableSorting: false,
|
|
52
|
+
cell: ({ row }) => <BadgeCell {...STATUS_BADGES[row.original.status]} />,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
accessorKey: 'type',
|
|
56
|
+
header: 'Type',
|
|
57
|
+
enableSorting: false,
|
|
58
|
+
cell: ({ row }) => <Badge variant={TYPE_VARIANTS[row.original.type]}>{TYPE_LABELS[row.original.type]}</Badge>,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
accessorKey: 'createdAt',
|
|
62
|
+
header: ({ column }) => <DataTableColumnHeader column={column} title="Created" />,
|
|
63
|
+
meta: { label: 'Created' },
|
|
64
|
+
cell: ({ row }) => <DateCell value={row.original.createdAt} />,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
accessorKey: 'owner',
|
|
68
|
+
header: 'Owner',
|
|
69
|
+
enableSorting: false,
|
|
70
|
+
cell: ({ row }) => <TruncatedCell value={row.original.owner} />,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: 'actions',
|
|
74
|
+
header: () => <span className="sr-only">Actions</span>,
|
|
75
|
+
enableSorting: false,
|
|
76
|
+
enableHiding: false,
|
|
77
|
+
cell: ({ row }) => <EntityActionsMenu entity={row.original} />,
|
|
78
|
+
},
|
|
79
|
+
];
|