@igstack/app-catalog-frontend-core 6.4.0 → 6.5.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/dist/esm/__tests__/modules/appCatalog/ui/catalog/RequestAppDialog.test.d.ts +0 -0
- package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js +19 -0
- package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.d.ts +14 -0
- package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.js +115 -0
- package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +5 -3
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +18 -1
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/modules/appCatalog/ui/catalog/RequestAppDialog.test.tsx +113 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +24 -0
- package/src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx +24 -0
- package/src/modules/appCatalog/ui/catalog/RequestAppDialog.tsx +169 -0
- package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +8 -1
- package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +20 -1
|
File without changes
|
|
@@ -12,6 +12,7 @@ import { displayUrl } from "../../utils/displayUrl.js";
|
|
|
12
12
|
import { useUiSettings } from "../../../../context/UiSettingsContext.js";
|
|
13
13
|
import { AppCatalogContext } from "../../context/AppCatalogContext.js";
|
|
14
14
|
import { groupByArea, areaLabel } from "../../utils/areaGrouping.js";
|
|
15
|
+
import { RequestAppDialog } from "./RequestAppDialog.js";
|
|
15
16
|
const typeLabel = (t) => {
|
|
16
17
|
if (!t || t === "application") return null;
|
|
17
18
|
return t.charAt(0).toUpperCase() + t.slice(1);
|
|
@@ -182,6 +183,7 @@ function SearchResultsList({
|
|
|
182
183
|
const [expandedParents, setExpandedParents] = useState(
|
|
183
184
|
() => /* @__PURE__ */ new Set()
|
|
184
185
|
);
|
|
186
|
+
const [requestOpen, setRequestOpen] = useState(false);
|
|
185
187
|
const results = useMemo(
|
|
186
188
|
() => searchResources(apps, searchValue),
|
|
187
189
|
[apps, searchValue]
|
|
@@ -273,6 +275,23 @@ function SearchResultsList({
|
|
|
273
275
|
}, [rows, focusedIndex, activateRow, onClear, keyboardEnabled]);
|
|
274
276
|
return /* @__PURE__ */ jsxs("div", { className: "max-w-[620px] mx-auto mt-3", children: [
|
|
275
277
|
/* @__PURE__ */ jsx("div", { className: "text-[13px] text-muted-foreground mb-2 px-1", children: matchCount === 0 ? `No results for "${searchValue}"` : `${matchCount} result${matchCount === 1 ? "" : "s"}` }),
|
|
278
|
+
matchCount === 0 && !didDeprecatedFallback && /* @__PURE__ */ jsx("div", { className: "mb-3 px-1", children: /* @__PURE__ */ jsx(
|
|
279
|
+
"button",
|
|
280
|
+
{
|
|
281
|
+
type: "button",
|
|
282
|
+
onClick: () => setRequestOpen(true),
|
|
283
|
+
className: "text-sm underline decoration-dotted underline-offset-2 hover:text-foreground text-muted-foreground",
|
|
284
|
+
children: "Not in the catalog? Ask for it to be added"
|
|
285
|
+
}
|
|
286
|
+
) }),
|
|
287
|
+
/* @__PURE__ */ jsx(
|
|
288
|
+
RequestAppDialog,
|
|
289
|
+
{
|
|
290
|
+
open: requestOpen,
|
|
291
|
+
onOpenChange: setRequestOpen,
|
|
292
|
+
initialSubject: searchValue
|
|
293
|
+
}
|
|
294
|
+
),
|
|
276
295
|
didDeprecatedFallback && /* @__PURE__ */ jsxs(
|
|
277
296
|
"div",
|
|
278
297
|
{
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AppCatalogGrid.js","sources":["../../../../../../src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { ArrowUpRight, Search, X } from 'lucide-react'\nimport { use, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { cn } from '~/lib/utils'\nimport { useAppClickHistory } from '../../hooks/useAppClickHistory'\nimport { markdownToPlainText } from '../../utils/markdownToPlainText'\nimport { pickNewThisWeek } from '../../utils/launcherHelpers'\nimport { ResourceIcon } from './ResourceIcon'\nimport { searchResources } from '@igstack/app-catalog-shared-core'\nimport { Highlight } from '../components/Highlight'\nimport { displayUrl } from '~/modules/appCatalog/utils/displayUrl'\nimport { useUiSettings } from '~/context/UiSettingsContext'\nimport type { AreaIcon } from '~/types/uiSettings'\nimport { AppCatalogContext } from '../../context/AppCatalogContext'\nimport { areaLabel, groupByArea } from '../../utils/areaGrouping'\n\n/**\n * Adaptive-home discovery spine (issue #38, increment 1) — matches the\n * option-a-launcher prototype:\n * Your apps (frequent) → New this week → all areas, grouped\n *\n * This is the PERSISTENT shell for the catalog: idle browse, active search and\n * an open detail overlay all render inside it. Only the area below the hero\n * swaps (discovery spine ↔ results list), so the hero copy, the search box and\n * the page container never move while the user types. Do not switch the page to\n * a different container on search — that was the layout-jitter bug.\n */\n\n// Type pill text. Plain \"application\" resources are the common case and don't\n// need a badge (the user asked not to show a bare \"App\" badge); only surface a\n// pill for distinctive types (database, cloud, service, …).\nconst typeLabel = (t?: string): string | null => {\n if (!t || t === 'application') return null\n return t.charAt(0).toUpperCase() + t.slice(1)\n}\n\nexport interface AppCatalogGridProps {\n /** All root (non-child) resources, already filtered for deprecated per settings. */\n apps: Resource[]\n /**\n * All resources INCLUDING children/sub-resources. Passed to search so a query\n * matching a sub-resource (e.g. an AWS account, an alias/account ID) surfaces\n * its parent — preserving the existing cross-sub-resource search behavior.\n * Falls back to `apps` when omitted.\n */\n allResources?: Resource[]\n searchValue: string\n onSearchChange: (v: string) => void\n onAppClick: (app: Resource) => void\n /** Opens a matched sub-resource: its parent's detail, singled out to it. */\n onSubClick: (parentSlug: string, subSlug: string) => void\n onLaunch: (app: Resource) => void\n /**\n * @deprecated Unused — it labelled the removed \"Browse all\" header. Still\n * accepted so existing callers keep compiling; drop it on the next major.\n */\n totalCount?: number\n /**\n * True while an app/sub-resource detail overlay is open above the launcher.\n * The results list then stops binding ↑↓/↵/Esc so the overlay owns the\n * keyboard (otherwise Esc would ALSO wipe the search behind the overlay).\n */\n detailOpen?: boolean\n /** Currently open sub-resource slug, for highlighting its row. */\n selectedSubSlug?: string\n}\n\nfunction LaunchButton({\n app,\n onLaunch,\n className,\n}: {\n app: Resource\n onLaunch: (app: Resource) => void\n className?: string\n}) {\n if (!app.appUrl) return null\n // Show the destination URL on hover (user ask) — the native title tooltip\n // reveals where the ↗ jumps to, e.g. \"Open → console.aws.amazon.com\".\n const prettyUrl = displayUrl(app.appUrl)\n return (\n <a\n href={app.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n title={`Open → ${prettyUrl}`}\n aria-label={`Open ${app.displayName} in a new tab (${prettyUrl})`}\n onClick={(e) => {\n e.stopPropagation()\n onLaunch(app)\n }}\n className={cn(\n 'grid place-items-center rounded-full border border-border bg-card text-muted-foreground',\n 'hover:bg-primary hover:text-primary-foreground hover:border-primary transition-colors',\n className,\n )}\n >\n <ArrowUpRight className=\"size-3.5\" />\n </a>\n )\n}\n\nfunction SectionHead({\n title,\n count,\n action,\n}: {\n title: string\n count?: string\n action?: React.ReactNode\n}) {\n return (\n <div className=\"flex items-center gap-2.5 mb-4\">\n <h2 className=\"font-serif font-semibold text-[15px] tracking-tight m-0\">\n {title}\n </h2>\n {count && (\n <span className=\"text-[13px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5\">\n {count}\n </span>\n )}\n {action && <div className=\"ml-auto\">{action}</div>}\n </div>\n )\n}\n\n/** Small row used by \"New this week\" and the search results. */\nfunction ResourceRow({\n app,\n onAppClick,\n onLaunch,\n showNew = false,\n}: {\n app: Resource\n onAppClick: (app: Resource) => void\n onLaunch: (app: Resource) => void\n showNew?: boolean\n}) {\n return (\n <button\n type=\"button\"\n onClick={() => onAppClick(app)}\n title={`View ${app.displayName}`}\n className={cn(\n 'group flex items-center gap-3.5 w-full text-left',\n 'bg-card border border-border rounded-[var(--radius)] px-3.5 py-3',\n 'hover:border-ring hover:shadow-sm transition-all',\n )}\n >\n <ResourceIcon app={app} size={38} />\n <span className=\"flex-1 min-w-0\">\n <span className=\"block text-[14.5px] font-semibold truncate\">\n {app.displayName}\n </span>\n {app.description && (\n <span className=\"block text-[13px] text-muted-foreground truncate\">\n {markdownToPlainText(app.description)}\n </span>\n )}\n </span>\n {showNew && (\n <span className=\"text-[11px] font-bold text-[#65a443] bg-[#65a443]/12 rounded-full px-2.5 py-0.5\">\n New\n </span>\n )}\n {typeLabel(app.type) && (\n <span className=\"text-[12px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5 whitespace-nowrap\">\n {typeLabel(app.type)}\n </span>\n )}\n <LaunchButton app={app} onLaunch={onLaunch} className=\"size-8\" />\n </button>\n )\n}\n\n/**\n * Wide area card: icon on the left, name and description beside it.\n *\n * The grid cell keeps a fixed footprint and the visible panel is absolute inside\n * it, so a card growing on hover OVERLAYS the row below instead of reflowing the\n * whole grid. Hover reveals the rest of the description and nothing else — an\n * earlier version staggered a delayed tag block in behind the growth, which read\n * as jitter. Keyboard focus expands the same way, and `motion-reduce` drops the\n * animation.\n *\n * The footprint is a fixed height rather than an aspect ratio: at two or three\n * columns a ratio makes the card grow taller as the window widens, and the\n * description — three lines at most — leaves the bottom half empty.\n */\nfunction AppCard({\n app,\n onAppClick,\n onLaunch,\n}: {\n app: Resource\n onAppClick: (app: Resource) => void\n onLaunch: (app: Resource) => void\n}) {\n return (\n <div className=\"relative h-[92px]\">\n <button\n type=\"button\"\n onClick={() => onAppClick(app)}\n title={`View ${app.displayName}`}\n className={cn(\n 'group absolute inset-x-0 top-0 min-h-full max-h-full overflow-hidden',\n 'flex gap-3.5 text-left bg-card border border-border',\n 'rounded-[var(--radius)] p-[15px] pr-11 outline-none',\n 'transition-[max-height,box-shadow,border-color] duration-200',\n 'hover:max-h-96 hover:border-ring hover:shadow-lg hover:z-10',\n 'focus-visible:max-h-96 focus-visible:border-ring focus-visible:shadow-lg focus-visible:z-10',\n 'motion-reduce:transition-none',\n )}\n >\n <LaunchButton\n app={app}\n onLaunch={onLaunch}\n className=\"absolute top-3 right-3 size-[26px] opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100\"\n />\n <div className=\"shrink-0\">\n <ResourceIcon app={app} size={40} />\n </div>\n {/* min-w-0 so a long unbroken word truncates instead of pushing the\n column wider than the card. */}\n <div className=\"flex min-w-0 flex-col gap-1.5\">\n {/* shrink-0: while max-height animates, the un-clamped description\n overflows the panel for a few frames. Without it flexbox squeezes\n this line to 0 and back, which reads as the title flickering. */}\n <span className=\"block shrink-0 text-[14.5px] font-bold leading-tight line-clamp-1 group-hover:line-clamp-2 group-focus-visible:line-clamp-2\">\n {app.displayName}\n </span>\n {app.description && (\n <span\n className={cn(\n 'block shrink-0 text-[12.5px] leading-snug text-muted-foreground',\n 'line-clamp-2 group-hover:line-clamp-none group-focus-visible:line-clamp-none',\n )}\n >\n {markdownToPlainText(app.description)}\n </span>\n )}\n </div>\n </button>\n </div>\n )\n}\n\n/**\n * One area: icon, big title, count, then its cards. The icon stands in for the\n * accent bar the header used to carry — it does the same colour job and also\n * says which area you are in. It is decorative: the `h3` carries the name.\n */\nfunction AreaSection({\n label,\n Icon,\n apps,\n onAppClick,\n onLaunch,\n}: {\n label: string\n Icon?: AreaIcon\n apps: Resource[]\n onAppClick: (app: Resource) => void\n onLaunch: (app: Resource) => void\n}) {\n return (\n <section className=\"mt-11\">\n <div className=\"flex items-center gap-2.5 mb-4\">\n {Icon ? (\n <Icon\n size={26}\n strokeWidth={1.75}\n className=\"text-primary shrink-0\"\n />\n ) : (\n <div className=\"h-[3px] w-11 rounded-full bg-primary\" />\n )}\n <h3 className=\"font-serif font-semibold text-[27px] leading-tight tracking-tight m-0\">\n {label}\n </h3>\n <span className=\"text-[13px] text-muted-foreground\">\n {apps.length} {apps.length === 1 ? 'tool' : 'tools'}\n </span>\n </div>\n <div className=\"grid gap-3 grid-cols-1 md:grid-cols-2 xl:grid-cols-3\">\n {apps.map((app) => (\n <AppCard\n key={app.slug}\n app={app}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n />\n ))}\n </div>\n </section>\n )\n}\n\n/** Matched sub-resources listed under a parent before the \"… N more\" row. */\nconst COLLAPSED_SUB_LIMIT = 5\n\n/** One line in the flat, keyboard-navigable results list. */\ntype ResultRow =\n | { kind: 'app'; key: string; app: Resource }\n | { kind: 'sub'; key: string; sub: Resource; parent: Resource }\n | { kind: 'more'; key: string; parent: Resource; hidden: number }\n\n/**\n * Keyboard-navigable search results list — the launcher's only search surface.\n *\n * It renders directly BELOW the hero search box, inside the same persistent\n * launcher shell: the hero copy, the search input and the page container never\n * move when the user types, and no filter chrome (tabs / category / deprecated\n * toggle) appears. Those filters live in the browse view only — clearing the\n * search brings them back.\n *\n * Rows are flat and individually clickable. A matched sub-resource gets its own\n * indented row under its parent, so a query that only hits a sub-resource\n * (e.g. a cloud account) can be opened directly instead of forcing the user to\n * dig through the parent's detail. ↑↓ moves over every row, ↵ activates the\n * focused one (the first row until something else is focused), Esc clears the\n * search.\n */\nfunction SearchResultsList({\n apps,\n searchValue,\n onAppClick,\n onSubClick,\n onLaunch,\n onClear,\n keyboardEnabled = true,\n selectedSubSlug,\n}: {\n apps: Resource[]\n searchValue: string\n onAppClick: (app: Resource) => void\n onSubClick: (parentSlug: string, subSlug: string) => void\n onLaunch: (app: Resource) => void\n onClear: () => void\n keyboardEnabled?: boolean\n selectedSubSlug?: string\n}) {\n // The first row starts focused: Enter straight after typing opens it, and the\n // focus style shows which row that is. Nothing-focused (-1) left Enter dead\n // until the user pressed ↓ first (#167).\n const [focusedIndex, setFocusedIndex] = useState(0)\n const [expandedParents, setExpandedParents] = useState<Set<string>>(\n () => new Set(),\n )\n\n const results = useMemo(\n () => searchResources(apps, searchValue),\n [apps, searchValue],\n )\n\n // For each result, the child/sub-resources of it that themselves match the\n // query — so a parent surfaced only via its children (e.g. AWS Console via a\n // \"biom\" account) tells the user WHICH sub-resources matched, instead of an\n // unexplained parent. Keyed by parent slug.\n const matchedChildrenByParent = useMemo(() => {\n const q = searchValue.trim().toLowerCase()\n const map = new Map<string, Resource[]>()\n if (!q) return map\n const resultSlugs = new Set(results.map((r) => r.slug))\n for (const r of apps) {\n if (!r.parentSlug || !resultSlugs.has(r.parentSlug)) continue\n const hay = `${r.displayName} ${r.abbreviation ?? ''} ${(\n r.aliases ?? []\n ).join(' ')} ${r.description ?? ''}`.toLowerCase()\n if (!hay.includes(q)) continue\n const list = map.get(r.parentSlug) ?? []\n list.push(r)\n map.set(r.parentSlug, list)\n }\n return map\n }, [apps, results, searchValue])\n\n // #11 deprecated fallback: when EVERY match is a deprecated app (i.e. there\n // are no active matches for the query, only deprecated ones), surface a\n // notice so the user understands why they're seeing archived tools. The\n // deprecated apps are already in `results` because the launcher feeds search\n // the full resource set; this just labels the situation.\n const didDeprecatedFallback = useMemo(\n () => results.length > 0 && results.every((r) => Boolean(r.deprecated)),\n [results],\n )\n\n // Flatten parents + their matched sub-resources into one navigable list, so a\n // sub-resource row is a first-class result (clickable, focusable) rather than\n // a footnote on its parent.\n const rows = useMemo(() => {\n const out: ResultRow[] = []\n for (const app of results) {\n out.push({ kind: 'app', key: `app:${app.slug}`, app })\n const kids = matchedChildrenByParent.get(app.slug) ?? []\n const expanded = expandedParents.has(app.slug)\n const visible = expanded ? kids : kids.slice(0, COLLAPSED_SUB_LIMIT)\n for (const sub of visible) {\n out.push({\n kind: 'sub',\n key: `sub:${app.slug}:${sub.slug}`,\n sub,\n parent: app,\n })\n }\n const hidden = kids.length - visible.length\n if (hidden > 0) {\n out.push({ kind: 'more', key: `more:${app.slug}`, parent: app, hidden })\n }\n }\n return out\n }, [results, matchedChildrenByParent, expandedParents])\n\n const matchCount = useMemo(() => {\n let n = results.length\n for (const kids of matchedChildrenByParent.values()) n += kids.length\n return n\n }, [results, matchedChildrenByParent])\n\n const activateRow = useCallback(\n (row: ResultRow) => {\n if (row.kind === 'app') {\n onAppClick(row.app)\n } else if (row.kind === 'sub') {\n // Open the parent's detail singled out to THIS sub-resource. Filtering\n // the parent's sub-resource table by the query instead would leave the\n // clicked row buried among every other match (\"project\" matches 47).\n onSubClick(row.parent.slug, row.sub.slug)\n } else {\n setExpandedParents((prev) => new Set(prev).add(row.parent.slug))\n }\n },\n [onAppClick, onSubClick],\n )\n\n // Back to the first row and collapse expanded sub-resource lists when the query changes\n useEffect(() => {\n setFocusedIndex(0)\n setExpandedParents(new Set())\n }, [searchValue])\n\n useEffect(() => {\n // While a detail overlay is open it owns the keyboard: without this guard\n // Esc would clear the search behind the overlay and ↑↓/↵ would navigate to\n // a different app from a list the user cannot see.\n if (!keyboardEnabled) return\n const onKey = (e: KeyboardEvent) => {\n if (rows.length === 0) return\n if (e.key === 'ArrowDown') {\n e.preventDefault()\n setFocusedIndex((i) => Math.min(i + 1, rows.length - 1))\n } else if (e.key === 'ArrowUp') {\n e.preventDefault()\n setFocusedIndex((i) => Math.max(i - 1, 0))\n } else if (e.key === 'Enter') {\n // A focused row or button activates itself through its native click;\n // acting here as well would open the first result on top of it.\n if (e.defaultPrevented) return\n if (\n e.target instanceof HTMLElement &&\n e.target.closest('button, a, [role=\"option\"]')\n )\n return\n const row = focusedIndex >= 0 ? rows[focusedIndex] : undefined\n if (row) activateRow(row)\n } else if (e.key === 'Escape') {\n onClear()\n }\n }\n document.addEventListener('keydown', onKey)\n return () => document.removeEventListener('keydown', onKey)\n }, [rows, focusedIndex, activateRow, onClear, keyboardEnabled])\n\n return (\n <div className=\"max-w-[620px] mx-auto mt-3\">\n {/* Meta line. Counts matched sub-resources too — they are results in\n their own right, and a parent surfaced only via 47 matching accounts\n used to be reported as \"1 result\". Collapsed ones still count, so the\n number doesn't jump when the \"... N more\" row is expanded. */}\n <div className=\"text-[13px] text-muted-foreground mb-2 px-1\">\n {matchCount === 0\n ? `No results for \"${searchValue}\"`\n : `${matchCount} result${matchCount === 1 ? '' : 's'}`}\n </div>\n\n {/* #11: deprecated-only fallback notice */}\n {didDeprecatedFallback && (\n <div\n role=\"status\"\n className=\"text-[13px] text-muted-foreground mb-2 px-1\"\n >\n No active apps for "{searchValue}" — showing deprecated\n matches.\n </div>\n )}\n\n {/* Results — parents and their matched sub-resources as one flat list */}\n <div\n className=\"flex flex-col gap-1.5\"\n role=\"listbox\"\n aria-label=\"Search results\"\n >\n {rows.map((row, i) => {\n const focused = focusedIndex === i\n\n if (row.kind === 'sub') {\n const { sub, parent } = row\n const isOpen = selectedSubSlug === sub.slug\n return (\n <button\n key={row.key}\n type=\"button\"\n role=\"option\"\n data-kind=\"sub\"\n aria-selected={focused}\n // \"Which one am I looking at\" — set while this sub-resource is\n // the one singled out in its parent's detail behind the overlay.\n aria-current={isOpen ? 'true' : undefined}\n aria-label={`${sub.displayName} — sub-resource of ${parent.displayName}`}\n title={sub.displayName}\n onMouseEnter={() => setFocusedIndex(i)}\n onClick={() => activateRow(row)}\n className={cn(\n // Indented + left rule so the parent/child relation reads at\n // a glance without an extra text prefix.\n 'flex items-center ml-8 border-l-2 pl-3 pr-3 py-1.5 text-left',\n 'text-[13px] rounded-r-[var(--radius)] transition-colors',\n focused || isOpen\n ? 'border-primary bg-muted/40 text-foreground'\n : 'border-border text-muted-foreground hover:border-primary hover:bg-muted/30 hover:text-foreground',\n )}\n >\n <Highlight text={sub.displayName} query={searchValue} />\n </button>\n )\n }\n\n if (row.kind === 'more') {\n return (\n <button\n key={row.key}\n type=\"button\"\n role=\"option\"\n data-kind=\"more\"\n aria-selected={focused}\n aria-label={`Show ${row.hidden} more matching sub-resources of ${row.parent.displayName}`}\n onMouseEnter={() => setFocusedIndex(i)}\n onClick={() => activateRow(row)}\n className={cn(\n 'flex items-center ml-8 border-l-2 pl-3 pr-3 py-1.5 text-left',\n 'text-[13px] rounded-r-[var(--radius)] transition-colors',\n focused\n ? 'border-primary bg-muted/40 text-foreground'\n : 'border-border text-muted-foreground hover:border-primary hover:text-foreground',\n )}\n >\n ... {row.hidden} more\n </button>\n )\n }\n\n const app = row.app\n return (\n <button\n key={row.key}\n type=\"button\"\n role=\"option\"\n title={`View ${app.displayName}`}\n aria-selected={focused}\n onMouseEnter={() => setFocusedIndex(i)}\n onClick={() => activateRow(row)}\n className={cn(\n 'group flex items-center gap-3.5 w-full text-left',\n 'bg-card border rounded-[var(--radius)] px-3.5 py-2.5',\n 'transition-all',\n focused\n ? 'border-ring shadow-sm bg-muted/30'\n : 'border-border hover:border-ring hover:shadow-sm',\n )}\n >\n <ResourceIcon app={app} size={40} />\n <span className=\"flex-1 min-w-0\">\n <span className=\"block text-[14px] font-semibold truncate\">\n <Highlight text={app.displayName} query={searchValue} />\n {app.abbreviation && app.abbreviation !== app.displayName && (\n <span className=\"ml-1.5 text-[12px] font-normal text-muted-foreground\">\n (<Highlight text={app.abbreviation} query={searchValue} />\n )\n </span>\n )}\n </span>\n {app.description && (\n <span className=\"block text-[12.5px] text-muted-foreground truncate\">\n {markdownToPlainText(app.description)}\n </span>\n )}\n </span>\n {typeLabel(app.type) && (\n <span className=\"text-[11.5px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5 whitespace-nowrap shrink-0\">\n {typeLabel(app.type)}\n </span>\n )}\n <LaunchButton\n app={app}\n onLaunch={onLaunch}\n className=\"size-7 shrink-0 opacity-0 group-hover:opacity-100\"\n />\n </button>\n )\n })}\n </div>\n\n {/* Keyboard hint */}\n {rows.length > 0 && (\n <p className=\"text-[11.5px] text-muted-foreground/60 text-center mt-3\">\n ↑↓ navigate · ↵ view access · esc clear\n </p>\n )}\n </div>\n )\n}\n\nexport function AppCatalogGrid({\n apps,\n allResources,\n searchValue,\n onSearchChange,\n onAppClick,\n onSubClick,\n onLaunch,\n detailOpen = false,\n selectedSubSlug,\n}: AppCatalogGridProps) {\n const { getTopApps } = useAppClickHistory()\n const [topSlugs, setTopSlugs] = useState<string[]>([])\n // Area labels are data (the catalog's own `category` tag definition); only the\n // icons and the day-to-day fold are deployment config.\n const { areas } = useUiSettings()\n const tagsDefinitions = use(AppCatalogContext)?.tagsDefinitions\n\n useEffect(() => {\n void getTopApps(5).then(setTopSlugs)\n }, [getTopApps])\n\n const bySlug = useMemo(() => {\n const m = new Map<string, Resource>()\n for (const a of apps) m.set(a.slug, a)\n return m\n }, [apps])\n\n // Your apps: frequently-clicked (personalized). Empty on a cold new machine.\n const frequent = useMemo(\n () =>\n topSlugs\n .map((s) => bySlug.get(s))\n .filter((a): a is Resource => Boolean(a))\n .slice(0, 5),\n [topSlugs, bySlug],\n )\n\n // New this week: apps ADDED in the last 7 days — see pickNewThisWeek (#96).\n const fresh = useMemo(() => pickNewThisWeek(apps), [apps])\n\n // Everything not already promoted to \"Your apps\", alpha by name.\n const browse = useMemo(() => {\n const promoted = new Set(frequent.map((a) => a.slug))\n return [...apps]\n .filter((a) => !promoted.has(a.slug))\n .sort((a, b) => a.displayName.localeCompare(b.displayName))\n }, [apps, frequent])\n\n // Grouped: day-to-day tools first, then areas biggest-first so the page opens\n // on the areas most people need. Within an area, alpha (from browse).\n const browseGroups = useMemo(() => groupByArea(browse), [browse])\n\n const isSearching = searchValue.trim() !== ''\n const searchRef = useRef<HTMLInputElement>(null)\n\n // ⌘K / Ctrl+K focuses the hero search box from anywhere on the page.\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if ((e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault()\n searchRef.current?.focus()\n }\n }\n document.addEventListener('keydown', onKey)\n return () => document.removeEventListener('keydown', onKey)\n }, [])\n\n return (\n <div className=\"mx-auto w-full max-w-[1000px] pb-8\">\n {/* hero */}\n <div className=\"text-center pt-10 pb-1.5\">\n <h1 className=\"font-serif font-semibold text-[30px] tracking-tight m-0 mb-1.5\">\n What do you need to get into?\n </h1>\n <p className=\"text-muted-foreground m-0 mb-6 text-[15px]\">\n Find any tool, see how to request access, or jump straight in.\n </p>\n <div className=\"max-w-[620px] mx-auto\">\n <div className=\"flex items-center gap-3 bg-card border-[1.5px] border-input rounded-full px-5 h-14 shadow-sm focus-within:border-primary focus-within:ring-4 focus-within:ring-primary/15 transition-colors\">\n <Search className=\"size-5 shrink-0 text-muted-foreground\" />\n <input\n type=\"text\"\n value={searchValue}\n onChange={(e) => onSearchChange(e.target.value)}\n placeholder=\"Search apps, cloud accounts, or keywords\"\n autoComplete=\"off\"\n autoFocus\n ref={searchRef}\n aria-label=\"Search apps\"\n className=\"flex-1 bg-transparent border-0 outline-none text-[16px] text-foreground placeholder:text-muted-foreground\"\n />\n {isSearching ? (\n <button\n type=\"button\"\n aria-label=\"Clear search\"\n onClick={() => {\n onSearchChange('')\n searchRef.current?.focus()\n }}\n className=\"shrink-0 rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors\"\n >\n <X className=\"size-4\" />\n </button>\n ) : (\n <kbd className=\"hidden sm:inline-flex items-center gap-0.5 text-[11px] text-muted-foreground/60 border border-muted-foreground/20 rounded px-1.5 py-0.5 font-mono shrink-0\">\n {typeof navigator !== 'undefined' &&\n navigator.platform.includes('Mac')\n ? '⌘K'\n : 'Ctrl+K'}\n </kbd>\n )}\n </div>\n </div>\n </div>\n\n {/* Search results: rendered in place, directly under the (unmoved) hero\n search box. The discovery spine below is what gets replaced — the\n shell, hero and search box stay put, so typing never shifts layout. */}\n {isSearching && (\n <SearchResultsList\n apps={allResources ?? apps}\n searchValue={searchValue}\n onAppClick={onAppClick}\n onSubClick={onSubClick}\n onLaunch={onLaunch}\n onClear={() => onSearchChange('')}\n keyboardEnabled={!detailOpen}\n selectedSubSlug={selectedSubSlug}\n />\n )}\n\n {/* Discovery spine: only shown when not searching */}\n {!isSearching && (\n <>\n {/* Your apps */}\n {frequent.length > 0 && (\n <section className=\"mt-9\">\n <SectionHead title=\"Your apps\" count=\"frequent\" />\n <div className=\"grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3.5\">\n {frequent.map((app) => (\n <button\n type=\"button\"\n key={app.slug}\n onClick={() => onAppClick(app)}\n title={`View ${app.displayName}`}\n className=\"group relative bg-card border border-border rounded-[var(--radius)] px-3.5 pt-[18px] pb-4 text-center shadow-sm hover:-translate-y-0.5 hover:shadow-md transition-all\"\n >\n <LaunchButton\n app={app}\n onLaunch={onLaunch}\n className=\"absolute top-2.5 right-2.5 size-[26px] opacity-0 group-hover:opacity-100\"\n />\n <ResourceIcon\n app={app}\n size={46}\n className=\"mx-auto mb-3\"\n />\n <span className=\"block text-[13.5px] font-bold truncate\">\n {app.displayName}\n </span>\n </button>\n ))}\n </div>\n </section>\n )}\n\n {/* New this week */}\n {fresh.length > 0 && (\n <section className=\"mt-9\">\n <SectionHead\n title=\"New this week\"\n count={`${fresh.length} added`}\n />\n <div className=\"flex flex-col gap-2\">\n {fresh.map((app) => (\n <ResourceRow\n key={app.slug}\n app={app}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n showNew\n />\n ))}\n </div>\n </section>\n )}\n\n {/* Everything else, grouped by area — the area titles carry it, so\n there is no \"Browse all\" header above them. */}\n <section className=\"mt-9\">\n {browseGroups.map(([key, list]) => (\n <AreaSection\n key={key}\n label={areaLabel(key, tagsDefinitions, areas?.dayToDayLabel)}\n Icon={areas?.icons?.[key]}\n apps={list}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n />\n ))}\n </section>\n </>\n )}\n </div>\n )\n}\n"],"names":["_a"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"AppCatalogGrid.js","sources":["../../../../../../src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { ArrowUpRight, Search, X } from 'lucide-react'\nimport { use, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { cn } from '~/lib/utils'\nimport { useAppClickHistory } from '../../hooks/useAppClickHistory'\nimport { markdownToPlainText } from '../../utils/markdownToPlainText'\nimport { pickNewThisWeek } from '../../utils/launcherHelpers'\nimport { ResourceIcon } from './ResourceIcon'\nimport { searchResources } from '@igstack/app-catalog-shared-core'\nimport { Highlight } from '../components/Highlight'\nimport { displayUrl } from '~/modules/appCatalog/utils/displayUrl'\nimport { useUiSettings } from '~/context/UiSettingsContext'\nimport type { AreaIcon } from '~/types/uiSettings'\nimport { AppCatalogContext } from '../../context/AppCatalogContext'\nimport { areaLabel, groupByArea } from '../../utils/areaGrouping'\nimport { RequestAppDialog } from './RequestAppDialog'\n\n/**\n * Adaptive-home discovery spine (issue #38, increment 1) — matches the\n * option-a-launcher prototype:\n * Your apps (frequent) → New this week → all areas, grouped\n *\n * This is the PERSISTENT shell for the catalog: idle browse, active search and\n * an open detail overlay all render inside it. Only the area below the hero\n * swaps (discovery spine ↔ results list), so the hero copy, the search box and\n * the page container never move while the user types. Do not switch the page to\n * a different container on search — that was the layout-jitter bug.\n */\n\n// Type pill text. Plain \"application\" resources are the common case and don't\n// need a badge (the user asked not to show a bare \"App\" badge); only surface a\n// pill for distinctive types (database, cloud, service, …).\nconst typeLabel = (t?: string): string | null => {\n if (!t || t === 'application') return null\n return t.charAt(0).toUpperCase() + t.slice(1)\n}\n\nexport interface AppCatalogGridProps {\n /** All root (non-child) resources, already filtered for deprecated per settings. */\n apps: Resource[]\n /**\n * All resources INCLUDING children/sub-resources. Passed to search so a query\n * matching a sub-resource (e.g. an AWS account, an alias/account ID) surfaces\n * its parent — preserving the existing cross-sub-resource search behavior.\n * Falls back to `apps` when omitted.\n */\n allResources?: Resource[]\n searchValue: string\n onSearchChange: (v: string) => void\n onAppClick: (app: Resource) => void\n /** Opens a matched sub-resource: its parent's detail, singled out to it. */\n onSubClick: (parentSlug: string, subSlug: string) => void\n onLaunch: (app: Resource) => void\n /**\n * @deprecated Unused — it labelled the removed \"Browse all\" header. Still\n * accepted so existing callers keep compiling; drop it on the next major.\n */\n totalCount?: number\n /**\n * True while an app/sub-resource detail overlay is open above the launcher.\n * The results list then stops binding ↑↓/↵/Esc so the overlay owns the\n * keyboard (otherwise Esc would ALSO wipe the search behind the overlay).\n */\n detailOpen?: boolean\n /** Currently open sub-resource slug, for highlighting its row. */\n selectedSubSlug?: string\n}\n\nfunction LaunchButton({\n app,\n onLaunch,\n className,\n}: {\n app: Resource\n onLaunch: (app: Resource) => void\n className?: string\n}) {\n if (!app.appUrl) return null\n // Show the destination URL on hover (user ask) — the native title tooltip\n // reveals where the ↗ jumps to, e.g. \"Open → console.aws.amazon.com\".\n const prettyUrl = displayUrl(app.appUrl)\n return (\n <a\n href={app.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n title={`Open → ${prettyUrl}`}\n aria-label={`Open ${app.displayName} in a new tab (${prettyUrl})`}\n onClick={(e) => {\n e.stopPropagation()\n onLaunch(app)\n }}\n className={cn(\n 'grid place-items-center rounded-full border border-border bg-card text-muted-foreground',\n 'hover:bg-primary hover:text-primary-foreground hover:border-primary transition-colors',\n className,\n )}\n >\n <ArrowUpRight className=\"size-3.5\" />\n </a>\n )\n}\n\nfunction SectionHead({\n title,\n count,\n action,\n}: {\n title: string\n count?: string\n action?: React.ReactNode\n}) {\n return (\n <div className=\"flex items-center gap-2.5 mb-4\">\n <h2 className=\"font-serif font-semibold text-[15px] tracking-tight m-0\">\n {title}\n </h2>\n {count && (\n <span className=\"text-[13px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5\">\n {count}\n </span>\n )}\n {action && <div className=\"ml-auto\">{action}</div>}\n </div>\n )\n}\n\n/** Small row used by \"New this week\" and the search results. */\nfunction ResourceRow({\n app,\n onAppClick,\n onLaunch,\n showNew = false,\n}: {\n app: Resource\n onAppClick: (app: Resource) => void\n onLaunch: (app: Resource) => void\n showNew?: boolean\n}) {\n return (\n <button\n type=\"button\"\n onClick={() => onAppClick(app)}\n title={`View ${app.displayName}`}\n className={cn(\n 'group flex items-center gap-3.5 w-full text-left',\n 'bg-card border border-border rounded-[var(--radius)] px-3.5 py-3',\n 'hover:border-ring hover:shadow-sm transition-all',\n )}\n >\n <ResourceIcon app={app} size={38} />\n <span className=\"flex-1 min-w-0\">\n <span className=\"block text-[14.5px] font-semibold truncate\">\n {app.displayName}\n </span>\n {app.description && (\n <span className=\"block text-[13px] text-muted-foreground truncate\">\n {markdownToPlainText(app.description)}\n </span>\n )}\n </span>\n {showNew && (\n <span className=\"text-[11px] font-bold text-[#65a443] bg-[#65a443]/12 rounded-full px-2.5 py-0.5\">\n New\n </span>\n )}\n {typeLabel(app.type) && (\n <span className=\"text-[12px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5 whitespace-nowrap\">\n {typeLabel(app.type)}\n </span>\n )}\n <LaunchButton app={app} onLaunch={onLaunch} className=\"size-8\" />\n </button>\n )\n}\n\n/**\n * Wide area card: icon on the left, name and description beside it.\n *\n * The grid cell keeps a fixed footprint and the visible panel is absolute inside\n * it, so a card growing on hover OVERLAYS the row below instead of reflowing the\n * whole grid. Hover reveals the rest of the description and nothing else — an\n * earlier version staggered a delayed tag block in behind the growth, which read\n * as jitter. Keyboard focus expands the same way, and `motion-reduce` drops the\n * animation.\n *\n * The footprint is a fixed height rather than an aspect ratio: at two or three\n * columns a ratio makes the card grow taller as the window widens, and the\n * description — three lines at most — leaves the bottom half empty.\n */\nfunction AppCard({\n app,\n onAppClick,\n onLaunch,\n}: {\n app: Resource\n onAppClick: (app: Resource) => void\n onLaunch: (app: Resource) => void\n}) {\n return (\n <div className=\"relative h-[92px]\">\n <button\n type=\"button\"\n onClick={() => onAppClick(app)}\n title={`View ${app.displayName}`}\n className={cn(\n 'group absolute inset-x-0 top-0 min-h-full max-h-full overflow-hidden',\n 'flex gap-3.5 text-left bg-card border border-border',\n 'rounded-[var(--radius)] p-[15px] pr-11 outline-none',\n 'transition-[max-height,box-shadow,border-color] duration-200',\n 'hover:max-h-96 hover:border-ring hover:shadow-lg hover:z-10',\n 'focus-visible:max-h-96 focus-visible:border-ring focus-visible:shadow-lg focus-visible:z-10',\n 'motion-reduce:transition-none',\n )}\n >\n <LaunchButton\n app={app}\n onLaunch={onLaunch}\n className=\"absolute top-3 right-3 size-[26px] opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100\"\n />\n <div className=\"shrink-0\">\n <ResourceIcon app={app} size={40} />\n </div>\n {/* min-w-0 so a long unbroken word truncates instead of pushing the\n column wider than the card. */}\n <div className=\"flex min-w-0 flex-col gap-1.5\">\n {/* shrink-0: while max-height animates, the un-clamped description\n overflows the panel for a few frames. Without it flexbox squeezes\n this line to 0 and back, which reads as the title flickering. */}\n <span className=\"block shrink-0 text-[14.5px] font-bold leading-tight line-clamp-1 group-hover:line-clamp-2 group-focus-visible:line-clamp-2\">\n {app.displayName}\n </span>\n {app.description && (\n <span\n className={cn(\n 'block shrink-0 text-[12.5px] leading-snug text-muted-foreground',\n 'line-clamp-2 group-hover:line-clamp-none group-focus-visible:line-clamp-none',\n )}\n >\n {markdownToPlainText(app.description)}\n </span>\n )}\n </div>\n </button>\n </div>\n )\n}\n\n/**\n * One area: icon, big title, count, then its cards. The icon stands in for the\n * accent bar the header used to carry — it does the same colour job and also\n * says which area you are in. It is decorative: the `h3` carries the name.\n */\nfunction AreaSection({\n label,\n Icon,\n apps,\n onAppClick,\n onLaunch,\n}: {\n label: string\n Icon?: AreaIcon\n apps: Resource[]\n onAppClick: (app: Resource) => void\n onLaunch: (app: Resource) => void\n}) {\n return (\n <section className=\"mt-11\">\n <div className=\"flex items-center gap-2.5 mb-4\">\n {Icon ? (\n <Icon\n size={26}\n strokeWidth={1.75}\n className=\"text-primary shrink-0\"\n />\n ) : (\n <div className=\"h-[3px] w-11 rounded-full bg-primary\" />\n )}\n <h3 className=\"font-serif font-semibold text-[27px] leading-tight tracking-tight m-0\">\n {label}\n </h3>\n <span className=\"text-[13px] text-muted-foreground\">\n {apps.length} {apps.length === 1 ? 'tool' : 'tools'}\n </span>\n </div>\n <div className=\"grid gap-3 grid-cols-1 md:grid-cols-2 xl:grid-cols-3\">\n {apps.map((app) => (\n <AppCard\n key={app.slug}\n app={app}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n />\n ))}\n </div>\n </section>\n )\n}\n\n/** Matched sub-resources listed under a parent before the \"… N more\" row. */\nconst COLLAPSED_SUB_LIMIT = 5\n\n/** One line in the flat, keyboard-navigable results list. */\ntype ResultRow =\n | { kind: 'app'; key: string; app: Resource }\n | { kind: 'sub'; key: string; sub: Resource; parent: Resource }\n | { kind: 'more'; key: string; parent: Resource; hidden: number }\n\n/**\n * Keyboard-navigable search results list — the launcher's only search surface.\n *\n * It renders directly BELOW the hero search box, inside the same persistent\n * launcher shell: the hero copy, the search input and the page container never\n * move when the user types, and no filter chrome (tabs / category / deprecated\n * toggle) appears. Those filters live in the browse view only — clearing the\n * search brings them back.\n *\n * Rows are flat and individually clickable. A matched sub-resource gets its own\n * indented row under its parent, so a query that only hits a sub-resource\n * (e.g. a cloud account) can be opened directly instead of forcing the user to\n * dig through the parent's detail. ↑↓ moves over every row, ↵ activates the\n * focused one (the first row until something else is focused), Esc clears the\n * search.\n */\nfunction SearchResultsList({\n apps,\n searchValue,\n onAppClick,\n onSubClick,\n onLaunch,\n onClear,\n keyboardEnabled = true,\n selectedSubSlug,\n}: {\n apps: Resource[]\n searchValue: string\n onAppClick: (app: Resource) => void\n onSubClick: (parentSlug: string, subSlug: string) => void\n onLaunch: (app: Resource) => void\n onClear: () => void\n keyboardEnabled?: boolean\n selectedSubSlug?: string\n}) {\n // The first row starts focused: Enter straight after typing opens it, and the\n // focus style shows which row that is. Nothing-focused (-1) left Enter dead\n // until the user pressed ↓ first (#167).\n const [focusedIndex, setFocusedIndex] = useState(0)\n const [expandedParents, setExpandedParents] = useState<Set<string>>(\n () => new Set(),\n )\n // Asking for something absent. Local state rather than a url param: a half-typed\n // request has no business in a shareable link.\n const [requestOpen, setRequestOpen] = useState(false)\n\n const results = useMemo(\n () => searchResources(apps, searchValue),\n [apps, searchValue],\n )\n\n // For each result, the child/sub-resources of it that themselves match the\n // query — so a parent surfaced only via its children (e.g. AWS Console via a\n // \"biom\" account) tells the user WHICH sub-resources matched, instead of an\n // unexplained parent. Keyed by parent slug.\n const matchedChildrenByParent = useMemo(() => {\n const q = searchValue.trim().toLowerCase()\n const map = new Map<string, Resource[]>()\n if (!q) return map\n const resultSlugs = new Set(results.map((r) => r.slug))\n for (const r of apps) {\n if (!r.parentSlug || !resultSlugs.has(r.parentSlug)) continue\n const hay = `${r.displayName} ${r.abbreviation ?? ''} ${(\n r.aliases ?? []\n ).join(' ')} ${r.description ?? ''}`.toLowerCase()\n if (!hay.includes(q)) continue\n const list = map.get(r.parentSlug) ?? []\n list.push(r)\n map.set(r.parentSlug, list)\n }\n return map\n }, [apps, results, searchValue])\n\n // #11 deprecated fallback: when EVERY match is a deprecated app (i.e. there\n // are no active matches for the query, only deprecated ones), surface a\n // notice so the user understands why they're seeing archived tools. The\n // deprecated apps are already in `results` because the launcher feeds search\n // the full resource set; this just labels the situation.\n const didDeprecatedFallback = useMemo(\n () => results.length > 0 && results.every((r) => Boolean(r.deprecated)),\n [results],\n )\n\n // Flatten parents + their matched sub-resources into one navigable list, so a\n // sub-resource row is a first-class result (clickable, focusable) rather than\n // a footnote on its parent.\n const rows = useMemo(() => {\n const out: ResultRow[] = []\n for (const app of results) {\n out.push({ kind: 'app', key: `app:${app.slug}`, app })\n const kids = matchedChildrenByParent.get(app.slug) ?? []\n const expanded = expandedParents.has(app.slug)\n const visible = expanded ? kids : kids.slice(0, COLLAPSED_SUB_LIMIT)\n for (const sub of visible) {\n out.push({\n kind: 'sub',\n key: `sub:${app.slug}:${sub.slug}`,\n sub,\n parent: app,\n })\n }\n const hidden = kids.length - visible.length\n if (hidden > 0) {\n out.push({ kind: 'more', key: `more:${app.slug}`, parent: app, hidden })\n }\n }\n return out\n }, [results, matchedChildrenByParent, expandedParents])\n\n const matchCount = useMemo(() => {\n let n = results.length\n for (const kids of matchedChildrenByParent.values()) n += kids.length\n return n\n }, [results, matchedChildrenByParent])\n\n const activateRow = useCallback(\n (row: ResultRow) => {\n if (row.kind === 'app') {\n onAppClick(row.app)\n } else if (row.kind === 'sub') {\n // Open the parent's detail singled out to THIS sub-resource. Filtering\n // the parent's sub-resource table by the query instead would leave the\n // clicked row buried among every other match (\"project\" matches 47).\n onSubClick(row.parent.slug, row.sub.slug)\n } else {\n setExpandedParents((prev) => new Set(prev).add(row.parent.slug))\n }\n },\n [onAppClick, onSubClick],\n )\n\n // Back to the first row and collapse expanded sub-resource lists when the query changes\n useEffect(() => {\n setFocusedIndex(0)\n setExpandedParents(new Set())\n }, [searchValue])\n\n useEffect(() => {\n // While a detail overlay is open it owns the keyboard: without this guard\n // Esc would clear the search behind the overlay and ↑↓/↵ would navigate to\n // a different app from a list the user cannot see.\n if (!keyboardEnabled) return\n const onKey = (e: KeyboardEvent) => {\n if (rows.length === 0) return\n if (e.key === 'ArrowDown') {\n e.preventDefault()\n setFocusedIndex((i) => Math.min(i + 1, rows.length - 1))\n } else if (e.key === 'ArrowUp') {\n e.preventDefault()\n setFocusedIndex((i) => Math.max(i - 1, 0))\n } else if (e.key === 'Enter') {\n // A focused row or button activates itself through its native click;\n // acting here as well would open the first result on top of it.\n if (e.defaultPrevented) return\n if (\n e.target instanceof HTMLElement &&\n e.target.closest('button, a, [role=\"option\"]')\n )\n return\n const row = focusedIndex >= 0 ? rows[focusedIndex] : undefined\n if (row) activateRow(row)\n } else if (e.key === 'Escape') {\n onClear()\n }\n }\n document.addEventListener('keydown', onKey)\n return () => document.removeEventListener('keydown', onKey)\n }, [rows, focusedIndex, activateRow, onClear, keyboardEnabled])\n\n return (\n <div className=\"max-w-[620px] mx-auto mt-3\">\n {/* Meta line. Counts matched sub-resources too — they are results in\n their own right, and a parent surfaced only via 47 matching accounts\n used to be reported as \"1 result\". Collapsed ones still count, so the\n number doesn't jump when the \"... N more\" row is expanded. */}\n <div className=\"text-[13px] text-muted-foreground mb-2 px-1\">\n {matchCount === 0\n ? `No results for \"${searchValue}\"`\n : `${matchCount} result${matchCount === 1 ? '' : 's'}`}\n </div>\n\n {/* A zero-result search is the most informative moment in the catalog: someone\n has said exactly what they expected to find. Showing only \"No results\"\n throws that away and leaves them with nowhere to go. */}\n {matchCount === 0 && !didDeprecatedFallback && (\n <div className=\"mb-3 px-1\">\n <button\n type=\"button\"\n onClick={() => setRequestOpen(true)}\n className=\"text-sm underline decoration-dotted underline-offset-2 hover:text-foreground text-muted-foreground\"\n >\n Not in the catalog? Ask for it to be added\n </button>\n </div>\n )}\n <RequestAppDialog\n open={requestOpen}\n onOpenChange={setRequestOpen}\n initialSubject={searchValue}\n />\n\n {/* #11: deprecated-only fallback notice */}\n {didDeprecatedFallback && (\n <div\n role=\"status\"\n className=\"text-[13px] text-muted-foreground mb-2 px-1\"\n >\n No active apps for "{searchValue}" — showing deprecated\n matches.\n </div>\n )}\n\n {/* Results — parents and their matched sub-resources as one flat list */}\n <div\n className=\"flex flex-col gap-1.5\"\n role=\"listbox\"\n aria-label=\"Search results\"\n >\n {rows.map((row, i) => {\n const focused = focusedIndex === i\n\n if (row.kind === 'sub') {\n const { sub, parent } = row\n const isOpen = selectedSubSlug === sub.slug\n return (\n <button\n key={row.key}\n type=\"button\"\n role=\"option\"\n data-kind=\"sub\"\n aria-selected={focused}\n // \"Which one am I looking at\" — set while this sub-resource is\n // the one singled out in its parent's detail behind the overlay.\n aria-current={isOpen ? 'true' : undefined}\n aria-label={`${sub.displayName} — sub-resource of ${parent.displayName}`}\n title={sub.displayName}\n onMouseEnter={() => setFocusedIndex(i)}\n onClick={() => activateRow(row)}\n className={cn(\n // Indented + left rule so the parent/child relation reads at\n // a glance without an extra text prefix.\n 'flex items-center ml-8 border-l-2 pl-3 pr-3 py-1.5 text-left',\n 'text-[13px] rounded-r-[var(--radius)] transition-colors',\n focused || isOpen\n ? 'border-primary bg-muted/40 text-foreground'\n : 'border-border text-muted-foreground hover:border-primary hover:bg-muted/30 hover:text-foreground',\n )}\n >\n <Highlight text={sub.displayName} query={searchValue} />\n </button>\n )\n }\n\n if (row.kind === 'more') {\n return (\n <button\n key={row.key}\n type=\"button\"\n role=\"option\"\n data-kind=\"more\"\n aria-selected={focused}\n aria-label={`Show ${row.hidden} more matching sub-resources of ${row.parent.displayName}`}\n onMouseEnter={() => setFocusedIndex(i)}\n onClick={() => activateRow(row)}\n className={cn(\n 'flex items-center ml-8 border-l-2 pl-3 pr-3 py-1.5 text-left',\n 'text-[13px] rounded-r-[var(--radius)] transition-colors',\n focused\n ? 'border-primary bg-muted/40 text-foreground'\n : 'border-border text-muted-foreground hover:border-primary hover:text-foreground',\n )}\n >\n ... {row.hidden} more\n </button>\n )\n }\n\n const app = row.app\n return (\n <button\n key={row.key}\n type=\"button\"\n role=\"option\"\n title={`View ${app.displayName}`}\n aria-selected={focused}\n onMouseEnter={() => setFocusedIndex(i)}\n onClick={() => activateRow(row)}\n className={cn(\n 'group flex items-center gap-3.5 w-full text-left',\n 'bg-card border rounded-[var(--radius)] px-3.5 py-2.5',\n 'transition-all',\n focused\n ? 'border-ring shadow-sm bg-muted/30'\n : 'border-border hover:border-ring hover:shadow-sm',\n )}\n >\n <ResourceIcon app={app} size={40} />\n <span className=\"flex-1 min-w-0\">\n <span className=\"block text-[14px] font-semibold truncate\">\n <Highlight text={app.displayName} query={searchValue} />\n {app.abbreviation && app.abbreviation !== app.displayName && (\n <span className=\"ml-1.5 text-[12px] font-normal text-muted-foreground\">\n (<Highlight text={app.abbreviation} query={searchValue} />\n )\n </span>\n )}\n </span>\n {app.description && (\n <span className=\"block text-[12.5px] text-muted-foreground truncate\">\n {markdownToPlainText(app.description)}\n </span>\n )}\n </span>\n {typeLabel(app.type) && (\n <span className=\"text-[11.5px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5 whitespace-nowrap shrink-0\">\n {typeLabel(app.type)}\n </span>\n )}\n <LaunchButton\n app={app}\n onLaunch={onLaunch}\n className=\"size-7 shrink-0 opacity-0 group-hover:opacity-100\"\n />\n </button>\n )\n })}\n </div>\n\n {/* Keyboard hint */}\n {rows.length > 0 && (\n <p className=\"text-[11.5px] text-muted-foreground/60 text-center mt-3\">\n ↑↓ navigate · ↵ view access · esc clear\n </p>\n )}\n </div>\n )\n}\n\nexport function AppCatalogGrid({\n apps,\n allResources,\n searchValue,\n onSearchChange,\n onAppClick,\n onSubClick,\n onLaunch,\n detailOpen = false,\n selectedSubSlug,\n}: AppCatalogGridProps) {\n const { getTopApps } = useAppClickHistory()\n const [topSlugs, setTopSlugs] = useState<string[]>([])\n // Area labels are data (the catalog's own `category` tag definition); only the\n // icons and the day-to-day fold are deployment config.\n const { areas } = useUiSettings()\n const tagsDefinitions = use(AppCatalogContext)?.tagsDefinitions\n\n useEffect(() => {\n void getTopApps(5).then(setTopSlugs)\n }, [getTopApps])\n\n const bySlug = useMemo(() => {\n const m = new Map<string, Resource>()\n for (const a of apps) m.set(a.slug, a)\n return m\n }, [apps])\n\n // Your apps: frequently-clicked (personalized). Empty on a cold new machine.\n const frequent = useMemo(\n () =>\n topSlugs\n .map((s) => bySlug.get(s))\n .filter((a): a is Resource => Boolean(a))\n .slice(0, 5),\n [topSlugs, bySlug],\n )\n\n // New this week: apps ADDED in the last 7 days — see pickNewThisWeek (#96).\n const fresh = useMemo(() => pickNewThisWeek(apps), [apps])\n\n // Everything not already promoted to \"Your apps\", alpha by name.\n const browse = useMemo(() => {\n const promoted = new Set(frequent.map((a) => a.slug))\n return [...apps]\n .filter((a) => !promoted.has(a.slug))\n .sort((a, b) => a.displayName.localeCompare(b.displayName))\n }, [apps, frequent])\n\n // Grouped: day-to-day tools first, then areas biggest-first so the page opens\n // on the areas most people need. Within an area, alpha (from browse).\n const browseGroups = useMemo(() => groupByArea(browse), [browse])\n\n const isSearching = searchValue.trim() !== ''\n const searchRef = useRef<HTMLInputElement>(null)\n\n // ⌘K / Ctrl+K focuses the hero search box from anywhere on the page.\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if ((e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault()\n searchRef.current?.focus()\n }\n }\n document.addEventListener('keydown', onKey)\n return () => document.removeEventListener('keydown', onKey)\n }, [])\n\n return (\n <div className=\"mx-auto w-full max-w-[1000px] pb-8\">\n {/* hero */}\n <div className=\"text-center pt-10 pb-1.5\">\n <h1 className=\"font-serif font-semibold text-[30px] tracking-tight m-0 mb-1.5\">\n What do you need to get into?\n </h1>\n <p className=\"text-muted-foreground m-0 mb-6 text-[15px]\">\n Find any tool, see how to request access, or jump straight in.\n </p>\n <div className=\"max-w-[620px] mx-auto\">\n <div className=\"flex items-center gap-3 bg-card border-[1.5px] border-input rounded-full px-5 h-14 shadow-sm focus-within:border-primary focus-within:ring-4 focus-within:ring-primary/15 transition-colors\">\n <Search className=\"size-5 shrink-0 text-muted-foreground\" />\n <input\n type=\"text\"\n value={searchValue}\n onChange={(e) => onSearchChange(e.target.value)}\n placeholder=\"Search apps, cloud accounts, or keywords\"\n autoComplete=\"off\"\n autoFocus\n ref={searchRef}\n aria-label=\"Search apps\"\n className=\"flex-1 bg-transparent border-0 outline-none text-[16px] text-foreground placeholder:text-muted-foreground\"\n />\n {isSearching ? (\n <button\n type=\"button\"\n aria-label=\"Clear search\"\n onClick={() => {\n onSearchChange('')\n searchRef.current?.focus()\n }}\n className=\"shrink-0 rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors\"\n >\n <X className=\"size-4\" />\n </button>\n ) : (\n <kbd className=\"hidden sm:inline-flex items-center gap-0.5 text-[11px] text-muted-foreground/60 border border-muted-foreground/20 rounded px-1.5 py-0.5 font-mono shrink-0\">\n {typeof navigator !== 'undefined' &&\n navigator.platform.includes('Mac')\n ? '⌘K'\n : 'Ctrl+K'}\n </kbd>\n )}\n </div>\n </div>\n </div>\n\n {/* Search results: rendered in place, directly under the (unmoved) hero\n search box. The discovery spine below is what gets replaced — the\n shell, hero and search box stay put, so typing never shifts layout. */}\n {isSearching && (\n <SearchResultsList\n apps={allResources ?? apps}\n searchValue={searchValue}\n onAppClick={onAppClick}\n onSubClick={onSubClick}\n onLaunch={onLaunch}\n onClear={() => onSearchChange('')}\n keyboardEnabled={!detailOpen}\n selectedSubSlug={selectedSubSlug}\n />\n )}\n\n {/* Discovery spine: only shown when not searching */}\n {!isSearching && (\n <>\n {/* Your apps */}\n {frequent.length > 0 && (\n <section className=\"mt-9\">\n <SectionHead title=\"Your apps\" count=\"frequent\" />\n <div className=\"grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3.5\">\n {frequent.map((app) => (\n <button\n type=\"button\"\n key={app.slug}\n onClick={() => onAppClick(app)}\n title={`View ${app.displayName}`}\n className=\"group relative bg-card border border-border rounded-[var(--radius)] px-3.5 pt-[18px] pb-4 text-center shadow-sm hover:-translate-y-0.5 hover:shadow-md transition-all\"\n >\n <LaunchButton\n app={app}\n onLaunch={onLaunch}\n className=\"absolute top-2.5 right-2.5 size-[26px] opacity-0 group-hover:opacity-100\"\n />\n <ResourceIcon\n app={app}\n size={46}\n className=\"mx-auto mb-3\"\n />\n <span className=\"block text-[13.5px] font-bold truncate\">\n {app.displayName}\n </span>\n </button>\n ))}\n </div>\n </section>\n )}\n\n {/* New this week */}\n {fresh.length > 0 && (\n <section className=\"mt-9\">\n <SectionHead\n title=\"New this week\"\n count={`${fresh.length} added`}\n />\n <div className=\"flex flex-col gap-2\">\n {fresh.map((app) => (\n <ResourceRow\n key={app.slug}\n app={app}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n showNew\n />\n ))}\n </div>\n </section>\n )}\n\n {/* Everything else, grouped by area — the area titles carry it, so\n there is no \"Browse all\" header above them. */}\n <section className=\"mt-9\">\n {browseGroups.map(([key, list]) => (\n <AreaSection\n key={key}\n label={areaLabel(key, tagsDefinitions, areas?.dayToDayLabel)}\n Icon={areas?.icons?.[key]}\n apps={list}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n />\n ))}\n </section>\n </>\n )}\n </div>\n )\n}\n"],"names":["_a"],"mappings":"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"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
interface RequestAppDialogProps {
|
|
2
|
+
open: boolean;
|
|
3
|
+
onOpenChange: (open: boolean) => void;
|
|
4
|
+
/** What they searched for. Pre-fills the name, because they just typed it. */
|
|
5
|
+
initialSubject?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Mounts nothing until it is opened, which is what keeps `useTRPC()` out of the
|
|
9
|
+
* closed path. The catalog grid is rendered bare in several tests with no
|
|
10
|
+
* TRPCProvider, and a hook cannot be called conditionally — so the gate has to be a
|
|
11
|
+
* component boundary rather than an `if` inside one.
|
|
12
|
+
*/
|
|
13
|
+
export declare function RequestAppDialog(props: RequestAppDialogProps): import("react/jsx-runtime").JSX.Element | null;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useQueryClient, useMutation } from "@tanstack/react-query";
|
|
3
|
+
import { useState, useEffect } from "react";
|
|
4
|
+
import { useTRPC } from "../../../../api/infra/trpc.js";
|
|
5
|
+
import { Button } from "../../../../ui/button.js";
|
|
6
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../../../../ui/dialog.js";
|
|
7
|
+
import { cn } from "../../../../lib/utils.js";
|
|
8
|
+
const FIELD_CLASSES = "placeholder:text-muted-foreground dark:bg-input/30 border-input w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50";
|
|
9
|
+
const MAX_SUBJECT = 120;
|
|
10
|
+
function RequestAppDialog(props) {
|
|
11
|
+
if (!props.open) return null;
|
|
12
|
+
return /* @__PURE__ */ jsx(RequestAppDialogBody, { ...props });
|
|
13
|
+
}
|
|
14
|
+
function RequestAppDialogBody({
|
|
15
|
+
open,
|
|
16
|
+
onOpenChange,
|
|
17
|
+
initialSubject = ""
|
|
18
|
+
}) {
|
|
19
|
+
const trpc = useTRPC();
|
|
20
|
+
const queryClient = useQueryClient();
|
|
21
|
+
const [subject, setSubject] = useState(initialSubject);
|
|
22
|
+
const [body, setBody] = useState("");
|
|
23
|
+
const [sent, setSent] = useState(false);
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (open) {
|
|
26
|
+
setSubject(initialSubject);
|
|
27
|
+
setBody("");
|
|
28
|
+
setSent(false);
|
|
29
|
+
}
|
|
30
|
+
}, [open, initialSubject]);
|
|
31
|
+
const add = useMutation(
|
|
32
|
+
trpc.feedback.add.mutationOptions({
|
|
33
|
+
onSuccess: () => {
|
|
34
|
+
setSent(true);
|
|
35
|
+
void queryClient.invalidateQueries({
|
|
36
|
+
queryKey: trpc.feedback.mine.queryKey()
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
);
|
|
41
|
+
const trimmed = subject.trim();
|
|
42
|
+
const send = () => {
|
|
43
|
+
if (!trimmed) return;
|
|
44
|
+
add.mutate({
|
|
45
|
+
subject: trimmed.slice(0, MAX_SUBJECT),
|
|
46
|
+
...body.trim() ? { body: body.trim() } : {}
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
return /* @__PURE__ */ jsx(Dialog, { open, onOpenChange, children: /* @__PURE__ */ jsxs(DialogContent, { className: "sm:max-w-[480px]", children: [
|
|
50
|
+
/* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(DialogTitle, { children: sent ? "Thanks — that is logged" : "Ask for an app to be added" }) }),
|
|
51
|
+
sent ? /* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [
|
|
52
|
+
/* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-sm", children: "A maintainer will look at it. You can see what happened to it under your own requests." }),
|
|
53
|
+
/* @__PURE__ */ jsx(Button, { type: "button", size: "sm", onClick: () => onOpenChange(false), children: "Close" })
|
|
54
|
+
] }) : /* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [
|
|
55
|
+
/* @__PURE__ */ jsxs("div", { className: "space-y-1.5", children: [
|
|
56
|
+
/* @__PURE__ */ jsx("label", { htmlFor: "request-app-name", className: "text-sm font-medium", children: "What are you looking for?" }),
|
|
57
|
+
/* @__PURE__ */ jsx(
|
|
58
|
+
"input",
|
|
59
|
+
{
|
|
60
|
+
id: "request-app-name",
|
|
61
|
+
autoFocus: true,
|
|
62
|
+
className: cn(FIELD_CLASSES),
|
|
63
|
+
placeholder: "Name of the tool, or what it does",
|
|
64
|
+
maxLength: MAX_SUBJECT,
|
|
65
|
+
value: subject,
|
|
66
|
+
onChange: (event) => setSubject(event.target.value)
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
] }),
|
|
70
|
+
/* @__PURE__ */ jsxs("div", { className: "space-y-1.5", children: [
|
|
71
|
+
/* @__PURE__ */ jsxs(
|
|
72
|
+
"label",
|
|
73
|
+
{
|
|
74
|
+
htmlFor: "request-app-detail",
|
|
75
|
+
className: "text-sm font-medium",
|
|
76
|
+
children: [
|
|
77
|
+
"Anything that would help us find it",
|
|
78
|
+
" ",
|
|
79
|
+
/* @__PURE__ */ jsx("span", { className: "text-muted-foreground font-normal", children: "(optional)" })
|
|
80
|
+
]
|
|
81
|
+
}
|
|
82
|
+
),
|
|
83
|
+
/* @__PURE__ */ jsx(
|
|
84
|
+
"textarea",
|
|
85
|
+
{
|
|
86
|
+
id: "request-app-detail",
|
|
87
|
+
rows: 3,
|
|
88
|
+
className: cn(FIELD_CLASSES),
|
|
89
|
+
placeholder: "A link, the team that owns it, what you needed it for",
|
|
90
|
+
value: body,
|
|
91
|
+
onChange: (event) => setBody(event.target.value)
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
] }),
|
|
95
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [
|
|
96
|
+
/* @__PURE__ */ jsx(
|
|
97
|
+
Button,
|
|
98
|
+
{
|
|
99
|
+
type: "button",
|
|
100
|
+
size: "sm",
|
|
101
|
+
disabled: !trimmed || add.isPending,
|
|
102
|
+
onClick: send,
|
|
103
|
+
children: add.isPending ? "Sending…" : "Send request"
|
|
104
|
+
}
|
|
105
|
+
),
|
|
106
|
+
/* @__PURE__ */ jsx("span", { className: "text-muted-foreground text-xs", children: "Sent under a nickname. No login needed." })
|
|
107
|
+
] }),
|
|
108
|
+
add.error && /* @__PURE__ */ jsx("p", { className: "text-destructive text-xs", children: add.error instanceof Error ? add.error.message : "Something went wrong" })
|
|
109
|
+
] })
|
|
110
|
+
] }) });
|
|
111
|
+
}
|
|
112
|
+
export {
|
|
113
|
+
RequestAppDialog
|
|
114
|
+
};
|
|
115
|
+
//# sourceMappingURL=RequestAppDialog.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RequestAppDialog.js","sources":["../../../../../../src/modules/appCatalog/ui/catalog/RequestAppDialog.tsx"],"sourcesContent":["import { useMutation, useQueryClient } from '@tanstack/react-query'\nimport { useEffect, useState } from 'react'\nimport { useTRPC } from '~/api/infra/trpc'\nimport { Button } from '~/ui/button'\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/ui/dialog'\nimport { cn } from '~/lib/utils'\n\n/**\n * Asking for something that is not in the catalog yet.\n *\n * The search box is where people discover the gap — they type a tool's name, get\n * nothing, and until now had nowhere to go. A zero-result search is the single most\n * informative moment in the catalog: someone has told us exactly what they expected to\n * find. Throwing that away and showing only \"No results\" wastes it.\n *\n * This is a catalog-level ask rather than feedback on an entry: `feedback.add` with a\n * `subject` and NO `resourceSlug`. The server already distinguishes the two that way,\n * and the tracker lists both.\n */\n\n/** Same shape the input primitive uses, minus the fixed height. */\nconst FIELD_CLASSES =\n 'placeholder:text-muted-foreground dark:bg-input/30 border-input w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50'\n\nconst MAX_SUBJECT = 120\n\ninterface RequestAppDialogProps {\n open: boolean\n onOpenChange: (open: boolean) => void\n /** What they searched for. Pre-fills the name, because they just typed it. */\n initialSubject?: string\n}\n\n/**\n * Mounts nothing until it is opened, which is what keeps `useTRPC()` out of the\n * closed path. The catalog grid is rendered bare in several tests with no\n * TRPCProvider, and a hook cannot be called conditionally — so the gate has to be a\n * component boundary rather than an `if` inside one.\n */\nexport function RequestAppDialog(props: RequestAppDialogProps) {\n if (!props.open) return null\n return <RequestAppDialogBody {...props} />\n}\n\nfunction RequestAppDialogBody({\n open,\n onOpenChange,\n initialSubject = '',\n}: RequestAppDialogProps) {\n const trpc = useTRPC()\n const queryClient = useQueryClient()\n const [subject, setSubject] = useState(initialSubject)\n const [body, setBody] = useState('')\n const [sent, setSent] = useState(false)\n\n // Re-seed when opened for a different search. Keyed on the dialog opening rather\n // than on every keystroke, so it never overwrites something half-typed.\n useEffect(() => {\n if (open) {\n setSubject(initialSubject)\n setBody('')\n setSent(false)\n }\n }, [open, initialSubject])\n\n const add = useMutation(\n trpc.feedback.add.mutationOptions({\n onSuccess: () => {\n setSent(true)\n // The tracker counts this, so its badge has to notice.\n void queryClient.invalidateQueries({\n queryKey: trpc.feedback.mine.queryKey(),\n })\n },\n }),\n )\n\n const trimmed = subject.trim()\n const send = () => {\n if (!trimmed) return\n add.mutate({\n subject: trimmed.slice(0, MAX_SUBJECT),\n ...(body.trim() ? { body: body.trim() } : {}),\n })\n }\n\n return (\n <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent className=\"sm:max-w-[480px]\">\n <DialogHeader>\n <DialogTitle>\n {sent ? 'Thanks — that is logged' : 'Ask for an app to be added'}\n </DialogTitle>\n </DialogHeader>\n\n {sent ? (\n <div className=\"space-y-3\">\n <p className=\"text-muted-foreground text-sm\">\n A maintainer will look at it. You can see what happened to it\n under your own requests.\n </p>\n <Button type=\"button\" size=\"sm\" onClick={() => onOpenChange(false)}>\n Close\n </Button>\n </div>\n ) : (\n <div className=\"space-y-3\">\n <div className=\"space-y-1.5\">\n <label htmlFor=\"request-app-name\" className=\"text-sm font-medium\">\n What are you looking for?\n </label>\n <input\n id=\"request-app-name\"\n autoFocus\n className={cn(FIELD_CLASSES)}\n placeholder=\"Name of the tool, or what it does\"\n maxLength={MAX_SUBJECT}\n value={subject}\n onChange={(event) => setSubject(event.target.value)}\n />\n </div>\n\n <div className=\"space-y-1.5\">\n <label\n htmlFor=\"request-app-detail\"\n className=\"text-sm font-medium\"\n >\n Anything that would help us find it{' '}\n <span className=\"text-muted-foreground font-normal\">\n (optional)\n </span>\n </label>\n <textarea\n id=\"request-app-detail\"\n rows={3}\n className={cn(FIELD_CLASSES)}\n placeholder=\"A link, the team that owns it, what you needed it for\"\n value={body}\n onChange={(event) => setBody(event.target.value)}\n />\n </div>\n\n <div className=\"flex flex-wrap items-center gap-3\">\n <Button\n type=\"button\"\n size=\"sm\"\n disabled={!trimmed || add.isPending}\n onClick={send}\n >\n {add.isPending ? 'Sending…' : 'Send request'}\n </Button>\n <span className=\"text-muted-foreground text-xs\">\n Sent under a nickname. No login needed.\n </span>\n </div>\n\n {add.error && (\n <p className=\"text-destructive text-xs\">\n {add.error instanceof Error\n ? add.error.message\n : 'Something went wrong'}\n </p>\n )}\n </div>\n )}\n </DialogContent>\n </Dialog>\n )\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -10,10 +10,12 @@ function DetailTabs({
|
|
|
10
10
|
band,
|
|
11
11
|
bandClassName
|
|
12
12
|
}) {
|
|
13
|
+
var _a;
|
|
14
|
+
const defaultTabId = (_a = tabs[0]) == null ? void 0 : _a.id;
|
|
13
15
|
const [urlTab, setUrlTab] = useUrlSyncedState({
|
|
14
16
|
key: "tab",
|
|
15
17
|
defaultValue: "",
|
|
16
|
-
encode: (value) => value
|
|
18
|
+
encode: (value) => value && value !== defaultTabId ? value : void 0
|
|
17
19
|
});
|
|
18
20
|
const ids = tabs.map((t) => t.id);
|
|
19
21
|
const RETIRED = { metadata: "documentation" };
|
|
@@ -28,7 +30,7 @@ function DetailTabs({
|
|
|
28
30
|
const activeId = ids.includes(requested) ? requested : ids[0] ?? "";
|
|
29
31
|
const active = tabs.find((t) => t.id === activeId);
|
|
30
32
|
const onKeyDown = (event) => {
|
|
31
|
-
var
|
|
33
|
+
var _a2;
|
|
32
34
|
const delta = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
|
|
33
35
|
if (!delta) return;
|
|
34
36
|
event.preventDefault();
|
|
@@ -37,7 +39,7 @@ function DetailTabs({
|
|
|
37
39
|
if (!next) return;
|
|
38
40
|
setUserChose(true);
|
|
39
41
|
setUrlTab(next);
|
|
40
|
-
(
|
|
42
|
+
(_a2 = document.getElementById(`detail-tab-${next}`)) == null ? void 0 : _a2.focus();
|
|
41
43
|
};
|
|
42
44
|
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
43
45
|
/* @__PURE__ */ jsxs("div", { className: cn("detail-band", bandClassName), children: [
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DetailTabs.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/DetailTabs.tsx"],"sourcesContent":["import React from 'react'\nimport { cn } from '~/lib/utils'\nimport { useUrlSyncedState } from '~/modules/appCatalog/hooks/useUrlSyncedState'\n\nexport interface DetailTab {\n /** Stable id — this is what lands in `?tab=`, so treat it as public. */\n id: string\n label: string\n /** One short count beside the label (\"430\", \"55 roles\", \"2 steps\"). */\n chip?: string\n /** Marks unreviewed content. Also stated in the accessible name. */\n dot?: boolean\n /** Spoken instead of `label` when the chip and dot need explaining. */\n ariaLabel?: string\n render: () => React.ReactNode\n}\n\n/**\n * The detail card's tabs.\n *\n * Every entry gets them, including thin ones. Two earlier rules were worse:\n * hiding a tab with little in it turned \"we have not documented the way in\"\n * into a dead end you had to click to discover, and showing them only on heavy\n * entries made the card's shape change between apps, so \"where is access?\" had\n * two answers and no habit could form across a few hundred entries. Naming the\n * route in the tab itself removes the reason to hide anything — the label tells\n * you whether opening it is worth it.\n *\n * The active tab lives in the url so a card is linkable mid-conversation. A\n * `forcedTab` (today: arriving with `?sub=`) wins over `?tab=`, because a url\n * that selects a child and then shows a different tab is lying about itself.\n *\n * Panels mount only while active, which also resets their internal state on\n * the way out — the same choice the card makes when it navigates between apps.\n */\nexport function DetailTabs({\n tabs,\n forcedTab,\n requestTab,\n requestNonce,\n band,\n bandClassName,\n}: {\n tabs: DetailTab[]\n /** Overrides `?tab=` while set. */\n forcedTab?: string\n /**\n * A one-shot \"please show this tab\" from outside the strip — the band's\n * \"Suggest a change\" uses it.\n *\n * It has to come through here rather than the caller writing `?tab=` itself:\n * `useUrlSyncedState` keeps its own `useState` seeded once from the URL and only\n * ever pushes state OUT to the URL, so a second instance writing the same key\n * does not update this one. The two then fight — the strip's next render sees the\n * URL disagreeing with its own state and navigates back over it.\n */\n requestTab?: string\n /**\n * Bumped by the caller every time it asks again. Without it a repeat request is\n * indistinguishable from the same standing value, so the effect below does not\n * re-fire and the second ask does nothing — which is what made the band's\n * button work exactly once per card.\n */\n requestNonce?: number\n /**\n * Identity and actions, shown ABOVE the strip and inside the same tinted\n * surface. Owned here rather than by the caller because the band and the\n * strip have to be one visual unit with the panel outside it — render the\n * panel inside the band and it inherits the tint, which is the mistake this\n * prop exists to make impossible.\n */\n band?: React.ReactNode\n /** Cancels the dialog's own padding so the tint reaches the card's edges. */\n bandClassName?: string\n}) {\n const [urlTab, setUrlTab] = useUrlSyncedState<string>({\n key: 'tab',\n defaultValue: '',\n encode: (value) => value || undefined,\n })\n\n const ids = tabs.map((t) => t.id)\n\n /**\n * Tab ids are public — they are in `?tab=` in links people have already shared.\n * Renaming one would silently drop those onto the first tab, so a retired id\n * keeps resolving to its replacement instead.\n */\n const RETIRED: Record<string, string> = { metadata: 'documentation' }\n\n /**\n * `forcedTab` picks the tab you ARRIVE on, not the tab you are stuck on.\n *\n * It used to win over `?tab=` for as long as it was set, and `?sub=` keeps it set\n * for the whole life of the card — so on any entry reached by a sub-resource\n * search (667 accounts on one of them) the strip was dead: every click wrote\n * nothing and the tab never moved. The same override also silently killed the\n * band's \"Suggest a change\", which navigates by writing `?tab=`.\n *\n * So it applies until the user expresses a preference, and then gets out of the\n * way. `userChose` is what distinguishes \"no opinion yet\" from \"chose the tab\n * that happens to be the forced one\".\n */\n const [userChose, setUserChose] = React.useState(false)\n\n // Applied when it changes, through the state that actually owns the tab.\n React.useEffect(() => {\n if (!requestTab) return\n setUserChose(true)\n setUrlTab(requestTab)\n }, [requestTab, requestNonce, setUrlTab])\n const asked = userChose || !forcedTab ? urlTab : forcedTab\n const requested = RETIRED[asked] ?? asked\n const activeId = ids.includes(requested) ? requested : (ids[0] ?? '')\n const active = tabs.find((t) => t.id === activeId)\n\n // Arrow keys move between tabs, per the tablist pattern. Selection follows\n // focus, which is correct here because every panel is already loaded.\n const onKeyDown = (event: React.KeyboardEvent) => {\n const delta =\n event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0\n if (!delta) return\n event.preventDefault()\n const from = ids.indexOf(activeId)\n const next = ids[(from + delta + ids.length) % ids.length]\n if (!next) return\n setUserChose(true)\n setUrlTab(next)\n // Focus follows selection, or the arrow keys would walk the visual\n // selection away from the keyboard's position. By id rather than by\n // position in a NodeList: each tab already carries one, and an id lookup\n // says \"missing\" with a null instead of an out-of-range index.\n document.getElementById(`detail-tab-${next}`)?.focus()\n }\n\n return (\n <>\n <div className={cn('detail-band', bandClassName)}>\n {band}\n <div role=\"tablist\" className=\"detail-tabs mt-5\" onKeyDown={onKeyDown}>\n {tabs.map((tab) => {\n const selected = tab.id === activeId\n return (\n <button\n key={tab.id}\n type=\"button\"\n role=\"tab\"\n id={`detail-tab-${tab.id}`}\n aria-selected={selected}\n aria-controls={`detail-panel-${tab.id}`}\n // Only the selected tab stays in the tab order; the rest are\n // reached with the arrow keys, as the pattern requires.\n tabIndex={selected ? 0 : -1}\n aria-label={tab.ariaLabel}\n className=\"detail-tab\"\n onClick={() => {\n setUserChose(true)\n setUrlTab(tab.id)\n }}\n >\n {tab.label}\n {tab.chip && (\n <span className=\"detail-tab__chip\">{tab.chip}</span>\n )}\n {tab.dot && (\n <span className=\"detail-tab__dot\" aria-hidden=\"true\" />\n )}\n </button>\n )\n })}\n </div>\n </div>\n {active && (\n <div\n role=\"tabpanel\"\n id={`detail-panel-${active.id}`}\n aria-labelledby={`detail-tab-${active.id}`}\n // Focusable so that tabbing out of the strip lands in the content it\n // controls rather than skipping past it.\n tabIndex={0}\n className=\"pt-5 outline-none\"\n >\n {active.render()}\n </div>\n )}\n </>\n )\n}\n"],"names":["React"],"mappings":";;;;AAmCO,SAAS,WAAW;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAgCG;AACD,QAAM,CAAC,QAAQ,SAAS,IAAI,kBAA0B;AAAA,IACpD,KAAK;AAAA,IACL,cAAc;AAAA,IACd,QAAQ,CAAC,UAAU,SAAS;AAAA,EAAA,CAC7B;AAED,QAAM,MAAM,KAAK,IAAI,CAAC,MAAM,EAAE,EAAE;AAOhC,QAAM,UAAkC,EAAE,UAAU,gBAAA;AAepD,QAAM,CAAC,WAAW,YAAY,IAAIA,eAAM,SAAS,KAAK;AAGtDA,iBAAM,UAAU,MAAM;AACpB,QAAI,CAAC,WAAY;AACjB,iBAAa,IAAI;AACjB,cAAU,UAAU;AAAA,EACtB,GAAG,CAAC,YAAY,cAAc,SAAS,CAAC;AACxC,QAAM,QAAQ,aAAa,CAAC,YAAY,SAAS;AACjD,QAAM,YAAY,QAAQ,KAAK,KAAK;AACpC,QAAM,WAAW,IAAI,SAAS,SAAS,IAAI,YAAa,IAAI,CAAC,KAAK;AAClE,QAAM,SAAS,KAAK,KAAK,CAAC,MAAM,EAAE,OAAO,QAAQ;AAIjD,QAAM,YAAY,CAAC,UAA+B;;AAChD,UAAM,QACJ,MAAM,QAAQ,eAAe,IAAI,MAAM,QAAQ,cAAc,KAAK;AACpE,QAAI,CAAC,MAAO;AACZ,UAAM,eAAA;AACN,UAAM,OAAO,IAAI,QAAQ,QAAQ;AACjC,UAAM,OAAO,KAAK,OAAO,QAAQ,IAAI,UAAU,IAAI,MAAM;AACzD,QAAI,CAAC,KAAM;AACX,iBAAa,IAAI;AACjB,cAAU,IAAI;AAKd,mBAAS,eAAe,cAAc,IAAI,EAAE,MAA5C,mBAA+C;AAAA,EACjD;AAEA,SACE,qBAAA,UAAA,EACE,UAAA;AAAA,IAAA,qBAAC,OAAA,EAAI,WAAW,GAAG,eAAe,aAAa,GAC5C,UAAA;AAAA,MAAA;AAAA,MACD,oBAAC,OAAA,EAAI,MAAK,WAAU,WAAU,oBAAmB,WAC9C,UAAA,KAAK,IAAI,CAAC,QAAQ;AACjB,cAAM,WAAW,IAAI,OAAO;AAC5B,eACE;AAAA,UAAC;AAAA,UAAA;AAAA,YAEC,MAAK;AAAA,YACL,MAAK;AAAA,YACL,IAAI,cAAc,IAAI,EAAE;AAAA,YACxB,iBAAe;AAAA,YACf,iBAAe,gBAAgB,IAAI,EAAE;AAAA,YAGrC,UAAU,WAAW,IAAI;AAAA,YACzB,cAAY,IAAI;AAAA,YAChB,WAAU;AAAA,YACV,SAAS,MAAM;AACb,2BAAa,IAAI;AACjB,wBAAU,IAAI,EAAE;AAAA,YAClB;AAAA,YAEC,UAAA;AAAA,cAAA,IAAI;AAAA,cACJ,IAAI,QACH,oBAAC,UAAK,WAAU,oBAAoB,cAAI,MAAK;AAAA,cAE9C,IAAI,OACH,oBAAC,UAAK,WAAU,mBAAkB,eAAY,OAAA,CAAO;AAAA,YAAA;AAAA,UAAA;AAAA,UArBlD,IAAI;AAAA,QAAA;AAAA,MAyBf,CAAC,EAAA,CACH;AAAA,IAAA,GACF;AAAA,IACC,UACC;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,MAAK;AAAA,QACL,IAAI,gBAAgB,OAAO,EAAE;AAAA,QAC7B,mBAAiB,cAAc,OAAO,EAAE;AAAA,QAGxC,UAAU;AAAA,QACV,WAAU;AAAA,QAET,iBAAO,OAAA;AAAA,MAAO;AAAA,IAAA;AAAA,EACjB,GAEJ;AAEJ;"}
|
|
1
|
+
{"version":3,"file":"DetailTabs.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/DetailTabs.tsx"],"sourcesContent":["import React from 'react'\nimport { cn } from '~/lib/utils'\nimport { useUrlSyncedState } from '~/modules/appCatalog/hooks/useUrlSyncedState'\n\nexport interface DetailTab {\n /** Stable id — this is what lands in `?tab=`, so treat it as public. */\n id: string\n label: string\n /** One short count beside the label (\"430\", \"55 roles\", \"2 steps\"). */\n chip?: string\n /** Marks unreviewed content. Also stated in the accessible name. */\n dot?: boolean\n /** Spoken instead of `label` when the chip and dot need explaining. */\n ariaLabel?: string\n render: () => React.ReactNode\n}\n\n/**\n * The detail card's tabs.\n *\n * Every entry gets them, including thin ones. Two earlier rules were worse:\n * hiding a tab with little in it turned \"we have not documented the way in\"\n * into a dead end you had to click to discover, and showing them only on heavy\n * entries made the card's shape change between apps, so \"where is access?\" had\n * two answers and no habit could form across a few hundred entries. Naming the\n * route in the tab itself removes the reason to hide anything — the label tells\n * you whether opening it is worth it.\n *\n * The active tab lives in the url so a card is linkable mid-conversation. A\n * `forcedTab` (today: arriving with `?sub=`) wins over `?tab=`, because a url\n * that selects a child and then shows a different tab is lying about itself.\n *\n * Panels mount only while active, which also resets their internal state on\n * the way out — the same choice the card makes when it navigates between apps.\n */\nexport function DetailTabs({\n tabs,\n forcedTab,\n requestTab,\n requestNonce,\n band,\n bandClassName,\n}: {\n tabs: DetailTab[]\n /** Overrides `?tab=` while set. */\n forcedTab?: string\n /**\n * A one-shot \"please show this tab\" from outside the strip — the band's\n * \"Suggest a change\" uses it.\n *\n * It has to come through here rather than the caller writing `?tab=` itself:\n * `useUrlSyncedState` keeps its own `useState` seeded once from the URL and only\n * ever pushes state OUT to the URL, so a second instance writing the same key\n * does not update this one. The two then fight — the strip's next render sees the\n * URL disagreeing with its own state and navigates back over it.\n */\n requestTab?: string\n /**\n * Bumped by the caller every time it asks again. Without it a repeat request is\n * indistinguishable from the same standing value, so the effect below does not\n * re-fire and the second ask does nothing — which is what made the band's\n * button work exactly once per card.\n */\n requestNonce?: number\n /**\n * Identity and actions, shown ABOVE the strip and inside the same tinted\n * surface. Owned here rather than by the caller because the band and the\n * strip have to be one visual unit with the panel outside it — render the\n * panel inside the band and it inherits the tint, which is the mistake this\n * prop exists to make impossible.\n */\n band?: React.ReactNode\n /** Cancels the dialog's own padding so the tint reaches the card's edges. */\n bandClassName?: string\n}) {\n /**\n * The first tab is what the card opens on, so naming it in the URL says nothing.\n * `encode` returning undefined drops the param, which keeps `?tab=` out of a link\n * until someone has actually chosen something other than the default — and keeps a\n * shared link honest about what the sender was looking at.\n */\n const defaultTabId = tabs[0]?.id\n const [urlTab, setUrlTab] = useUrlSyncedState<string>({\n key: 'tab',\n defaultValue: '',\n encode: (value) => (value && value !== defaultTabId ? value : undefined),\n })\n\n const ids = tabs.map((t) => t.id)\n\n /**\n * Tab ids are public — they are in `?tab=` in links people have already shared.\n * Renaming one would silently drop those onto the first tab, so a retired id\n * keeps resolving to its replacement instead.\n */\n const RETIRED: Record<string, string> = { metadata: 'documentation' }\n\n /**\n * `forcedTab` picks the tab you ARRIVE on, not the tab you are stuck on.\n *\n * It used to win over `?tab=` for as long as it was set, and `?sub=` keeps it set\n * for the whole life of the card — so on any entry reached by a sub-resource\n * search (667 accounts on one of them) the strip was dead: every click wrote\n * nothing and the tab never moved. The same override also silently killed the\n * band's \"Suggest a change\", which navigates by writing `?tab=`.\n *\n * So it applies until the user expresses a preference, and then gets out of the\n * way. `userChose` is what distinguishes \"no opinion yet\" from \"chose the tab\n * that happens to be the forced one\".\n */\n const [userChose, setUserChose] = React.useState(false)\n\n // Applied when it changes, through the state that actually owns the tab.\n React.useEffect(() => {\n if (!requestTab) return\n setUserChose(true)\n setUrlTab(requestTab)\n }, [requestTab, requestNonce, setUrlTab])\n const asked = userChose || !forcedTab ? urlTab : forcedTab\n const requested = RETIRED[asked] ?? asked\n const activeId = ids.includes(requested) ? requested : (ids[0] ?? '')\n const active = tabs.find((t) => t.id === activeId)\n\n // Arrow keys move between tabs, per the tablist pattern. Selection follows\n // focus, which is correct here because every panel is already loaded.\n const onKeyDown = (event: React.KeyboardEvent) => {\n const delta =\n event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0\n if (!delta) return\n event.preventDefault()\n const from = ids.indexOf(activeId)\n const next = ids[(from + delta + ids.length) % ids.length]\n if (!next) return\n setUserChose(true)\n setUrlTab(next)\n // Focus follows selection, or the arrow keys would walk the visual\n // selection away from the keyboard's position. By id rather than by\n // position in a NodeList: each tab already carries one, and an id lookup\n // says \"missing\" with a null instead of an out-of-range index.\n document.getElementById(`detail-tab-${next}`)?.focus()\n }\n\n return (\n <>\n <div className={cn('detail-band', bandClassName)}>\n {band}\n <div role=\"tablist\" className=\"detail-tabs mt-5\" onKeyDown={onKeyDown}>\n {tabs.map((tab) => {\n const selected = tab.id === activeId\n return (\n <button\n key={tab.id}\n type=\"button\"\n role=\"tab\"\n id={`detail-tab-${tab.id}`}\n aria-selected={selected}\n aria-controls={`detail-panel-${tab.id}`}\n // Only the selected tab stays in the tab order; the rest are\n // reached with the arrow keys, as the pattern requires.\n tabIndex={selected ? 0 : -1}\n aria-label={tab.ariaLabel}\n className=\"detail-tab\"\n onClick={() => {\n setUserChose(true)\n setUrlTab(tab.id)\n }}\n >\n {tab.label}\n {tab.chip && (\n <span className=\"detail-tab__chip\">{tab.chip}</span>\n )}\n {tab.dot && (\n <span className=\"detail-tab__dot\" aria-hidden=\"true\" />\n )}\n </button>\n )\n })}\n </div>\n </div>\n {active && (\n <div\n role=\"tabpanel\"\n id={`detail-panel-${active.id}`}\n aria-labelledby={`detail-tab-${active.id}`}\n // Focusable so that tabbing out of the strip lands in the content it\n // controls rather than skipping past it.\n tabIndex={0}\n className=\"pt-5 outline-none\"\n >\n {active.render()}\n </div>\n )}\n </>\n )\n}\n"],"names":["React","_a"],"mappings":";;;;AAmCO,SAAS,WAAW;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAgCG;;AAOD,QAAM,gBAAe,UAAK,CAAC,MAAN,mBAAS;AAC9B,QAAM,CAAC,QAAQ,SAAS,IAAI,kBAA0B;AAAA,IACpD,KAAK;AAAA,IACL,cAAc;AAAA,IACd,QAAQ,CAAC,UAAW,SAAS,UAAU,eAAe,QAAQ;AAAA,EAAA,CAC/D;AAED,QAAM,MAAM,KAAK,IAAI,CAAC,MAAM,EAAE,EAAE;AAOhC,QAAM,UAAkC,EAAE,UAAU,gBAAA;AAepD,QAAM,CAAC,WAAW,YAAY,IAAIA,eAAM,SAAS,KAAK;AAGtDA,iBAAM,UAAU,MAAM;AACpB,QAAI,CAAC,WAAY;AACjB,iBAAa,IAAI;AACjB,cAAU,UAAU;AAAA,EACtB,GAAG,CAAC,YAAY,cAAc,SAAS,CAAC;AACxC,QAAM,QAAQ,aAAa,CAAC,YAAY,SAAS;AACjD,QAAM,YAAY,QAAQ,KAAK,KAAK;AACpC,QAAM,WAAW,IAAI,SAAS,SAAS,IAAI,YAAa,IAAI,CAAC,KAAK;AAClE,QAAM,SAAS,KAAK,KAAK,CAAC,MAAM,EAAE,OAAO,QAAQ;AAIjD,QAAM,YAAY,CAAC,UAA+B;;AAChD,UAAM,QACJ,MAAM,QAAQ,eAAe,IAAI,MAAM,QAAQ,cAAc,KAAK;AACpE,QAAI,CAAC,MAAO;AACZ,UAAM,eAAA;AACN,UAAM,OAAO,IAAI,QAAQ,QAAQ;AACjC,UAAM,OAAO,KAAK,OAAO,QAAQ,IAAI,UAAU,IAAI,MAAM;AACzD,QAAI,CAAC,KAAM;AACX,iBAAa,IAAI;AACjB,cAAU,IAAI;AAKd,KAAAC,MAAA,SAAS,eAAe,cAAc,IAAI,EAAE,MAA5C,gBAAAA,IAA+C;AAAA,EACjD;AAEA,SACE,qBAAA,UAAA,EACE,UAAA;AAAA,IAAA,qBAAC,OAAA,EAAI,WAAW,GAAG,eAAe,aAAa,GAC5C,UAAA;AAAA,MAAA;AAAA,MACD,oBAAC,OAAA,EAAI,MAAK,WAAU,WAAU,oBAAmB,WAC9C,UAAA,KAAK,IAAI,CAAC,QAAQ;AACjB,cAAM,WAAW,IAAI,OAAO;AAC5B,eACE;AAAA,UAAC;AAAA,UAAA;AAAA,YAEC,MAAK;AAAA,YACL,MAAK;AAAA,YACL,IAAI,cAAc,IAAI,EAAE;AAAA,YACxB,iBAAe;AAAA,YACf,iBAAe,gBAAgB,IAAI,EAAE;AAAA,YAGrC,UAAU,WAAW,IAAI;AAAA,YACzB,cAAY,IAAI;AAAA,YAChB,WAAU;AAAA,YACV,SAAS,MAAM;AACb,2BAAa,IAAI;AACjB,wBAAU,IAAI,EAAE;AAAA,YAClB;AAAA,YAEC,UAAA;AAAA,cAAA,IAAI;AAAA,cACJ,IAAI,QACH,oBAAC,UAAK,WAAU,oBAAoB,cAAI,MAAK;AAAA,cAE9C,IAAI,OACH,oBAAC,UAAK,WAAU,mBAAkB,eAAY,OAAA,CAAO;AAAA,YAAA;AAAA,UAAA;AAAA,UArBlD,IAAI;AAAA,QAAA;AAAA,MAyBf,CAAC,EAAA,CACH;AAAA,IAAA,GACF;AAAA,IACC,UACC;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,MAAK;AAAA,QACL,IAAI,gBAAgB,OAAO,EAAE;AAAA,QAC7B,mBAAiB,cAAc,OAAO,EAAE;AAAA,QAGxC,UAAU;AAAA,QACV,WAAU;AAAA,QAET,iBAAO,OAAA;AAAA,MAAO;AAAA,IAAA;AAAA,EACjB,GAEJ;AAEJ;"}
|
|
@@ -114,7 +114,24 @@ function AppCatalogPage({
|
|
|
114
114
|
{
|
|
115
115
|
app: selectedApp,
|
|
116
116
|
subResource: selectedSub,
|
|
117
|
-
onClose: () => void navigate({
|
|
117
|
+
onClose: () => void navigate({
|
|
118
|
+
to: "/",
|
|
119
|
+
/**
|
|
120
|
+
* Drop the card's own params, keep the catalog's.
|
|
121
|
+
*
|
|
122
|
+
* `navigate({ to: '/' })` alone preserves the whole search object, so
|
|
123
|
+
* closing a card left `?tab=notes` (and `?sub=`, `?qj=`) behind on the
|
|
124
|
+
* catalog, where they mean nothing — and a link copied afterwards
|
|
125
|
+
* carried them. Clearing the lot instead would throw away the
|
|
126
|
+
* visitor's filters, which belong to the catalog and outlive any card.
|
|
127
|
+
*/
|
|
128
|
+
search: (prev) => ({
|
|
129
|
+
...prev,
|
|
130
|
+
tab: void 0,
|
|
131
|
+
sub: void 0,
|
|
132
|
+
qj: void 0
|
|
133
|
+
})
|
|
134
|
+
}),
|
|
118
135
|
onAppClick: handleAppClick,
|
|
119
136
|
onBackToParent: handleBackToParent
|
|
120
137
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AppCatalogPage.js","sources":["../../../../../../src/modules/appCatalog/ui/pages/AppCatalogPage.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { useNavigate, useSearch } from '@tanstack/react-router'\nimport { Suspense, lazy, useCallback, useEffect, useMemo } from 'react'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'\nimport { AppCatalogGrid } from '../catalog/AppCatalogGrid'\nimport { AttributionFooter } from '../catalog/AttributionFooter'\n\n// The detail card (and the markdown stack it renders) loads on first open, not\n// with the catalog grid every visitor sees first (#120).\nconst AppDetailPanel = lazy(() =>\n import('../catalog/AppDetailPanel').then((m) => ({\n default: m.AppDetailPanel,\n })),\n)\n\nexport function AppCatalogPage({\n selectedSlug,\n selectedSubSlug: subPageSlug,\n}: { selectedSlug?: string; selectedSubSlug?: string } = {}) {\n const { resources, isLoadingApps } = useAppCatalogContext()\n const { state: filterState, actions } = useAppCatalogFilters()\n const navigate = useNavigate()\n\n // Selected app comes from the route path (/app/<slug>); undefined = nothing open\n const selectedAppSlug = selectedSlug\n\n // Search value from context (module-scoped, see AppCatalogFiltersContext)\n const search = useSearch({ strict: false })\n\n // Two distinct states, deliberately on different parts of the URL:\n // - `?sub=<slug>` the parent's detail, singled out to one child\n // - `/app/<slug>/sub/<child>` that child's OWN page, with its access chain\n // Clicking a matched sub-resource in the search results reaches the first;\n // clicking its name in the parent's table reaches the second.\n const highlightSubSlug = search.sub\n\n const handleSubClick = useCallback(\n (parentSlug: string, subSlug: string) => {\n void navigate({\n to: '/app/$slug',\n params: { slug: parentSlug },\n search: (prev) => ({ ...prev, sub: subSlug }),\n })\n },\n [navigate],\n )\n\n const handleBackToParent = useCallback(() => {\n // Leave the sub-resource's page for its parent's detail, keeping `?sub=` so\n // the row the user came from stays singled out — going \"back\" shouldn't\n // dump them into all forty-seven siblings.\n if (selectedAppSlug) {\n void navigate({\n to: '/app/$slug',\n params: { slug: selectedAppSlug },\n search: (prev) => ({ ...prev, sub: subPageSlug }),\n })\n }\n }, [navigate, selectedAppSlug, subPageSlug])\n\n const searchValue = filterState.searchValue\n const setSearchValue = actions.setSearchValue\n\n // Get root resources for display (children surface through search)\n const rootResources = useMemo(\n () => resources.filter((r) => !r.parentSlug),\n [resources],\n )\n\n // Dev-only skew warning: data arrived (resources > 0) but nothing is top-level\n // (rootResources === 0). That fingerprints a frontend/backend-core version skew\n // or a stale service worker — NOT a normal empty search/filter (those still have\n // rootResources). Guarded to dev so prod users never see it.\n useEffect(() => {\n if (\n import.meta.env.DEV &&\n !isLoadingApps &&\n resources.length > 0 &&\n rootResources.length === 0\n ) {\n console.warn(\n `[app-catalog] Loaded ${resources.length} resources but 0 are top-level — ` +\n `likely a frontend/backend-core version skew or a stale service worker. ` +\n `Check the version footer (be X / fe Y) and hard-reload.`,\n )\n }\n }, [isLoadingApps, resources.length, rootResources.length])\n\n // #22: alias → canonical redirect. When an app is renamed its old slug is\n // kept in `aliases[]`. If the URL slug matches no canonical slug but does\n // match some app's alias, redirect (replace) to that app's canonical slug so\n // old bookmarks resolve instead of showing a blank catalog. Guard on a real\n // canonical miss so we never fight the normal detail-open path. Note: this is\n // a client-side SPA redirect (replace), not an HTTP 301 — see #22.\n useEffect(() => {\n if (!selectedAppSlug || resources.length === 0) return\n const canonical = resources.some((r) => r.slug === selectedAppSlug)\n if (canonical) return\n const aliased = resources.find((r) => r.aliases?.includes(selectedAppSlug))\n if (aliased) {\n void navigate({\n to: '/app/$slug',\n params: { slug: aliased.slug },\n search: (prev) => prev,\n replace: true,\n })\n }\n }, [selectedAppSlug, resources, navigate])\n\n const handleAppClick = (app: Resource) => {\n void navigate({\n to: '/app/$slug',\n params: { slug: app.slug },\n search: (prev) => prev,\n })\n }\n\n const handleLaunch = (app: Resource) => {\n if (app.appUrl) window.open(app.appUrl, '_blank', 'noopener,noreferrer')\n }\n\n // The app whose detail card is open over the catalog backdrop.\n const selectedApp = useMemo(\n () =>\n selectedAppSlug\n ? (resources.find((r) => r.slug === selectedAppSlug) ?? null)\n : null,\n [selectedAppSlug, resources],\n )\n\n // …and the sub-resource whose OWN page is open (`/app/<slug>/sub/<child>`).\n // `?sub=` deliberately does NOT land here: that state stays on the parent's\n // detail with the child singled out in its table.\n const selectedSub = useMemo(() => {\n if (!subPageSlug || !selectedApp) return null\n return (\n resources.find(\n (r) => r.slug === subPageSlug && r.parentSlug === selectedApp.slug,\n ) ?? null\n )\n }, [subPageSlug, selectedApp, resources])\n\n if (isLoadingApps) {\n return <div className=\"py-6 text-muted-foreground\">Loading…</div>\n }\n\n // The catalog is one persistent shell (#38): the discovery spine or, while\n // typing, the in-place results list — both handled inside AppCatalogGrid, so\n // searching never swaps containers and the hero never moves. It owns vertical\n // scroll (MainLayout is h-screen/overflow-hidden), and `scrollbar-gutter:\n // stable` reserves the track so a shorter results list doesn't shift the hero\n // sideways when the scrollbar disappears.\n return (\n // `pb-24` lives here, on whatever ends up last — the footer when the app\n // configures one, the final card row otherwise — so nothing sits flush\n // against the bottom edge or under the floating support bubble.\n <div className=\"flex-1 min-h-0 overflow-y-auto [scrollbar-gutter:stable] pb-24\">\n <AppCatalogGrid\n apps={rootResources.filter(\n (a) => filterState.showDeprecated || !a.deprecated,\n )}\n // All resources incl. children — so search matches sub-resources\n // (e.g. a cloud account) and surfaces their parent.\n allResources={resources}\n searchValue={searchValue}\n onSearchChange={setSearchValue}\n onAppClick={handleAppClick}\n onSubClick={handleSubClick}\n onLaunch={handleLaunch}\n detailOpen={selectedApp !== null}\n selectedSubSlug={highlightSubSlug}\n />\n {/* Outside the grid's 1000px column so its rule spans the full width. */}\n <AttributionFooter />\n {/* #38 item B: the detail as a card over the catalog backdrop. */}\n {selectedApp && (\n <Suspense fallback={null}>\n <AppDetailPanel\n app={selectedApp}\n subResource={selectedSub}\n onClose={() => void navigate({ to: '/' })}\n onAppClick={handleAppClick}\n onBackToParent={handleBackToParent}\n />\n </Suspense>\n )}\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"AppCatalogPage.js","sources":["../../../../../../src/modules/appCatalog/ui/pages/AppCatalogPage.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { useNavigate, useSearch } from '@tanstack/react-router'\nimport { Suspense, lazy, useCallback, useEffect, useMemo } from 'react'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'\nimport { AppCatalogGrid } from '../catalog/AppCatalogGrid'\nimport { AttributionFooter } from '../catalog/AttributionFooter'\n\n// The detail card (and the markdown stack it renders) loads on first open, not\n// with the catalog grid every visitor sees first (#120).\nconst AppDetailPanel = lazy(() =>\n import('../catalog/AppDetailPanel').then((m) => ({\n default: m.AppDetailPanel,\n })),\n)\n\nexport function AppCatalogPage({\n selectedSlug,\n selectedSubSlug: subPageSlug,\n}: { selectedSlug?: string; selectedSubSlug?: string } = {}) {\n const { resources, isLoadingApps } = useAppCatalogContext()\n const { state: filterState, actions } = useAppCatalogFilters()\n const navigate = useNavigate()\n\n // Selected app comes from the route path (/app/<slug>); undefined = nothing open\n const selectedAppSlug = selectedSlug\n\n // Search value from context (module-scoped, see AppCatalogFiltersContext)\n const search = useSearch({ strict: false })\n\n // Two distinct states, deliberately on different parts of the URL:\n // - `?sub=<slug>` the parent's detail, singled out to one child\n // - `/app/<slug>/sub/<child>` that child's OWN page, with its access chain\n // Clicking a matched sub-resource in the search results reaches the first;\n // clicking its name in the parent's table reaches the second.\n const highlightSubSlug = search.sub\n\n const handleSubClick = useCallback(\n (parentSlug: string, subSlug: string) => {\n void navigate({\n to: '/app/$slug',\n params: { slug: parentSlug },\n search: (prev) => ({ ...prev, sub: subSlug }),\n })\n },\n [navigate],\n )\n\n const handleBackToParent = useCallback(() => {\n // Leave the sub-resource's page for its parent's detail, keeping `?sub=` so\n // the row the user came from stays singled out — going \"back\" shouldn't\n // dump them into all forty-seven siblings.\n if (selectedAppSlug) {\n void navigate({\n to: '/app/$slug',\n params: { slug: selectedAppSlug },\n search: (prev) => ({ ...prev, sub: subPageSlug }),\n })\n }\n }, [navigate, selectedAppSlug, subPageSlug])\n\n const searchValue = filterState.searchValue\n const setSearchValue = actions.setSearchValue\n\n // Get root resources for display (children surface through search)\n const rootResources = useMemo(\n () => resources.filter((r) => !r.parentSlug),\n [resources],\n )\n\n // Dev-only skew warning: data arrived (resources > 0) but nothing is top-level\n // (rootResources === 0). That fingerprints a frontend/backend-core version skew\n // or a stale service worker — NOT a normal empty search/filter (those still have\n // rootResources). Guarded to dev so prod users never see it.\n useEffect(() => {\n if (\n import.meta.env.DEV &&\n !isLoadingApps &&\n resources.length > 0 &&\n rootResources.length === 0\n ) {\n console.warn(\n `[app-catalog] Loaded ${resources.length} resources but 0 are top-level — ` +\n `likely a frontend/backend-core version skew or a stale service worker. ` +\n `Check the version footer (be X / fe Y) and hard-reload.`,\n )\n }\n }, [isLoadingApps, resources.length, rootResources.length])\n\n // #22: alias → canonical redirect. When an app is renamed its old slug is\n // kept in `aliases[]`. If the URL slug matches no canonical slug but does\n // match some app's alias, redirect (replace) to that app's canonical slug so\n // old bookmarks resolve instead of showing a blank catalog. Guard on a real\n // canonical miss so we never fight the normal detail-open path. Note: this is\n // a client-side SPA redirect (replace), not an HTTP 301 — see #22.\n useEffect(() => {\n if (!selectedAppSlug || resources.length === 0) return\n const canonical = resources.some((r) => r.slug === selectedAppSlug)\n if (canonical) return\n const aliased = resources.find((r) => r.aliases?.includes(selectedAppSlug))\n if (aliased) {\n void navigate({\n to: '/app/$slug',\n params: { slug: aliased.slug },\n search: (prev) => prev,\n replace: true,\n })\n }\n }, [selectedAppSlug, resources, navigate])\n\n const handleAppClick = (app: Resource) => {\n void navigate({\n to: '/app/$slug',\n params: { slug: app.slug },\n search: (prev) => prev,\n })\n }\n\n const handleLaunch = (app: Resource) => {\n if (app.appUrl) window.open(app.appUrl, '_blank', 'noopener,noreferrer')\n }\n\n // The app whose detail card is open over the catalog backdrop.\n const selectedApp = useMemo(\n () =>\n selectedAppSlug\n ? (resources.find((r) => r.slug === selectedAppSlug) ?? null)\n : null,\n [selectedAppSlug, resources],\n )\n\n // …and the sub-resource whose OWN page is open (`/app/<slug>/sub/<child>`).\n // `?sub=` deliberately does NOT land here: that state stays on the parent's\n // detail with the child singled out in its table.\n const selectedSub = useMemo(() => {\n if (!subPageSlug || !selectedApp) return null\n return (\n resources.find(\n (r) => r.slug === subPageSlug && r.parentSlug === selectedApp.slug,\n ) ?? null\n )\n }, [subPageSlug, selectedApp, resources])\n\n if (isLoadingApps) {\n return <div className=\"py-6 text-muted-foreground\">Loading…</div>\n }\n\n // The catalog is one persistent shell (#38): the discovery spine or, while\n // typing, the in-place results list — both handled inside AppCatalogGrid, so\n // searching never swaps containers and the hero never moves. It owns vertical\n // scroll (MainLayout is h-screen/overflow-hidden), and `scrollbar-gutter:\n // stable` reserves the track so a shorter results list doesn't shift the hero\n // sideways when the scrollbar disappears.\n return (\n // `pb-24` lives here, on whatever ends up last — the footer when the app\n // configures one, the final card row otherwise — so nothing sits flush\n // against the bottom edge or under the floating support bubble.\n <div className=\"flex-1 min-h-0 overflow-y-auto [scrollbar-gutter:stable] pb-24\">\n <AppCatalogGrid\n apps={rootResources.filter(\n (a) => filterState.showDeprecated || !a.deprecated,\n )}\n // All resources incl. children — so search matches sub-resources\n // (e.g. a cloud account) and surfaces their parent.\n allResources={resources}\n searchValue={searchValue}\n onSearchChange={setSearchValue}\n onAppClick={handleAppClick}\n onSubClick={handleSubClick}\n onLaunch={handleLaunch}\n detailOpen={selectedApp !== null}\n selectedSubSlug={highlightSubSlug}\n />\n {/* Outside the grid's 1000px column so its rule spans the full width. */}\n <AttributionFooter />\n {/* #38 item B: the detail as a card over the catalog backdrop. */}\n {selectedApp && (\n <Suspense fallback={null}>\n <AppDetailPanel\n app={selectedApp}\n subResource={selectedSub}\n onClose={() =>\n void navigate({\n to: '/',\n /**\n * Drop the card's own params, keep the catalog's.\n *\n * `navigate({ to: '/' })` alone preserves the whole search object, so\n * closing a card left `?tab=notes` (and `?sub=`, `?qj=`) behind on the\n * catalog, where they mean nothing — and a link copied afterwards\n * carried them. Clearing the lot instead would throw away the\n * visitor's filters, which belong to the catalog and outlive any card.\n */\n search: (prev) => ({\n ...prev,\n tab: undefined,\n sub: undefined,\n qj: undefined,\n }),\n })\n }\n onAppClick={handleAppClick}\n onBackToParent={handleBackToParent}\n />\n </Suspense>\n )}\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.
|
|
3
|
+
"version": "6.5.0",
|
|
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.
|
|
115
|
+
"@igstack/app-catalog-shared-core": "6.5.0"
|
|
116
116
|
},
|
|
117
117
|
"devDependencies": {
|
|
118
118
|
"@tailwindcss/vite": "^4.0.5",
|
|
@@ -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.
|
|
142
|
+
"@igstack/app-catalog-backend-core": "6.5.0"
|
|
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": "fff8c62c5136b8b1fdb9631ee42c261ba7a35bc6",
|
|
152
152
|
"scripts": {
|
|
153
153
|
"build": "vite build",
|
|
154
154
|
"build:lenient": "vite build --mode lenient",
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import '@testing-library/jest-dom/vitest'
|
|
5
|
+
|
|
6
|
+
const state = vi.hoisted(() => ({ sent: [] as unknown[] }))
|
|
7
|
+
|
|
8
|
+
vi.mock('@tanstack/react-query', () => ({
|
|
9
|
+
useMutation: vi.fn((options?: { onSuccess?: () => void }) => ({
|
|
10
|
+
mutate: (vars: unknown) => {
|
|
11
|
+
state.sent.push(vars)
|
|
12
|
+
options?.onSuccess?.()
|
|
13
|
+
},
|
|
14
|
+
isPending: false,
|
|
15
|
+
})),
|
|
16
|
+
useQueryClient: vi.fn(() => ({ invalidateQueries: vi.fn() })),
|
|
17
|
+
}))
|
|
18
|
+
vi.mock('~/api/infra/trpc', () => ({
|
|
19
|
+
useTRPC: vi.fn(() => ({
|
|
20
|
+
feedback: {
|
|
21
|
+
add: { mutationOptions: (o: unknown) => o },
|
|
22
|
+
mine: { queryKey: () => ['feedback', 'mine'] },
|
|
23
|
+
},
|
|
24
|
+
})),
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
const { RequestAppDialog } =
|
|
28
|
+
await import('~/modules/appCatalog/ui/catalog/RequestAppDialog')
|
|
29
|
+
|
|
30
|
+
describe('RequestAppDialog', () => {
|
|
31
|
+
/**
|
|
32
|
+
* The grid is rendered bare in several tests with no TRPCProvider, so the closed
|
|
33
|
+
* dialog must not reach a tRPC hook. A hook cannot be called conditionally, which is
|
|
34
|
+
* why the gate is a component boundary — and why this test exists: inlining the body
|
|
35
|
+
* back into one component breaks six unrelated grid tests with
|
|
36
|
+
* "useTRPC() can only be used inside of a <TRPCProvider>".
|
|
37
|
+
*/
|
|
38
|
+
it('mounts nothing at all while closed', () => {
|
|
39
|
+
const { container } = render(
|
|
40
|
+
<RequestAppDialog open={false} onOpenChange={() => {}} />,
|
|
41
|
+
)
|
|
42
|
+
expect(container).toBeEmptyDOMElement()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('pre-fills what the visitor searched for', () => {
|
|
46
|
+
render(
|
|
47
|
+
<RequestAppDialog
|
|
48
|
+
open
|
|
49
|
+
onOpenChange={() => {}}
|
|
50
|
+
initialSubject="hoppy budget tool"
|
|
51
|
+
/>,
|
|
52
|
+
)
|
|
53
|
+
// They just typed it into the search box; asking again is a toll on the one
|
|
54
|
+
// person who already told us what is missing.
|
|
55
|
+
expect(screen.getByLabelText(/What are you looking for/)).toHaveValue(
|
|
56
|
+
'hoppy budget tool',
|
|
57
|
+
)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('refuses to send with no name, then sends a catalog-level ask', async () => {
|
|
61
|
+
state.sent = []
|
|
62
|
+
render(<RequestAppDialog open onOpenChange={() => {}} initialSubject="" />)
|
|
63
|
+
|
|
64
|
+
const send = screen.getByRole('button', { name: /Send request/ })
|
|
65
|
+
expect(send).toBeDisabled()
|
|
66
|
+
|
|
67
|
+
await userEvent.type(
|
|
68
|
+
screen.getByLabelText(/What are you looking for/),
|
|
69
|
+
'Grafana',
|
|
70
|
+
)
|
|
71
|
+
await userEvent.click(screen.getByRole('button', { name: /Send request/ }))
|
|
72
|
+
|
|
73
|
+
// No resourceSlug: that is what makes it a request for something absent rather
|
|
74
|
+
// than feedback on an existing entry. The server splits them the same way.
|
|
75
|
+
expect(state.sent).toEqual([{ subject: 'Grafana' }])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('carries the optional detail when given, and omits it when blank', async () => {
|
|
79
|
+
state.sent = []
|
|
80
|
+
render(
|
|
81
|
+
<RequestAppDialog
|
|
82
|
+
open
|
|
83
|
+
onOpenChange={() => {}}
|
|
84
|
+
initialSubject="Grafana"
|
|
85
|
+
/>,
|
|
86
|
+
)
|
|
87
|
+
await userEvent.type(
|
|
88
|
+
screen.getByLabelText(/Anything that would help/),
|
|
89
|
+
'the observability team runs it',
|
|
90
|
+
)
|
|
91
|
+
await userEvent.click(screen.getByRole('button', { name: /Send request/ }))
|
|
92
|
+
|
|
93
|
+
expect(state.sent).toEqual([
|
|
94
|
+
{ subject: 'Grafana', body: 'the observability team runs it' },
|
|
95
|
+
])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('confirms rather than leaving them wondering whether it sent', async () => {
|
|
99
|
+
state.sent = []
|
|
100
|
+
render(
|
|
101
|
+
<RequestAppDialog
|
|
102
|
+
open
|
|
103
|
+
onOpenChange={() => {}}
|
|
104
|
+
initialSubject="Grafana"
|
|
105
|
+
/>,
|
|
106
|
+
)
|
|
107
|
+
await userEvent.click(screen.getByRole('button', { name: /Send request/ }))
|
|
108
|
+
|
|
109
|
+
// Reporting into silence is how a person learns not to bother.
|
|
110
|
+
expect(screen.getByText(/that is logged/i)).toBeInTheDocument()
|
|
111
|
+
expect(screen.queryByRole('button', { name: /Send request/ })).toBeNull()
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -102,6 +102,30 @@ describe('the tab strip', () => {
|
|
|
102
102
|
expect(wroteTab).toBe(true)
|
|
103
103
|
})
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The card opens on the first tab, so naming it in `?tab=` says nothing — and it
|
|
107
|
+
* made every shared link carry a param the sender never chose. Only a tab that is
|
|
108
|
+
* NOT the default belongs in the url.
|
|
109
|
+
*/
|
|
110
|
+
it('keeps the default tab out of the url', () => {
|
|
111
|
+
render(<AppDetails app={withUrl} onClosePanel={vi.fn()} />)
|
|
112
|
+
|
|
113
|
+
// Go somewhere else, then come back to the first tab.
|
|
114
|
+
fireEvent.click(screen.getByRole('tab', { name: /^access/i }))
|
|
115
|
+
navigate.mockClear()
|
|
116
|
+
fireEvent.click(screen.getByRole('tab', { name: /^overview/i }))
|
|
117
|
+
|
|
118
|
+
const wroteDefault = navigate.mock.calls.some(
|
|
119
|
+
([arg]) =>
|
|
120
|
+
(arg as { search?: Record<string, unknown> }).search?.tab ===
|
|
121
|
+
'overview',
|
|
122
|
+
)
|
|
123
|
+
expect(
|
|
124
|
+
wroteDefault,
|
|
125
|
+
'returning to the default tab must clear ?tab=, not set it',
|
|
126
|
+
).toBe(false)
|
|
127
|
+
})
|
|
128
|
+
|
|
105
129
|
it('opens on the tab named in the url', () => {
|
|
106
130
|
search.tab = 'documentation'
|
|
107
131
|
render(<AppDetails app={withUrl} onClosePanel={vi.fn()} />)
|
|
@@ -13,6 +13,7 @@ import { useUiSettings } from '~/context/UiSettingsContext'
|
|
|
13
13
|
import type { AreaIcon } from '~/types/uiSettings'
|
|
14
14
|
import { AppCatalogContext } from '../../context/AppCatalogContext'
|
|
15
15
|
import { areaLabel, groupByArea } from '../../utils/areaGrouping'
|
|
16
|
+
import { RequestAppDialog } from './RequestAppDialog'
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Adaptive-home discovery spine (issue #38, increment 1) — matches the
|
|
@@ -347,6 +348,9 @@ function SearchResultsList({
|
|
|
347
348
|
const [expandedParents, setExpandedParents] = useState<Set<string>>(
|
|
348
349
|
() => new Set(),
|
|
349
350
|
)
|
|
351
|
+
// Asking for something absent. Local state rather than a url param: a half-typed
|
|
352
|
+
// request has no business in a shareable link.
|
|
353
|
+
const [requestOpen, setRequestOpen] = useState(false)
|
|
350
354
|
|
|
351
355
|
const results = useMemo(
|
|
352
356
|
() => searchResources(apps, searchValue),
|
|
@@ -483,6 +487,26 @@ function SearchResultsList({
|
|
|
483
487
|
: `${matchCount} result${matchCount === 1 ? '' : 's'}`}
|
|
484
488
|
</div>
|
|
485
489
|
|
|
490
|
+
{/* A zero-result search is the most informative moment in the catalog: someone
|
|
491
|
+
has said exactly what they expected to find. Showing only "No results"
|
|
492
|
+
throws that away and leaves them with nowhere to go. */}
|
|
493
|
+
{matchCount === 0 && !didDeprecatedFallback && (
|
|
494
|
+
<div className="mb-3 px-1">
|
|
495
|
+
<button
|
|
496
|
+
type="button"
|
|
497
|
+
onClick={() => setRequestOpen(true)}
|
|
498
|
+
className="text-sm underline decoration-dotted underline-offset-2 hover:text-foreground text-muted-foreground"
|
|
499
|
+
>
|
|
500
|
+
Not in the catalog? Ask for it to be added
|
|
501
|
+
</button>
|
|
502
|
+
</div>
|
|
503
|
+
)}
|
|
504
|
+
<RequestAppDialog
|
|
505
|
+
open={requestOpen}
|
|
506
|
+
onOpenChange={setRequestOpen}
|
|
507
|
+
initialSubject={searchValue}
|
|
508
|
+
/>
|
|
509
|
+
|
|
486
510
|
{/* #11: deprecated-only fallback notice */}
|
|
487
511
|
{didDeprecatedFallback && (
|
|
488
512
|
<div
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import { useEffect, useState } from 'react'
|
|
3
|
+
import { useTRPC } from '~/api/infra/trpc'
|
|
4
|
+
import { Button } from '~/ui/button'
|
|
5
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/ui/dialog'
|
|
6
|
+
import { cn } from '~/lib/utils'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Asking for something that is not in the catalog yet.
|
|
10
|
+
*
|
|
11
|
+
* The search box is where people discover the gap — they type a tool's name, get
|
|
12
|
+
* nothing, and until now had nowhere to go. A zero-result search is the single most
|
|
13
|
+
* informative moment in the catalog: someone has told us exactly what they expected to
|
|
14
|
+
* find. Throwing that away and showing only "No results" wastes it.
|
|
15
|
+
*
|
|
16
|
+
* This is a catalog-level ask rather than feedback on an entry: `feedback.add` with a
|
|
17
|
+
* `subject` and NO `resourceSlug`. The server already distinguishes the two that way,
|
|
18
|
+
* and the tracker lists both.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Same shape the input primitive uses, minus the fixed height. */
|
|
22
|
+
const FIELD_CLASSES =
|
|
23
|
+
'placeholder:text-muted-foreground dark:bg-input/30 border-input w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50'
|
|
24
|
+
|
|
25
|
+
const MAX_SUBJECT = 120
|
|
26
|
+
|
|
27
|
+
interface RequestAppDialogProps {
|
|
28
|
+
open: boolean
|
|
29
|
+
onOpenChange: (open: boolean) => void
|
|
30
|
+
/** What they searched for. Pre-fills the name, because they just typed it. */
|
|
31
|
+
initialSubject?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Mounts nothing until it is opened, which is what keeps `useTRPC()` out of the
|
|
36
|
+
* closed path. The catalog grid is rendered bare in several tests with no
|
|
37
|
+
* TRPCProvider, and a hook cannot be called conditionally — so the gate has to be a
|
|
38
|
+
* component boundary rather than an `if` inside one.
|
|
39
|
+
*/
|
|
40
|
+
export function RequestAppDialog(props: RequestAppDialogProps) {
|
|
41
|
+
if (!props.open) return null
|
|
42
|
+
return <RequestAppDialogBody {...props} />
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function RequestAppDialogBody({
|
|
46
|
+
open,
|
|
47
|
+
onOpenChange,
|
|
48
|
+
initialSubject = '',
|
|
49
|
+
}: RequestAppDialogProps) {
|
|
50
|
+
const trpc = useTRPC()
|
|
51
|
+
const queryClient = useQueryClient()
|
|
52
|
+
const [subject, setSubject] = useState(initialSubject)
|
|
53
|
+
const [body, setBody] = useState('')
|
|
54
|
+
const [sent, setSent] = useState(false)
|
|
55
|
+
|
|
56
|
+
// Re-seed when opened for a different search. Keyed on the dialog opening rather
|
|
57
|
+
// than on every keystroke, so it never overwrites something half-typed.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (open) {
|
|
60
|
+
setSubject(initialSubject)
|
|
61
|
+
setBody('')
|
|
62
|
+
setSent(false)
|
|
63
|
+
}
|
|
64
|
+
}, [open, initialSubject])
|
|
65
|
+
|
|
66
|
+
const add = useMutation(
|
|
67
|
+
trpc.feedback.add.mutationOptions({
|
|
68
|
+
onSuccess: () => {
|
|
69
|
+
setSent(true)
|
|
70
|
+
// The tracker counts this, so its badge has to notice.
|
|
71
|
+
void queryClient.invalidateQueries({
|
|
72
|
+
queryKey: trpc.feedback.mine.queryKey(),
|
|
73
|
+
})
|
|
74
|
+
},
|
|
75
|
+
}),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
const trimmed = subject.trim()
|
|
79
|
+
const send = () => {
|
|
80
|
+
if (!trimmed) return
|
|
81
|
+
add.mutate({
|
|
82
|
+
subject: trimmed.slice(0, MAX_SUBJECT),
|
|
83
|
+
...(body.trim() ? { body: body.trim() } : {}),
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
89
|
+
<DialogContent className="sm:max-w-[480px]">
|
|
90
|
+
<DialogHeader>
|
|
91
|
+
<DialogTitle>
|
|
92
|
+
{sent ? 'Thanks — that is logged' : 'Ask for an app to be added'}
|
|
93
|
+
</DialogTitle>
|
|
94
|
+
</DialogHeader>
|
|
95
|
+
|
|
96
|
+
{sent ? (
|
|
97
|
+
<div className="space-y-3">
|
|
98
|
+
<p className="text-muted-foreground text-sm">
|
|
99
|
+
A maintainer will look at it. You can see what happened to it
|
|
100
|
+
under your own requests.
|
|
101
|
+
</p>
|
|
102
|
+
<Button type="button" size="sm" onClick={() => onOpenChange(false)}>
|
|
103
|
+
Close
|
|
104
|
+
</Button>
|
|
105
|
+
</div>
|
|
106
|
+
) : (
|
|
107
|
+
<div className="space-y-3">
|
|
108
|
+
<div className="space-y-1.5">
|
|
109
|
+
<label htmlFor="request-app-name" className="text-sm font-medium">
|
|
110
|
+
What are you looking for?
|
|
111
|
+
</label>
|
|
112
|
+
<input
|
|
113
|
+
id="request-app-name"
|
|
114
|
+
autoFocus
|
|
115
|
+
className={cn(FIELD_CLASSES)}
|
|
116
|
+
placeholder="Name of the tool, or what it does"
|
|
117
|
+
maxLength={MAX_SUBJECT}
|
|
118
|
+
value={subject}
|
|
119
|
+
onChange={(event) => setSubject(event.target.value)}
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div className="space-y-1.5">
|
|
124
|
+
<label
|
|
125
|
+
htmlFor="request-app-detail"
|
|
126
|
+
className="text-sm font-medium"
|
|
127
|
+
>
|
|
128
|
+
Anything that would help us find it{' '}
|
|
129
|
+
<span className="text-muted-foreground font-normal">
|
|
130
|
+
(optional)
|
|
131
|
+
</span>
|
|
132
|
+
</label>
|
|
133
|
+
<textarea
|
|
134
|
+
id="request-app-detail"
|
|
135
|
+
rows={3}
|
|
136
|
+
className={cn(FIELD_CLASSES)}
|
|
137
|
+
placeholder="A link, the team that owns it, what you needed it for"
|
|
138
|
+
value={body}
|
|
139
|
+
onChange={(event) => setBody(event.target.value)}
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
144
|
+
<Button
|
|
145
|
+
type="button"
|
|
146
|
+
size="sm"
|
|
147
|
+
disabled={!trimmed || add.isPending}
|
|
148
|
+
onClick={send}
|
|
149
|
+
>
|
|
150
|
+
{add.isPending ? 'Sending…' : 'Send request'}
|
|
151
|
+
</Button>
|
|
152
|
+
<span className="text-muted-foreground text-xs">
|
|
153
|
+
Sent under a nickname. No login needed.
|
|
154
|
+
</span>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
{add.error && (
|
|
158
|
+
<p className="text-destructive text-xs">
|
|
159
|
+
{add.error instanceof Error
|
|
160
|
+
? add.error.message
|
|
161
|
+
: 'Something went wrong'}
|
|
162
|
+
</p>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
</DialogContent>
|
|
167
|
+
</Dialog>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
@@ -73,10 +73,17 @@ export function DetailTabs({
|
|
|
73
73
|
/** Cancels the dialog's own padding so the tint reaches the card's edges. */
|
|
74
74
|
bandClassName?: string
|
|
75
75
|
}) {
|
|
76
|
+
/**
|
|
77
|
+
* The first tab is what the card opens on, so naming it in the URL says nothing.
|
|
78
|
+
* `encode` returning undefined drops the param, which keeps `?tab=` out of a link
|
|
79
|
+
* until someone has actually chosen something other than the default — and keeps a
|
|
80
|
+
* shared link honest about what the sender was looking at.
|
|
81
|
+
*/
|
|
82
|
+
const defaultTabId = tabs[0]?.id
|
|
76
83
|
const [urlTab, setUrlTab] = useUrlSyncedState<string>({
|
|
77
84
|
key: 'tab',
|
|
78
85
|
defaultValue: '',
|
|
79
|
-
encode: (value) => value
|
|
86
|
+
encode: (value) => (value && value !== defaultTabId ? value : undefined),
|
|
80
87
|
})
|
|
81
88
|
|
|
82
89
|
const ids = tabs.map((t) => t.id)
|
|
@@ -179,7 +179,26 @@ export function AppCatalogPage({
|
|
|
179
179
|
<AppDetailPanel
|
|
180
180
|
app={selectedApp}
|
|
181
181
|
subResource={selectedSub}
|
|
182
|
-
onClose={() =>
|
|
182
|
+
onClose={() =>
|
|
183
|
+
void navigate({
|
|
184
|
+
to: '/',
|
|
185
|
+
/**
|
|
186
|
+
* Drop the card's own params, keep the catalog's.
|
|
187
|
+
*
|
|
188
|
+
* `navigate({ to: '/' })` alone preserves the whole search object, so
|
|
189
|
+
* closing a card left `?tab=notes` (and `?sub=`, `?qj=`) behind on the
|
|
190
|
+
* catalog, where they mean nothing — and a link copied afterwards
|
|
191
|
+
* carried them. Clearing the lot instead would throw away the
|
|
192
|
+
* visitor's filters, which belong to the catalog and outlive any card.
|
|
193
|
+
*/
|
|
194
|
+
search: (prev) => ({
|
|
195
|
+
...prev,
|
|
196
|
+
tab: undefined,
|
|
197
|
+
sub: undefined,
|
|
198
|
+
qj: undefined,
|
|
199
|
+
}),
|
|
200
|
+
})
|
|
201
|
+
}
|
|
183
202
|
onAppClick={handleAppClick}
|
|
184
203
|
onBackToParent={handleBackToParent}
|
|
185
204
|
/>
|