@crvouga/atlas 1.13.0 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -1
- package/dist/playwright/src/index.d.ts +4 -0
- package/dist/playwright/src/index.js +4 -0
- package/dist/playwright/src/ui-catalog.d.ts +51 -0
- package/dist/playwright/src/ui-catalog.js +44 -0
- package/dist/reports/src/evidence-types.d.ts +3 -0
- package/dist/reports/src/layer-merge.d.ts +5 -1
- package/dist/reports/src/layer-merge.js +9 -2
- package/dist/reports/src/report-index.js +9 -5
- package/dist/reports/src/ui-catalog-publish.d.ts +92 -0
- package/dist/reports/src/ui-catalog-publish.js +68 -0
- package/dist/reports/src/ui-catalog.d.ts +229 -0
- package/dist/reports/src/ui-catalog.js +273 -0
- package/dist/reports/src/ui-state.d.ts +70 -0
- package/dist/reports/src/ui-state.js +66 -0
- package/dist/schema/src/api.d.ts +350 -0
- package/dist/schema/src/api.js +71 -0
- package/dist/server/src/app.js +15 -0
- package/dist/server/src/openapi.js +22 -2
- package/docs/reference/http-api.md +46 -0
- package/docs/reference/playwright.md +63 -0
- package/docs/reference/reports.md +588 -1
- package/docs/reference/schema.md +33 -0
- package/docs/report-contract.md +2 -2
- package/docs/rich-evidence.md +5 -0
- package/docs/server.md +2 -0
- package/docs/ui-catalog.md +173 -0
- package/docs/ui.md +15 -0
- package/llms-full.txt +246 -2
- package/llms.txt +1 -0
- package/package.json +2 -1
- package/packages/schema/schemas/effective-delta.schema.json +73 -0
- package/packages/schema/schemas/effective-evidence.schema.json +73 -0
- package/packages/schema/schemas/layer-rows.schema.json +73 -0
- package/packages/server/openapi.json +1441 -79
- package/packages/visualizer/src/app/AppShell.tsx +36 -30
- package/packages/visualizer/src/app/router.tsx +10 -1
- package/packages/visualizer/src/catalog/UiCatalogFilters.tsx +65 -0
- package/packages/visualizer/src/catalog/UiCatalogPage.tsx +342 -0
- package/packages/visualizer/src/catalog/provider.tsx +25 -0
- package/packages/visualizer/src/catalog/search.ts +68 -0
- package/packages/visualizer/src/catalog/words.ts +26 -0
- package/packages/visualizer/src/components/Header.tsx +17 -4
|
@@ -2,6 +2,8 @@ import { isStatechartFile } from '@crvouga/atlas/schema';
|
|
|
2
2
|
import { Alert, Box, Burger, Button, Container, Group, AppShell as MantineAppShell, ScrollArea, Stack, Text } from '@mantine/core';
|
|
3
3
|
import { Outlet, useLocation, useSearch } from '@tanstack/react-router';
|
|
4
4
|
import { lazy, Suspense, useEffect, useState } from 'react';
|
|
5
|
+
import { UiCatalogFilters } from '../catalog/UiCatalogFilters';
|
|
6
|
+
import { UiCatalogProvider } from '../catalog/provider';
|
|
5
7
|
import { Header } from '../components/Header';
|
|
6
8
|
import { EvidenceProvider, useEvidence } from '../evidence/evidence-context';
|
|
7
9
|
import { SourcesPanel } from '../evidence/sources-panel';
|
|
@@ -20,7 +22,9 @@ function Index() {
|
|
|
20
22
|
const evidence = useEvidence();
|
|
21
23
|
const { pathname } = useLocation();
|
|
22
24
|
const kind = kindOfAddress(pathname);
|
|
23
|
-
return
|
|
25
|
+
return pathname === '/catalog' ? (
|
|
26
|
+
<UiCatalogFilters />
|
|
27
|
+
) : kind ? (
|
|
24
28
|
<SpecIndex kind={kind} />
|
|
25
29
|
) : pathname.startsWith('/specs/') ? (
|
|
26
30
|
<Box component="nav" aria-label="Specs" />
|
|
@@ -35,7 +39,7 @@ function Body() {
|
|
|
35
39
|
const { view, spec } = useAtlas();
|
|
36
40
|
const { pathname } = useLocation();
|
|
37
41
|
if (pathname === '/' && evidence.ready && !evidence.filesPending && !evidence.files.some(isStatechartFile)) return <Explorer />;
|
|
38
|
-
if (!pathname.startsWith('/specs/') && !view && spec.error)
|
|
42
|
+
if (!pathname.startsWith('/specs/') && pathname !== '/catalog' && !view && spec.error)
|
|
39
43
|
return (
|
|
40
44
|
<Container py="xl">
|
|
41
45
|
<Alert color="red" title="The spec couldn’t be loaded">
|
|
@@ -53,34 +57,36 @@ function Layout() {
|
|
|
53
57
|
const location = useLocation();
|
|
54
58
|
useEffect(() => setOpened(false), [location.href]);
|
|
55
59
|
return (
|
|
56
|
-
<
|
|
57
|
-
<MantineAppShell
|
|
58
|
-
<
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
<
|
|
73
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
<
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
60
|
+
<UiCatalogProvider enabled={location.pathname === '/catalog'}>
|
|
61
|
+
<MantineAppShell header={{ height: 64 }} navbar={{ width: 288, breakpoint: 'md', collapsed: { mobile: !opened } }} padding={0}>
|
|
62
|
+
<MantineAppShell.Header>
|
|
63
|
+
<Group h="100%" px="md" gap="sm" wrap="nowrap">
|
|
64
|
+
<Burger
|
|
65
|
+
aria-expanded={opened}
|
|
66
|
+
aria-controls="atlas-navigation"
|
|
67
|
+
opened={opened}
|
|
68
|
+
onClick={() => setOpened((v) => !v)}
|
|
69
|
+
hiddenFrom="md"
|
|
70
|
+
size="sm"
|
|
71
|
+
aria-label="Toggle navigation"
|
|
72
|
+
/>
|
|
73
|
+
<Header />
|
|
74
|
+
</Group>
|
|
75
|
+
</MantineAppShell.Header>
|
|
76
|
+
<MantineAppShell.Navbar id="atlas-navigation" component="div">
|
|
77
|
+
<ScrollArea h="100%">
|
|
78
|
+
<Index />
|
|
79
|
+
</ScrollArea>
|
|
80
|
+
</MantineAppShell.Navbar>
|
|
81
|
+
<MantineAppShell.Main>
|
|
82
|
+
<Body />
|
|
83
|
+
</MantineAppShell.Main>
|
|
84
|
+
<SourcesPanel />
|
|
85
|
+
<Suspense fallback={null}>
|
|
86
|
+
<Finder />
|
|
87
|
+
</Suspense>
|
|
88
|
+
</MantineAppShell>
|
|
89
|
+
</UiCatalogProvider>
|
|
84
90
|
);
|
|
85
91
|
}
|
|
86
92
|
export function AppShell() {
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
type RouterHistory
|
|
11
11
|
} from '@tanstack/react-router';
|
|
12
12
|
import { z } from 'zod/v4';
|
|
13
|
+
import { CatalogSearchFields } from '../catalog/search';
|
|
14
|
+
import { UiCatalogPage } from '../catalog/UiCatalogPage';
|
|
13
15
|
import { SpecPage } from '../evidence/spec-page';
|
|
14
16
|
import { ExplorerSearchFields } from '../explorer/search';
|
|
15
17
|
import { ProductPage } from '../views/ProductPage';
|
|
@@ -87,7 +89,14 @@ function routeTree() {
|
|
|
87
89
|
validateSearch: (search) => SpecSearch.parse(search),
|
|
88
90
|
component: SpecPage
|
|
89
91
|
});
|
|
90
|
-
|
|
92
|
+
/** The UI catalog: the captured states of the interface, filtered by the address (see `catalog/search.ts`). */
|
|
93
|
+
const catalogRoute = createRoute({
|
|
94
|
+
getParentRoute: () => rootRoute,
|
|
95
|
+
path: '/catalog',
|
|
96
|
+
validateSearch: (search) => RootSearch.extend(CatalogSearchFields).parse(search),
|
|
97
|
+
component: UiCatalogPage
|
|
98
|
+
});
|
|
99
|
+
return rootRoute.addChildren([productRoute, chartRoute, specRoute, catalogRoute]);
|
|
91
100
|
}
|
|
92
101
|
export function createAtlasRouter(history: RouterHistory) {
|
|
93
102
|
return createRouter({ routeTree: routeTree(), history, defaultPreload: false, scrollRestoration: true });
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module UI catalog filters
|
|
3
|
+
*
|
|
4
|
+
* The controls beside the catalog: words, coverage, profile, area, route, cohort, feature flag, surface and producer. Each offers
|
|
5
|
+
* the values the catalog has with how many states have them. What is chosen is the address, so copying it or reloading restores
|
|
6
|
+
* the same view; choices combine with AND across the controls and OR within one.
|
|
7
|
+
*/
|
|
8
|
+
import { UI_COVERAGE, isUiCatalogFiltered, type UiCatalogQuery, type UiFacetValue } from '@crvouga/atlas/reports/ui-catalog';
|
|
9
|
+
import { joinList } from '@crvouga/atlas/reports/artifact-query';
|
|
10
|
+
import { Button, MultiSelect, Stack, TextInput, Title } from '@mantine/core';
|
|
11
|
+
import { useMemo } from 'react';
|
|
12
|
+
|
|
13
|
+
import { useUiCatalog } from './provider';
|
|
14
|
+
import { useCatalogNavigate, useCatalogSearch } from './search';
|
|
15
|
+
import { coverageWord } from './words';
|
|
16
|
+
|
|
17
|
+
type Field = 'coverage' | 'profile' | 'area' | 'route' | 'cohort' | 'flag' | 'surface' | 'producer';
|
|
18
|
+
|
|
19
|
+
function Facet({ field, label, options, query }: { field: Field; label: string; options: readonly UiFacetValue[]; query: UiCatalogQuery }) {
|
|
20
|
+
const go = useCatalogNavigate();
|
|
21
|
+
const chosen = (query[field] ?? []) as readonly string[];
|
|
22
|
+
const data = useMemo(() => options.map((option) => ({ value: option.value, label: `${option.label ?? (field === 'coverage' ? coverageWord(option.value) : option.value)} (${option.count})` })), [options, field]);
|
|
23
|
+
if (!data.length && !chosen.length) return null;
|
|
24
|
+
return (
|
|
25
|
+
<MultiSelect
|
|
26
|
+
label={label}
|
|
27
|
+
data={data}
|
|
28
|
+
value={[...chosen]}
|
|
29
|
+
searchable
|
|
30
|
+
clearable
|
|
31
|
+
placeholder={chosen.length ? undefined : `Any ${label.toLowerCase()}`}
|
|
32
|
+
nothingFoundMessage="Nothing found"
|
|
33
|
+
onChange={(next) => go({ [field]: joinList(next) })}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The navigation column of the catalog page. */
|
|
39
|
+
export function UiCatalogFilters() {
|
|
40
|
+
const catalog = useUiCatalog();
|
|
41
|
+
const { query } = useCatalogSearch();
|
|
42
|
+
const go = useCatalogNavigate();
|
|
43
|
+
if (!catalog) return null;
|
|
44
|
+
const { facets } = catalog;
|
|
45
|
+
const coverage = UI_COVERAGE.map((value) => facets.coverage.find((facet) => facet.value === value) ?? { value, count: 0 }).filter((facet) => facet.count > 0 || query.coverage?.includes(facet.value as never));
|
|
46
|
+
return (
|
|
47
|
+
<Stack component="nav" aria-label="UI catalog filters" p="md" gap="sm">
|
|
48
|
+
<Title order={2}>Find states</Title>
|
|
49
|
+
<TextInput label="Search states" description="Id, title, route, fixture, cohort, flag or surface" value={query.q ?? ''} onChange={(event) => go({ q: event.currentTarget.value || undefined }, { replace: true })} />
|
|
50
|
+
<Facet field="coverage" label="Coverage" options={coverage} query={query} />
|
|
51
|
+
<Facet field="profile" label="Profile" options={facets.profiles} query={query} />
|
|
52
|
+
<Facet field="area" label="Area" options={facets.areas} query={query} />
|
|
53
|
+
<Facet field="route" label="Route" options={facets.routes} query={query} />
|
|
54
|
+
<Facet field="cohort" label="Cohort" options={facets.cohorts} query={query} />
|
|
55
|
+
<Facet field="flag" label="Feature flag" options={facets.flags} query={query} />
|
|
56
|
+
<Facet field="surface" label="Surface" options={facets.surfaces} query={query} />
|
|
57
|
+
<Facet field="producer" label="Producer" options={facets.producers} query={query} />
|
|
58
|
+
{isUiCatalogFiltered(query) && (
|
|
59
|
+
<Button type="button" variant="subtle" onClick={() => go({ q: undefined, coverage: undefined, profile: undefined, area: undefined, route: undefined, cohort: undefined, flag: undefined, surface: undefined, producer: undefined })}>
|
|
60
|
+
Clear filters
|
|
61
|
+
</Button>
|
|
62
|
+
)}
|
|
63
|
+
</Stack>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module UI catalog page
|
|
3
|
+
*
|
|
4
|
+
* A browsable inventory of the product's user interface: each state captured once per profile (viewport, color scheme, device),
|
|
5
|
+
* with where each picture came from, and what is expected and not captured yet. It reads the report the viewer already holds, so
|
|
6
|
+
* states appear as layers arrive and no reload or rebuilt file is needed. A state is shown as captured only when a passing
|
|
7
|
+
* capture exists; a backend or layer that could not be read is named, and nothing that depended on it is shown as captured.
|
|
8
|
+
* Pictures are loaded lazily, only for the cards on screen.
|
|
9
|
+
*/
|
|
10
|
+
import { IconDownload } from '@tabler/icons-react';
|
|
11
|
+
import type { UiCapture, UiCatalog, UiCatalogState, UiCell, UiImage } from '@crvouga/atlas/reports/ui-catalog';
|
|
12
|
+
import { UI_COVERAGE, filterUiCatalog, isUiCatalogFiltered } from '@crvouga/atlas/reports/ui-catalog';
|
|
13
|
+
import { Accordion, Alert, Anchor, Badge, Button, Card, Code, Container, Drawer, Group, Image, SimpleGrid, Stack, Table, Text, Title } from '@mantine/core';
|
|
14
|
+
import { Link } from '@tanstack/react-router';
|
|
15
|
+
import { useMemo, useState } from 'react';
|
|
16
|
+
|
|
17
|
+
import { reportAssetUrl } from '../evidence/api';
|
|
18
|
+
import { useEvidence } from '../evidence/evidence-context';
|
|
19
|
+
import { ago, exactTime, plural, safeUri, shortSha, useNow } from '../evidence/format';
|
|
20
|
+
import { EvidenceIncomplete, EvidenceWaiting } from '../evidence/load-state';
|
|
21
|
+
import { StatusMark } from '../evidence/marks';
|
|
22
|
+
import { useUiCatalog } from './provider';
|
|
23
|
+
import { useCatalogNavigate, useCatalogSearch } from './search';
|
|
24
|
+
import { coverageStatus, coverageWord } from './words';
|
|
25
|
+
|
|
26
|
+
/** How many state cards are drawn before "Show more". */
|
|
27
|
+
const CARDS = 48;
|
|
28
|
+
|
|
29
|
+
/** Where a browser loads a picture from: its stored file in the layer, else its safe web address. */
|
|
30
|
+
export function imageSource(image: UiImage | null | undefined): string | null {
|
|
31
|
+
if (!image) return null;
|
|
32
|
+
return image.path ? reportAssetUrl(image.path) : safeUri(image.uri);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function Picture({ cell, title, height }: { cell: UiCell; title: string; height?: number }) {
|
|
36
|
+
const image = cell.capture?.image;
|
|
37
|
+
// A failed capture may carry a picture of what the page showed, for diagnosis; nothing else has one worth showing.
|
|
38
|
+
const source = cell.coverage === 'captured' || cell.coverage === 'failed' ? imageSource(image) : null;
|
|
39
|
+
if (source)
|
|
40
|
+
return <Image src={source} alt={`${title}, ${cell.profile.label}`} loading="lazy" fit="contain" {...(height ? { h: height } : {})} {...(image?.width ? { w: 'auto', maw: '100%' } : {})} />;
|
|
41
|
+
return (
|
|
42
|
+
<Text c="dimmed" fz="sm" role="img" aria-label={`${title}, ${cell.profile.label}: no picture`}>
|
|
43
|
+
{cell.coverage === 'captured' ? (image?.omitted ? `Picture left out (${image.omitted})` : 'Captured without a picture') : coverageWord(cell.coverage)}
|
|
44
|
+
</Text>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function CoverageBadge({ cell }: { cell: UiCell }) {
|
|
49
|
+
return (
|
|
50
|
+
<Badge variant="light" color={cell.coverage === 'captured' ? 'green' : cell.coverage === 'failed' ? 'red' : cell.coverage === 'capturing' ? 'blue' : 'gray'} leftSection={<StatusMark status={coverageStatus(cell.coverage)} size={12} />}>
|
|
51
|
+
{cell.profile.label}: {coverageWord(cell.coverage)}
|
|
52
|
+
</Badge>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function StateCard({ state, shown }: { state: UiCatalogState; shown: UiCell }) {
|
|
57
|
+
return (
|
|
58
|
+
<Card withBorder role="listitem" data-state={state.id}>
|
|
59
|
+
<Card.Section p="sm">
|
|
60
|
+
<Picture cell={shown} title={state.title} height={160} />
|
|
61
|
+
</Card.Section>
|
|
62
|
+
<Stack gap="xs" mt="sm">
|
|
63
|
+
<Anchor
|
|
64
|
+
fw={700}
|
|
65
|
+
aria-label={`Open ${state.title}`}
|
|
66
|
+
renderRoot={(props) => <Link {...props} to="/catalog" search={((prev: Record<string, unknown>) => ({ ...prev, state: state.id })) as never} resetScroll={false} />}
|
|
67
|
+
>
|
|
68
|
+
{state.title}
|
|
69
|
+
</Anchor>
|
|
70
|
+
<Text c="dimmed" fz="xs">
|
|
71
|
+
{state.id}
|
|
72
|
+
{state.route ? ` · ${state.route}` : ''}
|
|
73
|
+
</Text>
|
|
74
|
+
<Group gap="xs" aria-label={`${state.title} by profile`}>
|
|
75
|
+
{state.cells.map((cell) => (
|
|
76
|
+
<CoverageBadge key={cell.profile.key} cell={cell} />
|
|
77
|
+
))}
|
|
78
|
+
</Group>
|
|
79
|
+
</Stack>
|
|
80
|
+
</Card>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function Origin({ capture, now }: { capture: UiCapture; now: number }) {
|
|
85
|
+
const { origin } = capture;
|
|
86
|
+
const sha = shortSha(origin.sha);
|
|
87
|
+
return (
|
|
88
|
+
<Text c="dimmed" fz="xs" component="div">
|
|
89
|
+
From layer <Code>{origin.layer.slice(0, 12)}</Code> in backend <Code>{origin.backend}</Code>, producer {origin.producer}
|
|
90
|
+
{capture.labels.shard ? `, shard ${capture.labels.shard}` : ''}
|
|
91
|
+
{origin.branch ? `, ${origin.branch}` : ''}
|
|
92
|
+
{sha ? <> at <Code>{sha}</Code></> : ''}
|
|
93
|
+
{', '}
|
|
94
|
+
<time dateTime={new Date(origin.recordedAtMs).toISOString()} title={exactTime(origin.recordedAtMs)}>
|
|
95
|
+
{ago(origin.recordedAtMs, now)}
|
|
96
|
+
</time>
|
|
97
|
+
{origin.freshness === 'modified' ? ', recorded with local changes' : origin.freshness === 'unknown' ? ', origin not captured' : ''}
|
|
98
|
+
{capture.alternatives > 0 ? `, ${plural(capture.alternatives, 'other capture', 'other captures')} from other producers` : ''}
|
|
99
|
+
</Text>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function CellColumn({ cell, state, now }: { cell: UiCell; state: UiCatalogState; now: number }) {
|
|
104
|
+
const source = cell.coverage === 'captured' || cell.coverage === 'failed' ? imageSource(cell.capture?.image) : null;
|
|
105
|
+
return (
|
|
106
|
+
<Stack gap="xs" component="section" aria-label={`${state.title} in ${cell.profile.label}`}>
|
|
107
|
+
<Title order={3}>{cell.profile.label}</Title>
|
|
108
|
+
<Group gap="xs">
|
|
109
|
+
<StatusMark status={coverageStatus(cell.coverage)} size={16} />
|
|
110
|
+
<Text fw={600}>{coverageWord(cell.coverage)}</Text>
|
|
111
|
+
{cell.expected === 'undeclared' && <Badge variant="outline">Not in the expected set</Badge>}
|
|
112
|
+
{cell.expected === 'declared' && cell.coverage !== 'captured' && <Badge variant="outline">Expected</Badge>}
|
|
113
|
+
</Group>
|
|
114
|
+
<Picture cell={cell} title={state.title} />
|
|
115
|
+
{source && (
|
|
116
|
+
<Anchor href={source} target="_blank" rel="noreferrer noopener" fz="sm">
|
|
117
|
+
Open the picture{cell.capture?.image?.width && cell.capture.image.height ? ` (${cell.capture.image.width}×${cell.capture.image.height})` : ''}
|
|
118
|
+
</Anchor>
|
|
119
|
+
)}
|
|
120
|
+
{cell.capture?.detail && (
|
|
121
|
+
<Text fz="sm" role="note">
|
|
122
|
+
{cell.capture.detail}
|
|
123
|
+
</Text>
|
|
124
|
+
)}
|
|
125
|
+
{cell.capture ? <Origin capture={cell.capture} now={now} /> : cell.planned ? <Text c="dimmed" fz="xs">Expected by plan layer <Code>{cell.planned.layer.slice(0, 12)}</Code> in backend <Code>{cell.planned.backend}</Code>. Nothing has been captured.</Text> : null}
|
|
126
|
+
</Stack>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The open state: every profile side by side, with the facts declared about it and where each picture came from. */
|
|
131
|
+
function StateDetail({ state, onClose }: { state: UiCatalogState; onClose: () => void }) {
|
|
132
|
+
const now = useNow();
|
|
133
|
+
const facts: [string, string][] = [
|
|
134
|
+
['Route', state.route ?? ''], ['Fixture', state.fixture ?? ''], ['Cohort', state.cohort ?? ''], ['Area', state.area ?? ''],
|
|
135
|
+
['Feature flags', state.flags.join(', ')], ['Surfaces covered', state.covers.join(', ')], ['Producers', state.producers.join(', ')]
|
|
136
|
+
];
|
|
137
|
+
return (
|
|
138
|
+
<Drawer opened onClose={onClose} position="right" size="xl" title={<Title order={2}>{state.title}</Title>} closeButtonProps={{ 'aria-label': 'Close the state' }}>
|
|
139
|
+
<Stack gap="md">
|
|
140
|
+
<Text c="dimmed" fz="sm">
|
|
141
|
+
{state.id}
|
|
142
|
+
</Text>
|
|
143
|
+
<Table aria-label="Declared facts" withRowBorders>
|
|
144
|
+
<Table.Tbody>
|
|
145
|
+
{facts.map(([name, value]) => (
|
|
146
|
+
<Table.Tr key={name}>
|
|
147
|
+
<Table.Th scope="row" w="40%">{name}</Table.Th>
|
|
148
|
+
<Table.Td>{value || <Text span c="dimmed">Not declared</Text>}</Table.Td>
|
|
149
|
+
</Table.Tr>
|
|
150
|
+
))}
|
|
151
|
+
</Table.Tbody>
|
|
152
|
+
</Table>
|
|
153
|
+
<Title order={3}>Profiles side by side</Title>
|
|
154
|
+
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
|
155
|
+
{state.cells.map((cell) => (
|
|
156
|
+
<CellColumn key={cell.profile.key} cell={cell} state={state} now={now} />
|
|
157
|
+
))}
|
|
158
|
+
</SimpleGrid>
|
|
159
|
+
</Stack>
|
|
160
|
+
</Drawer>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function download(catalog: UiCatalog) {
|
|
165
|
+
const url = URL.createObjectURL(new Blob([JSON.stringify(catalog, null, 2)], { type: 'application/json' }));
|
|
166
|
+
const link = document.createElement('a');
|
|
167
|
+
link.href = url;
|
|
168
|
+
link.download = 'ui-catalog.json';
|
|
169
|
+
link.click();
|
|
170
|
+
URL.revokeObjectURL(url);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function Summary({ catalog, narrowed }: { catalog: UiCatalog; narrowed: boolean }) {
|
|
174
|
+
const { summary } = catalog;
|
|
175
|
+
return (
|
|
176
|
+
<Stack gap="xs">
|
|
177
|
+
<Group gap="xs" role="status" aria-label="Catalog summary">
|
|
178
|
+
<Badge variant="light">{plural(summary.states, 'state', 'states')}</Badge>
|
|
179
|
+
<Badge variant="light">{plural(summary.profiles, 'profile', 'profiles')}</Badge>
|
|
180
|
+
{UI_COVERAGE.map((value) =>
|
|
181
|
+
summary.coverage[value] > 0 ? (
|
|
182
|
+
<Badge key={value} variant="light" color={value === 'captured' ? 'green' : value === 'failed' ? 'red' : value === 'capturing' ? 'blue' : 'gray'}>
|
|
183
|
+
{summary.coverage[value]} {coverageWord(value).toLowerCase()}
|
|
184
|
+
</Badge>
|
|
185
|
+
) : null
|
|
186
|
+
)}
|
|
187
|
+
{summary.undeclared > 0 && <Badge variant="outline">{summary.undeclared} not in the expected set</Badge>}
|
|
188
|
+
</Group>
|
|
189
|
+
{narrowed && (
|
|
190
|
+
<Text c="dimmed" fz="sm">
|
|
191
|
+
Counts are for the states that match the filters.
|
|
192
|
+
</Text>
|
|
193
|
+
)}
|
|
194
|
+
{!summary.planned && summary.cells > 0 && (
|
|
195
|
+
<Text c="dimmed" fz="sm">
|
|
196
|
+
No expected set has been published, so states that should exist but were never captured cannot be listed. Publish a <Code>ui-plan</Code> layer to see them as not captured yet.
|
|
197
|
+
</Text>
|
|
198
|
+
)}
|
|
199
|
+
</Stack>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function Surfaces({ catalog }: { catalog: UiCatalog }) {
|
|
204
|
+
if (!catalog.surfaces.length) return null;
|
|
205
|
+
const open = catalog.summary.uncoveredSurfaces;
|
|
206
|
+
const inventoried = catalog.surfaces.some((surface) => surface.inventoried);
|
|
207
|
+
return (
|
|
208
|
+
<Accordion variant="contained" defaultValue={open ? 'surfaces' : null}>
|
|
209
|
+
<Accordion.Item value="surfaces">
|
|
210
|
+
<Accordion.Control>
|
|
211
|
+
Coverage of the product’s surfaces: {plural(catalog.surfaces.length, 'surface', 'surfaces')}, {open === 0 ? 'all with a captured state' : `${open} without one`}
|
|
212
|
+
</Accordion.Control>
|
|
213
|
+
<Accordion.Panel>
|
|
214
|
+
<Table.ScrollContainer minWidth={420}>
|
|
215
|
+
<Table aria-label="Surfaces">
|
|
216
|
+
<Table.Thead>
|
|
217
|
+
<Table.Tr>
|
|
218
|
+
<Table.Th scope="col">Surface</Table.Th>
|
|
219
|
+
<Table.Th scope="col">Coverage</Table.Th>
|
|
220
|
+
<Table.Th scope="col">States</Table.Th>
|
|
221
|
+
<Table.Th scope="col">Captured</Table.Th>
|
|
222
|
+
</Table.Tr>
|
|
223
|
+
</Table.Thead>
|
|
224
|
+
<Table.Tbody>
|
|
225
|
+
{catalog.surfaces.map((surface) => (
|
|
226
|
+
<Table.Tr key={surface.id} data-surface={surface.id}>
|
|
227
|
+
<Table.Td>
|
|
228
|
+
<Anchor
|
|
229
|
+
renderRoot={(props) => <Link {...props} to="/catalog" search={((prev: Record<string, unknown>) => ({ ...prev, surface: surface.id })) as never} resetScroll={false} />}
|
|
230
|
+
>
|
|
231
|
+
{surface.id}
|
|
232
|
+
</Anchor>
|
|
233
|
+
{inventoried && !surface.inventoried ? ' (not in the inventory)' : ''}
|
|
234
|
+
</Table.Td>
|
|
235
|
+
<Table.Td>{surface.status === 'covered' ? 'Covered' : surface.status === 'uncaptured' ? 'States cover it, none captured yet' : 'No state covers it'}</Table.Td>
|
|
236
|
+
<Table.Td>{surface.states}</Table.Td>
|
|
237
|
+
<Table.Td>{surface.captured}</Table.Td>
|
|
238
|
+
</Table.Tr>
|
|
239
|
+
))}
|
|
240
|
+
</Table.Tbody>
|
|
241
|
+
</Table>
|
|
242
|
+
</Table.ScrollContainer>
|
|
243
|
+
</Accordion.Panel>
|
|
244
|
+
</Accordion.Item>
|
|
245
|
+
</Accordion>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The `/catalog` route. */
|
|
250
|
+
export function UiCatalogPage() {
|
|
251
|
+
const evidence = useEvidence();
|
|
252
|
+
const whole = useUiCatalog();
|
|
253
|
+
const { query, search } = useCatalogSearch();
|
|
254
|
+
const go = useCatalogNavigate();
|
|
255
|
+
const [shown, setShown] = useState(CARDS);
|
|
256
|
+
const catalog = useMemo(() => (whole ? filterUiCatalog(whole, query) : null), [whole, query]);
|
|
257
|
+
const narrowed = isUiCatalogFiltered(query);
|
|
258
|
+
const unavailable = evidence.backends.filter((backend) => !backend.available);
|
|
259
|
+
const open = catalog && search.state ? whole?.states.find((state) => state.id === search.state) ?? null : null;
|
|
260
|
+
return (
|
|
261
|
+
<Container size="xl" w="100%" py="xl" component="article" aria-label="UI catalog">
|
|
262
|
+
<Stack gap="md">
|
|
263
|
+
<Group justify="space-between" align="flex-start">
|
|
264
|
+
<Stack gap={4}>
|
|
265
|
+
<Title order={1}>UI catalog</Title>
|
|
266
|
+
<Text c="dimmed">Each state of the product’s interface, captured once per profile, with where every picture came from.</Text>
|
|
267
|
+
</Stack>
|
|
268
|
+
{catalog && catalog.states.length > 0 && (
|
|
269
|
+
<Button type="button" leftSection={<IconDownload size={16} aria-hidden="true" />} onClick={() => download(catalog)}>
|
|
270
|
+
Download the manifest
|
|
271
|
+
</Button>
|
|
272
|
+
)}
|
|
273
|
+
</Group>
|
|
274
|
+
{evidence.unavailable && (
|
|
275
|
+
<Alert role="alert" color="red" title="Recorded results cannot be read">
|
|
276
|
+
<Group gap="sm">
|
|
277
|
+
<Text span inherit>{evidence.unavailable}</Text>
|
|
278
|
+
<Button type="button" variant="subtle" onClick={evidence.retry}>Try again</Button>
|
|
279
|
+
</Group>
|
|
280
|
+
</Alert>
|
|
281
|
+
)}
|
|
282
|
+
{unavailable.length > 0 && (
|
|
283
|
+
<Alert role="status" color="orange" title={`${plural(unavailable.length, 'backend is', 'backends are')} not available`}>
|
|
284
|
+
{unavailable.map((backend) => backend.label).join(', ')}: captures held there are not shown, and nothing that depends on them is shown as captured. This catalog may be incomplete.
|
|
285
|
+
</Alert>
|
|
286
|
+
)}
|
|
287
|
+
{evidence.notices.length > 0 && (
|
|
288
|
+
<Alert role="status" color="orange" title="Some results could not be read">
|
|
289
|
+
{evidence.notices.join(' ')}
|
|
290
|
+
</Alert>
|
|
291
|
+
)}
|
|
292
|
+
<EvidenceIncomplete />
|
|
293
|
+
{!evidence.ready && !evidence.unavailable && <EvidenceWaiting label="Reading the recorded states…" />}
|
|
294
|
+
{evidence.ready && evidence.pending > 0 && (
|
|
295
|
+
<Text role="status" c="dimmed" component="div">
|
|
296
|
+
Reading {plural(evidence.pending, 'more layer', 'more layers')}; states appear here as they arrive.
|
|
297
|
+
</Text>
|
|
298
|
+
)}
|
|
299
|
+
{catalog && whole && (
|
|
300
|
+
<>
|
|
301
|
+
<Summary catalog={catalog} narrowed={narrowed} />
|
|
302
|
+
<Surfaces catalog={whole} />
|
|
303
|
+
{whole.states.length === 0 && evidence.ready && (
|
|
304
|
+
<Text c="dimmed" role="status" component="div" data-empty>
|
|
305
|
+
No UI states have been recorded yet. A collector publishes a layer with a part of kind <Code>ui-state</Code>; states appear here the moment it arrives.
|
|
306
|
+
</Text>
|
|
307
|
+
)}
|
|
308
|
+
{whole.states.length > 0 && catalog.states.length === 0 && (
|
|
309
|
+
<Stack gap="xs" align="flex-start" role="status" data-no-match>
|
|
310
|
+
<Text c="dimmed">Nothing matches these filters.</Text>
|
|
311
|
+
<Button type="button" variant="light" onClick={() => go({ q: undefined, coverage: undefined, profile: undefined, area: undefined, route: undefined, cohort: undefined, flag: undefined, surface: undefined, producer: undefined })}>
|
|
312
|
+
Clear filters
|
|
313
|
+
</Button>
|
|
314
|
+
</Stack>
|
|
315
|
+
)}
|
|
316
|
+
{catalog.states.length > 0 && (
|
|
317
|
+
<>
|
|
318
|
+
<SimpleGrid role="list" cols={{ base: 1, sm: 2, lg: 3, xl: 4 }} spacing="md" aria-label="States">
|
|
319
|
+
{catalog.states.slice(0, shown).map((state) => (
|
|
320
|
+
<StateCard key={state.id} state={state} shown={state.cells.find((cell) => cell.coverage === 'captured') ?? state.cells[0]!} />
|
|
321
|
+
))}
|
|
322
|
+
</SimpleGrid>
|
|
323
|
+
{catalog.states.length > shown && (
|
|
324
|
+
<Button type="button" variant="subtle" onClick={() => setShown(shown + CARDS)}>
|
|
325
|
+
Show more ({catalog.states.length - shown} more)
|
|
326
|
+
</Button>
|
|
327
|
+
)}
|
|
328
|
+
</>
|
|
329
|
+
)}
|
|
330
|
+
</>
|
|
331
|
+
)}
|
|
332
|
+
{search.state && catalog && !open && evidence.ready && evidence.pending === 0 && (
|
|
333
|
+
<Alert role="status" color="gray" title="That state is not in the catalog">
|
|
334
|
+
<Text span inherit>{search.state}</Text> has no recorded capture or plan yet. It appears here if a layer that holds it arrives.{' '}
|
|
335
|
+
<Anchor renderRoot={(props) => <Link {...props} to="/catalog" search={((prev: Record<string, unknown>) => ({ ...prev, state: undefined })) as never} />}>Back to the catalog</Anchor>
|
|
336
|
+
</Alert>
|
|
337
|
+
)}
|
|
338
|
+
</Stack>
|
|
339
|
+
{open && <StateDetail state={open} onClose={() => go({ state: undefined })} />}
|
|
340
|
+
</Container>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module UI catalog data
|
|
3
|
+
*
|
|
4
|
+
* The UI catalog of the report the viewer already holds. It is computed here, from the same layers the rest of the viewer
|
|
5
|
+
* shows (`uiCatalog` from `@crvouga/atlas/reports/ui-catalog`), so it fills in as layers arrive over the server's event stream
|
|
6
|
+
* and `?since=` deltas, and works unchanged when the viewer reads a store or a static export without a server.
|
|
7
|
+
*/
|
|
8
|
+
import { uiCatalog, type UiCatalog } from '@crvouga/atlas/reports/ui-catalog';
|
|
9
|
+
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
|
10
|
+
|
|
11
|
+
import { useEvidence } from '../evidence/evidence-context';
|
|
12
|
+
|
|
13
|
+
const CatalogContext = createContext<UiCatalog | null>(null);
|
|
14
|
+
|
|
15
|
+
/** Computes the catalog for its children, only while `enabled` (the catalog page is open): other pages pay nothing. */
|
|
16
|
+
export function UiCatalogProvider({ enabled, children }: { enabled: boolean; children: ReactNode }) {
|
|
17
|
+
const { evidence, backends, notices, version } = useEvidence();
|
|
18
|
+
const catalog = useMemo(() => (enabled ? uiCatalog(evidence, { backends, notices, ...(version ? { version } : {}) }) : null), [enabled, evidence, backends, notices, version]);
|
|
19
|
+
return <CatalogContext.Provider value={catalog}>{children}</CatalogContext.Provider>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The whole catalog (before the address's filters), or null when the catalog page is not open. */
|
|
23
|
+
export function useUiCatalog(): UiCatalog | null {
|
|
24
|
+
return useContext(CatalogContext);
|
|
25
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module UI catalog address
|
|
3
|
+
*
|
|
4
|
+
* Everything the UI catalog shows is in the address of the `/catalog` route, so a link reproduces the view: the words searched,
|
|
5
|
+
* the filters and the open state. Filters take comma-separated values (a comma or backslash inside a value is written `\,` or
|
|
6
|
+
* `\\`), the same form `GET /api/reports/ui-catalog` takes. Anything unreadable is dropped, never an error.
|
|
7
|
+
*/
|
|
8
|
+
import { parseUiCatalogQuery, uiCatalogParams, type UiCatalogQuery } from '@crvouga/atlas/reports/ui-catalog';
|
|
9
|
+
import { useNavigate, useSearch } from '@tanstack/react-router';
|
|
10
|
+
import { useMemo } from 'react';
|
|
11
|
+
import { z } from 'zod/v4';
|
|
12
|
+
|
|
13
|
+
/** A search value as text: the router reads `123` as a number and `["a"]` as an array, both of which are meant as text here. */
|
|
14
|
+
const text = z
|
|
15
|
+
.preprocess((value) => (typeof value === 'number' || typeof value === 'boolean' ? String(value) : value !== null && typeof value === 'object' ? JSON.stringify(value) : value), z.string().min(1))
|
|
16
|
+
.optional()
|
|
17
|
+
.catch(undefined);
|
|
18
|
+
|
|
19
|
+
/** The `/catalog` route's search: the query of `GET /api/reports/ui-catalog` and the state opened. */
|
|
20
|
+
export const CatalogSearchFields = {
|
|
21
|
+
q: text,
|
|
22
|
+
coverage: text,
|
|
23
|
+
profile: text,
|
|
24
|
+
area: text,
|
|
25
|
+
route: text,
|
|
26
|
+
cohort: text,
|
|
27
|
+
flag: text,
|
|
28
|
+
surface: text,
|
|
29
|
+
producer: text,
|
|
30
|
+
/** The open state: a state id. */
|
|
31
|
+
state: text
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** The address's parts, as the router holds them. */
|
|
35
|
+
export type CatalogSearch = { readonly [K in keyof typeof CatalogSearchFields]?: string | undefined };
|
|
36
|
+
|
|
37
|
+
/** The query an address asks for. */
|
|
38
|
+
export function catalogQueryOf(search: CatalogSearch): UiCatalogQuery {
|
|
39
|
+
const params = new URLSearchParams();
|
|
40
|
+
for (const [name, value] of Object.entries(search)) if (name !== 'state' && typeof value === 'string') params.set(name, value);
|
|
41
|
+
return parseUiCatalogQuery(params);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The address parts of a query, with the opened state kept as given. */
|
|
45
|
+
export function catalogSearchOf(query: UiCatalogQuery, state?: string): CatalogSearch {
|
|
46
|
+
return { ...Object.fromEntries(uiCatalogParams(query)), ...(state ? { state } : {}) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The catalog's search parameters, read from the address. */
|
|
50
|
+
export function useCatalogSearch(): { readonly search: CatalogSearch; readonly query: UiCatalogQuery } {
|
|
51
|
+
const raw = useSearch({ strict: false }) as Record<string, unknown>;
|
|
52
|
+
return useMemo(() => {
|
|
53
|
+
const search: Record<string, string> = {};
|
|
54
|
+
for (const name of Object.keys(CatalogSearchFields)) {
|
|
55
|
+
const value = raw[name];
|
|
56
|
+
if (typeof value === 'string' && value) search[name] = value;
|
|
57
|
+
else if (typeof value === 'number') search[name] = String(value);
|
|
58
|
+
}
|
|
59
|
+
return { search, query: catalogQueryOf(search) };
|
|
60
|
+
}, [raw]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Returns `go(patch)`, which moves the catalog to the address with `patch` applied (fields set to undefined are removed). */
|
|
64
|
+
export function useCatalogNavigate() {
|
|
65
|
+
const navigate = useNavigate();
|
|
66
|
+
return (patch: CatalogSearch, options: { replace?: boolean } = {}) =>
|
|
67
|
+
void navigate({ to: '/catalog', search: ((prev: Record<string, unknown>) => ({ ...prev, ...patch })) as never, replace: options.replace ?? false, resetScroll: false });
|
|
68
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module UI catalog wording
|
|
3
|
+
*
|
|
4
|
+
* The words the catalog uses for a cell's coverage. Only `captured` is called captured; a state that is expected and not yet
|
|
5
|
+
* captured says so, and never looks like a picture exists.
|
|
6
|
+
*/
|
|
7
|
+
import type { UiCoverage } from '@crvouga/atlas/reports/ui-catalog';
|
|
8
|
+
import type { SpecStatus } from '@crvouga/atlas/reports/evidence-view';
|
|
9
|
+
|
|
10
|
+
const WORDS: Readonly<Record<UiCoverage, string>> = {
|
|
11
|
+
captured: 'Captured',
|
|
12
|
+
failed: 'Capture failed',
|
|
13
|
+
capturing: 'Capturing',
|
|
14
|
+
skipped: 'Skipped',
|
|
15
|
+
missing: 'Not captured yet'
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** The words for a coverage value; an unknown one is shown as it is. */
|
|
19
|
+
export function coverageWord(value: string): string {
|
|
20
|
+
return WORDS[value as UiCoverage] ?? value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The outcome mark that stands for a coverage value: every one but `captured` is a different mark, never a pass. */
|
|
24
|
+
export function coverageStatus(value: UiCoverage): SpecStatus {
|
|
25
|
+
return value === 'captured' ? 'passed' : value === 'failed' ? 'failed' : value === 'capturing' ? 'running' : value === 'skipped' ? 'not-run' : 'none';
|
|
26
|
+
}
|