Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 1 addition & 7 deletions tsworkspace/webapps/studio/src/components/NamespacesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,7 @@ import { Button } from '@/components/ui/button';
import { EmptyState } from '@/components/ui/empty-state';
import { Input } from '@/components/ui/input';
import { api, type NamespaceRegistryRow } from '@/lib/api';

/** The owner a namespace gets when none is named. Matches the server. */
const DEFAULT_OWNER = 'default';

function ownerLabel(row: NamespaceRegistryRow): string {
return row.parent === '' ? DEFAULT_OWNER : row.parent;
}
import { ownerLabel } from '@/lib/namespace-owner';

export function NamespacesPage() {
const [rows, setRows] = useState<NamespaceRegistryRow[] | null>(null);
Expand Down
46 changes: 46 additions & 0 deletions tsworkspace/webapps/studio/src/components/OverviewPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ vi.mock('@/lib/api', () => ({
api: {
overview: vi.fn(),
search: vi.fn(),
namespaceRegistry: vi.fn(),
},
}));

Expand Down Expand Up @@ -71,6 +72,7 @@ describe('OverviewPage', () => {
window.history.replaceState(null, '', '/');
(api.overview as ReturnType<typeof vi.fn>).mockResolvedValue(EMPTY_OVERVIEW);
(api.search as ReturnType<typeof vi.fn>).mockResolvedValue({ results: [] });
(api.namespaceRegistry as ReturnType<typeof vi.fn>).mockResolvedValue({ namespaces: [] });
});

afterEach(() => {
Expand Down Expand Up @@ -348,6 +350,50 @@ describe('OverviewPage', () => {
});
});

describe('models grouped by owner', () => {
const em = (namespace: string, slug: string) => ({
eventModel: { id: { namespace, slug, version: 1 }, title: slug },
});
const reg = (name: string, parent: string) => ({ id: name, name, parent, entityCount: 1, registered: true });

beforeEach(() => {
(api.overview as ReturnType<typeof vi.fn>).mockResolvedValue({
entities: [em('acme-orders', 'checkout'), em('acme-probe', 'probe'), em('other-ns', 'misc')],
});
});

it('puts namespaces that share a registry parent under one owner section', async () => {
(api.namespaceRegistry as ReturnType<typeof vi.fn>).mockResolvedValue({
namespaces: [reg('acme-orders', 'acme'), reg('acme-probe', 'acme'), reg('other-ns', '')],
});
const { container } = render(<OverviewPage />);
await act(async () => {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
});
const sections = [...container.querySelectorAll('details')];
expect(sections.map((d) => d.querySelector('summary')?.firstChild?.textContent)).toEqual(['acme', 'default']);
expect([...sections[0].querySelectorAll('h3')].map((h) => h.firstChild?.textContent)).toEqual([
'acme-orders',
'acme-probe',
]);
expect(sections[1].textContent).toContain('other-ns');
});

it('still lists every namespace when the registry cannot be read', async () => {
(api.namespaceRegistry as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('forbidden'));
const { container } = render(<OverviewPage />);
await act(async () => {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
});
expect(container.querySelectorAll('details')).toHaveLength(0);
expect([...container.querySelectorAll('h3')].map((h) => h.firstChild?.textContent)).toEqual([
'acme-orders',
'acme-probe',
'other-ns',
]);
});
});

it('surfaces a domain charter when a domain chart node is selected', async () => {
(api.overview as ReturnType<typeof vi.fn>).mockResolvedValue({
entities: [
Expand Down
143 changes: 88 additions & 55 deletions tsworkspace/webapps/studio/src/components/OverviewPage.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Overview, the studio's landing surface (Decision: the studio is navigated,
// not filtered). One view at a time:
// - Models: the EventModel index, grouped by namespace, each card linking
// to /em/... This is the landing view, because picking a model is what
// most visits are for.
// - Models: the EventModel index, grouped by namespace owner and then by
// namespace, each card linking to /em/... This is the landing view,
// because picking a model is what most visits are for.
// - Domain chart: the problem-space knowledge graph (lightweight).
// - Contexts: the context map.
// Renders without loading the whole store: only EventModel entities and
Expand All @@ -17,7 +17,7 @@ import { ErrorBoundary } from '@/components/ErrorBoundary';
import { buttonVariants } from '@/components/ui/button';
import { EmptyState } from '@/components/ui/empty-state';
import { Tabs, TabsList, TabsTab } from '@/components/ui/tabs';
import { api } from '@/lib/api';
import { api, type NamespaceRegistryRow } from '@/lib/api';
import { readBranchFromUrl, withBranchQuery } from '@/lib/branch';
import {
asId,
Expand All @@ -29,6 +29,7 @@ import {
realizesOf,
wireKind,
} from '@/lib/model';
import { groupByOwner } from '@/lib/namespace-owner';
import { cn } from '@/lib/utils';

const STORAGE_KEY = 'overview:filter:domains';
Expand Down Expand Up @@ -125,6 +126,7 @@ function persistMode(mode: OverviewMode) {
export function OverviewPage() {
const [chart, setChart] = useState<Model>();
const [eventModels, setEventModels] = useState<Entity[]>([]);
const [registry, setRegistry] = useState<NamespaceRegistryRow[]>();
const [overviewError, setOverviewError] = useState<string>();
const [searchError, setSearchError] = useState<string>();
const [searchQuery, setSearchQuery] = useState('');
Expand Down Expand Up @@ -182,6 +184,19 @@ export function OverviewPage() {
// oxlint-disable-next-line react-hooks/exhaustive-deps -- intentionally mount-only, see above
}, []);

// Owners only shape the grouping, so a failed registry read keeps the
// page usable with plain namespace groups.
useEffect(() => {
const ctrl = new AbortController();
api
.namespaceRegistry({ signal: ctrl.signal })
.then((res) => {
if (!ctrl.signal.aborted) setRegistry(res.namespaces ?? []);
})
.catch(() => {});
return () => ctrl.abort();
}, []);

useEffect(() => {
const q = searchQuery.trim();
setSearchError(undefined);
Expand Down Expand Up @@ -351,18 +366,17 @@ export function OverviewPage() {
}, [chart, selectedDomains]);

// Memoize so `mode` / `error` / unrelated state toggles don't rerun the
// O(n) group + sort. Only changes to the actual event-model set should
// trigger recompute.
const grouped = useMemo(() => {
const map = new Map<string, Entity[]>();
for (const em of visibleEventModels) {
const list = map.get(em.id.namespace) ?? [];
list.push(em);
map.set(em.id.namespace, list);
}
return map;
}, [visibleEventModels]);
const sortedNamespaces = useMemo(() => [...grouped.keys()].sort(), [grouped]);
// O(n) group + sort. Only changes to the event-model set or the registry
// should trigger recompute.
const ownerGroups = useMemo(
() =>
groupByOwner(
[...visibleEventModels].sort((a, b) => a.title.localeCompare(b.title)),
(em) => em.id.namespace,
registry,
),
[visibleEventModels, registry],
);
const chartFocusEntity = chartFocusKey && visibleChart ? visibleChart.byKey.get(chartFocusKey) : undefined;
const searchOpen = searchQuery.trim().length >= 2;

Expand Down Expand Up @@ -575,53 +589,72 @@ export function OverviewPage() {
{selectedDomains.length > 0 ? ` of ${eventModels.length}` : ''})
</h2>
<div className="flex flex-col gap-6">
{sortedNamespaces.map((ns) => {
const list = (grouped.get(ns) ?? []).sort((a, b) => a.title.localeCompare(b.title));
{ownerGroups.map((group) => {
const count = group.namespaces.reduce((n, g) => n + g.items.length, 0);
const namespaces = group.namespaces.map(({ namespace: ns, items: list }) => {
return (
<div key={ns}>
<h3 className="mb-2 font-mono text-[11px] font-semibold uppercase text-muted-foreground">
{ns}
<span className="ml-2 text-muted-foreground">·</span>
<span className="ml-2 font-sans normal-case">
{list.length} model{list.length === 1 ? '' : 's'}
</span>
</h3>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{list.map((em) => {
const members = Array.isArray(em.raw.members) ? em.raw.members.length : 0;
return (
<a
key={em.key}
href={withBranchQuery(
`/em/${encodeURIComponent(em.id.namespace)}/${encodeURIComponent(em.id.slug)}`,
branch,
)}
className={cn(
'block rounded-lg border border-border bg-card px-3 py-2 text-sm hover:border-indigo-300 hover:bg-indigo-50/40',
)}
>
<div className="truncate font-semibold">{em.title}</div>
<div className="mt-0.5 font-mono text-[10px] text-muted-foreground">
{em.id.namespace}/{em.id.slug}@v{em.id.version}
</div>
<div className="mt-1 text-[10px] text-muted-foreground">
{members} member{members === 1 ? '' : 's'}
{em.doc ? ` · ${em.doc.slice(0, 80)}${em.doc.length > 80 ? '…' : ''}` : ''}
</div>
</a>
);
})}
</div>
</div>
);
});
if (registry === undefined)
return (
<div key="all" className="flex flex-col gap-6">
{namespaces}
</div>
);
return (
<div key={ns}>
<h3 className="mb-2 font-mono text-[11px] font-semibold uppercase text-muted-foreground">
{ns}
<span className="ml-2 text-muted-foreground">·</span>
<span className="ml-2 font-sans normal-case">
{list.length} model{list.length === 1 ? '' : 's'}
<details key={group.owner === null ? 'no-owner' : `owner:${group.owner}`} open>
<summary className="mb-3 cursor-pointer text-xs font-semibold text-foreground">
{group.owner ?? 'no owner'}
<span className="ml-2 font-normal text-muted-foreground">
{`${group.namespaces.length} namespace${group.namespaces.length === 1 ? '' : 's'} · ${count} model${count === 1 ? '' : 's'}`}
</span>
</h3>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{list.map((em) => {
const members = Array.isArray(em.raw.members) ? em.raw.members.length : 0;
return (
<a
key={em.key}
href={withBranchQuery(
`/em/${encodeURIComponent(em.id.namespace)}/${encodeURIComponent(em.id.slug)}`,
branch,
)}
className={cn(
'block rounded-lg border border-border bg-card px-3 py-2 text-sm hover:border-indigo-300 hover:bg-indigo-50/40',
)}
>
<div className="truncate font-semibold">{em.title}</div>
<div className="mt-0.5 font-mono text-[10px] text-muted-foreground">
{em.id.namespace}/{em.id.slug}@v{em.id.version}
</div>
<div className="mt-1 text-[10px] text-muted-foreground">
{members} member{members === 1 ? '' : 's'}
{em.doc ? ` · ${em.doc.slice(0, 80)}${em.doc.length > 80 ? '…' : ''}` : ''}
</div>
</a>
);
})}
</div>
</div>
</summary>
<div className="flex flex-col gap-6 pl-3">{namespaces}</div>
</details>
);
})}
</div>
{chart && sortedNamespaces.length === 0 ? (
{chart && ownerGroups.length === 0 ? (
<div className="flex flex-1 items-center justify-center">
{eventModels.length === 0 ? (
<EmptyState icon={Boxes} title="No event models yet">
Event models show up here grouped by namespace as soon as the store has one. Each card opens its
board.
Event models show up here grouped by owner and namespace as soon as the store has one. Each card opens
its board.
</EmptyState>
) : (
<EmptyState
Expand Down
107 changes: 107 additions & 0 deletions tsworkspace/webapps/studio/src/lib/namespace-owner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest';
import type { NamespaceRegistryRow } from './api';
import { DEFAULT_OWNER, groupByOwner, ownerLabel } from './namespace-owner';

function row(overrides: Partial<NamespaceRegistryRow> & { name: string }): NamespaceRegistryRow {
return { id: overrides.name, parent: '', entityCount: 1, registered: true, ...overrides };
}

const model = (namespace: string, slug: string) => ({ namespace, slug });
const shape = (groups: ReturnType<typeof groupByOwner<{ namespace: string; slug: string }>>) =>
groups.map((g) => ({
owner: g.owner,
namespaces: g.namespaces.map((n) => [n.namespace, n.items.map((m) => m.slug)]),
}));

describe('ownerLabel', () => {
it('names the server default when a row has no parent', () => {
expect(ownerLabel(row({ name: 'acme-orders' }))).toBe(DEFAULT_OWNER);
expect(ownerLabel(row({ name: 'acme-orders', parent: 'acme' }))).toBe('acme');
});
});

describe('groupByOwner', () => {
const registry = [
row({ name: 'acme-orders', parent: 'acme' }),
row({ name: 'acme-probe', parent: 'acme' }),
row({ name: 'other-ns' }),
];

it('puts namespaces that share a parent under one owner', () => {
const items = [
model('other-ns', 'x'),
model('acme-probe', 'p'),
model('acme-orders', 'b'),
model('acme-orders', 'a'),
];
expect(shape(groupByOwner(items, (m) => m.namespace, registry))).toEqual([
{
owner: 'acme',
namespaces: [
['acme-orders', ['b', 'a']],
['acme-probe', ['p']],
],
},
{ owner: DEFAULT_OWNER, namespaces: [['other-ns', ['x']]] },
]);
});

it('matches a minted id, since entity keys carry the id and not the name', () => {
const minted = [row({ id: 'ns_01', name: 'acme-billing', parent: 'acme' })];
expect(shape(groupByOwner([model('ns_01', 'a')], (m) => m.namespace, minted))).toEqual([
{ owner: 'acme', namespaces: [['ns_01', ['a']]] },
]);
});

it('lists namespaces without an owner last, in a group no owner name can claim', () => {
const rows = [...registry, row({ id: '', name: 'legacy', registered: false })];
const items = [model('legacy', 'l'), model('missing', 'm'), model('acme-orders', 'a')];
expect(shape(groupByOwner(items, (m) => m.namespace, rows))).toEqual([
{ owner: 'acme', namespaces: [['acme-orders', ['a']]] },
{
owner: null,
namespaces: [
['legacy', ['l']],
['missing', ['m']],
],
},
]);
});

it('leaves an unregistered name ownerless when a minted row shares that name', () => {
const rows = [
row({ id: 'ns_01', name: 'acme-billing', parent: 'acme' }),
row({ id: '', name: 'acme-billing', registered: false }),
];
const items = [model('acme-billing', 'u'), model('ns_01', 'm')];
expect(shape(groupByOwner(items, (m) => m.namespace, rows))).toEqual([
{ owner: 'acme', namespaces: [['ns_01', ['m']]] },
{ owner: null, namespaces: [['acme-billing', ['u']]] },
]);
});

it('keeps an owner literally named unregistered apart from the no-owner group', () => {
const rows = [
row({ name: 'acme-orders', parent: 'unregistered' }),
row({ id: '', name: 'legacy', registered: false }),
];
const items = [model('legacy', 'l'), model('acme-orders', 'a')];
expect(shape(groupByOwner(items, (m) => m.namespace, rows))).toEqual([
{ owner: 'unregistered', namespaces: [['acme-orders', ['a']]] },
{ owner: null, namespaces: [['legacy', ['l']]] },
]);
});

it('keeps one ownerless group when the registry could not be read', () => {
const items = [model('b-ns', 'b'), model('a-ns', 'a')];
expect(shape(groupByOwner(items, (m) => m.namespace, undefined))).toEqual([
{
owner: null,
namespaces: [
['a-ns', ['a']],
['b-ns', ['b']],
],
},
]);
});
});
Loading
Loading