@eventcatalog/core 3.48.3 → 4.0.0-beta.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.
Files changed (109) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +3 -3
  5. package/dist/catalog-to-astro-content-directory.cjs +1 -1
  6. package/dist/{chunk-4BRGEUVO.js → chunk-7PMNFOIL.js} +1 -1
  7. package/dist/chunk-BWN544LB.js +73 -0
  8. package/dist/{chunk-X7GMZVB3.js → chunk-D3MI3OFO.js} +1 -1
  9. package/dist/{chunk-EE5Q6MJZ.js → chunk-HN45LKSL.js} +1 -1
  10. package/dist/{chunk-PWBFBCJ4.js → chunk-JMADW4J7.js} +1 -1
  11. package/dist/{chunk-XEUW7PEQ.js → chunk-NAOHQDLC.js} +1 -1
  12. package/dist/constants.cjs +1 -1
  13. package/dist/constants.js +1 -1
  14. package/dist/core-node-modules.cjs +108 -0
  15. package/dist/core-node-modules.d.cts +7 -0
  16. package/dist/core-node-modules.d.ts +7 -0
  17. package/dist/core-node-modules.js +8 -0
  18. package/dist/docs/development/components/components/15-prompt.md +5 -1
  19. package/dist/eventcatalog.cjs +124 -48
  20. package/dist/eventcatalog.js +26 -15
  21. package/dist/generate.cjs +1 -1
  22. package/dist/generate.js +3 -3
  23. package/dist/utils/cli-logger.cjs +1 -1
  24. package/dist/utils/cli-logger.js +2 -2
  25. package/eventcatalog/astro.config.mjs +27 -23
  26. package/eventcatalog/public/icons/languages/java.svg +1 -0
  27. package/eventcatalog/public/icons/payments/stripe.svg +1 -0
  28. package/eventcatalog/src/components/Badge.astro +10 -10
  29. package/eventcatalog/src/components/CopyAsMarkdown.tsx +154 -0
  30. package/eventcatalog/src/components/Grids/DomainGrid.tsx +207 -44
  31. package/eventcatalog/src/components/Grids/MessageGrid.tsx +0 -20
  32. package/eventcatalog/src/components/Grids/SystemGrid.tsx +185 -0
  33. package/eventcatalog/src/components/MDX/ADRTable/ADRTable.astro +99 -0
  34. package/eventcatalog/src/components/MDX/ADRTable/ADRTable.client.tsx +400 -0
  35. package/eventcatalog/src/components/MDX/ContextDiagram/ContextDiagramPortal.tsx +21 -0
  36. package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +1 -0
  37. package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +42 -6
  38. package/eventcatalog/src/components/MDX/Prompt/Prompt.astro +62 -9
  39. package/eventcatalog/src/components/MDX/ResourceRef/ResourceRef.astro +8 -1
  40. package/eventcatalog/src/components/MDX/SystemContextMap/SystemContextMapPortal.tsx +22 -0
  41. package/eventcatalog/src/components/MDX/components.tsx +7 -0
  42. package/eventcatalog/src/components/SchemaExplorer/SchemaExplorer.tsx +1 -1
  43. package/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx +67 -37
  44. package/eventcatalog/src/components/Tables/Discover/DiscoverTable.tsx +47 -0
  45. package/eventcatalog/src/components/Tables/Discover/FilterComponents.tsx +10 -2
  46. package/eventcatalog/src/components/Tables/Discover/columns.tsx +115 -27
  47. package/eventcatalog/src/components/Tables/Discover/index.ts +7 -1
  48. package/eventcatalog/src/components/Tables/SystemResources/SystemResourcesTable.tsx +267 -0
  49. package/eventcatalog/src/content.config.ts +94 -8
  50. package/eventcatalog/src/enterprise/collections/resource-docs-utils.ts +4 -1
  51. package/eventcatalog/src/enterprise/custom-documentation/pages/docs/custom/index.astro +12 -13
  52. package/eventcatalog/src/enterprise/custom-documentation/utils/custom-docs.ts +6 -0
  53. package/eventcatalog/src/enterprise/feature.ts +3 -1
  54. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +85 -129
  55. package/eventcatalog/src/layouts/VisualiserLayout.astro +66 -0
  56. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.ts +4 -2
  57. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro +2 -0
  58. package/eventcatalog/src/pages/diagrams/[id]/[version]/index.astro +15 -15
  59. package/eventcatalog/src/pages/discover/[type]/_index.data.ts +1 -0
  60. package/eventcatalog/src/pages/discover/[type]/index.astro +47 -2
  61. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/[docVersion]/index.astro +15 -14
  62. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/index.astro +13 -14
  63. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/_index.data.ts +1 -0
  64. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/_index.data.ts +11 -1
  65. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro +81 -26
  66. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.ts +89 -0
  67. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/index.astro +85 -0
  68. package/eventcatalog/src/pages/docs/[type]/[id]/[version].md.ts +2 -0
  69. package/eventcatalog/src/pages/docs/[type]/[id]/[version].mdx.ts +2 -0
  70. package/eventcatalog/src/pages/docs/[type]/[id]/_index.data.ts +1 -0
  71. package/eventcatalog/src/pages/docs/teams/[id]/index.astro +24 -1
  72. package/eventcatalog/src/pages/docs/users/[id]/index.astro +24 -1
  73. package/eventcatalog/src/pages/index.astro +30 -7
  74. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/_index.data.ts +1 -0
  75. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/index.astro +1 -1
  76. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version].mermaid.ts +3 -0
  77. package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/systems-context/_index.data.ts +94 -0
  78. package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/systems-context/index.astro +51 -0
  79. package/eventcatalog/src/pages/visualiser/system-context-map/index.astro +41 -0
  80. package/eventcatalog/src/pages/visualiser/systems/[id]/[version]/context/_index.data.ts +91 -0
  81. package/eventcatalog/src/pages/visualiser/systems/[id]/[version]/context/index.astro +51 -0
  82. package/eventcatalog/src/stores/sidebar-store/builders/adr.ts +3 -1
  83. package/eventcatalog/src/stores/sidebar-store/builders/agent.ts +3 -1
  84. package/eventcatalog/src/stores/sidebar-store/builders/container.ts +1 -1
  85. package/eventcatalog/src/stores/sidebar-store/builders/data-product.ts +1 -1
  86. package/eventcatalog/src/stores/sidebar-store/builders/domain.ts +127 -49
  87. package/eventcatalog/src/stores/sidebar-store/builders/message.ts +1 -1
  88. package/eventcatalog/src/stores/sidebar-store/builders/service.ts +4 -2
  89. package/eventcatalog/src/stores/sidebar-store/builders/shared.ts +2 -1
  90. package/eventcatalog/src/stores/sidebar-store/builders/system.ts +166 -0
  91. package/eventcatalog/src/stores/sidebar-store/state.ts +99 -6
  92. package/eventcatalog/src/types/index.ts +2 -0
  93. package/eventcatalog/src/utils/collection-colors.ts +6 -1
  94. package/eventcatalog/src/utils/collections/domains.ts +35 -0
  95. package/eventcatalog/src/utils/collections/icons.ts +4 -5
  96. package/eventcatalog/src/utils/collections/systems.ts +110 -0
  97. package/eventcatalog/src/utils/collections/teams.ts +15 -10
  98. package/eventcatalog/src/utils/collections/users.ts +7 -4
  99. package/eventcatalog/src/utils/collections/util.ts +2 -0
  100. package/eventcatalog/src/utils/icon-map.ts +20 -0
  101. package/eventcatalog/src/utils/icon.ts +11 -0
  102. package/eventcatalog/src/utils/node-graphs/container-node-graph.ts +12 -2
  103. package/eventcatalog/src/utils/node-graphs/domains-node-graph.ts +0 -170
  104. package/eventcatalog/src/utils/node-graphs/system-context-node-graph.ts +400 -0
  105. package/eventcatalog/src/utils/node-graphs/systems-node-graph.ts +208 -0
  106. package/eventcatalog/src/utils/node-graphs/utils/utils.ts +12 -0
  107. package/eventcatalog/src/utils/page-loaders/page-data-loader.ts +2 -0
  108. package/package.json +17 -14
  109. package/eventcatalog/src/pages/visualiser/context-map/index.astro +0 -30
@@ -0,0 +1,185 @@
1
+ import { useMemo } from 'react';
2
+ import { ServerIcon, GlobeAltIcon, CircleStackIcon } from '@heroicons/react/24/outline';
3
+ import { buildUrl } from '@utils/url-builder';
4
+ import { BoxIcon, Group as GroupIcon, Waypoints, Database as DatabaseIcon } from 'lucide-react';
5
+ import { ServiceCard, EntityBadge } from './DomainGrid';
6
+
7
+ interface SystemGridProps {
8
+ system: any;
9
+ }
10
+
11
+ const FlowLink = ({ flow }: { flow: any }) => {
12
+ const data = flow?.data || flow;
13
+ const id = data?.id || flow?.id;
14
+ const name = data?.name || id;
15
+ const version = data?.version || 'latest';
16
+
17
+ return (
18
+ <a
19
+ href={buildUrl(`/docs/flows/${id}/${version}`)}
20
+ className="inline-flex items-center gap-2 px-3 py-2 bg-[rgb(var(--ec-card-bg,var(--ec-page-bg)))] border border-[rgb(var(--ec-page-border))] rounded-lg text-sm font-medium text-[rgb(var(--ec-page-text))] hover:bg-[rgb(var(--ec-content-hover))] hover:border-[rgb(var(--ec-accent)/0.5)] transition-all shadow-xs"
21
+ >
22
+ <Waypoints className="h-4 w-4 text-[rgb(var(--ec-accent))]" />
23
+ <span>{name}</span>
24
+ <span className="text-xs text-[rgb(var(--ec-icon-color))]">v{version}</span>
25
+ </a>
26
+ );
27
+ };
28
+
29
+ const DataStoreLink = ({ container }: { container: any }) => {
30
+ const data = container?.data || container;
31
+ const id = data?.id || container?.id;
32
+ const name = data?.name || id;
33
+ const version = data?.version || 'latest';
34
+ const technology = data?.technology;
35
+
36
+ return (
37
+ <a
38
+ href={buildUrl(`/docs/containers/${id}/${version}`)}
39
+ className="inline-flex items-center gap-2 px-3 py-2 bg-[rgb(var(--ec-card-bg,var(--ec-page-bg)))] border border-[rgb(var(--ec-page-border))] rounded-lg text-sm font-medium text-[rgb(var(--ec-page-text))] hover:bg-[rgb(var(--ec-content-hover))] hover:border-[rgb(var(--ec-accent)/0.5)] transition-all shadow-xs"
40
+ >
41
+ <CircleStackIcon className="h-4 w-4 text-amber-500" />
42
+ <span>{name}</span>
43
+ {technology && <span className="text-xs text-[rgb(var(--ec-icon-color))]">{technology}</span>}
44
+ </a>
45
+ );
46
+ };
47
+
48
+ export default function SystemGrid({ system }: SystemGridProps) {
49
+ const data = system?.data;
50
+ if (!data) return <div className="text-[rgb(var(--ec-page-text-muted))]">No system data</div>;
51
+
52
+ const entities = data.entities || [];
53
+ const services = data.services || [];
54
+ const flows = data.flows || [];
55
+ const containers = data.containers || [];
56
+
57
+ // Split services into internal services and external integrations
58
+ // (mirrors the sidebar, where externalSystem services live under "External Integrations")
59
+ const internalServices = useMemo(() => services.filter((s: any) => !(s?.data || s)?.externalSystem), [services]);
60
+ const externalServices = useMemo(() => services.filter((s: any) => (s?.data || s)?.externalSystem), [services]);
61
+
62
+ return (
63
+ <div className="w-full">
64
+ {/* System Header - Doc style */}
65
+ <div className="border-b border-[rgb(var(--ec-page-border))] md:pb-4">
66
+ <div className="flex items-start justify-between">
67
+ <div>
68
+ <h2 className="text-2xl md:text-4xl font-bold text-[rgb(var(--ec-page-text))]">{data.name || data.id}</h2>
69
+ {data.summary && <p className="text-lg pt-2 text-[rgb(var(--ec-page-text-muted))] font-light">{data.summary}</p>}
70
+ </div>
71
+ </div>
72
+ </div>
73
+
74
+ {/* System Content */}
75
+ <div className="py-4 space-y-8">
76
+ {/* System Entities */}
77
+ {entities.length > 0 && (
78
+ <div>
79
+ <div className="flex items-center gap-2 mb-4">
80
+ <BoxIcon className="h-5 w-5 text-[rgb(var(--ec-accent))]" />
81
+ <h3 className="text-lg font-semibold text-[rgb(var(--ec-page-text))]">Entities</h3>
82
+ <span className="text-sm text-[rgb(var(--ec-page-text-muted))] bg-[rgb(var(--ec-content-hover))] px-2.5 py-0.5 rounded-full font-medium">
83
+ {entities.length}
84
+ </span>
85
+ </div>
86
+ <div className="flex flex-wrap gap-2">
87
+ {entities.map((entity: any) => {
88
+ const entityId = entity?.data?.id || entity?.id;
89
+ return entityId ? <EntityBadge key={entityId} entity={entity} /> : null;
90
+ })}
91
+ </div>
92
+ </div>
93
+ )}
94
+
95
+ {/* Services */}
96
+ {internalServices.length > 0 && (
97
+ <div>
98
+ <div className="flex items-center gap-2 mb-4">
99
+ <ServerIcon className="h-5 w-5 text-pink-600 dark:text-pink-400" />
100
+ <h3 className="text-lg font-semibold text-[rgb(var(--ec-page-text))]">Services</h3>
101
+ <span className="text-sm text-[rgb(var(--ec-page-text-muted))] bg-[rgb(var(--ec-content-hover))] px-2.5 py-0.5 rounded-full font-medium">
102
+ {internalServices.length}
103
+ </span>
104
+ </div>
105
+ <div className="space-y-3">
106
+ {internalServices.map((service: any) => {
107
+ const serviceId = service?.data?.id || service?.id;
108
+ return serviceId ? <ServiceCard key={serviceId} service={service} /> : null;
109
+ })}
110
+ </div>
111
+ </div>
112
+ )}
113
+
114
+ {/* External Integrations (external systems) */}
115
+ {externalServices.length > 0 && (
116
+ <div>
117
+ <div className="flex items-center gap-2 mb-4">
118
+ <GlobeAltIcon className="h-5 w-5 text-sky-600 dark:text-sky-400" />
119
+ <h3 className="text-lg font-semibold text-[rgb(var(--ec-page-text))]">External Integrations</h3>
120
+ <span className="text-sm text-[rgb(var(--ec-page-text-muted))] bg-[rgb(var(--ec-content-hover))] px-2.5 py-0.5 rounded-full font-medium">
121
+ {externalServices.length}
122
+ </span>
123
+ </div>
124
+ <div className="space-y-3">
125
+ {externalServices.map((service: any) => {
126
+ const serviceId = service?.data?.id || service?.id;
127
+ return serviceId ? <ServiceCard key={serviceId} service={service} /> : null;
128
+ })}
129
+ </div>
130
+ </div>
131
+ )}
132
+
133
+ {/* Data Stores (containers) */}
134
+ {containers.length > 0 && (
135
+ <div>
136
+ <div className="flex items-center gap-2 mb-4">
137
+ <DatabaseIcon className="h-5 w-5 text-amber-500" />
138
+ <h3 className="text-lg font-semibold text-[rgb(var(--ec-page-text))]">Data Stores</h3>
139
+ <span className="text-sm text-[rgb(var(--ec-page-text-muted))] bg-[rgb(var(--ec-content-hover))] px-2.5 py-0.5 rounded-full font-medium">
140
+ {containers.length}
141
+ </span>
142
+ </div>
143
+ <div className="flex flex-wrap gap-2">
144
+ {containers.map((container: any) => {
145
+ const containerId = container?.data?.id || container?.id;
146
+ return containerId ? <DataStoreLink key={containerId} container={container} /> : null;
147
+ })}
148
+ </div>
149
+ </div>
150
+ )}
151
+
152
+ {/* Flows */}
153
+ {flows.length > 0 && (
154
+ <div>
155
+ <div className="flex items-center gap-2 mb-4">
156
+ <Waypoints className="h-5 w-5 text-[rgb(var(--ec-accent))]" />
157
+ <h3 className="text-lg font-semibold text-[rgb(var(--ec-page-text))]">Flows</h3>
158
+ <span className="text-sm text-[rgb(var(--ec-page-text-muted))] bg-[rgb(var(--ec-content-hover))] px-2.5 py-0.5 rounded-full font-medium">
159
+ {flows.length}
160
+ </span>
161
+ </div>
162
+ <div className="flex flex-wrap gap-2">
163
+ {flows.map((flow: any) => {
164
+ const flowId = flow?.data?.id || flow?.id;
165
+ return flowId ? <FlowLink key={flowId} flow={flow} /> : null;
166
+ })}
167
+ </div>
168
+ </div>
169
+ )}
170
+
171
+ {/* Empty state */}
172
+ {entities.length === 0 && services.length === 0 && flows.length === 0 && containers.length === 0 && (
173
+ <div className="text-center py-12">
174
+ <div className="flex items-center justify-center w-16 h-16 mx-auto mb-4 bg-[rgb(var(--ec-content-hover))] rounded-2xl">
175
+ <GroupIcon className="h-8 w-8 text-[rgb(var(--ec-icon-color))]" />
176
+ </div>
177
+ <p className="text-[rgb(var(--ec-page-text-muted))]">
178
+ This system has no entities, services, data stores, or flows defined.
179
+ </p>
180
+ </div>
181
+ )}
182
+ </div>
183
+ </div>
184
+ );
185
+ }
@@ -0,0 +1,99 @@
1
+ ---
2
+ import ADRTableClient from './ADRTable.client';
3
+ import Admonition from '../Admonition';
4
+ import { getAdrs, getAdrsForResource, type AdrResource } from '@utils/collections/adrs';
5
+ import { ADR_STATUS_VALUES, type AdrStatus } from '@utils/collections/adr-constants';
6
+ import { getResourceReferenceStyle } from '@utils/resource-reference-colors';
7
+
8
+ // The ADR resource-reference styling (colour + CSS custom props), computed here on
9
+ // the server so the table's names match the `[[adr|...]]` wiki reference look. We
10
+ // parse the CSS declaration string into a React style object for the client island.
11
+ const adrRefStyle = getResourceReferenceStyle('adr')
12
+ .split(';')
13
+ .map((declaration) => declaration.trim())
14
+ .filter(Boolean)
15
+ .reduce<Record<string, string>>((style, declaration) => {
16
+ const [property, ...rest] = declaration.split(':');
17
+ const value = rest.join(':').trim();
18
+ if (!property || !value) return style;
19
+ const key = property.trim().startsWith('--')
20
+ ? property.trim()
21
+ : property.trim().replace(/-([a-z])/g, (_, c) => c.toUpperCase());
22
+ style[key] = value;
23
+ return style;
24
+ }, {});
25
+
26
+ export interface Props {
27
+ // Filter to one or more statuses. Accepts a single status ("accepted") or a
28
+ // comma-separated list ("accepted,proposed"). When omitted, all statuses show.
29
+ status?: string;
30
+ // Page size for the table. Defaults to 10.
31
+ limit?: number;
32
+ // The page resource is injected by the MDX runtime via `...props`.
33
+ collection?: string;
34
+ data?: any;
35
+ }
36
+
37
+ const { status, limit } = Astro.props;
38
+ const collection = Astro.props.collection as string;
39
+ const rawStatus: string = status || '';
40
+
41
+ // ADRs can apply to any documented resource. The only types that can't be the
42
+ // subject of an ADRTable are non-resource pages.
43
+ const isComponentEnabled = !!collection && !!Astro.props.data?.id;
44
+
45
+ // Parse the `status` prop into a validated list of statuses.
46
+ const requestedStatuses = rawStatus
47
+ .split(',')
48
+ .map((value) => value.trim().toLowerCase())
49
+ .filter((value): value is AdrStatus => (ADR_STATUS_VALUES as readonly string[]).includes(value));
50
+
51
+ let adrs: ReturnType<typeof getEssentialPropsForAdr>[] = [];
52
+
53
+ function getEssentialPropsForAdr(adr: Awaited<ReturnType<typeof getAdrs>>[number]) {
54
+ return {
55
+ id: adr.data.id,
56
+ name: adr.data.name,
57
+ version: adr.data.version,
58
+ status: adr.data.status as AdrStatus,
59
+ // Serialise the date so it survives the server -> client island boundary.
60
+ date: adr.data.date ? new Date(adr.data.date).toISOString() : null,
61
+ summary: adr.data.summary || '',
62
+ };
63
+ }
64
+
65
+ if (isComponentEnabled) {
66
+ const allAdrs = await getAdrs();
67
+ const resource = Astro.props as unknown as AdrResource;
68
+ let relevant = getAdrsForResource(resource, allAdrs);
69
+
70
+ if (requestedStatuses.length > 0) {
71
+ relevant = relevant.filter((adr) => requestedStatuses.includes(adr.data.status as AdrStatus));
72
+ }
73
+
74
+ adrs = relevant.map(getEssentialPropsForAdr);
75
+ }
76
+ ---
77
+
78
+ {
79
+ isComponentEnabled && (
80
+ <ADRTableClient
81
+ client:load
82
+ adrs={adrs}
83
+ limit={limit}
84
+ resourceName={Astro.props.data?.name || collection}
85
+ refStyle={adrRefStyle}
86
+ />
87
+ )
88
+ }
89
+
90
+ {
91
+ !isComponentEnabled && (
92
+ <Admonition type="warning">
93
+ <div>
94
+ <span class="font-bold">{`<ADRTable/>`} component could not be rendered.</span>
95
+ <span class="block">It must be used on a documented resource page (a service, system, domain, etc.).</span>
96
+ </div>
97
+ </Admonition>
98
+ )
99
+ }
@@ -0,0 +1,400 @@
1
+ import { buildUrl } from '@utils/url-builder';
2
+ import { ADR_STATUS_VALUES, adrStatusBadgeColor, formatAdrStatus, type AdrStatus } from '@utils/collections/adr-constants';
3
+ import { useState, useMemo, memo, type CSSProperties } from 'react';
4
+
5
+ type AdrTableItem = {
6
+ id: string;
7
+ name: string;
8
+ version: string;
9
+ status: AdrStatus;
10
+ date: string | null;
11
+ summary: string;
12
+ };
13
+
14
+ type ADRTableProps = {
15
+ adrs: AdrTableItem[];
16
+ limit?: number;
17
+ resourceName: string;
18
+ // React-ready style object for the ADR resource reference (colour + CSS custom
19
+ // props), computed server-side so we match the `[[adr|...]]` wiki reference look
20
+ // without pulling server-only utils into the client bundle.
21
+ refStyle: CSSProperties;
22
+ };
23
+
24
+ type SortKey = 'name' | 'status' | 'date';
25
+ type SortDirection = 'asc' | 'desc';
26
+
27
+ // Tailwind classes per status colour. The colour names come from adrStatusBadgeColor.
28
+ const STATUS_BADGE_CLASSES: Record<string, string> = {
29
+ amber: 'bg-amber-100 text-amber-800 ring-amber-600/20 dark:bg-amber-500/15 dark:text-amber-300',
30
+ green: 'bg-green-100 text-green-800 ring-green-600/20 dark:bg-green-500/15 dark:text-green-300',
31
+ red: 'bg-red-100 text-red-800 ring-red-600/20 dark:bg-red-500/15 dark:text-red-300',
32
+ gray: 'bg-gray-100 text-gray-700 ring-gray-500/20 dark:bg-gray-500/15 dark:text-gray-300',
33
+ purple: 'bg-purple-100 text-purple-800 ring-purple-600/20 dark:bg-purple-500/15 dark:text-purple-300',
34
+ };
35
+
36
+ const StatusBadge = memo(({ status }: { status: AdrStatus }) => {
37
+ const color = adrStatusBadgeColor[status] || 'gray';
38
+ return (
39
+ <span
40
+ className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ring-1 ring-inset ${STATUS_BADGE_CLASSES[color] || STATUS_BADGE_CLASSES.gray}`}
41
+ >
42
+ {formatAdrStatus(status)}
43
+ </span>
44
+ );
45
+ });
46
+
47
+ const formatDate = (iso: string | null) => {
48
+ if (!iso) return '-';
49
+ return new Intl.DateTimeFormat('en-US', { timeZone: 'UTC', year: 'numeric', month: 'short', day: 'numeric' }).format(
50
+ new Date(iso)
51
+ );
52
+ };
53
+
54
+ const AdrRow = memo(({ adr, refStyle }: { adr: AdrTableItem; refStyle: CSSProperties }) => {
55
+ const url = buildUrl(`/docs/adrs/${adr.id}/${adr.version}`);
56
+ return (
57
+ <tr className="group hover:bg-[rgb(var(--ec-content-hover))]">
58
+ <td className="py-4 pl-4 pr-3 text-sm font-medium sm:pl-6 relative">
59
+ <a href={url} className="absolute inset-0 z-10" aria-label={`View details for ${adr.name}`} />
60
+ <span className="rounded px-1 -mx-1 break-words transition-colors hover:bg-[var(--ec-resource-ref-bg)]" style={refStyle}>
61
+ {adr.name}
62
+ </span>
63
+ </td>
64
+ <td className="whitespace-nowrap px-3 py-4 text-sm relative">
65
+ <a href={url} className="absolute inset-0 z-10" aria-hidden="true" />
66
+ <StatusBadge status={adr.status} />
67
+ </td>
68
+ <td className="whitespace-nowrap px-3 py-4 text-sm text-[rgb(var(--ec-page-text-muted))] relative">
69
+ <a href={url} className="absolute inset-0 z-10" aria-hidden="true" />
70
+ <span>{formatDate(adr.date)}</span>
71
+ </td>
72
+ <td className="px-3 py-4 text-sm text-[rgb(var(--ec-page-text-muted))] relative">
73
+ <a href={url} className="absolute inset-0 z-10" aria-hidden="true" />
74
+ <span className="line-clamp-2 break-words">{adr.summary || '-'}</span>
75
+ </td>
76
+ </tr>
77
+ );
78
+ });
79
+
80
+ const FilterButton = memo(
81
+ ({
82
+ status,
83
+ active,
84
+ count,
85
+ onToggle,
86
+ }: {
87
+ status: AdrStatus;
88
+ active: boolean;
89
+ count: number;
90
+ onToggle: (status: AdrStatus) => void;
91
+ }) => (
92
+ <button
93
+ onClick={() => onToggle(status)}
94
+ className={`px-3 py-1 rounded-md text-sm font-medium border border-[rgb(var(--ec-page-border))] ${
95
+ active
96
+ ? 'bg-[rgb(var(--ec-button-bg))] text-[rgb(var(--ec-button-text))] hover:bg-[rgb(var(--ec-button-bg-hover))]'
97
+ : 'bg-[rgb(var(--ec-page-bg))] text-[rgb(var(--ec-page-text))] hover:bg-[rgb(var(--ec-content-hover))]'
98
+ }`}
99
+ >
100
+ {formatAdrStatus(status)} ({count})
101
+ </button>
102
+ )
103
+ );
104
+
105
+ const SortHeader = ({
106
+ label,
107
+ sortKey,
108
+ activeSort,
109
+ direction,
110
+ onSort,
111
+ className,
112
+ }: {
113
+ label: string;
114
+ sortKey: SortKey;
115
+ activeSort: SortKey;
116
+ direction: SortDirection;
117
+ onSort: (key: SortKey) => void;
118
+ className?: string;
119
+ }) => {
120
+ const isActive = activeSort === sortKey;
121
+ return (
122
+ <th scope="col" className={className}>
123
+ <button
124
+ onClick={() => onSort(sortKey)}
125
+ className="group inline-flex items-center gap-1 text-left font-semibold text-[rgb(var(--ec-page-text))]"
126
+ >
127
+ {label}
128
+ <span
129
+ className={`text-xs ${isActive ? 'text-[rgb(var(--ec-page-text))]' : 'text-[rgb(var(--ec-page-text-muted))] opacity-0 group-hover:opacity-100'}`}
130
+ >
131
+ {isActive ? (direction === 'asc' ? '▲' : '▼') : '↕'}
132
+ </span>
133
+ </button>
134
+ </th>
135
+ );
136
+ };
137
+
138
+ const ADRTable = ({ adrs, limit, resourceName, refStyle }: ADRTableProps) => {
139
+ const [searchTerm, setSearchTerm] = useState('');
140
+ const [statusFilters, setStatusFilters] = useState<Set<AdrStatus>>(new Set());
141
+ const [sortKey, setSortKey] = useState<SortKey>('date');
142
+ const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
143
+ const [currentPage, setCurrentPage] = useState(1);
144
+ const itemsPerPage = limit || 10;
145
+
146
+ const toggleStatus = (status: AdrStatus) => {
147
+ setStatusFilters((prev) => {
148
+ const next = new Set(prev);
149
+ if (next.has(status)) next.delete(status);
150
+ else next.add(status);
151
+ return next;
152
+ });
153
+ setCurrentPage(1);
154
+ };
155
+
156
+ const onSort = (key: SortKey) => {
157
+ if (key === sortKey) {
158
+ setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'));
159
+ } else {
160
+ setSortKey(key);
161
+ setSortDirection(key === 'date' ? 'desc' : 'asc');
162
+ }
163
+ setCurrentPage(1);
164
+ };
165
+
166
+ // Counts per status across the full (unfiltered) set, for the filter buttons.
167
+ const statusCounts = useMemo(() => {
168
+ const counts = new Map<AdrStatus, number>();
169
+ adrs.forEach((adr) => counts.set(adr.status, (counts.get(adr.status) || 0) + 1));
170
+ return counts;
171
+ }, [adrs]);
172
+
173
+ const availableStatuses = useMemo(
174
+ () => ADR_STATUS_VALUES.filter((status) => (statusCounts.get(status) || 0) > 0),
175
+ [statusCounts]
176
+ );
177
+
178
+ const filtered = useMemo(() => {
179
+ let result = adrs;
180
+
181
+ if (statusFilters.size > 0) {
182
+ result = result.filter((adr) => statusFilters.has(adr.status));
183
+ }
184
+
185
+ if (searchTerm) {
186
+ const term = searchTerm.toLowerCase();
187
+ result = result.filter(
188
+ (adr) =>
189
+ adr.name.toLowerCase().includes(term) ||
190
+ adr.summary.toLowerCase().includes(term) ||
191
+ adr.status.toLowerCase().includes(term)
192
+ );
193
+ }
194
+
195
+ const sorted = [...result].sort((a, b) => {
196
+ let comparison = 0;
197
+ if (sortKey === 'name') comparison = a.name.localeCompare(b.name);
198
+ else if (sortKey === 'status') comparison = a.status.localeCompare(b.status);
199
+ else comparison = Number(new Date(a.date || 0)) - Number(new Date(b.date || 0));
200
+ return sortDirection === 'asc' ? comparison : -comparison;
201
+ });
202
+
203
+ return sorted;
204
+ }, [adrs, statusFilters, searchTerm, sortKey, sortDirection]);
205
+
206
+ const totalPages = Math.ceil(filtered.length / itemsPerPage);
207
+ const startIndex = (currentPage - 1) * itemsPerPage;
208
+ const paginated = useMemo(() => filtered.slice(startIndex, startIndex + itemsPerPage), [filtered, startIndex, itemsPerPage]);
209
+
210
+ const hasFilters = statusFilters.size > 0 || searchTerm.length > 0;
211
+
212
+ return (
213
+ <div className="mx-auto not-prose py-4 space-y-4 my-4">
214
+ <div className="flow-root bg-[rgb(var(--ec-card-bg,var(--ec-page-bg)))] border border-[rgb(var(--ec-page-border))] p-4 pb-2 rounded-lg text-[rgb(var(--ec-page-text))]">
215
+ <div className="space-y-4">
216
+ <h2 className="text-xl font-semibold">
217
+ Architecture Decision Records ({hasFilters ? `${filtered.length}/${adrs.length}` : adrs.length})
218
+ </h2>
219
+ <span className="text-sm text-[rgb(var(--ec-page-text-muted))]">
220
+ Decisions that apply to this {resourceName}. Search, filter by status, or sort by clicking a column.
221
+ </span>
222
+
223
+ {availableStatuses.length > 0 && (
224
+ <div className="flex flex-wrap gap-2 pb-2">
225
+ {availableStatuses.map((status) => (
226
+ <FilterButton
227
+ key={status}
228
+ status={status}
229
+ active={statusFilters.has(status)}
230
+ count={statusCounts.get(status) || 0}
231
+ onToggle={toggleStatus}
232
+ />
233
+ ))}
234
+ </div>
235
+ )}
236
+
237
+ <div className="relative">
238
+ <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
239
+ <svg
240
+ className="h-5 w-5 text-[rgb(var(--ec-icon-color))]"
241
+ viewBox="0 0 20 20"
242
+ fill="currentColor"
243
+ aria-hidden="true"
244
+ >
245
+ <path
246
+ fillRule="evenodd"
247
+ d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
248
+ clipRule="evenodd"
249
+ />
250
+ </svg>
251
+ </div>
252
+ <input
253
+ type="text"
254
+ value={searchTerm}
255
+ onChange={(e) => {
256
+ setSearchTerm(e.target.value);
257
+ setCurrentPage(1);
258
+ }}
259
+ placeholder="Search by name, status, or summary..."
260
+ className="block w-full rounded-md border-0 py-1.5 pl-10 pr-10 text-[rgb(var(--ec-page-text))] bg-[rgb(var(--ec-input-bg))] ring-1 ring-inset ring-[rgb(var(--ec-input-border))] placeholder:text-[rgb(var(--ec-input-placeholder))] focus:ring-2 focus:ring-inset focus:ring-[rgb(var(--ec-accent))] sm:text-sm sm:leading-6"
261
+ />
262
+ {searchTerm && (
263
+ <button
264
+ onClick={() => {
265
+ setSearchTerm('');
266
+ setCurrentPage(1);
267
+ }}
268
+ className="absolute inset-y-0 right-0 flex items-center pr-3"
269
+ aria-label="Clear search"
270
+ >
271
+ <svg
272
+ className="h-5 w-5 text-[rgb(var(--ec-icon-color))] hover:text-[rgb(var(--ec-icon-hover))]"
273
+ viewBox="0 0 20 20"
274
+ fill="currentColor"
275
+ >
276
+ <path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
277
+ </svg>
278
+ </button>
279
+ )}
280
+ </div>
281
+ </div>
282
+
283
+ <div className="overflow-x-auto">
284
+ <div className="inline-block min-w-full py-2 align-middle">
285
+ <div className="max-w-full overflow-hidden">
286
+ <table className="min-w-full table-fixed divide-y divide-[rgb(var(--ec-page-border))] rounded-xs bg-[rgb(var(--ec-page-bg))]">
287
+ <thead>
288
+ <tr>
289
+ <SortHeader
290
+ label="Name"
291
+ sortKey="name"
292
+ activeSort={sortKey}
293
+ direction={sortDirection}
294
+ onSort={onSort}
295
+ className="w-2/5 py-3.5 pl-4 pr-3 text-left text-sm sm:pl-6"
296
+ />
297
+ <SortHeader
298
+ label="Status"
299
+ sortKey="status"
300
+ activeSort={sortKey}
301
+ direction={sortDirection}
302
+ onSort={onSort}
303
+ className="w-[120px] px-3 py-3.5 text-left text-sm"
304
+ />
305
+ <SortHeader
306
+ label="Date"
307
+ sortKey="date"
308
+ activeSort={sortKey}
309
+ direction={sortDirection}
310
+ onSort={onSort}
311
+ className="w-[140px] px-3 py-3.5 text-left text-sm"
312
+ />
313
+ <th scope="col" className="w-2/5 px-3 py-3.5 text-left text-sm font-semibold text-[rgb(var(--ec-page-text))]">
314
+ Summary
315
+ </th>
316
+ </tr>
317
+ </thead>
318
+ <tbody className="divide-y divide-[rgb(var(--ec-page-border))]">
319
+ {paginated.length > 0 ? (
320
+ paginated.map((adr) => <AdrRow key={`${adr.id}-${adr.version}`} adr={adr} refStyle={refStyle} />)
321
+ ) : (
322
+ <tr>
323
+ <td colSpan={4} className="text-center py-4 text-sm text-[rgb(var(--ec-page-text-muted))]">
324
+ No decision records found
325
+ </td>
326
+ </tr>
327
+ )}
328
+ </tbody>
329
+ </table>
330
+ </div>
331
+ </div>
332
+ </div>
333
+
334
+ {totalPages > 1 && (
335
+ <div className="flex items-center justify-between border-t border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-page-bg))] px-4 py-3 sm:px-6 -mt-2">
336
+ <div className="flex flex-1 justify-between sm:hidden">
337
+ <button
338
+ onClick={() => setCurrentPage(currentPage - 1)}
339
+ disabled={currentPage === 1}
340
+ className={`relative inline-flex items-center rounded-md border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-page-bg))] px-4 py-2 text-sm font-medium ${currentPage === 1 ? 'text-[rgb(var(--ec-page-text-muted))] opacity-50' : 'text-[rgb(var(--ec-page-text))] hover:bg-[rgb(var(--ec-content-hover))]'}`}
341
+ >
342
+ Previous
343
+ </button>
344
+ <button
345
+ onClick={() => setCurrentPage(currentPage + 1)}
346
+ disabled={currentPage === totalPages}
347
+ className={`relative ml-3 inline-flex items-center rounded-md border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-page-bg))] px-4 py-2 text-sm font-medium ${currentPage === totalPages ? 'text-[rgb(var(--ec-page-text-muted))] opacity-50' : 'text-[rgb(var(--ec-page-text))] hover:bg-[rgb(var(--ec-content-hover))]'}`}
348
+ >
349
+ Next
350
+ </button>
351
+ </div>
352
+ <div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
353
+ <div>
354
+ <p className="text-sm text-[rgb(var(--ec-page-text-muted))]">
355
+ Showing <span className="font-medium">{startIndex + 1}</span> to{' '}
356
+ <span className="font-medium">{Math.min(startIndex + itemsPerPage, filtered.length)}</span> of{' '}
357
+ <span className="font-medium">{filtered.length}</span> results
358
+ </p>
359
+ </div>
360
+ <div>
361
+ <nav className="isolate inline-flex -space-x-px rounded-md shadow-xs" aria-label="Pagination">
362
+ <button
363
+ onClick={() => setCurrentPage(currentPage - 1)}
364
+ disabled={currentPage === 1}
365
+ className={`relative inline-flex items-center rounded-l-md px-2 py-2 ${currentPage === 1 ? 'text-[rgb(var(--ec-page-text-muted))] opacity-50' : 'text-[rgb(var(--ec-icon-color))] hover:bg-[rgb(var(--ec-content-hover))]'} ring-1 ring-inset ring-[rgb(var(--ec-page-border))]`}
366
+ >
367
+ <span className="sr-only">Previous</span>
368
+ <svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
369
+ <path
370
+ fillRule="evenodd"
371
+ d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z"
372
+ clipRule="evenodd"
373
+ />
374
+ </svg>
375
+ </button>
376
+ <button
377
+ onClick={() => setCurrentPage(currentPage + 1)}
378
+ disabled={currentPage === totalPages}
379
+ className={`relative inline-flex items-center rounded-r-md px-2 py-2 ${currentPage === totalPages ? 'text-[rgb(var(--ec-page-text-muted))] opacity-50' : 'text-[rgb(var(--ec-icon-color))] hover:bg-[rgb(var(--ec-content-hover))]'} ring-1 ring-inset ring-[rgb(var(--ec-page-border))]`}
380
+ >
381
+ <span className="sr-only">Next</span>
382
+ <svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
383
+ <path
384
+ fillRule="evenodd"
385
+ d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
386
+ clipRule="evenodd"
387
+ />
388
+ </svg>
389
+ </button>
390
+ </nav>
391
+ </div>
392
+ </div>
393
+ </div>
394
+ )}
395
+ </div>
396
+ </div>
397
+ );
398
+ };
399
+
400
+ export default ADRTable;