@eventcatalog/core 5.0.0-beta.2 → 5.0.0-beta.3
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/dist/analytics/analytics.cjs +1 -1
- package/dist/analytics/analytics.js +2 -2
- package/dist/analytics/log-build.cjs +1 -1
- package/dist/analytics/log-build.js +3 -3
- package/dist/{chunk-AYNZXUB5.js → chunk-3PKS2IIP.js} +4 -4
- package/dist/{chunk-HGRFXTFC.js → chunk-423S2KHN.js} +1 -1
- package/dist/{chunk-KIDISU6X.js → chunk-6OC46WQJ.js} +1 -1
- package/dist/{chunk-4J2ZM2HX.js → chunk-BH2F5WPN.js} +1 -1
- package/dist/{chunk-62EV4IJA.js → chunk-GYIVJXOJ.js} +1 -1
- package/dist/{chunk-XARPIXWF.js → chunk-NDJOAITI.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +1 -1
- package/dist/eventcatalog.js +13 -13
- package/dist/federation/federate.js +4 -4
- package/dist/federation/source-provider.js +2 -2
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/eventcatalog/src/components/Grids/utils.tsx +0 -23
- package/eventcatalog/src/components/MDX/CodeGroup/CodeGroup.tsx +6 -1
- package/eventcatalog/src/components/SchemaExplorer/SchemaDetailsPanel.tsx +12 -4
- package/eventcatalog/src/components/Tables/Discover/DiscoverTable.tsx +4 -62
- package/eventcatalog/src/components/Tables/Discover/columns.tsx +17 -150
- package/eventcatalog/src/components/Tables/Resources/ResourcesTable.tsx +425 -0
- package/eventcatalog/src/components/Tables/RowActionsMenu.tsx +157 -0
- package/eventcatalog/src/components/Tables/TablePagination.tsx +82 -0
- package/eventcatalog/src/features/ai/chat-api.ts +3 -3
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +75 -74
- package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +0 -2
- package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.spec.ts +52 -63
- package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.ts +34 -46
- package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro +11 -29
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_ResourcesPage.astro +79 -0
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.spec.ts +32 -14
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.ts +63 -58
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/index.astro +2 -62
- package/eventcatalog/src/pages/docs/services/[id]/[version]/resources/index.astro +13 -0
- package/eventcatalog/src/stores/sidebar-store/builders/domain.ts +24 -42
- package/eventcatalog/src/stores/sidebar-store/builders/flow.ts +2 -0
- package/eventcatalog/src/stores/sidebar-store/builders/message.ts +2 -0
- package/eventcatalog/src/stores/sidebar-store/builders/service.ts +23 -23
- package/eventcatalog/src/stores/sidebar-store/builders/shared.ts +13 -0
- package/eventcatalog/src/stores/sidebar-store/builders/system.ts +18 -27
- package/eventcatalog/src/stores/sidebar-store/state.ts +5 -0
- package/eventcatalog/src/styles/tailwind.css +2 -2
- package/eventcatalog/src/utils/collections/channels.ts +56 -1
- package/eventcatalog/src/utils/collections/flows.ts +50 -1
- package/eventcatalog/src/utils/collections/message-connections.ts +60 -0
- package/eventcatalog/src/utils/collections/message-triggers.ts +2 -1
- package/eventcatalog/src/utils/collections/resource-owners.ts +193 -0
- package/eventcatalog/src/utils/collections/resources.ts +310 -0
- package/eventcatalog/src/utils/collections/util.ts +31 -0
- package/package.json +6 -6
- package/eventcatalog/src/components/Grids/DomainGrid.tsx +0 -838
- package/eventcatalog/src/components/Grids/MessageGrid.tsx +0 -344
- package/eventcatalog/src/components/Grids/SystemGrid.tsx +0 -185
- package/eventcatalog/src/components/Grids/components.tsx +0 -170
- package/eventcatalog/src/components/Grids/message-link.ts +0 -38
- package/eventcatalog/src/components/Tables/SystemResources/SystemResourcesTable.tsx +0 -294
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_resources.spec.ts +0 -65
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_resources.ts +0 -46
- package/dist/{chunk-A2RZR3U4.js → chunk-6K7XZAYI.js} +3 -3
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
createColumnHelper,
|
|
4
|
+
flexRender,
|
|
5
|
+
getCoreRowModel,
|
|
6
|
+
getPaginationRowModel,
|
|
7
|
+
getSortedRowModel,
|
|
8
|
+
useReactTable,
|
|
9
|
+
type SortingState,
|
|
10
|
+
} from '@tanstack/react-table';
|
|
11
|
+
import {
|
|
12
|
+
ArrowDown,
|
|
13
|
+
ArrowDownLeft,
|
|
14
|
+
ArrowLeftRight,
|
|
15
|
+
ArrowRightLeft,
|
|
16
|
+
ArrowUp,
|
|
17
|
+
ArrowUpDown,
|
|
18
|
+
ArrowUpRight,
|
|
19
|
+
ClipboardList,
|
|
20
|
+
CornerDownRight,
|
|
21
|
+
KeyRound,
|
|
22
|
+
Route,
|
|
23
|
+
Search,
|
|
24
|
+
SearchX,
|
|
25
|
+
Workflow,
|
|
26
|
+
X,
|
|
27
|
+
type LucideIcon,
|
|
28
|
+
} from 'lucide-react';
|
|
29
|
+
import { getColorAndIconForCollection } from '@utils/collections/icons';
|
|
30
|
+
import { getCollectionTextColorClass } from '@utils/collection-colors';
|
|
31
|
+
import { isIconPath, resolveIconUrl } from '@utils/icon';
|
|
32
|
+
import { RowActionsMenu } from '../RowActionsMenu';
|
|
33
|
+
import { TablePagination, useStoredPageSize } from '../TablePagination';
|
|
34
|
+
import type { ResourceCollection, ResourceRelationship } from '@utils/collections/resources';
|
|
35
|
+
|
|
36
|
+
export type { ResourceCollection, ResourceRelationship };
|
|
37
|
+
|
|
38
|
+
export interface ResourceItem {
|
|
39
|
+
collection: ResourceCollection;
|
|
40
|
+
id: string;
|
|
41
|
+
name: string;
|
|
42
|
+
version: string;
|
|
43
|
+
summary?: string;
|
|
44
|
+
icon?: string;
|
|
45
|
+
href: string;
|
|
46
|
+
/** Link to the resource in the visualiser, when it has a visualiser page. */
|
|
47
|
+
visualiserHref?: string;
|
|
48
|
+
/** Link to the resource's schema page, when it has a schema or contract. */
|
|
49
|
+
schemaHref?: string;
|
|
50
|
+
/** Matches the key the sidebar and the docs page favorite button use, so all three stay in sync. */
|
|
51
|
+
favorite: { nodeKey: string; badge: string };
|
|
52
|
+
relationship?: ResourceRelationship;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ResourcesTableProps {
|
|
56
|
+
resources: ResourceItem[];
|
|
57
|
+
title: string;
|
|
58
|
+
description?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Every kind of resource with its plural label (filter pills) and singular label (Type column).
|
|
62
|
+
// The key order is the order the filter pills follow on the page.
|
|
63
|
+
const RESOURCE_TYPE_LABELS: Record<ResourceCollection, { plural: string; singular: string }> = {
|
|
64
|
+
domains: { plural: 'Subdomains', singular: 'Subdomain' },
|
|
65
|
+
systems: { plural: 'Systems', singular: 'System' },
|
|
66
|
+
agents: { plural: 'Agents', singular: 'Agent' },
|
|
67
|
+
services: { plural: 'Services', singular: 'Service' },
|
|
68
|
+
flows: { plural: 'Flows', singular: 'Flow' },
|
|
69
|
+
entities: { plural: 'Entities', singular: 'Entity' },
|
|
70
|
+
'data-products': { plural: 'Data Products', singular: 'Data Product' },
|
|
71
|
+
containers: { plural: 'Data Stores', singular: 'Data Store' },
|
|
72
|
+
events: { plural: 'Events', singular: 'Event' },
|
|
73
|
+
commands: { plural: 'Commands', singular: 'Command' },
|
|
74
|
+
queries: { plural: 'Queries', singular: 'Query' },
|
|
75
|
+
channels: { plural: 'Channels', singular: 'Channel' },
|
|
76
|
+
adrs: { plural: 'Decision Records', singular: 'Decision Record' },
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const TYPE_ORDER = Object.keys(RESOURCE_TYPE_LABELS) as ResourceCollection[];
|
|
80
|
+
|
|
81
|
+
type Direction = 'inbound' | 'outbound';
|
|
82
|
+
|
|
83
|
+
const RELATIONSHIPS: Record<ResourceRelationship, { label: string; directions: Direction[]; Icon: LucideIcon }> = {
|
|
84
|
+
receives: { label: 'Receives', directions: ['inbound'], Icon: ArrowDownLeft },
|
|
85
|
+
sends: { label: 'Sends', directions: ['outbound'], Icon: ArrowUpRight },
|
|
86
|
+
'sends-and-receives': { label: 'Sends & receives', directions: ['inbound', 'outbound'], Icon: ArrowLeftRight },
|
|
87
|
+
reads: { label: 'Reads from', directions: ['inbound'], Icon: ArrowDownLeft },
|
|
88
|
+
writes: { label: 'Writes to', directions: ['outbound'], Icon: ArrowUpRight },
|
|
89
|
+
'reads-and-writes': { label: 'Reads & writes', directions: ['inbound', 'outbound'], Icon: ArrowLeftRight },
|
|
90
|
+
contains: { label: 'Contains', directions: [], Icon: CornerDownRight },
|
|
91
|
+
owns: { label: 'Owns', directions: [], Icon: KeyRound },
|
|
92
|
+
'appears-in': { label: 'Appears in', directions: [], Icon: Workflow },
|
|
93
|
+
'governed-by': { label: 'Governed by', directions: [], Icon: ClipboardList },
|
|
94
|
+
includes: { label: 'Includes', directions: [], Icon: Route },
|
|
95
|
+
// Channel pages: the channel transports messages; services produce messages onto it or receive them from it.
|
|
96
|
+
transports: { label: 'Transports', directions: [], Icon: ArrowRightLeft },
|
|
97
|
+
'produces-messages': { label: 'Produces messages', directions: ['inbound'], Icon: ArrowDownLeft },
|
|
98
|
+
'receives-messages': { label: 'Receives messages', directions: ['outbound'], Icon: ArrowUpRight },
|
|
99
|
+
'produces-and-receives-messages': { label: 'Produces & receives', directions: ['inbound', 'outbound'], Icon: ArrowLeftRight },
|
|
100
|
+
// Message pages: services, agents and data products produce or consume the message, which can trigger other messages.
|
|
101
|
+
produces: { label: 'Produces', directions: ['inbound'], Icon: ArrowDownLeft },
|
|
102
|
+
consumes: { label: 'Consumes', directions: ['outbound'], Icon: ArrowUpRight },
|
|
103
|
+
'produces-and-consumes': { label: 'Produces & consumes', directions: ['inbound', 'outbound'], Icon: ArrowLeftRight },
|
|
104
|
+
triggers: { label: 'Triggers', directions: ['outbound'], Icon: ArrowUpRight },
|
|
105
|
+
'triggered-by': { label: 'Triggered by', directions: ['inbound'], Icon: ArrowDownLeft },
|
|
106
|
+
'triggers-and-triggered-by': { label: 'Triggers & triggered by', directions: ['inbound', 'outbound'], Icon: ArrowLeftRight },
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const DIRECTIONS: Array<{ id: Direction; label: string; Icon: LucideIcon }> = [
|
|
110
|
+
{ id: 'inbound', label: 'Inbound', Icon: ArrowDownLeft },
|
|
111
|
+
{ id: 'outbound', label: 'Outbound', Icon: ArrowUpRight },
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
const FilterPill = ({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) => (
|
|
115
|
+
<button
|
|
116
|
+
type="button"
|
|
117
|
+
aria-pressed={active}
|
|
118
|
+
onClick={onClick}
|
|
119
|
+
className={[
|
|
120
|
+
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors',
|
|
121
|
+
active
|
|
122
|
+
? 'border-[rgb(var(--ec-accent))] bg-[rgb(var(--ec-accent-subtle))] text-[rgb(var(--ec-accent))]'
|
|
123
|
+
: 'border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text-muted))] hover:text-[rgb(var(--ec-page-text))]',
|
|
124
|
+
].join(' ')}
|
|
125
|
+
>
|
|
126
|
+
{children}
|
|
127
|
+
</button>
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// Small icon for a collection type (used in filter pills and the type column).
|
|
131
|
+
const CollectionTypeIcon = ({ collection, className }: { collection: ResourceCollection; className?: string }) => {
|
|
132
|
+
const { color, Icon } = getColorAndIconForCollection(collection);
|
|
133
|
+
const colorClass = getCollectionTextColorClass(color, 'text-[rgb(var(--ec-icon-color))]');
|
|
134
|
+
return <Icon className={`${className ?? 'h-3.5 w-3.5'} ${colorClass}`} aria-hidden="true" />;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const ResourceIcon = ({ item }: { item: ResourceItem }) => {
|
|
138
|
+
if (item.icon && isIconPath(item.icon)) {
|
|
139
|
+
return <img src={resolveIconUrl(item.icon)} alt="" className="h-5 w-5" loading="lazy" />;
|
|
140
|
+
}
|
|
141
|
+
return <CollectionTypeIcon collection={item.collection} className="h-5 w-5" />;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const columnHelper = createColumnHelper<ResourceItem>();
|
|
145
|
+
|
|
146
|
+
const SortIcon = ({ direction }: { direction: false | 'asc' | 'desc' }) => {
|
|
147
|
+
if (direction === 'asc') return <ArrowUp className="h-3.5 w-3.5" aria-hidden="true" />;
|
|
148
|
+
if (direction === 'desc') return <ArrowDown className="h-3.5 w-3.5" aria-hidden="true" />;
|
|
149
|
+
return <ArrowUpDown className="h-3.5 w-3.5 opacity-40" aria-hidden="true" />;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export function ResourcesTable({ resources, title, description }: ResourcesTableProps) {
|
|
153
|
+
const [search, setSearch] = useState('');
|
|
154
|
+
const [pageSize, setPageSize] = useStoredPageSize('eventcatalog-resources-page-size');
|
|
155
|
+
const [selectedTypes, setSelectedTypes] = useState<ResourceCollection[]>([]);
|
|
156
|
+
const [selectedDirections, setSelectedDirections] = useState<Direction[]>([]);
|
|
157
|
+
const [sorting, setSorting] = useState<SortingState>([{ id: 'name', desc: false }]);
|
|
158
|
+
|
|
159
|
+
// Only show type filters that actually have resources in this system.
|
|
160
|
+
const availableTypes = useMemo(
|
|
161
|
+
() => TYPE_ORDER.filter((type) => resources.some((resource) => resource.collection === type)),
|
|
162
|
+
[resources]
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Every built-in page sets a relationship; keep the column out if a caller passes none.
|
|
166
|
+
const hasRelationships = useMemo(() => resources.some((resource) => resource.relationship), [resources]);
|
|
167
|
+
|
|
168
|
+
// Like the type filters, only offer direction filters when there is more than one direction to pick from.
|
|
169
|
+
const availableDirections = useMemo(
|
|
170
|
+
() =>
|
|
171
|
+
DIRECTIONS.filter(({ id }) =>
|
|
172
|
+
resources.some((resource) => resource.relationship && RELATIONSHIPS[resource.relationship].directions.includes(id))
|
|
173
|
+
),
|
|
174
|
+
[resources]
|
|
175
|
+
);
|
|
176
|
+
const showDirectionFilters = availableDirections.length > 1;
|
|
177
|
+
|
|
178
|
+
const filtered = useMemo(() => {
|
|
179
|
+
const query = search.trim().toLowerCase();
|
|
180
|
+
return resources.filter((resource) => {
|
|
181
|
+
if (selectedTypes.length > 0 && !selectedTypes.includes(resource.collection)) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
if (selectedDirections.length > 0) {
|
|
185
|
+
const directions = resource.relationship ? RELATIONSHIPS[resource.relationship].directions : [];
|
|
186
|
+
if (!directions.some((direction) => selectedDirections.includes(direction))) return false;
|
|
187
|
+
}
|
|
188
|
+
if (query) {
|
|
189
|
+
const haystack = `${resource.name} ${resource.id} ${resource.summary ?? ''}`.toLowerCase();
|
|
190
|
+
if (!haystack.includes(query)) return false;
|
|
191
|
+
}
|
|
192
|
+
return true;
|
|
193
|
+
});
|
|
194
|
+
}, [resources, search, selectedTypes, selectedDirections]);
|
|
195
|
+
|
|
196
|
+
const columns = useMemo(
|
|
197
|
+
() => [
|
|
198
|
+
columnHelper.accessor('name', {
|
|
199
|
+
header: 'Name',
|
|
200
|
+
cell: (info) => {
|
|
201
|
+
const resource = info.row.original;
|
|
202
|
+
return (
|
|
203
|
+
<a href={resource.href} className="group flex items-center gap-3">
|
|
204
|
+
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))]">
|
|
205
|
+
<ResourceIcon item={resource} />
|
|
206
|
+
</span>
|
|
207
|
+
<span className="text-sm font-medium text-[rgb(var(--ec-page-text))] group-hover:text-[rgb(var(--ec-accent))]">
|
|
208
|
+
{resource.name}
|
|
209
|
+
</span>
|
|
210
|
+
</a>
|
|
211
|
+
);
|
|
212
|
+
},
|
|
213
|
+
}),
|
|
214
|
+
columnHelper.accessor((row) => RESOURCE_TYPE_LABELS[row.collection].singular, {
|
|
215
|
+
id: 'type',
|
|
216
|
+
header: 'Type',
|
|
217
|
+
cell: (info) => {
|
|
218
|
+
const { collection } = info.row.original;
|
|
219
|
+
return (
|
|
220
|
+
<span className="inline-flex items-center gap-1.5 whitespace-nowrap text-sm text-[rgb(var(--ec-page-text-muted))]">
|
|
221
|
+
<CollectionTypeIcon collection={collection} />
|
|
222
|
+
{RESOURCE_TYPE_LABELS[collection].singular}
|
|
223
|
+
</span>
|
|
224
|
+
);
|
|
225
|
+
},
|
|
226
|
+
}),
|
|
227
|
+
columnHelper.accessor((row) => (row.relationship ? RELATIONSHIPS[row.relationship].label : ''), {
|
|
228
|
+
id: 'relationship',
|
|
229
|
+
header: 'Relationship',
|
|
230
|
+
cell: (info) => {
|
|
231
|
+
const { relationship } = info.row.original;
|
|
232
|
+
if (!relationship) return <span className="text-sm text-[rgb(var(--ec-page-text-muted))]">—</span>;
|
|
233
|
+
const { label, Icon } = RELATIONSHIPS[relationship];
|
|
234
|
+
return (
|
|
235
|
+
<span className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-md border border-[rgb(var(--ec-page-border))] px-2 py-0.5 text-xs font-medium text-[rgb(var(--ec-page-text))]">
|
|
236
|
+
<Icon className="h-3.5 w-3.5 text-[rgb(var(--ec-accent))]" aria-hidden="true" />
|
|
237
|
+
{label}
|
|
238
|
+
</span>
|
|
239
|
+
);
|
|
240
|
+
},
|
|
241
|
+
}),
|
|
242
|
+
columnHelper.accessor('version', {
|
|
243
|
+
header: 'Version',
|
|
244
|
+
cell: (info) => (
|
|
245
|
+
<span className="inline-flex items-center rounded-md border border-[rgb(var(--ec-page-border))] px-2 py-0.5 text-xs text-[rgb(var(--ec-page-text-muted))]">
|
|
246
|
+
v{info.getValue()}
|
|
247
|
+
</span>
|
|
248
|
+
),
|
|
249
|
+
}),
|
|
250
|
+
columnHelper.accessor((row) => row.summary ?? '', {
|
|
251
|
+
id: 'summary',
|
|
252
|
+
header: 'Summary',
|
|
253
|
+
enableSorting: false,
|
|
254
|
+
cell: (info) => (
|
|
255
|
+
<span className="line-clamp-2 text-sm text-[rgb(var(--ec-page-text-muted))]">{info.getValue() || '-'}</span>
|
|
256
|
+
),
|
|
257
|
+
}),
|
|
258
|
+
columnHelper.display({
|
|
259
|
+
id: 'actions',
|
|
260
|
+
header: () => <span className="sr-only">Actions</span>,
|
|
261
|
+
cell: (info) => {
|
|
262
|
+
const resource = info.row.original;
|
|
263
|
+
return (
|
|
264
|
+
<RowActionsMenu
|
|
265
|
+
title={resource.name}
|
|
266
|
+
href={resource.href}
|
|
267
|
+
visualiserHref={resource.visualiserHref}
|
|
268
|
+
schemaHref={resource.schemaHref}
|
|
269
|
+
favorite={resource.favorite}
|
|
270
|
+
/>
|
|
271
|
+
);
|
|
272
|
+
},
|
|
273
|
+
}),
|
|
274
|
+
],
|
|
275
|
+
[]
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
const table = useReactTable({
|
|
279
|
+
data: filtered,
|
|
280
|
+
columns,
|
|
281
|
+
state: { sorting, columnVisibility: { relationship: hasRelationships } },
|
|
282
|
+
onSortingChange: setSorting,
|
|
283
|
+
getCoreRowModel: getCoreRowModel(),
|
|
284
|
+
getSortedRowModel: getSortedRowModel(),
|
|
285
|
+
getPaginationRowModel: getPaginationRowModel(),
|
|
286
|
+
initialState: { pagination: { pageIndex: 0, pageSize } },
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
useEffect(() => {
|
|
290
|
+
table.setPageSize(pageSize);
|
|
291
|
+
}, [pageSize, table]);
|
|
292
|
+
|
|
293
|
+
const toggleType = (type: ResourceCollection) => {
|
|
294
|
+
setSelectedTypes((prev) => (prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]));
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const toggleDirection = (direction: Direction) => {
|
|
298
|
+
setSelectedDirections((prev) => (prev.includes(direction) ? prev.filter((d) => d !== direction) : [...prev, direction]));
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
|
303
|
+
{/* Header: title on the left, type filters and search on the right */}
|
|
304
|
+
<div className="flex flex-wrap items-end justify-between gap-x-6 gap-y-3 px-6 py-5">
|
|
305
|
+
<div className="min-w-0">
|
|
306
|
+
<h2 className="text-2xl font-semibold text-[rgb(var(--ec-page-text))] md:text-4xl">{title}</h2>
|
|
307
|
+
{description && (
|
|
308
|
+
<p className="max-w-3xl pt-2 text-base font-light text-[rgb(var(--ec-page-text-muted))]">{description}</p>
|
|
309
|
+
)}
|
|
310
|
+
</div>
|
|
311
|
+
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
312
|
+
{showDirectionFilters &&
|
|
313
|
+
availableDirections.map(({ id, label, Icon }) => (
|
|
314
|
+
<FilterPill key={id} active={selectedDirections.includes(id)} onClick={() => toggleDirection(id)}>
|
|
315
|
+
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
|
316
|
+
{label}
|
|
317
|
+
</FilterPill>
|
|
318
|
+
))}
|
|
319
|
+
{showDirectionFilters && availableTypes.length > 1 && (
|
|
320
|
+
<span aria-hidden className="mx-1 h-4 w-px bg-[rgb(var(--ec-page-border))]" />
|
|
321
|
+
)}
|
|
322
|
+
{availableTypes.length > 1 &&
|
|
323
|
+
availableTypes.map((type) => (
|
|
324
|
+
<FilterPill key={type} active={selectedTypes.includes(type)} onClick={() => toggleType(type)}>
|
|
325
|
+
<CollectionTypeIcon collection={type} />
|
|
326
|
+
{RESOURCE_TYPE_LABELS[type].plural}
|
|
327
|
+
</FilterPill>
|
|
328
|
+
))}
|
|
329
|
+
<div className="relative">
|
|
330
|
+
<Search
|
|
331
|
+
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[rgb(var(--ec-icon-color))]"
|
|
332
|
+
aria-hidden="true"
|
|
333
|
+
/>
|
|
334
|
+
<input
|
|
335
|
+
type="text"
|
|
336
|
+
value={search}
|
|
337
|
+
onChange={(event) => setSearch(event.target.value)}
|
|
338
|
+
placeholder="Filter..."
|
|
339
|
+
aria-label="Filter resources"
|
|
340
|
+
className="w-64 rounded-md border-0 bg-[rgb(var(--ec-page-bg))] py-1.5 pl-9 pr-3 text-sm font-light text-[rgb(var(--ec-header-text))] shadow-xs ring-1 ring-inset ring-[rgb(var(--ec-dropdown-border))] placeholder:text-[rgb(var(--ec-icon-color))] focus:outline-hidden focus:ring-2 focus:ring-[rgb(var(--ec-accent))]"
|
|
341
|
+
/>
|
|
342
|
+
{search && (
|
|
343
|
+
<button
|
|
344
|
+
type="button"
|
|
345
|
+
onClick={() => setSearch('')}
|
|
346
|
+
aria-label="Clear filter"
|
|
347
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 text-[rgb(var(--ec-icon-color))] hover:text-[rgb(var(--ec-page-text))]"
|
|
348
|
+
>
|
|
349
|
+
<X className="h-3.5 w-3.5" />
|
|
350
|
+
</button>
|
|
351
|
+
)}
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
355
|
+
|
|
356
|
+
{/* Table */}
|
|
357
|
+
<div className="min-h-0 flex-1 overflow-auto px-6 pb-5">
|
|
358
|
+
<div className="overflow-hidden rounded-xl border border-[rgb(var(--ec-page-border)/0.72)] bg-[rgb(var(--ec-dropdown-bg)/0.66)]">
|
|
359
|
+
<table className="min-w-full divide-y divide-[rgb(var(--ec-page-border)/0.62)]">
|
|
360
|
+
<thead className="sticky top-0 z-10 bg-[rgb(var(--ec-content-hover)/0.45)]">
|
|
361
|
+
{table.getHeaderGroups().map((headerGroup) => (
|
|
362
|
+
<tr key={headerGroup.id}>
|
|
363
|
+
{headerGroup.headers.map((header) => {
|
|
364
|
+
const canSort = header.column.getCanSort();
|
|
365
|
+
const isSummary = header.column.id === 'summary';
|
|
366
|
+
return (
|
|
367
|
+
<th
|
|
368
|
+
key={header.id}
|
|
369
|
+
className={[
|
|
370
|
+
'px-4 py-2.5 text-left text-[11px] font-medium uppercase tracking-wider text-[rgb(var(--ec-page-text-muted))]',
|
|
371
|
+
isSummary ? 'hidden md:table-cell' : '',
|
|
372
|
+
].join(' ')}
|
|
373
|
+
>
|
|
374
|
+
{canSort ? (
|
|
375
|
+
<button
|
|
376
|
+
type="button"
|
|
377
|
+
onClick={header.column.getToggleSortingHandler()}
|
|
378
|
+
className="inline-flex items-center gap-1.5 uppercase tracking-wider hover:text-[rgb(var(--ec-page-text))]"
|
|
379
|
+
>
|
|
380
|
+
{flexRender(header.column.columnDef.header, header.getContext())}
|
|
381
|
+
<SortIcon direction={header.column.getIsSorted()} />
|
|
382
|
+
</button>
|
|
383
|
+
) : (
|
|
384
|
+
flexRender(header.column.columnDef.header, header.getContext())
|
|
385
|
+
)}
|
|
386
|
+
</th>
|
|
387
|
+
);
|
|
388
|
+
})}
|
|
389
|
+
</tr>
|
|
390
|
+
))}
|
|
391
|
+
</thead>
|
|
392
|
+
<tbody className="divide-y divide-[rgb(var(--ec-page-border)/0.5)]">
|
|
393
|
+
{table.getRowModel().rows.length === 0 && (
|
|
394
|
+
<tr>
|
|
395
|
+
<td colSpan={table.getVisibleLeafColumns().length} className="px-4 py-12">
|
|
396
|
+
<div className="flex flex-col items-center justify-center gap-2 text-center text-[rgb(var(--ec-page-text-muted))]">
|
|
397
|
+
<SearchX className="h-6 w-6" aria-hidden="true" />
|
|
398
|
+
<p className="text-sm">No resources found.</p>
|
|
399
|
+
</div>
|
|
400
|
+
</td>
|
|
401
|
+
</tr>
|
|
402
|
+
)}
|
|
403
|
+
{table.getRowModel().rows.map((row) => (
|
|
404
|
+
<tr key={row.id} className="transition-colors hover:bg-[rgb(var(--ec-content-hover)/0.38)]">
|
|
405
|
+
{row.getVisibleCells().map((cell) => {
|
|
406
|
+
const isSummary = cell.column.id === 'summary';
|
|
407
|
+
return (
|
|
408
|
+
<td key={cell.id} className={['px-4 py-3', isSummary ? 'hidden max-w-md md:table-cell' : ''].join(' ')}>
|
|
409
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
410
|
+
</td>
|
|
411
|
+
);
|
|
412
|
+
})}
|
|
413
|
+
</tr>
|
|
414
|
+
))}
|
|
415
|
+
</tbody>
|
|
416
|
+
</table>
|
|
417
|
+
</div>
|
|
418
|
+
</div>
|
|
419
|
+
|
|
420
|
+
<TablePagination table={table} pageSize={pageSize} onPageSizeChange={setPageSize} />
|
|
421
|
+
</div>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export default ResourcesTable;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { CodeBracketIcon, DocumentTextIcon, MapIcon } from '@heroicons/react/24/solid';
|
|
4
|
+
import { EllipsisVerticalIcon, StarIcon } from '@heroicons/react/24/outline';
|
|
5
|
+
import { useStore } from '@nanostores/react';
|
|
6
|
+
import { favoritesStore, toggleFavorite } from '@stores/favorites-store';
|
|
7
|
+
|
|
8
|
+
const MENU_WIDTH = 280;
|
|
9
|
+
const MENU_GAP = 6;
|
|
10
|
+
|
|
11
|
+
interface RowActionsMenuProps {
|
|
12
|
+
title: string;
|
|
13
|
+
/** Link to the resource's documentation page. */
|
|
14
|
+
href: string;
|
|
15
|
+
/** Link to the resource in the visualiser. The menu leaves the visualiser out when absent. */
|
|
16
|
+
visualiserHref?: string;
|
|
17
|
+
/** Link to the resource's schema page. The menu leaves the schema out when absent. */
|
|
18
|
+
schemaHref?: string;
|
|
19
|
+
favorite: { nodeKey: string; badge: string };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The "..." menu at the end of a table row, with links to the resource and a favorite toggle. */
|
|
23
|
+
export function RowActionsMenu({ title, href, visualiserHref, schemaHref, favorite }: RowActionsMenuProps) {
|
|
24
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
25
|
+
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
26
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
27
|
+
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
|
|
28
|
+
const favorites = useStore(favoritesStore);
|
|
29
|
+
const isFavorite = favorites.some((fav) => fav.nodeKey === favorite.nodeKey);
|
|
30
|
+
|
|
31
|
+
// The table lives inside scrollable, overflow-hidden containers, so the menu is
|
|
32
|
+
// rendered in a portal with fixed positioning to avoid being clipped.
|
|
33
|
+
const updatePosition = () => {
|
|
34
|
+
const button = buttonRef.current;
|
|
35
|
+
if (!button) return;
|
|
36
|
+
const rect = button.getBoundingClientRect();
|
|
37
|
+
const menuHeight = menuRef.current?.offsetHeight ?? 0;
|
|
38
|
+
const spaceBelow = window.innerHeight - rect.bottom;
|
|
39
|
+
|
|
40
|
+
// Flip above the trigger when there isn't enough room below it.
|
|
41
|
+
const openUpwards = menuHeight > 0 && spaceBelow < menuHeight + MENU_GAP && rect.top > spaceBelow;
|
|
42
|
+
const top = openUpwards ? rect.top - menuHeight - MENU_GAP : rect.bottom + MENU_GAP;
|
|
43
|
+
const left = Math.max(MENU_GAP, rect.right - MENU_WIDTH);
|
|
44
|
+
|
|
45
|
+
setMenuPosition({ top, left });
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
useLayoutEffect(() => {
|
|
49
|
+
if (!isOpen) return;
|
|
50
|
+
updatePosition();
|
|
51
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
52
|
+
}, [isOpen]);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!isOpen) return;
|
|
56
|
+
|
|
57
|
+
const handlePointerDown = (event: MouseEvent) => {
|
|
58
|
+
const target = event.target as Node;
|
|
59
|
+
if (!menuRef.current?.contains(target) && !buttonRef.current?.contains(target)) {
|
|
60
|
+
setIsOpen(false);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const handleEscape = (event: KeyboardEvent) => {
|
|
65
|
+
if (event.key === 'Escape') {
|
|
66
|
+
setIsOpen(false);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const handleReposition = () => updatePosition();
|
|
71
|
+
|
|
72
|
+
document.addEventListener('mousedown', handlePointerDown);
|
|
73
|
+
document.addEventListener('keydown', handleEscape);
|
|
74
|
+
window.addEventListener('resize', handleReposition);
|
|
75
|
+
window.addEventListener('scroll', handleReposition, true);
|
|
76
|
+
|
|
77
|
+
return () => {
|
|
78
|
+
document.removeEventListener('mousedown', handlePointerDown);
|
|
79
|
+
document.removeEventListener('keydown', handleEscape);
|
|
80
|
+
window.removeEventListener('resize', handleReposition);
|
|
81
|
+
window.removeEventListener('scroll', handleReposition, true);
|
|
82
|
+
};
|
|
83
|
+
}, [isOpen]);
|
|
84
|
+
|
|
85
|
+
const handleToggleFavorite = () => {
|
|
86
|
+
toggleFavorite({ nodeKey: favorite.nodeKey, path: [], title, badge: favorite.badge, href });
|
|
87
|
+
setIsOpen(false);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<div className="relative flex justify-end">
|
|
92
|
+
<button
|
|
93
|
+
ref={buttonRef}
|
|
94
|
+
type="button"
|
|
95
|
+
aria-label="Actions"
|
|
96
|
+
aria-haspopup="menu"
|
|
97
|
+
aria-expanded={isOpen}
|
|
98
|
+
onClick={() => setIsOpen((prev) => !prev)}
|
|
99
|
+
className="rounded-md p-1.5 text-[rgb(var(--ec-icon-color))] transition-colors hover:bg-[rgb(var(--ec-content-hover)/0.5)] hover:text-[rgb(var(--ec-page-text))]"
|
|
100
|
+
>
|
|
101
|
+
<EllipsisVerticalIcon className="h-4 w-4" />
|
|
102
|
+
</button>
|
|
103
|
+
|
|
104
|
+
{isOpen &&
|
|
105
|
+
typeof document !== 'undefined' &&
|
|
106
|
+
createPortal(
|
|
107
|
+
<div
|
|
108
|
+
ref={menuRef}
|
|
109
|
+
role="menu"
|
|
110
|
+
style={{
|
|
111
|
+
position: 'fixed',
|
|
112
|
+
top: menuPosition?.top ?? -9999,
|
|
113
|
+
left: menuPosition?.left ?? -9999,
|
|
114
|
+
width: MENU_WIDTH,
|
|
115
|
+
visibility: menuPosition ? 'visible' : 'hidden',
|
|
116
|
+
}}
|
|
117
|
+
className="z-50 overflow-hidden rounded-xl border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-dropdown-bg))] shadow-xl"
|
|
118
|
+
>
|
|
119
|
+
<a
|
|
120
|
+
href={href}
|
|
121
|
+
className="flex items-center gap-2.5 px-3 py-2.5 text-xs font-medium text-[rgb(var(--ec-page-text))] transition-colors hover:bg-[rgb(var(--ec-content-hover))] hover:text-[rgb(var(--ec-accent))]"
|
|
122
|
+
>
|
|
123
|
+
<DocumentTextIcon className="h-3.5 w-3.5 text-[rgb(var(--ec-page-text-muted))]" />
|
|
124
|
+
View documentation
|
|
125
|
+
</a>
|
|
126
|
+
{visualiserHref && (
|
|
127
|
+
<a
|
|
128
|
+
href={visualiserHref}
|
|
129
|
+
className="flex items-center gap-2.5 px-3 py-2.5 text-xs font-medium text-[rgb(var(--ec-page-text))] transition-colors hover:bg-[rgb(var(--ec-content-hover))] hover:text-[rgb(var(--ec-accent))]"
|
|
130
|
+
>
|
|
131
|
+
<MapIcon className="h-3.5 w-3.5 text-[rgb(var(--ec-page-text-muted))]" />
|
|
132
|
+
View in visualiser
|
|
133
|
+
</a>
|
|
134
|
+
)}
|
|
135
|
+
{schemaHref && (
|
|
136
|
+
<a
|
|
137
|
+
href={schemaHref}
|
|
138
|
+
className="flex items-center gap-2.5 px-3 py-2.5 text-xs font-medium text-[rgb(var(--ec-page-text))] transition-colors hover:bg-[rgb(var(--ec-content-hover))] hover:text-[rgb(var(--ec-accent))]"
|
|
139
|
+
>
|
|
140
|
+
<CodeBracketIcon className="h-3.5 w-3.5 text-[rgb(var(--ec-page-text-muted))]" />
|
|
141
|
+
View schema
|
|
142
|
+
</a>
|
|
143
|
+
)}
|
|
144
|
+
<button
|
|
145
|
+
type="button"
|
|
146
|
+
onClick={handleToggleFavorite}
|
|
147
|
+
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left text-xs font-medium text-[rgb(var(--ec-page-text))] transition-colors hover:bg-[rgb(var(--ec-content-hover))] hover:text-[rgb(var(--ec-accent))]"
|
|
148
|
+
>
|
|
149
|
+
<StarIcon className="h-3.5 w-3.5 text-[rgb(var(--ec-page-text-muted))]" />
|
|
150
|
+
{isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
|
151
|
+
</button>
|
|
152
|
+
</div>,
|
|
153
|
+
document.body
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import type { Table } from '@tanstack/react-table';
|
|
3
|
+
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
export const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
|
6
|
+
|
|
7
|
+
/** Page size for a table, remembered in the browser under `storageKey`. */
|
|
8
|
+
export function useStoredPageSize(storageKey: string) {
|
|
9
|
+
const [pageSize, setPageSize] = useState<number>(() => {
|
|
10
|
+
if (typeof window === 'undefined') return 10;
|
|
11
|
+
const stored = Number(window.localStorage.getItem(storageKey));
|
|
12
|
+
return PAGE_SIZE_OPTIONS.includes(stored) ? stored : 10;
|
|
13
|
+
});
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (typeof window !== 'undefined') {
|
|
16
|
+
window.localStorage.setItem(storageKey, String(pageSize));
|
|
17
|
+
}
|
|
18
|
+
}, [pageSize, storageKey]);
|
|
19
|
+
return [pageSize, setPageSize] as const;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface TablePaginationProps<TData> {
|
|
23
|
+
table: Table<TData>;
|
|
24
|
+
pageSize: number;
|
|
25
|
+
onPageSizeChange: (pageSize: number) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Footer with the result count, page size picker and previous/next page controls. */
|
|
29
|
+
export function TablePagination<TData>({ table, pageSize, onPageSizeChange }: TablePaginationProps<TData>) {
|
|
30
|
+
const totalResults = table.getPrePaginationRowModel().rows.length;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="flex-shrink-0 flex items-center justify-between px-6 py-3 border-t border-[rgb(var(--ec-page-border))]">
|
|
34
|
+
<div className="flex items-center gap-3 text-xs text-[rgb(var(--ec-page-text-muted))]">
|
|
35
|
+
{totalResults > 0 && (
|
|
36
|
+
<span>
|
|
37
|
+
<span className="font-medium text-[rgb(var(--ec-page-text))]">{table.getRowModel().rows.length}</span> of{' '}
|
|
38
|
+
<span className="font-medium text-[rgb(var(--ec-page-text))]">{totalResults}</span> results
|
|
39
|
+
</span>
|
|
40
|
+
)}
|
|
41
|
+
{totalResults > 0 && <span aria-hidden className="h-3 w-px bg-[rgb(var(--ec-page-border))]" />}
|
|
42
|
+
<label className="flex items-center gap-1.5">
|
|
43
|
+
<span>Per page</span>
|
|
44
|
+
<select
|
|
45
|
+
value={pageSize}
|
|
46
|
+
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
|
47
|
+
className="cursor-pointer rounded bg-transparent px-1 py-0.5 font-medium text-[rgb(var(--ec-page-text))] hover:bg-[rgb(var(--ec-content-hover))] focus:bg-[rgb(var(--ec-content-hover))] focus:outline-none"
|
|
48
|
+
>
|
|
49
|
+
{PAGE_SIZE_OPTIONS.map((size) => (
|
|
50
|
+
<option key={size} value={size}>
|
|
51
|
+
{size}
|
|
52
|
+
</option>
|
|
53
|
+
))}
|
|
54
|
+
</select>
|
|
55
|
+
</label>
|
|
56
|
+
</div>
|
|
57
|
+
<div className="flex items-center gap-1.5">
|
|
58
|
+
<button
|
|
59
|
+
className="p-1.5 text-[rgb(var(--ec-icon-color))] hover:text-[rgb(var(--ec-page-text))] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
|
60
|
+
onClick={() => table.previousPage()}
|
|
61
|
+
disabled={!table.getCanPreviousPage()}
|
|
62
|
+
title="Previous page"
|
|
63
|
+
>
|
|
64
|
+
<ChevronLeft className="w-4 h-4" />
|
|
65
|
+
</button>
|
|
66
|
+
<span className="text-xs tabular-nums text-[rgb(var(--ec-page-text-muted))] min-w-[60px] text-center">
|
|
67
|
+
<span className="font-medium text-[rgb(var(--ec-page-text))]">{table.getState().pagination.pageIndex + 1}</span>
|
|
68
|
+
{' / '}
|
|
69
|
+
<span>{table.getPageCount() || 1}</span>
|
|
70
|
+
</span>
|
|
71
|
+
<button
|
|
72
|
+
className="p-1.5 text-[rgb(var(--ec-icon-color))] hover:text-[rgb(var(--ec-page-text))] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
|
73
|
+
onClick={() => table.nextPage()}
|
|
74
|
+
disabled={!table.getCanNextPage()}
|
|
75
|
+
title="Next page"
|
|
76
|
+
>
|
|
77
|
+
<ChevronRight className="w-4 h-4" />
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|