@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.
Files changed (64) 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/{chunk-AYNZXUB5.js → chunk-3PKS2IIP.js} +4 -4
  6. package/dist/{chunk-HGRFXTFC.js → chunk-423S2KHN.js} +1 -1
  7. package/dist/{chunk-KIDISU6X.js → chunk-6OC46WQJ.js} +1 -1
  8. package/dist/{chunk-4J2ZM2HX.js → chunk-BH2F5WPN.js} +1 -1
  9. package/dist/{chunk-62EV4IJA.js → chunk-GYIVJXOJ.js} +1 -1
  10. package/dist/{chunk-XARPIXWF.js → chunk-NDJOAITI.js} +1 -1
  11. package/dist/constants.cjs +1 -1
  12. package/dist/constants.js +1 -1
  13. package/dist/eventcatalog.cjs +1 -1
  14. package/dist/eventcatalog.js +13 -13
  15. package/dist/federation/federate.js +4 -4
  16. package/dist/federation/source-provider.js +2 -2
  17. package/dist/generate.cjs +1 -1
  18. package/dist/generate.js +3 -3
  19. package/dist/utils/cli-logger.cjs +1 -1
  20. package/dist/utils/cli-logger.js +2 -2
  21. package/eventcatalog/src/components/Grids/utils.tsx +0 -23
  22. package/eventcatalog/src/components/MDX/CodeGroup/CodeGroup.tsx +6 -1
  23. package/eventcatalog/src/components/SchemaExplorer/SchemaDetailsPanel.tsx +12 -4
  24. package/eventcatalog/src/components/Tables/Discover/DiscoverTable.tsx +4 -62
  25. package/eventcatalog/src/components/Tables/Discover/columns.tsx +17 -150
  26. package/eventcatalog/src/components/Tables/Resources/ResourcesTable.tsx +425 -0
  27. package/eventcatalog/src/components/Tables/RowActionsMenu.tsx +157 -0
  28. package/eventcatalog/src/components/Tables/TablePagination.tsx +82 -0
  29. package/eventcatalog/src/features/ai/chat-api.ts +3 -3
  30. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +75 -74
  31. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +0 -2
  32. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.spec.ts +52 -63
  33. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.ts +34 -46
  34. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro +11 -29
  35. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_ResourcesPage.astro +79 -0
  36. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.spec.ts +32 -14
  37. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.ts +63 -58
  38. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/index.astro +2 -62
  39. package/eventcatalog/src/pages/docs/services/[id]/[version]/resources/index.astro +13 -0
  40. package/eventcatalog/src/stores/sidebar-store/builders/domain.ts +24 -42
  41. package/eventcatalog/src/stores/sidebar-store/builders/flow.ts +2 -0
  42. package/eventcatalog/src/stores/sidebar-store/builders/message.ts +2 -0
  43. package/eventcatalog/src/stores/sidebar-store/builders/service.ts +23 -23
  44. package/eventcatalog/src/stores/sidebar-store/builders/shared.ts +13 -0
  45. package/eventcatalog/src/stores/sidebar-store/builders/system.ts +18 -27
  46. package/eventcatalog/src/stores/sidebar-store/state.ts +5 -0
  47. package/eventcatalog/src/styles/tailwind.css +2 -2
  48. package/eventcatalog/src/utils/collections/channels.ts +56 -1
  49. package/eventcatalog/src/utils/collections/flows.ts +50 -1
  50. package/eventcatalog/src/utils/collections/message-connections.ts +60 -0
  51. package/eventcatalog/src/utils/collections/message-triggers.ts +2 -1
  52. package/eventcatalog/src/utils/collections/resource-owners.ts +193 -0
  53. package/eventcatalog/src/utils/collections/resources.ts +310 -0
  54. package/eventcatalog/src/utils/collections/util.ts +31 -0
  55. package/package.json +6 -6
  56. package/eventcatalog/src/components/Grids/DomainGrid.tsx +0 -838
  57. package/eventcatalog/src/components/Grids/MessageGrid.tsx +0 -344
  58. package/eventcatalog/src/components/Grids/SystemGrid.tsx +0 -185
  59. package/eventcatalog/src/components/Grids/components.tsx +0 -170
  60. package/eventcatalog/src/components/Grids/message-link.ts +0 -38
  61. package/eventcatalog/src/components/Tables/SystemResources/SystemResourcesTable.tsx +0 -294
  62. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_resources.spec.ts +0 -65
  63. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_resources.ts +0 -46
  64. package/dist/{chunk-A2RZR3U4.js → chunk-6K7XZAYI.js} +3 -3
@@ -323,7 +323,6 @@ const isNavigationItemCurrent = (item: Pick<VerticalNavigationItem, 'href' | 'ma
323
323
  return (
324
324
  pathname === itemPath ||
325
325
  (pathname.includes('/docs') && !pathname.includes('/docs/custom')) ||
326
- pathname.includes('/architecture/') ||
327
326
  pathname.includes('/visualiser') ||
328
327
  (pathname.includes('/schemas') && !pathname.includes('/schemas/explorer') && !pathname.includes('/schemas/fields'))
329
328
  );
@@ -889,7 +888,6 @@ const verticalNavAutoCollapsePaths = [buildUrl('/discover', true), buildUrl('/do
889
888
  return (
890
889
  pathname === targetPath ||
891
890
  (pathname.includes('/docs') && !pathname.includes('/docs/custom')) ||
892
- pathname.includes('/architecture/') ||
893
891
  pathname.includes('/visualiser') ||
894
892
  (pathname.includes('/schemas') && !pathname.includes('/schemas/explorer') && !pathname.includes('/schemas/fields'))
895
893
  );
@@ -1,88 +1,77 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { loadArchitectureItems, Page } from './_index.data';
2
+ import { Page } from './_index.data';
3
3
 
4
- const collectionMocks = vi.hoisted(() => ({
4
+ const mocks = vi.hoisted(() => ({
5
+ isSSR: vi.fn(() => false),
5
6
  getDomains: vi.fn(),
6
7
  getServices: vi.fn(),
7
8
  getSystems: vi.fn(),
9
+ getOwnersWithResources: vi.fn(),
8
10
  }));
9
11
 
10
- vi.mock('@utils/feature', () => ({
11
- isSSR: () => false,
12
- }));
12
+ const loaders: Record<string, () => Promise<unknown>> = {
13
+ domains: () => mocks.getDomains(),
14
+ services: () => mocks.getServices(),
15
+ systems: () => mocks.getSystems(),
16
+ };
13
17
 
14
- vi.mock('@utils/collections/domains', () => ({ getDomains: collectionMocks.getDomains }));
15
- vi.mock('@utils/collections/systems', () => ({ getSystems: collectionMocks.getSystems }));
16
- vi.mock('@utils/page-loaders/page-data-loader', () => ({
17
- pageDataLoader: {
18
- services: (...args: unknown[]) => collectionMocks.getServices(...args),
19
- domains: (...args: unknown[]) => collectionMocks.getDomains(...args),
20
- systems: (...args: unknown[]) => collectionMocks.getSystems(...args),
21
- },
18
+ vi.mock('@utils/feature', () => ({ isSSR: mocks.isSSR }));
19
+ vi.mock('@utils/url-builder', () => ({ buildUrl: (path: string) => path }));
20
+ vi.mock('@utils/collections/resource-owners', () => ({
21
+ createResourcesCatalog: () => ({}),
22
+ loadResourceOwners: (type: string) => loaders[type](),
23
+ getOwnersWithResources: mocks.getOwnersWithResources,
22
24
  }));
23
25
 
24
- const domain = {
25
- collection: 'domains',
26
- data: { id: 'ordering', name: 'Ordering', version: '1.0.0' },
27
- };
28
-
29
- const service = {
30
- collection: 'services',
31
- data: { id: 'order-service', name: 'Order Service', version: '1.0.0' },
32
- };
26
+ const entry = (collection: string, id: string, version = '1.0.0') => ({ collection, data: { id, name: id, version } });
33
27
 
34
- const system = {
35
- collection: 'systems',
36
- data: { id: 'order-management-system', name: 'Order Management', version: '1.0.0' },
37
- };
28
+ const ordering = entry('domains', 'ordering');
29
+ const orderService = entry('services', 'order-service');
30
+ const orderServiceV0 = entry('services', 'order-service', '0.1.0');
31
+ const cartSystem = entry('systems', 'cart-system');
38
32
 
39
- describe('architecture page data', () => {
33
+ describe('architecture page redirects', () => {
40
34
  beforeEach(() => {
41
- collectionMocks.getDomains.mockReset().mockResolvedValue([domain]);
42
- collectionMocks.getServices.mockReset().mockResolvedValue([service]);
43
- collectionMocks.getSystems.mockReset().mockResolvedValue([system]);
35
+ mocks.isSSR.mockReturnValue(false);
36
+ mocks.getDomains.mockReset().mockResolvedValue([ordering]);
37
+ mocks.getServices.mockReset().mockResolvedValue([orderService, orderServiceV0]);
38
+ mocks.getSystems.mockReset().mockResolvedValue([cartSystem]);
39
+ // Only the latest order service and the cart system have a Resources page.
40
+ mocks.getOwnersWithResources.mockReset().mockImplementation(async (type: string) => {
41
+ if (type === 'services') return [orderService];
42
+ if (type === 'systems') return [cartSystem];
43
+ return [];
44
+ });
44
45
  });
45
46
 
46
- it('hydrates domain and system services for static architecture paths', async () => {
47
+ it('redirects every architecture page to its Resources page, or the docs page when it has no Resources page', async () => {
47
48
  const paths = await Page.getStaticPaths();
48
49
 
49
- expect(collectionMocks.getDomains).toHaveBeenCalledWith({ enrichServices: true });
50
- expect(collectionMocks.getSystems).toHaveBeenCalledWith({ enrichServices: true });
51
- expect(paths).toEqual(
52
- expect.arrayContaining([
53
- expect.objectContaining({
54
- params: { type: 'domains', id: 'ordering', version: '1.0.0' },
55
- }),
56
- expect.objectContaining({
57
- params: { type: 'systems', id: 'order-management-system', version: '1.0.0' },
58
- }),
59
- expect.objectContaining({
60
- params: { type: 'services', id: 'order-service', version: '1.0.0' },
61
- }),
62
- ])
63
- );
50
+ expect(
51
+ paths.map(({ params, props }) => [`/architecture/${params.type}/${params.id}/${params.version}`, props.redirectPath])
52
+ ).toEqual([
53
+ ['/architecture/services/order-service/1.0.0', '/docs/services/order-service/1.0.0/resources'],
54
+ ['/architecture/services/order-service/0.1.0', '/docs/services/order-service/0.1.0'],
55
+ ['/architecture/domains/ordering/1.0.0', '/docs/domains/ordering/1.0.0'],
56
+ ['/architecture/systems/cart-system/1.0.0', '/docs/systems/cart-system/1.0.0/resources'],
57
+ ]);
64
58
  });
65
59
 
66
- it('hydrates domain services when architecture pages are fetched in SSR', async () => {
67
- await Page.getData({
68
- props: {},
69
- params: { type: 'domains', id: 'ordering', version: '1.0.0' },
70
- } as any);
60
+ it('works out the redirect when the page is requested on a server', async () => {
61
+ mocks.isSSR.mockReturnValue(true);
71
62
 
72
- expect(collectionMocks.getDomains).toHaveBeenCalledWith({ enrichServices: true });
63
+ await expect(
64
+ Page.getData({ params: { type: 'systems', id: 'cart-system', version: '1.0.0' }, props: {} } as any)
65
+ ).resolves.toMatchObject({ redirectPath: '/docs/systems/cart-system/1.0.0/resources' });
73
66
  });
74
67
 
75
- it('hydrates system services when architecture pages are fetched in SSR', async () => {
76
- await Page.getData({
77
- props: {},
78
- params: { type: 'systems', id: 'order-management-system', version: '1.0.0' },
79
- } as any);
80
-
81
- expect(collectionMocks.getSystems).toHaveBeenCalledWith({ enrichServices: true });
82
- });
68
+ it('returns not found for resources without an architecture page', async () => {
69
+ mocks.isSSR.mockReturnValue(true);
83
70
 
84
- it('loads domains with enrichServices so DomainGrid command links keep their collection', async () => {
85
- await loadArchitectureItems('domains');
86
- expect(collectionMocks.getDomains).toHaveBeenCalledWith({ enrichServices: true });
71
+ await expect(
72
+ Page.getData({ params: { type: 'events', id: 'order-created', version: '1.0.0' }, props: {} } as any)
73
+ ).rejects.toMatchObject({
74
+ status: 404,
75
+ });
87
76
  });
88
77
  });
@@ -1,53 +1,48 @@
1
1
  import { isSSR } from '@utils/feature';
2
2
  import { HybridPage } from '@utils/page-loaders/hybrid-page';
3
- import type { PageTypes } from '@types';
4
- import { pageDataLoader } from '@utils/page-loaders/page-data-loader';
5
- import { getDomains } from '@utils/collections/domains';
6
- import { getSystems } from '@utils/collections/systems';
3
+ import {
4
+ createResourcesCatalog,
5
+ getOwnersWithResources,
6
+ loadResourceOwners,
7
+ type ResourcesCatalog,
8
+ } from '@utils/collections/resource-owners';
9
+ import type { ResourceOwner } from '@utils/collections/resources';
7
10
 
8
- const architecturePageTypes: PageTypes[] = ['services', 'domains', 'systems'];
11
+ // The architecture overview pages were replaced by the Resources pages. These routes stay so
12
+ // old links keep working: each one redirects to the resource's Resources page, or to its docs
13
+ // page when it has nothing to list.
14
+ const architecturePageTypes = ['services', 'domains', 'systems'] as const;
15
+ type ArchitecturePageType = (typeof architecturePageTypes)[number];
9
16
 
10
- /**
11
- * Architecture grids render service sends/receives as docs links, so domains and
12
- * systems must hydrate those messages (collection + name). `pageDataLoader`
13
- * uses the cheaper unenriched path used by docs/sidebar.
14
- */
15
- export const loadArchitectureItems = (type: PageTypes) => {
16
- if (type === 'domains') {
17
- return getDomains({ enrichServices: true });
18
- }
17
+ const isArchitecturePageType = (type: string): type is ArchitecturePageType =>
18
+ architecturePageTypes.includes(type as ArchitecturePageType);
19
19
 
20
- if (type === 'systems') {
21
- return getSystems({ enrichServices: true });
22
- }
20
+ const key = (item: ResourceOwner) => `${item.data.id}:${item.data.version}`;
21
+
22
+ const getRedirectPaths = async (type: ArchitecturePageType, catalog: ResourcesCatalog = createResourcesCatalog()) => {
23
+ const [items, ownersWithResources] = await Promise.all([loadResourceOwners(type), getOwnersWithResources(type, catalog)]);
24
+ const hasResourcesPage = new Set(ownersWithResources.map(key));
23
25
 
24
- return pageDataLoader[type as PageTypes]();
26
+ return items.map((item) => {
27
+ const docsPath = `/docs/${type}/${item.data.id}/${item.data.version}`;
28
+ return { item, redirectPath: hasResourcesPage.has(key(item)) ? `${docsPath}/resources` : docsPath };
29
+ });
25
30
  };
26
31
 
27
- /**
28
- * Documentation page class for all collection types with versioning
29
- */
30
32
  export class Page extends HybridPage {
31
33
  static async getStaticPaths() {
32
34
  if (isSSR()) {
33
35
  return [];
34
36
  }
35
37
 
36
- const pageData = await Promise.all(architecturePageTypes.map((type) => loadArchitectureItems(type)));
38
+ const catalog = createResourcesCatalog();
39
+ const redirects: Awaited<ReturnType<typeof getRedirectPaths>>[] = [];
40
+ for (const type of architecturePageTypes) redirects.push(await getRedirectPaths(type, catalog));
37
41
 
38
- return pageData.flatMap((items, index) =>
39
- items.map((item) => ({
40
- params: {
41
- type: architecturePageTypes[index],
42
- id: item.data.id,
43
- version: item.data.version,
44
- },
45
- props: {
46
- type: architecturePageTypes[index],
47
- ...item,
48
- // Not everything needs the body of the page itself.
49
- body: undefined,
50
- },
42
+ return architecturePageTypes.flatMap((type, index) =>
43
+ redirects[index].map(({ item, redirectPath }) => ({
44
+ params: { type, id: item.data.id, version: item.data.version },
45
+ props: { type, data: { id: item.data.id, name: item.data.name, version: item.data.version }, redirectPath },
51
46
  }))
52
47
  );
53
48
  }
@@ -55,23 +50,16 @@ export class Page extends HybridPage {
55
50
  protected static async fetchData(params: any) {
56
51
  const { type, id, version } = params;
57
52
 
58
- if (!type || !id || !version || !architecturePageTypes.includes(type)) {
53
+ if (!type || !id || !version || !isArchitecturePageType(type)) {
59
54
  return null;
60
55
  }
61
56
 
62
- const items = await loadArchitectureItems(type as PageTypes);
63
-
64
- // Find the specific item by id and version
65
- const item = items.find((i) => i.data.id === id && i.data.version === version);
66
-
67
- if (!item) {
57
+ const match = (await getRedirectPaths(type)).find(({ item }) => item.data.id === id && item.data.version === version);
58
+ if (!match) {
68
59
  return null;
69
60
  }
70
61
 
71
- return {
72
- type,
73
- ...item,
74
- };
62
+ return { type, data: { id, name: match.item.data.name, version }, redirectPath: match.redirectPath };
75
63
  }
76
64
 
77
65
  protected static createNotFoundResponse(): Response {
@@ -1,38 +1,20 @@
1
1
  ---
2
- import DomainGrid from '@components/Grids/DomainGrid';
3
- import SystemGrid from '@components/Grids/SystemGrid';
4
- import MessageGrid from '@components/Grids/MessageGrid';
5
- import { getSpecificationsForService } from '@utils/collections/services';
6
-
7
- import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro';
2
+ // The architecture overview pages were replaced by the Resources pages; this route only
3
+ // redirects so old links keep working.
4
+ import LatestVersionRedirect from '@components/LatestVersionRedirect.astro';
5
+ import { isSSR } from '@utils/feature';
6
+ import { buildUrl } from '@utils/url-builder';
8
7
  import { Page } from './_index.data';
9
8
 
10
9
  export const prerender = Page.prerender;
11
10
  export const getStaticPaths = Page.getStaticPaths;
12
11
 
13
- // Get data
14
- const props = await Page.getData(Astro);
15
- let domain = props;
16
-
17
- const pageTitle = `${props.type} | ${props.data.name}`.replace(/^\w/, (c) => c.toUpperCase());
18
-
19
- const type = props.type;
12
+ const { data, redirectPath } = await Page.getData(Astro);
13
+ const redirectUrl = buildUrl(redirectPath);
20
14
 
21
- const specifications = type === 'services' ? getSpecificationsForService(props) : [];
15
+ if (isSSR()) {
16
+ return Astro.redirect(`${redirectUrl}${Astro.url.search}${Astro.url.hash}`, 301);
17
+ }
22
18
  ---
23
19
 
24
- <VerticalSideBarLayout title={pageTitle}>
25
- <main class="flex docs-layout min-h-full bg-[rgb(var(--ec-page-bg))]">
26
- <div class="flex docs-layout w-full">
27
- <div class="w-full min-w-0 lg:mr-2 xl:pr-12 py-8 bg-[rgb(var(--ec-page-bg))]">
28
- {type === 'domains' && <DomainGrid domain={domain} client:load />}
29
- {type === 'systems' && <SystemGrid system={props} client:load />}
30
- {
31
- (type === 'agents' || type === 'services') && (
32
- <MessageGrid service={props} specifications={specifications} client:load />
33
- )
34
- }
35
- </div>
36
- </div>
37
- </main>
38
- </VerticalSideBarLayout>
20
+ <LatestVersionRedirect title={`${data.name} | Resources`} redirectUrl={redirectUrl} />
@@ -0,0 +1,79 @@
1
+ ---
2
+ import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro';
3
+ import { buildUrl } from '@utils/url-builder';
4
+ import { isVisualiserEnabled } from '@utils/feature';
5
+ import {
6
+ ResourcesTable,
7
+ type ResourceItem,
8
+ } from '@components/Tables/Resources/ResourcesTable';
9
+ import { getCollection } from 'astro:content';
10
+ import {
11
+ getDocsPath,
12
+ getFavorite,
13
+ getResourceGroups,
14
+ getResourceRelationship,
15
+ getSchemaPath,
16
+ getVisualiserPath,
17
+ type ResourceOwner,
18
+ type ResourceOwnerCollection,
19
+ } from '@utils/collections/resources';
20
+
21
+ // Shared by the generic Resources route and the services route.
22
+ interface Props {
23
+ resourceOwner: ResourceOwner;
24
+ }
25
+
26
+ const { resourceOwner } = Astro.props;
27
+ const { data } = resourceOwner;
28
+ const ownerCollection = resourceOwner.collection as ResourceOwnerCollection;
29
+
30
+ // Only show resources directly attached to the owner. Domain child resources are not
31
+ // flattened, so services inside a child system remain on that system's page.
32
+ const resourceGroups = getResourceGroups(data, ownerCollection, resourceOwner);
33
+
34
+ const withBaseUrl = (path: string | undefined) => (path ? buildUrl(path) : undefined);
35
+
36
+ // Messages with an entry in the schemas collection have a schema page.
37
+ const schemaKeys = new Set(
38
+ (await getCollection('schemas')).map(
39
+ ({ data: schema }) => `${schema.message.collectionName}:${schema.message.id}:${schema.message.version}`
40
+ )
41
+ );
42
+
43
+ const resources: ResourceItem[] = resourceGroups.flatMap(({ collection, items }) =>
44
+ items.map((item) => ({
45
+ collection,
46
+ id: item.data.id,
47
+ name: item.data.name || item.data.id,
48
+ version: item.data.version,
49
+ summary: item.data.summary,
50
+ icon: item.data?.styles?.icon,
51
+ href: buildUrl(getDocsPath(collection, item)),
52
+ visualiserHref: withBaseUrl(getVisualiserPath(collection, item, isVisualiserEnabled())),
53
+ schemaHref: withBaseUrl(getSchemaPath(collection, item, schemaKeys)),
54
+ favorite: getFavorite(collection, item),
55
+ relationship: getResourceRelationship(data, ownerCollection, collection, item, resourceOwner),
56
+ }))
57
+ );
58
+ ---
59
+
60
+ <VerticalSideBarLayout title={`${data.name} | Resources`}>
61
+ <style is:global>
62
+ :root[data-embedded='true'] #resources-page {
63
+ height: 100dvh !important;
64
+ }
65
+ </style>
66
+ {/* Like the discover pages: pull the table out of the content gutter and fill the viewport so it can paginate. */}
67
+ <main
68
+ id="resources-page"
69
+ class="flex h-full min-h-0 flex-col overflow-hidden bg-[rgb(var(--ec-page-bg))]"
70
+ style="margin-left: calc(var(--ec-app-content-padding-left, 5rem) * -1); margin-right: calc(var(--ec-app-content-padding-right, 5rem) * -1); height: calc(100dvh - 60px);"
71
+ >
72
+ <ResourcesTable
73
+ resources={resources}
74
+ title={`${data.name} Resources`}
75
+ description={`${resources.length} ${resources.length === 1 ? 'resource' : 'resources'} mapped to ${data.name}.`}
76
+ client:only="react"
77
+ />
78
+ </main>
79
+ </VerticalSideBarLayout>
@@ -1,28 +1,46 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { loadResourceOwner } from './_index.data';
2
+ import { Page, ServicePage } from './_index.data';
3
3
 
4
- const collectionMocks = vi.hoisted(() => ({
5
- getDomains: vi.fn(() => Promise.resolve([])),
6
- getSystems: vi.fn(() => Promise.resolve([])),
4
+ const mocks = vi.hoisted(() => ({
5
+ getOwnersWithResources: vi.fn(),
6
+ findOwnerWithResources: vi.fn(),
7
7
  }));
8
8
 
9
- vi.mock('@utils/collections/domains', () => ({ getDomains: collectionMocks.getDomains }));
10
- vi.mock('@utils/collections/systems', () => ({ getSystems: collectionMocks.getSystems }));
9
+ vi.mock('@utils/feature', () => ({ isSSR: () => false }));
10
+ vi.mock('@utils/collections/resource-owners', () => ({
11
+ RESOURCE_OWNER_COLLECTIONS: ['domains', 'systems', 'services', 'flows', 'channels', 'events', 'commands', 'queries'],
12
+ createResourcesCatalog: () => ({}),
13
+ getOwnersWithResources: mocks.getOwnersWithResources,
14
+ findOwnerWithResources: mocks.findOwnerWithResources,
15
+ }));
16
+
17
+ const owner = (collection: string, id: string) => ({ collection, data: { id, version: '1.0.0' } });
11
18
 
12
- describe('Resources page owner loader', () => {
19
+ describe('Resources routes', () => {
13
20
  beforeEach(() => {
14
- vi.clearAllMocks();
21
+ mocks.getOwnersWithResources.mockReset().mockImplementation(async (type: string) => [owner(type, `${type}-owner`)]);
22
+ mocks.findOwnerWithResources.mockReset();
15
23
  });
16
24
 
17
- it('loads domains without flattening child-subdomain services and agents', async () => {
18
- await loadResourceOwner('domains');
25
+ // /docs/services/[id]/[docType]/[docId] would otherwise catch /docs/services/{id}/{version}/resources,
26
+ // so services get their own route and the generic route leaves them out.
27
+ it('gives every owner type but services a path on the generic route', async () => {
28
+ const paths = await Page.getStaticPaths();
19
29
 
20
- expect(collectionMocks.getDomains).toHaveBeenCalledWith({ includeServicesInSubdomains: false });
30
+ expect(paths.map(({ params }) => params.type)).toEqual(['domains', 'systems', 'flows', 'channels', 'events', 'commands', 'queries']);
31
+ expect(paths[0]).toEqual({ params: { type: 'domains', id: 'domains-owner', version: '1.0.0' }, props: owner('domains', 'domains-owner') });
21
32
  });
22
33
 
23
- it('keeps the system loader unchanged', async () => {
24
- await loadResourceOwner('systems');
34
+ it('gives services their own route without a type param', async () => {
35
+ const paths = await ServicePage.getStaticPaths();
36
+
37
+ expect(paths).toEqual([{ params: { id: 'services-owner', version: '1.0.0' }, props: owner('services', 'services-owner') }]);
38
+ });
25
39
 
26
- expect(collectionMocks.getSystems).toHaveBeenCalledWith();
40
+ it('returns not found for types without a Resources page on a server request', async () => {
41
+ await expect(Page.getData({ params: { type: 'entities', id: 'Order', version: '1.0.0' }, props: {} } as any)).rejects.toMatchObject({
42
+ status: 404,
43
+ });
44
+ expect(mocks.findOwnerWithResources).not.toHaveBeenCalled();
27
45
  });
28
46
  });
@@ -1,74 +1,79 @@
1
1
  import { isSSR } from '@utils/feature';
2
2
  import { HybridPage } from '@utils/page-loaders/hybrid-page';
3
- import { hasResources } from './_resources';
3
+ import type { ResourceOwner, ResourceOwnerCollection } from '@utils/collections/resources';
4
+ import {
5
+ createResourcesCatalog,
6
+ findOwnerWithResources,
7
+ getOwnersWithResources,
8
+ RESOURCE_OWNER_COLLECTIONS,
9
+ } from '@utils/collections/resource-owners';
4
10
 
5
- // The Resources page lists the resources directly attached to a single system or
6
- // domain. It is only available for systems and domains — every other resource type 404s.
7
- const SUPPORTED_TYPES = ['systems', 'domains'] as const;
8
- type SupportedType = (typeof SUPPORTED_TYPES)[number];
11
+ // The Resources page lists the resources attached to a single system, domain, service, flow,
12
+ // channel or message. Every other resource type 404s.
13
+ //
14
+ // Services have their own route (pages/docs/services/[id]/[version]/resources), because the
15
+ // version-less alias /docs/services/[id]/[docType]/[docId] outranks this generic route and
16
+ // would otherwise catch /docs/services/{id}/{version}/resources.
17
+ const GENERIC_ROUTE_TYPES = RESOURCE_OWNER_COLLECTIONS.filter((type) => type !== 'services');
9
18
 
10
- const isSupportedType = (type: string): type is SupportedType => SUPPORTED_TYPES.includes(type as SupportedType);
19
+ type RouteParams = { type?: string; id?: string; version?: string };
11
20
 
12
- export const loadResourceOwner = async (type: SupportedType) => {
13
- if (type === 'systems') {
14
- const { getSystems } = await import('@utils/collections/systems');
15
- return getSystems();
16
- }
17
- const { getDomains } = await import('@utils/collections/domains');
18
- return getDomains({ includeServicesInSubdomains: false });
19
- };
21
+ const createResourcesPage = ({
22
+ types,
23
+ toParams,
24
+ typeOf,
25
+ }: {
26
+ types: ResourceOwnerCollection[];
27
+ toParams: (type: ResourceOwnerCollection, owner: ResourceOwner) => RouteParams;
28
+ typeOf: (params: RouteParams) => string | undefined;
29
+ }) =>
30
+ class extends HybridPage {
31
+ static get prerender(): boolean {
32
+ return !isSSR();
33
+ }
20
34
 
21
- export class Page extends HybridPage {
22
- static get prerender(): boolean {
23
- return !isSSR();
24
- }
35
+ static async getStaticPaths(): Promise<Array<{ params: RouteParams; props: ResourceOwner }>> {
36
+ if (isSSR()) {
37
+ return [];
38
+ }
25
39
 
26
- static async getStaticPaths(): Promise<Array<{ params: any; props: any }>> {
27
- if (isSSR()) {
28
- return [];
40
+ const catalog = createResourcesCatalog();
41
+ const paths: Array<{ params: RouteParams; props: ResourceOwner }> = [];
42
+ for (const type of types) {
43
+ for (const owner of await getOwnersWithResources(type, catalog)) {
44
+ paths.push({ params: toParams(type, owner), props: owner });
45
+ }
46
+ }
47
+ return paths;
29
48
  }
30
49
 
31
- const owners = await Promise.all(SUPPORTED_TYPES.map((type) => loadResourceOwner(type)));
32
-
33
- return SUPPORTED_TYPES.flatMap((type, index) =>
34
- owners[index]
35
- .filter((owner) => hasResources(owner, type))
36
- .map((owner) => ({
37
- params: {
38
- type,
39
- id: owner.data.id,
40
- version: owner.data.version,
41
- },
42
- props: {
43
- ...owner,
44
- },
45
- }))
46
- );
47
- }
50
+ protected static async fetchData(params: RouteParams) {
51
+ const type = typeOf(params);
52
+ const { id, version } = params;
48
53
 
49
- protected static async fetchData(params: any) {
50
- const { type, id, version } = params;
54
+ if (!type || !id || !version || !types.includes(type as ResourceOwnerCollection)) {
55
+ return null;
56
+ }
51
57
 
52
- if (!type || !id || !version || !isSupportedType(type)) {
53
- return null;
58
+ return findOwnerWithResources(type as ResourceOwnerCollection, id, version);
54
59
  }
55
60
 
56
- const owners = await loadResourceOwner(type);
57
- const owner = owners.find((o) => o.data.id === id && o.data.version === version);
58
-
59
- if (!owner || !hasResources(owner, type)) {
60
- return null;
61
+ protected static createNotFoundResponse(): Response {
62
+ return new Response(null, {
63
+ status: 404,
64
+ statusText: 'Resources not found',
65
+ });
61
66
  }
67
+ };
62
68
 
63
- return {
64
- ...owner,
65
- };
66
- }
69
+ export const Page = createResourcesPage({
70
+ types: GENERIC_ROUTE_TYPES,
71
+ toParams: (type, owner) => ({ type, id: owner.data.id, version: owner.data.version }),
72
+ typeOf: (params) => params.type,
73
+ });
67
74
 
68
- protected static createNotFoundResponse(): Response {
69
- return new Response(null, {
70
- status: 404,
71
- statusText: 'Resources not found',
72
- });
73
- }
74
- }
75
+ export const ServicePage = createResourcesPage({
76
+ types: ['services'],
77
+ toParams: (_type, owner) => ({ id: owner.data.id, version: owner.data.version }),
78
+ typeOf: () => 'services',
79
+ });