@igstack/app-catalog-frontend-core 6.1.3 → 6.1.4-alpha-20260926175024
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/modules/appCatalog/hooks/useSessionSyncedState.js +4 -4
- package/dist/esm/modules/appCatalog/hooks/useSessionSyncedState.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/SourcePulse.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +4 -4
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/modules/appCatalog/ui/components/AccessRequestSection.test.tsx +21 -27
- package/src/__tests__/modules/appCatalog/ui/components/SubResourceDetailPanel.test.tsx +1 -1
- package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.test.tsx +1 -1
- package/src/__tests__/modules/appCatalog/utils/resolveHelpers.test.ts +5 -2
- package/src/__tests__/setupTests.tsx +2 -2
- package/src/modules/appCatalog/hooks/useSessionSyncedState.ts +4 -4
- package/src/modules/appCatalog/ui/components/SourcePulse.tsx +4 -0
- package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +4 -4
- package/src/ui/markdown-editor/MarkdownEditor.tsx +4 -4
|
@@ -4,17 +4,17 @@ function useSessionSyncedState({
|
|
|
4
4
|
key,
|
|
5
5
|
defaultValue
|
|
6
6
|
}) {
|
|
7
|
-
const [state,
|
|
7
|
+
const [state, setState] = useState(
|
|
8
8
|
() => store.get(key) ?? defaultValue
|
|
9
9
|
);
|
|
10
|
-
const
|
|
10
|
+
const setAndPersist = useCallback(
|
|
11
11
|
(value) => {
|
|
12
|
-
|
|
12
|
+
setState(value);
|
|
13
13
|
store.set(key, value);
|
|
14
14
|
},
|
|
15
15
|
[key]
|
|
16
16
|
);
|
|
17
|
-
return [state,
|
|
17
|
+
return [state, setAndPersist];
|
|
18
18
|
}
|
|
19
19
|
const SEARCH_STORAGE_KEY = "app-catalog:search";
|
|
20
20
|
function seedSessionState(key, value) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useSessionSyncedState.js","sources":["../../../../../src/modules/appCatalog/hooks/useSessionSyncedState.ts"],"sourcesContent":["import { useCallback, useState } from 'react'\n\n/**\n * Options for useSessionSyncedState hook\n */\nexport interface UseSessionSyncedStateOptions<T> {\n /** The store key */\n key: string\n /** Default value when the stored value is absent */\n defaultValue: T\n}\n\n// The store. Module scope is the point: it outlives a component remount and\n// dies with the document, which is exactly the lifetime wanted here.\nconst store = new Map<string, unknown>()\n\n/**\n * Hook for state that survives this provider's remount without touching the URL.\n *\n * Why this exists (see #27): the catalog search value used to be synced to the\n * URL (`?q=`) so it survived the per-route remount of\n * `AppCatalogFiltersProvider` (#10). But `q` leaked into every shared/bookmarked\n * app link. Backing the value with a module-scoped store keeps the #10\n * guarantee — the value survives the remount and the auto-navigate to a single\n * match — while keeping the URL clean.\n *\n * Not `sessionStorage`: that also survives a page reload, so a query typed\n * before lunch came back on the next load, silently filtering the whole catalog\n * with a search box the user never filled in.\n *\n * Key features vs {@link useUrlSyncedState}:\n * - Initializes synchronously on mount.\n * - Writes synchronously in the setter (no async effect), which also removes the\n * effect-ordering race #10 fought (a child auto-navigate effect could run\n * before the provider's async state->URL sync).\n * - Never navigates, so it never touches the URL.\n */\nexport function useSessionSyncedState<T>({\n key,\n defaultValue,\n}: UseSessionSyncedStateOptions<T>): [T, (value: T) => void] {\n const [state,
|
|
1
|
+
{"version":3,"file":"useSessionSyncedState.js","sources":["../../../../../src/modules/appCatalog/hooks/useSessionSyncedState.ts"],"sourcesContent":["import { useCallback, useState } from 'react'\n\n/**\n * Options for useSessionSyncedState hook\n */\nexport interface UseSessionSyncedStateOptions<T> {\n /** The store key */\n key: string\n /** Default value when the stored value is absent */\n defaultValue: T\n}\n\n// The store. Module scope is the point: it outlives a component remount and\n// dies with the document, which is exactly the lifetime wanted here.\nconst store = new Map<string, unknown>()\n\n/**\n * Hook for state that survives this provider's remount without touching the URL.\n *\n * Why this exists (see #27): the catalog search value used to be synced to the\n * URL (`?q=`) so it survived the per-route remount of\n * `AppCatalogFiltersProvider` (#10). But `q` leaked into every shared/bookmarked\n * app link. Backing the value with a module-scoped store keeps the #10\n * guarantee — the value survives the remount and the auto-navigate to a single\n * match — while keeping the URL clean.\n *\n * Not `sessionStorage`: that also survives a page reload, so a query typed\n * before lunch came back on the next load, silently filtering the whole catalog\n * with a search box the user never filled in.\n *\n * Key features vs {@link useUrlSyncedState}:\n * - Initializes synchronously on mount.\n * - Writes synchronously in the setter (no async effect), which also removes the\n * effect-ordering race #10 fought (a child auto-navigate effect could run\n * before the provider's async state->URL sync).\n * - Never navigates, so it never touches the URL.\n */\nexport function useSessionSyncedState<T>({\n key,\n defaultValue,\n}: UseSessionSyncedStateOptions<T>): [T, (value: T) => void] {\n const [state, setState] = useState<T>(\n () => (store.get(key) as T | undefined) ?? defaultValue,\n )\n\n const setAndPersist = useCallback(\n (value: T) => {\n setState(value)\n store.set(key, value)\n },\n [key],\n )\n\n return [state, setAndPersist]\n}\n\n/**\n * Key backing the catalog search value (#27). Exported so tests (and any tooling\n * that needs to seed a \"returning user\" search) reference the same key as the\n * provider.\n */\nexport const SEARCH_STORAGE_KEY = 'app-catalog:search'\n\n/** Seed a value as if the user had already set it this page load (tests). */\nexport function seedSessionState(key: string, value: unknown): void {\n store.set(key, value)\n}\n\n/** Drop all seeded/typed values — the per-test reset of a page reload. */\nexport function clearSessionState(): void {\n store.clear()\n}\n"],"names":[],"mappings":";AAcA,MAAM,4BAAY,IAAA;AAuBX,SAAS,sBAAyB;AAAA,EACvC;AAAA,EACA;AACF,GAA6D;AAC3D,QAAM,CAAC,OAAO,QAAQ,IAAI;AAAA,IACxB,MAAO,MAAM,IAAI,GAAG,KAAuB;AAAA,EAAA;AAG7C,QAAM,gBAAgB;AAAA,IACpB,CAAC,UAAa;AACZ,eAAS,KAAK;AACd,YAAM,IAAI,KAAK,KAAK;AAAA,IACtB;AAAA,IACA,CAAC,GAAG;AAAA,EAAA;AAGN,SAAO,CAAC,OAAO,aAAa;AAC9B;AAOO,MAAM,qBAAqB;AAG3B,SAAS,iBAAiB,KAAa,OAAsB;AAClE,QAAM,IAAI,KAAK,KAAK;AACtB;AAGO,SAAS,oBAA0B;AACxC,QAAM,MAAA;AACR;"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SourcePulse.js","sources":["../../../../../../src/modules/appCatalog/ui/components/SourcePulse.tsx"],"sourcesContent":["/**\n * The per-source pulse mark: one small inline SVG in the Sources list saying how\n * volatile a source is, whether its reading is current, when it last changed and\n * when it was last checked.\n *\n * Two channels, kept separate on purpose: **shape carries volatility** (where the\n * ticks fall on a real 90-day axis — a burst of churn looks like a burst) and\n * **colour carries freshness**. Colour never carries it alone: the freshness\n * circle also goes hollow → filled → filled-with-ring, and the tooltip prints the\n * words. Those three hexes are the fixed status palette and are deliberately NOT\n * themed — a status colour that shifts with the theme stops being a status.\n *\n * At rest only the freshness circle is drawn, inside a fixed-width slot, so a list\n * of sources reads as a quiet column and hovering cannot reflow the row. Hover or\n * keyboard focus fades the track in and opens a tooltip that doubles as the\n * legend: every row is prefixed by the very glyph it explains.\n */\nimport type { SourcePulse as SourcePulseData } from '@igstack/app-catalog-backend-core'\nimport * as React from 'react'\nimport { cn } from '~/lib/utils'\nimport { Tooltip, TooltipContent, TooltipTrigger } from '~/ui/tooltip'\nimport { formatRelativeTime } from '../../utils/formatRelativeTime'\n\n/** Fixed status palette — never themed. */\nconst COLOR = {\n ok: '#0ca30c',\n due: '#fab219',\n stale: '#d03b3b',\n never: '#898781',\n} as const\n\n// Geometry. The 90-day axis runs X0 → X1; the dashed tail and the freshness\n// circle live to the right of it, in \"the future\".\nconst W = 70\nconst H = 18\nconst BASE = 13\nconst X0 = 2\nconst X1 = 50\nconst DOT_X = 64\nconst WINDOW_MS = 90 * 24 * 60 * 60 * 1000\n\n/** Where a check lands on the axis, clamped so anything older pins to the left. */\nfunction axisX(at: number, now: number): number {\n const age = Math.min(Math.max(now - at, 0), WINDOW_MS)\n return X0 + (X1 - X0) * (1 - age / WINDOW_MS)\n}\n\nfunction FreshnessDot({ state }: { state: SourcePulseData['state'] }) {\n const filled = state === 'due' || state === 'stale'\n return (\n <>\n {state === 'stale' && (\n <circle\n cx={DOT_X}\n cy={BASE}\n r={5.4}\n stroke={COLOR.stale}\n strokeWidth={1}\n opacity={0.55}\n />\n )}\n <circle\n cx={DOT_X}\n cy={BASE}\n r={3.2}\n fill={filled ? COLOR[state] : 'none'}\n stroke={filled ? 'none' : COLOR[state]}\n strokeWidth={1.6}\n strokeDasharray={state === 'never' ? '2 1.6' : undefined}\n />\n </>\n )\n}\n\nfunction Track({ pulse, now }: { pulse: SourcePulseData; now: number }) {\n const ticks = pulse.checks.flatMap((check) => {\n const at = Date.parse(check.date)\n if (Number.isNaN(at)) return []\n return [{ x: axisX(at, now), top: check.changed ? 2 : 9.5 }]\n })\n return (\n <g>\n <line\n x1={X0}\n y1={BASE}\n x2={X1}\n y2={BASE}\n stroke=\"currentColor\"\n strokeWidth={1}\n opacity={0.25}\n />\n {/* Every halo first, then every tick. Two checks hours apart land on the\n same pixel, and a 2px surface gap is what keeps them countable — but\n drawing halo-then-tick per check lets a newer halo erase an older tick.\n The gap has to be painted in the colour of whatever surface the mark is\n sitting on, which differs between the row and the inverted tooltip. */}\n {ticks.map((t, i) => (\n <line\n key={`halo-${i}`}\n x1={t.x}\n y1={BASE}\n x2={t.x}\n y2={t.top}\n strokeWidth={3.8}\n stroke=\"var(--pulse-surface)\"\n />\n ))}\n {ticks.map((t, i) => (\n <line\n key={`tick-${i}`}\n x1={t.x}\n y1={BASE}\n x2={t.x}\n y2={t.top}\n strokeWidth={1.8}\n strokeLinecap=\"round\"\n stroke=\"currentColor\"\n opacity={t.top === 2 ? 0.9 : 0.4}\n />\n ))}\n <line\n x1={X1 + 3}\n y1={BASE}\n x2={DOT_X - 6}\n y2={BASE}\n stroke=\"currentColor\"\n strokeWidth={1}\n strokeDasharray=\"1.5 2\"\n opacity={0.25}\n />\n </g>\n )\n}\n\n/** \"every 6d\" / \"every 4h\" — the cadence as a reader would say it. */\nfunction formatInterval(hours: number | null): string | null {\n if (hours == null) return null\n if (hours < 24) return `every ${Math.max(1, Math.round(hours))}h`\n return `every ${Math.round(hours / 24)}d`\n}\n\nconst GLYPH_BOX = 'inline-flex w-3 shrink-0 justify-center'\n\n/** The tooltip's legend rows: glyph, label, value. */\nfunction Row({\n glyph,\n label,\n value,\n sub,\n}: {\n glyph: React.ReactNode\n label: string\n value: string\n sub?: string\n}) {\n return (\n <>\n <span className={GLYPH_BOX} aria-hidden=\"true\">\n {glyph}\n </span>\n <span className=\"opacity-70\">{label}</span>\n <span>\n {value}\n {sub && <span className=\"opacity-70\"> {sub}</span>}\n </span>\n </>\n )\n}\n\nfunction Legend({ pulse, now }: { pulse: SourcePulseData; now: number }) {\n const changes = pulse.checks.filter((c) => c.changed).length\n const interval = formatInterval(pulse.intervalHours)\n\n const change = pulse.lastContentChangeAt\n ? `${pulse.contentChangeIsLowerBound ? 'over ' : ''}${formatRelativeTime(\n pulse.lastContentChangeAt,\n now,\n )}`\n : 'unknown'\n\n // \"Next check\" while it is still ahead; once it has passed, the honest label is\n // that it was DUE then — the reading may already be behind the source.\n const overdue = pulse.state === 'due' || pulse.state === 'stale'\n const dueLabel = overdue ? 'Was due' : 'Next check'\n const dueValue =\n pulse.state === 'never'\n ? 'first check pending'\n : pulse.dueAt\n ? formatRelativeTime(pulse.dueAt, now, true)\n : 'not scheduled'\n\n return (\n <div className=\"grid grid-cols-[auto_auto_1fr] items-baseline gap-x-2 gap-y-1\">\n <Row\n glyph={\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 11 11\" fill=\"none\">\n <path\n d=\"M1 8.5h2L4.5 3 6 8.5h3\"\n stroke=\"currentColor\"\n strokeWidth=\"1.2\"\n strokeLinecap=\"round\"\n />\n </svg>\n }\n label=\"Cadence\"\n value={interval ?? 'not scheduled'}\n sub={pulse.cadence ? `(${pulse.cadence})` : undefined}\n />\n <Row\n glyph={\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 11 11\" fill=\"none\">\n <line\n x1=\"5.5\"\n y1=\"10\"\n x2=\"5.5\"\n y2=\"1\"\n stroke=\"currentColor\"\n strokeWidth=\"1.8\"\n strokeLinecap=\"round\"\n />\n </svg>\n }\n label=\"Last change\"\n value={change}\n sub={\n pulse.checks.length\n ? `· ${changes} of last ${pulse.checks.length} checks`\n : undefined\n }\n />\n <Row\n glyph={\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 11 11\" fill=\"none\">\n <line\n x1=\"5.5\"\n y1=\"10\"\n x2=\"5.5\"\n y2=\"6.5\"\n stroke=\"currentColor\"\n strokeWidth=\"1.8\"\n strokeLinecap=\"round\"\n opacity=\"0.55\"\n />\n </svg>\n }\n label=\"Last check\"\n value={\n pulse.lastCheckedAt\n ? formatRelativeTime(pulse.lastCheckedAt, now)\n : 'never'\n }\n />\n <Row\n glyph={\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 11 11\" fill=\"none\">\n <circle\n cx=\"5.5\"\n cy=\"7\"\n r=\"3\"\n stroke={COLOR[pulse.state]}\n strokeWidth=\"1.5\"\n fill={overdue ? COLOR[pulse.state] : 'none'}\n />\n </svg>\n }\n label={dueLabel}\n value={dueValue}\n sub={pulse.state === 'stale' ? '· may be out of date' : undefined}\n />\n </div>\n )\n}\n\n/** The same sentence the legend says, for screen readers and the a11y tree. */\nfunction readout(pulse: SourcePulseData, now: number): string {\n const parts = [\n pulse.cadence ? `checked ${pulse.cadence}` : 'no check schedule',\n pulse.lastContentChangeAt\n ? `last changed ${pulse.contentChangeIsLowerBound ? 'over ' : ''}${formatRelativeTime(pulse.lastContentChangeAt, now)}`\n : 'last change unknown',\n pulse.lastCheckedAt\n ? `last checked ${formatRelativeTime(pulse.lastCheckedAt, now)}`\n : 'never checked',\n ]\n if (pulse.state === 'due') parts.push('a re-check is overdue')\n if (pulse.state === 'stale') parts.push('overdue enough that it may be stale')\n return `Source freshness: ${parts.join(', ')}.`\n}\n\nexport function SourcePulse({\n pulse,\n className,\n}: {\n pulse: SourcePulseData\n className?: string\n}) {\n // One timestamp for the whole render, so the mark and its legend cannot\n // disagree about \"now\" by a few milliseconds.\n const now = Date.now()\n\n return (\n <Tooltip>\n <TooltipTrigger\n aria-label={readout(pulse, now)}\n className={cn(\n 'group text-muted-foreground shrink-0 cursor-default rounded-sm [--pulse-surface:var(--card)] focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none',\n className,\n )}\n >\n <svg\n width={W}\n height={H}\n viewBox={`0 0 ${W} ${H}`}\n fill=\"none\"\n aria-hidden=\"true\"\n >\n <g className=\"opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100\">\n <Track pulse={pulse} now={now} />\n </g>\n <FreshnessDot state={pulse.state} />\n </svg>\n </TooltipTrigger>\n <TooltipContent\n side=\"top\"\n className=\"max-w-none px-3 py-2 [--pulse-surface:var(--foreground)]\"\n >\n <svg\n width={W * 2}\n height={H * 2}\n viewBox={`0 0 ${W} ${H}`}\n fill=\"none\"\n aria-hidden=\"true\"\n className=\"mb-1\"\n >\n <Track pulse={pulse} now={now} />\n <FreshnessDot state={pulse.state} />\n </svg>\n <div className=\"mb-1.5 flex justify-between text-[10px] opacity-60\">\n <span>90 days ago</span>\n <span>now</span>\n </div>\n <Legend pulse={pulse} now={now} />\n </TooltipContent>\n </Tooltip>\n )\n}\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"SourcePulse.js","sources":["../../../../../../src/modules/appCatalog/ui/components/SourcePulse.tsx"],"sourcesContent":["/**\n * The per-source pulse mark: one small inline SVG in the Sources list saying how\n * volatile a source is, whether its reading is current, when it last changed and\n * when it was last checked.\n *\n * Two channels, kept separate on purpose: **shape carries volatility** (where the\n * ticks fall on a real 90-day axis — a burst of churn looks like a burst) and\n * **colour carries freshness**. Colour never carries it alone: the freshness\n * circle also goes hollow → filled → filled-with-ring, and the tooltip prints the\n * words. Those three hexes are the fixed status palette and are deliberately NOT\n * themed — a status colour that shifts with the theme stops being a status.\n *\n * At rest only the freshness circle is drawn, inside a fixed-width slot, so a list\n * of sources reads as a quiet column and hovering cannot reflow the row. Hover or\n * keyboard focus fades the track in and opens a tooltip that doubles as the\n * legend: every row is prefixed by the very glyph it explains.\n */\nimport type { SourcePulse as SourcePulseData } from '@igstack/app-catalog-backend-core'\nimport * as React from 'react'\nimport { cn } from '~/lib/utils'\nimport { Tooltip, TooltipContent, TooltipTrigger } from '~/ui/tooltip'\nimport { formatRelativeTime } from '../../utils/formatRelativeTime'\n\n/** Fixed status palette — never themed. */\nconst COLOR = {\n ok: '#0ca30c',\n due: '#fab219',\n stale: '#d03b3b',\n never: '#898781',\n} as const\n\n// Geometry. The 90-day axis runs X0 → X1; the dashed tail and the freshness\n// circle live to the right of it, in \"the future\".\nconst W = 70\nconst H = 18\nconst BASE = 13\nconst X0 = 2\nconst X1 = 50\nconst DOT_X = 64\nconst WINDOW_MS = 90 * 24 * 60 * 60 * 1000\n\n/** Where a check lands on the axis, clamped so anything older pins to the left. */\nfunction axisX(at: number, now: number): number {\n const age = Math.min(Math.max(now - at, 0), WINDOW_MS)\n return X0 + (X1 - X0) * (1 - age / WINDOW_MS)\n}\n\nfunction FreshnessDot({ state }: { state: SourcePulseData['state'] }) {\n const filled = state === 'due' || state === 'stale'\n return (\n <>\n {state === 'stale' && (\n <circle\n cx={DOT_X}\n cy={BASE}\n r={5.4}\n stroke={COLOR.stale}\n strokeWidth={1}\n opacity={0.55}\n />\n )}\n <circle\n cx={DOT_X}\n cy={BASE}\n r={3.2}\n fill={filled ? COLOR[state] : 'none'}\n stroke={filled ? 'none' : COLOR[state]}\n strokeWidth={1.6}\n strokeDasharray={state === 'never' ? '2 1.6' : undefined}\n />\n </>\n )\n}\n\nfunction Track({ pulse, now }: { pulse: SourcePulseData; now: number }) {\n const ticks = pulse.checks.flatMap((check) => {\n const at = Date.parse(check.date)\n if (Number.isNaN(at)) return []\n return [{ x: axisX(at, now), top: check.changed ? 2 : 9.5 }]\n })\n return (\n <g>\n <line\n x1={X0}\n y1={BASE}\n x2={X1}\n y2={BASE}\n stroke=\"currentColor\"\n strokeWidth={1}\n opacity={0.25}\n />\n {/* Every halo first, then every tick. Two checks hours apart land on the\n same pixel, and a 2px surface gap is what keeps them countable — but\n drawing halo-then-tick per check lets a newer halo erase an older tick.\n The gap has to be painted in the colour of whatever surface the mark is\n sitting on, which differs between the row and the inverted tooltip. */}\n {ticks.map((t, i) => (\n <line\n key={`halo-${i}`}\n x1={t.x}\n y1={BASE}\n x2={t.x}\n y2={t.top}\n strokeWidth={3.8}\n stroke=\"var(--pulse-surface)\"\n />\n ))}\n {ticks.map((t, i) => (\n <line\n key={`tick-${i}`}\n x1={t.x}\n y1={BASE}\n x2={t.x}\n y2={t.top}\n strokeWidth={1.8}\n strokeLinecap=\"round\"\n stroke=\"currentColor\"\n opacity={t.top === 2 ? 0.9 : 0.4}\n />\n ))}\n <line\n x1={X1 + 3}\n y1={BASE}\n x2={DOT_X - 6}\n y2={BASE}\n stroke=\"currentColor\"\n strokeWidth={1}\n strokeDasharray=\"1.5 2\"\n opacity={0.25}\n />\n </g>\n )\n}\n\n/** \"every 6d\" / \"every 4h\" — the cadence as a reader would say it. */\nfunction formatInterval(hours: number | null): string | null {\n if (hours == null) return null\n if (hours < 24) return `every ${Math.max(1, Math.round(hours))}h`\n return `every ${Math.round(hours / 24)}d`\n}\n\nconst GLYPH_BOX = 'inline-flex w-3 shrink-0 justify-center'\n\n/** The tooltip's legend rows: glyph, label, value. */\nfunction Row({\n glyph,\n label,\n value,\n sub,\n}: {\n glyph: React.ReactNode\n label: string\n value: string\n sub?: string\n}) {\n return (\n <>\n <span className={GLYPH_BOX} aria-hidden=\"true\">\n {glyph}\n </span>\n <span className=\"opacity-70\">{label}</span>\n <span>\n {value}\n {sub && <span className=\"opacity-70\"> {sub}</span>}\n </span>\n </>\n )\n}\n\nfunction Legend({ pulse, now }: { pulse: SourcePulseData; now: number }) {\n const changes = pulse.checks.filter((c) => c.changed).length\n const interval = formatInterval(pulse.intervalHours)\n\n const change = pulse.lastContentChangeAt\n ? `${pulse.contentChangeIsLowerBound ? 'over ' : ''}${formatRelativeTime(\n pulse.lastContentChangeAt,\n now,\n )}`\n : 'unknown'\n\n // \"Next check\" while it is still ahead; once it has passed, the honest label is\n // that it was DUE then — the reading may already be behind the source.\n const overdue = pulse.state === 'due' || pulse.state === 'stale'\n const dueLabel = overdue ? 'Was due' : 'Next check'\n const dueValue =\n pulse.state === 'never'\n ? 'first check pending'\n : pulse.dueAt\n ? formatRelativeTime(pulse.dueAt, now, true)\n : 'not scheduled'\n\n return (\n <div className=\"grid grid-cols-[auto_auto_1fr] items-baseline gap-x-2 gap-y-1\">\n <Row\n glyph={\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 11 11\" fill=\"none\">\n <path\n d=\"M1 8.5h2L4.5 3 6 8.5h3\"\n stroke=\"currentColor\"\n strokeWidth=\"1.2\"\n strokeLinecap=\"round\"\n />\n </svg>\n }\n label=\"Cadence\"\n value={interval ?? 'not scheduled'}\n sub={pulse.cadence ? `(${pulse.cadence})` : undefined}\n />\n <Row\n glyph={\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 11 11\" fill=\"none\">\n <line\n x1=\"5.5\"\n y1=\"10\"\n x2=\"5.5\"\n y2=\"1\"\n stroke=\"currentColor\"\n strokeWidth=\"1.8\"\n strokeLinecap=\"round\"\n />\n </svg>\n }\n label=\"Last change\"\n value={change}\n sub={\n pulse.checks.length\n ? `· ${changes} of last ${pulse.checks.length} checks`\n : undefined\n }\n />\n <Row\n glyph={\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 11 11\" fill=\"none\">\n <line\n x1=\"5.5\"\n y1=\"10\"\n x2=\"5.5\"\n y2=\"6.5\"\n stroke=\"currentColor\"\n strokeWidth=\"1.8\"\n strokeLinecap=\"round\"\n opacity=\"0.55\"\n />\n </svg>\n }\n label=\"Last check\"\n value={\n pulse.lastCheckedAt\n ? formatRelativeTime(pulse.lastCheckedAt, now)\n : 'never'\n }\n />\n <Row\n glyph={\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 11 11\" fill=\"none\">\n <circle\n cx=\"5.5\"\n cy=\"7\"\n r=\"3\"\n stroke={COLOR[pulse.state]}\n strokeWidth=\"1.5\"\n fill={overdue ? COLOR[pulse.state] : 'none'}\n />\n </svg>\n }\n label={dueLabel}\n value={dueValue}\n sub={pulse.state === 'stale' ? '· may be out of date' : undefined}\n />\n </div>\n )\n}\n\n/** The same sentence the legend says, for screen readers and the a11y tree. */\nfunction readout(pulse: SourcePulseData, now: number): string {\n const parts = [\n pulse.cadence ? `checked ${pulse.cadence}` : 'no check schedule',\n pulse.lastContentChangeAt\n ? `last changed ${pulse.contentChangeIsLowerBound ? 'over ' : ''}${formatRelativeTime(pulse.lastContentChangeAt, now)}`\n : 'last change unknown',\n pulse.lastCheckedAt\n ? `last checked ${formatRelativeTime(pulse.lastCheckedAt, now)}`\n : 'never checked',\n ]\n if (pulse.state === 'due') parts.push('a re-check is overdue')\n if (pulse.state === 'stale') parts.push('overdue enough that it may be stale')\n return `Source freshness: ${parts.join(', ')}.`\n}\n\nexport function SourcePulse({\n pulse,\n className,\n}: {\n pulse: SourcePulseData\n className?: string\n}) {\n // One timestamp for the whole render, so the mark and its legend cannot\n // disagree about \"now\" by a few milliseconds.\n // Reading the clock during render is deliberate. Making it pure would change when\n // the readout refreshes — today it re-reads on any parent re-render — and that is a\n // behaviour question, not a lint fix, so it is left as a suppression on purpose.\n // eslint-disable-next-line @eslint-react/purity\n const now = Date.now()\n\n return (\n <Tooltip>\n <TooltipTrigger\n aria-label={readout(pulse, now)}\n className={cn(\n 'group text-muted-foreground shrink-0 cursor-default rounded-sm [--pulse-surface:var(--card)] focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none',\n className,\n )}\n >\n <svg\n width={W}\n height={H}\n viewBox={`0 0 ${W} ${H}`}\n fill=\"none\"\n aria-hidden=\"true\"\n >\n <g className=\"opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100\">\n <Track pulse={pulse} now={now} />\n </g>\n <FreshnessDot state={pulse.state} />\n </svg>\n </TooltipTrigger>\n <TooltipContent\n side=\"top\"\n className=\"max-w-none px-3 py-2 [--pulse-surface:var(--foreground)]\"\n >\n <svg\n width={W * 2}\n height={H * 2}\n viewBox={`0 0 ${W} ${H}`}\n fill=\"none\"\n aria-hidden=\"true\"\n className=\"mb-1\"\n >\n <Track pulse={pulse} now={now} />\n <FreshnessDot state={pulse.state} />\n </svg>\n <div className=\"mb-1.5 flex justify-between text-[10px] opacity-60\">\n <span>90 days ago</span>\n <span>now</span>\n </div>\n <Legend pulse={pulse} now={now} />\n </TooltipContent>\n </Tooltip>\n )\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -47,16 +47,16 @@ function SubResourcesSection({
|
|
|
47
47
|
if (selectedSubSlug) setHighlightSlug(selectedSubSlug);
|
|
48
48
|
}, [selectedSubSlug]);
|
|
49
49
|
const searchInputRef = useRef(null);
|
|
50
|
-
const
|
|
50
|
+
const focusSearchAfterClearRef = useRef(false);
|
|
51
51
|
useEffect(() => {
|
|
52
52
|
var _a;
|
|
53
|
-
if (!selectedSub &&
|
|
54
|
-
|
|
53
|
+
if (!selectedSub && focusSearchAfterClearRef.current) {
|
|
54
|
+
focusSearchAfterClearRef.current = false;
|
|
55
55
|
(_a = searchInputRef.current) == null ? void 0 : _a.focus();
|
|
56
56
|
}
|
|
57
57
|
}, [selectedSub]);
|
|
58
58
|
const clearSelection = () => {
|
|
59
|
-
|
|
59
|
+
focusSearchAfterClearRef.current = true;
|
|
60
60
|
void navigate({
|
|
61
61
|
to: ".",
|
|
62
62
|
search: (prev) => ({ ...prev, sub: void 0 })
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SubResourcesSection.js","sources":["../../../../../../src/modules/appCatalog/ui/components/SubResourcesSection.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { Link, useNavigate, useSearch } from '@tanstack/react-router'\nimport { Search, X } from 'lucide-react'\nimport { useEffect, useMemo, useRef, useState } from 'react'\nimport { Badge } from '~/ui/badge'\nimport { Input } from '~/ui/input'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/ui/table'\nimport { PersonBadge, PersonOrGroupBadge } from './PersonBadge'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '~/ui/select'\nimport { markdownToPlainText } from '~/modules/appCatalog/utils/markdownToPlainText'\n\ninterface SubResourcesSectionProps {\n subResources: Resource[]\n /** Slug of the parent, for linking to a child's own page. */\n parentSlug: string\n /**\n * Initial filter text (#38 item C). When the user reached this app by\n * searching a term that matched a sub-resource, seed the sub-resource filter\n * with that term so the matched child is revealed instead of buried in a\n * long list (e.g. searching \"data\" → open Cloud Console → the matching\n * account is pre-filtered). Only applied when it actually matches a child.\n */\n initialSearch?: string\n}\n\nfunction getTierBadgeVariant(\n tierSlug: string,\n): 'default' | 'secondary' | 'destructive' | 'outline' {\n if (tierSlug === 'prod' || tierSlug === 'production') return 'destructive'\n if (tierSlug === 'dev' || tierSlug === 'staging') return 'secondary'\n if (tierSlug === 'preprod') return 'outline'\n if (tierSlug === 'sandbox') return 'outline'\n return 'outline'\n}\n\nfunction getTierBadgeClassName(tierSlug: string): string {\n if (tierSlug === 'preprod')\n return 'border-amber-400 bg-amber-100 text-amber-800 hover:bg-amber-200'\n if (tierSlug === 'sandbox')\n return 'border-gray-400 bg-gray-100 text-gray-700 hover:bg-gray-200'\n return ''\n}\n\nfunction getTierDisplayLabel(tierSlug: string): string {\n if (tierSlug === 'preprod') return 'Pre-Prod'\n if (tierSlug === 'sandbox') return 'Sandbox'\n if (tierSlug === 'prod' || tierSlug === 'production') return 'Prod'\n if (tierSlug === 'dev') return 'Dev'\n if (tierSlug === 'staging') return 'Staging'\n return tierSlug\n}\n\nexport function SubResourcesSection({\n subResources,\n parentSlug,\n initialSearch,\n}: SubResourcesSectionProps) {\n // `?sub=<slug>` — the one sub-resource the user asked for, by clicking its row\n // in the search results. It wins over the query-seeded filter below: a query\n // like \"project\" matches every child, so seeding with it would bury the row\n // that was actually clicked.\n const search_ = useSearch({ strict: false })\n const selectedSubSlug = search_.sub\n const selectedSub = useMemo(\n () => subResources.find((sr) => sr.slug === selectedSubSlug) ?? null,\n [subResources, selectedSubSlug],\n )\n const navigate = useNavigate()\n\n // The row to mark as \"this one\". It outlives `?sub=` so clearing the filter\n // brings the siblings back without losing the user's place.\n const [highlightSlug, setHighlightSlug] = useState(selectedSubSlug)\n useEffect(() => {\n if (selectedSubSlug) setHighlightSlug(selectedSubSlug)\n }, [selectedSubSlug])\n\n // The search input only mounts once the selection is gone, so focus it then.\n const searchInputRef = useRef<HTMLInputElement>(null)\n const focusSearchAfterClear = useRef(false)\n useEffect(() => {\n if (!selectedSub && focusSearchAfterClear.current) {\n focusSearchAfterClear.current = false\n searchInputRef.current?.focus()\n }\n }, [selectedSub])\n\n const clearSelection = () => {\n focusSearchAfterClear.current = true\n // A navigation, not a replace, so Back restores the single-row view.\n void navigate({\n to: '.',\n search: (prev) => ({ ...prev, sub: undefined }),\n })\n }\n\n // Seed the filter with the incoming query ONLY if it matches a child, so we\n // reveal the matched sub-resource without hiding everything on a non-match.\n const seededSearch = useMemo(() => {\n const q = initialSearch?.trim().toLowerCase()\n if (!q) return ''\n const hit = subResources.some(\n (sr) =>\n sr.displayName.toLowerCase().includes(q) ||\n (sr.aliases ?? []).some((a) => a.toLowerCase().includes(q)),\n )\n return hit ? initialSearch!.trim() : ''\n }, [initialSearch, subResources])\n const [search, setSearch] = useState(seededSearch)\n const [tierFilter, setTierFilter] = useState<string>('all')\n\n // useState only reads its argument on the first render, so without this the\n // filter kept the query from whenever this panel first mounted — searching\n // again with the panel already open left the old term in the box.\n useEffect(() => setSearch(seededSearch), [seededSearch])\n\n const uniqueTiers = useMemo(() => {\n const tiers = new Set<string>()\n for (const sr of subResources) {\n if (sr.tier) tiers.add(sr.tier)\n }\n return [...tiers].sort()\n }, [subResources])\n\n const filtered = useMemo(() => {\n if (selectedSub) return [selectedSub]\n\n let result = subResources\n\n if (tierFilter !== 'all') {\n result = result.filter((sr) => sr.tier === tierFilter)\n }\n\n if (search.trim()) {\n const q = search.trim().toLowerCase()\n result = result.filter(\n (sr) =>\n sr.displayName.toLowerCase().includes(q) ||\n (sr.aliases ?? []).some((a) => a.toLowerCase().includes(q)) ||\n (sr.description?.toLowerCase().includes(q) ?? false) ||\n (\n (sr.extra as Record<string, unknown>).awsAccountId as\n | string\n | undefined\n )?.includes(q) === true,\n )\n }\n\n return result\n }, [subResources, search, tierFilter, selectedSub])\n\n if (subResources.length === 0) return null\n\n return (\n <div className=\"space-y-3\">\n <div className=\"flex items-center justify-between\">\n <div className=\"text-sm font-medium\">\n Sub-Resources ({filtered.length} of {subResources.length})\n </div>\n </div>\n\n {/* Filters */}\n <div className=\"flex gap-2\">\n {selectedSub ? (\n // `?sub=` wins over the search box, so show it where the box would be\n // and make it dismissible — otherwise the only way out is the URL.\n <div className=\"flex flex-1 items-center\">\n <Badge variant=\"secondary\" className=\"h-9 gap-1 pl-3 pr-1 text-sm\">\n Showing: {selectedSub.displayName}\n <button\n type=\"button\"\n aria-label=\"Clear sub-resource filter\"\n onClick={clearSelection}\n className=\"rounded-sm p-1 hover:bg-muted-foreground/20\"\n >\n <X className=\"size-4\" />\n </button>\n </Badge>\n </div>\n ) : (\n <div className=\"relative flex-1\">\n <Search className=\"absolute left-2.5 top-2.5 size-4 text-muted-foreground\" />\n <Input\n ref={searchInputRef}\n placeholder=\"Search resources by name or alias...\"\n value={search}\n onChange={(e) => setSearch(e.target.value)}\n className=\"pl-9 h-9\"\n />\n </div>\n )}\n {uniqueTiers.length > 1 && (\n <Select value={tierFilter} onValueChange={setTierFilter}>\n <SelectTrigger className=\"w-[130px] h-9\">\n <SelectValue placeholder=\"All tiers\" />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"all\">All tiers</SelectItem>\n {uniqueTiers.map((tier) => (\n <SelectItem key={tier} value={tier}>\n {tier}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n )}\n </div>\n\n {/* Table */}\n <div className=\"rounded-lg border max-h-[400px] overflow-auto\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>Name</TableHead>\n <TableHead className=\"w-[80px]\">Tier</TableHead>\n <TableHead>Owner</TableHead>\n <TableHead>Approvers</TableHead>\n <TableHead className=\"w-[140px]\">AWS Account</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {filtered.length === 0 ? (\n <TableRow>\n <TableCell\n colSpan={5}\n className=\"text-center text-muted-foreground py-8\"\n >\n No resources match your filters\n </TableCell>\n </TableRow>\n ) : (\n filtered.map((sr) => {\n // Approvers are Person OR Group slugs; the badge resolves both.\n const approvers = [...new Set(sr.approverSlugs ?? [])]\n\n return (\n <TableRow\n key={sr.slug}\n // The row the user asked for, so it reads as \"this one\" even\n // once the filter is cleared and its siblings come back.\n aria-current={\n sr.slug === highlightSlug ? 'true' : undefined\n }\n className={\n sr.slug === highlightSlug ? 'bg-primary/[0.06]' : ''\n }\n >\n <TableCell>\n {/* A real link, not a click handler on the row: this is a\n table of resources, each of which has its own page. */}\n <Link\n to=\"/app/$slug/sub/$subSlug\"\n params={{ slug: parentSlug, subSlug: sr.slug }}\n className=\"font-medium text-sm hover:text-primary hover:underline\"\n >\n {sr.displayName}\n </Link>\n {(sr.aliases ?? []).length > 0 && (\n <div className=\"text-xs text-muted-foreground mt-0.5\">\n {(sr.aliases ?? []).join(', ')}\n </div>\n )}\n {sr.description && (\n <div className=\"text-xs text-muted-foreground mt-0.5\">\n {markdownToPlainText(sr.description)}\n </div>\n )}\n </TableCell>\n <TableCell>\n {sr.tier && (\n <Badge\n variant={getTierBadgeVariant(sr.tier)}\n className={`text-xs ${getTierBadgeClassName(sr.tier)}`}\n >\n {getTierDisplayLabel(sr.tier)}\n </Badge>\n )}\n </TableCell>\n <TableCell>\n {sr.ownerPersonSlug && (\n <PersonBadge slug={sr.ownerPersonSlug} />\n )}\n </TableCell>\n <TableCell>\n {approvers.length > 0 ? (\n <div className=\"flex flex-wrap gap-1\">\n {approvers.map((slug) => (\n <PersonOrGroupBadge key={slug} slug={slug} />\n ))}\n </div>\n ) : (\n <span className=\"text-muted-foreground\">-</span>\n )}\n </TableCell>\n <TableCell>\n {(() => {\n const accountId = (\n sr.extra as Record<string, unknown> | null | undefined\n )?.awsAccountId as string | undefined\n if (!accountId)\n return (\n <span className=\"text-muted-foreground\">—</span>\n )\n // The account id is what people copy; when the resource\n // also carries its own `appUrl` that is the console\n // deep link for THAT account, so the id doubles as the\n // launch affordance.\n return sr.appUrl ? (\n <a\n href={sr.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"font-mono text-xs text-muted-foreground hover:text-primary hover:underline select-text\"\n >\n {accountId}\n </a>\n ) : (\n <span className=\"font-mono text-xs text-muted-foreground select-text\">\n {accountId}\n </span>\n )\n })()}\n </TableCell>\n </TableRow>\n )\n })\n )}\n </TableBody>\n </Table>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"SubResourcesSection.js","sources":["../../../../../../src/modules/appCatalog/ui/components/SubResourcesSection.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { Link, useNavigate, useSearch } from '@tanstack/react-router'\nimport { Search, X } from 'lucide-react'\nimport { useEffect, useMemo, useRef, useState } from 'react'\nimport { Badge } from '~/ui/badge'\nimport { Input } from '~/ui/input'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/ui/table'\nimport { PersonBadge, PersonOrGroupBadge } from './PersonBadge'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '~/ui/select'\nimport { markdownToPlainText } from '~/modules/appCatalog/utils/markdownToPlainText'\n\ninterface SubResourcesSectionProps {\n subResources: Resource[]\n /** Slug of the parent, for linking to a child's own page. */\n parentSlug: string\n /**\n * Initial filter text (#38 item C). When the user reached this app by\n * searching a term that matched a sub-resource, seed the sub-resource filter\n * with that term so the matched child is revealed instead of buried in a\n * long list (e.g. searching \"data\" → open Cloud Console → the matching\n * account is pre-filtered). Only applied when it actually matches a child.\n */\n initialSearch?: string\n}\n\nfunction getTierBadgeVariant(\n tierSlug: string,\n): 'default' | 'secondary' | 'destructive' | 'outline' {\n if (tierSlug === 'prod' || tierSlug === 'production') return 'destructive'\n if (tierSlug === 'dev' || tierSlug === 'staging') return 'secondary'\n if (tierSlug === 'preprod') return 'outline'\n if (tierSlug === 'sandbox') return 'outline'\n return 'outline'\n}\n\nfunction getTierBadgeClassName(tierSlug: string): string {\n if (tierSlug === 'preprod')\n return 'border-amber-400 bg-amber-100 text-amber-800 hover:bg-amber-200'\n if (tierSlug === 'sandbox')\n return 'border-gray-400 bg-gray-100 text-gray-700 hover:bg-gray-200'\n return ''\n}\n\nfunction getTierDisplayLabel(tierSlug: string): string {\n if (tierSlug === 'preprod') return 'Pre-Prod'\n if (tierSlug === 'sandbox') return 'Sandbox'\n if (tierSlug === 'prod' || tierSlug === 'production') return 'Prod'\n if (tierSlug === 'dev') return 'Dev'\n if (tierSlug === 'staging') return 'Staging'\n return tierSlug\n}\n\nexport function SubResourcesSection({\n subResources,\n parentSlug,\n initialSearch,\n}: SubResourcesSectionProps) {\n // `?sub=<slug>` — the one sub-resource the user asked for, by clicking its row\n // in the search results. It wins over the query-seeded filter below: a query\n // like \"project\" matches every child, so seeding with it would bury the row\n // that was actually clicked.\n const search_ = useSearch({ strict: false })\n const selectedSubSlug = search_.sub\n const selectedSub = useMemo(\n () => subResources.find((sr) => sr.slug === selectedSubSlug) ?? null,\n [subResources, selectedSubSlug],\n )\n const navigate = useNavigate()\n\n // The row to mark as \"this one\". It outlives `?sub=` so clearing the filter\n // brings the siblings back without losing the user's place.\n const [highlightSlug, setHighlightSlug] = useState(selectedSubSlug)\n useEffect(() => {\n if (selectedSubSlug) setHighlightSlug(selectedSubSlug)\n }, [selectedSubSlug])\n\n // The search input only mounts once the selection is gone, so focus it then.\n const searchInputRef = useRef<HTMLInputElement>(null)\n const focusSearchAfterClearRef = useRef(false)\n useEffect(() => {\n if (!selectedSub && focusSearchAfterClearRef.current) {\n focusSearchAfterClearRef.current = false\n searchInputRef.current?.focus()\n }\n }, [selectedSub])\n\n const clearSelection = () => {\n focusSearchAfterClearRef.current = true\n // A navigation, not a replace, so Back restores the single-row view.\n void navigate({\n to: '.',\n search: (prev) => ({ ...prev, sub: undefined }),\n })\n }\n\n // Seed the filter with the incoming query ONLY if it matches a child, so we\n // reveal the matched sub-resource without hiding everything on a non-match.\n const seededSearch = useMemo(() => {\n const q = initialSearch?.trim().toLowerCase()\n if (!q) return ''\n const hit = subResources.some(\n (sr) =>\n sr.displayName.toLowerCase().includes(q) ||\n (sr.aliases ?? []).some((a) => a.toLowerCase().includes(q)),\n )\n return hit ? initialSearch!.trim() : ''\n }, [initialSearch, subResources])\n const [search, setSearch] = useState(seededSearch)\n const [tierFilter, setTierFilter] = useState<string>('all')\n\n // useState only reads its argument on the first render, so without this the\n // filter kept the query from whenever this panel first mounted — searching\n // again with the panel already open left the old term in the box.\n useEffect(() => setSearch(seededSearch), [seededSearch])\n\n const uniqueTiers = useMemo(() => {\n const tiers = new Set<string>()\n for (const sr of subResources) {\n if (sr.tier) tiers.add(sr.tier)\n }\n return [...tiers].sort()\n }, [subResources])\n\n const filtered = useMemo(() => {\n if (selectedSub) return [selectedSub]\n\n let result = subResources\n\n if (tierFilter !== 'all') {\n result = result.filter((sr) => sr.tier === tierFilter)\n }\n\n if (search.trim()) {\n const q = search.trim().toLowerCase()\n result = result.filter(\n (sr) =>\n sr.displayName.toLowerCase().includes(q) ||\n (sr.aliases ?? []).some((a) => a.toLowerCase().includes(q)) ||\n (sr.description?.toLowerCase().includes(q) ?? false) ||\n (\n (sr.extra as Record<string, unknown>).awsAccountId as\n | string\n | undefined\n )?.includes(q) === true,\n )\n }\n\n return result\n }, [subResources, search, tierFilter, selectedSub])\n\n if (subResources.length === 0) return null\n\n return (\n <div className=\"space-y-3\">\n <div className=\"flex items-center justify-between\">\n <div className=\"text-sm font-medium\">\n Sub-Resources ({filtered.length} of {subResources.length})\n </div>\n </div>\n\n {/* Filters */}\n <div className=\"flex gap-2\">\n {selectedSub ? (\n // `?sub=` wins over the search box, so show it where the box would be\n // and make it dismissible — otherwise the only way out is the URL.\n <div className=\"flex flex-1 items-center\">\n <Badge variant=\"secondary\" className=\"h-9 gap-1 pl-3 pr-1 text-sm\">\n Showing: {selectedSub.displayName}\n <button\n type=\"button\"\n aria-label=\"Clear sub-resource filter\"\n onClick={clearSelection}\n className=\"rounded-sm p-1 hover:bg-muted-foreground/20\"\n >\n <X className=\"size-4\" />\n </button>\n </Badge>\n </div>\n ) : (\n <div className=\"relative flex-1\">\n <Search className=\"absolute left-2.5 top-2.5 size-4 text-muted-foreground\" />\n <Input\n ref={searchInputRef}\n placeholder=\"Search resources by name or alias...\"\n value={search}\n onChange={(e) => setSearch(e.target.value)}\n className=\"pl-9 h-9\"\n />\n </div>\n )}\n {uniqueTiers.length > 1 && (\n <Select value={tierFilter} onValueChange={setTierFilter}>\n <SelectTrigger className=\"w-[130px] h-9\">\n <SelectValue placeholder=\"All tiers\" />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"all\">All tiers</SelectItem>\n {uniqueTiers.map((tier) => (\n <SelectItem key={tier} value={tier}>\n {tier}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n )}\n </div>\n\n {/* Table */}\n <div className=\"rounded-lg border max-h-[400px] overflow-auto\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>Name</TableHead>\n <TableHead className=\"w-[80px]\">Tier</TableHead>\n <TableHead>Owner</TableHead>\n <TableHead>Approvers</TableHead>\n <TableHead className=\"w-[140px]\">AWS Account</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {filtered.length === 0 ? (\n <TableRow>\n <TableCell\n colSpan={5}\n className=\"text-center text-muted-foreground py-8\"\n >\n No resources match your filters\n </TableCell>\n </TableRow>\n ) : (\n filtered.map((sr) => {\n // Approvers are Person OR Group slugs; the badge resolves both.\n const approvers = [...new Set(sr.approverSlugs ?? [])]\n\n return (\n <TableRow\n key={sr.slug}\n // The row the user asked for, so it reads as \"this one\" even\n // once the filter is cleared and its siblings come back.\n aria-current={\n sr.slug === highlightSlug ? 'true' : undefined\n }\n className={\n sr.slug === highlightSlug ? 'bg-primary/[0.06]' : ''\n }\n >\n <TableCell>\n {/* A real link, not a click handler on the row: this is a\n table of resources, each of which has its own page. */}\n <Link\n to=\"/app/$slug/sub/$subSlug\"\n params={{ slug: parentSlug, subSlug: sr.slug }}\n className=\"font-medium text-sm hover:text-primary hover:underline\"\n >\n {sr.displayName}\n </Link>\n {(sr.aliases ?? []).length > 0 && (\n <div className=\"text-xs text-muted-foreground mt-0.5\">\n {(sr.aliases ?? []).join(', ')}\n </div>\n )}\n {sr.description && (\n <div className=\"text-xs text-muted-foreground mt-0.5\">\n {markdownToPlainText(sr.description)}\n </div>\n )}\n </TableCell>\n <TableCell>\n {sr.tier && (\n <Badge\n variant={getTierBadgeVariant(sr.tier)}\n className={`text-xs ${getTierBadgeClassName(sr.tier)}`}\n >\n {getTierDisplayLabel(sr.tier)}\n </Badge>\n )}\n </TableCell>\n <TableCell>\n {sr.ownerPersonSlug && (\n <PersonBadge slug={sr.ownerPersonSlug} />\n )}\n </TableCell>\n <TableCell>\n {approvers.length > 0 ? (\n <div className=\"flex flex-wrap gap-1\">\n {approvers.map((slug) => (\n <PersonOrGroupBadge key={slug} slug={slug} />\n ))}\n </div>\n ) : (\n <span className=\"text-muted-foreground\">-</span>\n )}\n </TableCell>\n <TableCell>\n {(() => {\n const accountId = (\n sr.extra as Record<string, unknown> | null | undefined\n )?.awsAccountId as string | undefined\n if (!accountId)\n return (\n <span className=\"text-muted-foreground\">—</span>\n )\n // The account id is what people copy; when the resource\n // also carries its own `appUrl` that is the console\n // deep link for THAT account, so the id doubles as the\n // launch affordance.\n return sr.appUrl ? (\n <a\n href={sr.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"font-mono text-xs text-muted-foreground hover:text-primary hover:underline select-text\"\n >\n {accountId}\n </a>\n ) : (\n <span className=\"font-mono text-xs text-muted-foreground select-text\">\n {accountId}\n </span>\n )\n })()}\n </TableCell>\n </TableRow>\n )\n })\n )}\n </TableBody>\n </Table>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@igstack/app-catalog-frontend-core",
|
|
3
|
-
"version": "6.1.
|
|
3
|
+
"version": "6.1.4-alpha-20260926175024",
|
|
4
4
|
"description": "Frontend core library for App Catalog",
|
|
5
5
|
"homepage": "https://github.com/lislon/app-catalog",
|
|
6
6
|
"repository": {
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
"tailwindcss": "^4.1.7",
|
|
113
113
|
"tiptap-markdown": "^0.8.0",
|
|
114
114
|
"tslib": "^2.3.0",
|
|
115
|
-
"@igstack/app-catalog-shared-core": "6.1.
|
|
115
|
+
"@igstack/app-catalog-shared-core": "6.1.4-alpha-20260926175024"
|
|
116
116
|
},
|
|
117
117
|
"devDependencies": {
|
|
118
118
|
"@tailwindcss/vite": "^4.0.5",
|
|
@@ -127,7 +127,7 @@
|
|
|
127
127
|
"@types/react-dom": "^19.1.6",
|
|
128
128
|
"@vitejs/plugin-react": "^4.3.4",
|
|
129
129
|
"@vitest/ui": "^4.1.2",
|
|
130
|
-
"eslint-plugin-react-hooks": "^
|
|
130
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
131
131
|
"fake-indexeddb": "^6.2.4",
|
|
132
132
|
"msw": "^2.12.14",
|
|
133
133
|
"msw-trpc": "^2.0.1",
|
|
@@ -139,7 +139,7 @@
|
|
|
139
139
|
"vite-plugin-static-copy": "^3.1.4",
|
|
140
140
|
"vite-plugin-svgr": "^4.2.0",
|
|
141
141
|
"vitest": "^4.1.2",
|
|
142
|
-
"@igstack/app-catalog-backend-core": "6.1.
|
|
142
|
+
"@igstack/app-catalog-backend-core": "6.1.4-alpha-20260926175024"
|
|
143
143
|
},
|
|
144
144
|
"peerDependencies": {
|
|
145
145
|
"react": "19.1.2",
|
|
@@ -148,7 +148,7 @@
|
|
|
148
148
|
"vite": "^6.3.5",
|
|
149
149
|
"vite-plugin-svgr": "^4.2.0"
|
|
150
150
|
},
|
|
151
|
-
"gitHead": "
|
|
151
|
+
"gitHead": "feee2cd960ee1d8e352ad84b77aa148da133dbdb",
|
|
152
152
|
"scripts": {
|
|
153
153
|
"build": "vite build",
|
|
154
154
|
"build:lenient": "vite build --mode lenient",
|
|
@@ -114,21 +114,17 @@ describe('AccessRequestSection — documentation placement', () => {
|
|
|
114
114
|
it('renders Documentation after Step 2, not inside Step 1', () => {
|
|
115
115
|
render(
|
|
116
116
|
<AccessRequestSection
|
|
117
|
-
app={
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
],
|
|
129
|
-
},
|
|
130
|
-
} as Resource
|
|
131
|
-
}
|
|
117
|
+
app={{
|
|
118
|
+
id: 'cloud',
|
|
119
|
+
slug: 'cloud',
|
|
120
|
+
displayName: 'Cloud',
|
|
121
|
+
accessRequest: {
|
|
122
|
+
approvalMethodSlug: 'service',
|
|
123
|
+
comments: 'Request it from the help desk.',
|
|
124
|
+
postApprovalInstructions: 'Ask the account maintainers.',
|
|
125
|
+
urls: [{ label: 'CLI guide', url: 'https://docs.example.com/cli' }],
|
|
126
|
+
},
|
|
127
|
+
}}
|
|
132
128
|
approvalMethods={[]}
|
|
133
129
|
/>,
|
|
134
130
|
)
|
|
@@ -165,18 +161,16 @@ describe('AccessRequestSection — chat channel mentions', () => {
|
|
|
165
161
|
>
|
|
166
162
|
<AppCatalogContext value={ctx}>
|
|
167
163
|
<AccessRequestSection
|
|
168
|
-
app={
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
} as Resource
|
|
179
|
-
}
|
|
164
|
+
app={{
|
|
165
|
+
id: 'swaggerhub',
|
|
166
|
+
slug: 'swaggerhub',
|
|
167
|
+
displayName: 'SwaggerHub',
|
|
168
|
+
accessRequest: {
|
|
169
|
+
approvalMethodSlug: 'service',
|
|
170
|
+
comments: 'For questions, ask in Slack #swaggerhub channel.',
|
|
171
|
+
postApprovalInstructions: 'Then say hi in #onboarding.',
|
|
172
|
+
},
|
|
173
|
+
}}
|
|
180
174
|
approvalMethods={[]}
|
|
181
175
|
/>
|
|
182
176
|
</AppCatalogContext>
|
|
@@ -26,7 +26,7 @@ function sub(slug: string, extra: unknown, appUrl?: string): Resource {
|
|
|
26
26
|
parentSlug: 'cloud-console',
|
|
27
27
|
appUrl,
|
|
28
28
|
extra: extra as Resource['extra'],
|
|
29
|
-
}
|
|
29
|
+
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
// A cloud account row shows its account id; when the entry also carries its own
|
|
@@ -5,8 +5,11 @@ import {
|
|
|
5
5
|
getParentResource,
|
|
6
6
|
} from '~/modules/appCatalog/utils/resolveHelpers'
|
|
7
7
|
|
|
8
|
-
const r = (over: Partial<Resource> & { slug: string }): Resource =>
|
|
9
|
-
|
|
8
|
+
const r = (over: Partial<Resource> & { slug: string }): Resource => ({
|
|
9
|
+
id: over.slug,
|
|
10
|
+
displayName: over.slug,
|
|
11
|
+
...over,
|
|
12
|
+
})
|
|
10
13
|
|
|
11
14
|
describe('getParentResource', () => {
|
|
12
15
|
it('returns undefined for a root resource', () => {
|
|
@@ -63,11 +63,11 @@ globalThis.IntersectionObserver = class IntersectionObserver {
|
|
|
63
63
|
readonly root = null
|
|
64
64
|
readonly rootMargin = ''
|
|
65
65
|
readonly thresholds = []
|
|
66
|
-
}
|
|
66
|
+
}
|
|
67
67
|
|
|
68
68
|
// Mock ResizeObserver — required by embla-carousel ResizeHandler in jsdom
|
|
69
69
|
globalThis.ResizeObserver = class ResizeObserver {
|
|
70
70
|
observe() {}
|
|
71
71
|
unobserve() {}
|
|
72
72
|
disconnect() {}
|
|
73
|
-
}
|
|
73
|
+
}
|
|
@@ -39,19 +39,19 @@ export function useSessionSyncedState<T>({
|
|
|
39
39
|
key,
|
|
40
40
|
defaultValue,
|
|
41
41
|
}: UseSessionSyncedStateOptions<T>): [T, (value: T) => void] {
|
|
42
|
-
const [state,
|
|
42
|
+
const [state, setState] = useState<T>(
|
|
43
43
|
() => (store.get(key) as T | undefined) ?? defaultValue,
|
|
44
44
|
)
|
|
45
45
|
|
|
46
|
-
const
|
|
46
|
+
const setAndPersist = useCallback(
|
|
47
47
|
(value: T) => {
|
|
48
|
-
|
|
48
|
+
setState(value)
|
|
49
49
|
store.set(key, value)
|
|
50
50
|
},
|
|
51
51
|
[key],
|
|
52
52
|
)
|
|
53
53
|
|
|
54
|
-
return [state,
|
|
54
|
+
return [state, setAndPersist]
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/**
|
|
@@ -296,6 +296,10 @@ export function SourcePulse({
|
|
|
296
296
|
}) {
|
|
297
297
|
// One timestamp for the whole render, so the mark and its legend cannot
|
|
298
298
|
// disagree about "now" by a few milliseconds.
|
|
299
|
+
// Reading the clock during render is deliberate. Making it pure would change when
|
|
300
|
+
// the readout refreshes — today it re-reads on any parent re-render — and that is a
|
|
301
|
+
// behaviour question, not a lint fix, so it is left as a suppression on purpose.
|
|
302
|
+
// eslint-disable-next-line @eslint-react/purity
|
|
299
303
|
const now = Date.now()
|
|
300
304
|
|
|
301
305
|
return (
|
|
@@ -89,16 +89,16 @@ export function SubResourcesSection({
|
|
|
89
89
|
|
|
90
90
|
// The search input only mounts once the selection is gone, so focus it then.
|
|
91
91
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
92
|
-
const
|
|
92
|
+
const focusSearchAfterClearRef = useRef(false)
|
|
93
93
|
useEffect(() => {
|
|
94
|
-
if (!selectedSub &&
|
|
95
|
-
|
|
94
|
+
if (!selectedSub && focusSearchAfterClearRef.current) {
|
|
95
|
+
focusSearchAfterClearRef.current = false
|
|
96
96
|
searchInputRef.current?.focus()
|
|
97
97
|
}
|
|
98
98
|
}, [selectedSub])
|
|
99
99
|
|
|
100
100
|
const clearSelection = () => {
|
|
101
|
-
|
|
101
|
+
focusSearchAfterClearRef.current = true
|
|
102
102
|
// A navigation, not a replace, so Back restores the single-row view.
|
|
103
103
|
void navigate({
|
|
104
104
|
to: '.',
|
|
@@ -28,7 +28,7 @@ export function MarkdownEditor({
|
|
|
28
28
|
}: MarkdownEditorProps) {
|
|
29
29
|
const [mode, setMode] = useState<'visual' | 'raw'>('visual')
|
|
30
30
|
const [rawValue, setRawValue] = useState(value)
|
|
31
|
-
const
|
|
31
|
+
const isInternalChangeRef = useRef(false)
|
|
32
32
|
|
|
33
33
|
const editor = useEditor({
|
|
34
34
|
extensions: [
|
|
@@ -47,7 +47,7 @@ export function MarkdownEditor({
|
|
|
47
47
|
content: value,
|
|
48
48
|
editable: !disabled,
|
|
49
49
|
onUpdate: ({ editor: editorInstance }) => {
|
|
50
|
-
|
|
50
|
+
isInternalChangeRef.current = true
|
|
51
51
|
const markdown = editorInstance.storage.markdown.getMarkdown()
|
|
52
52
|
onChange(markdown)
|
|
53
53
|
setRawValue(markdown)
|
|
@@ -56,8 +56,8 @@ export function MarkdownEditor({
|
|
|
56
56
|
|
|
57
57
|
// Sync external value changes to editor
|
|
58
58
|
useEffect(() => {
|
|
59
|
-
if (!editor ||
|
|
60
|
-
|
|
59
|
+
if (!editor || isInternalChangeRef.current) {
|
|
60
|
+
isInternalChangeRef.current = false
|
|
61
61
|
return
|
|
62
62
|
}
|
|
63
63
|
|