@featurevisor/catalog 0.0.1 → 3.1.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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/dist/assets/index-DB7fEbI6.js +25 -0
  3. package/dist/assets/index-DgA0Oqrg.css +2 -0
  4. package/dist/favicon.png +0 -0
  5. package/dist/index.html +14 -0
  6. package/dist/logo-text.png +0 -0
  7. package/lib/entityTypes.d.ts +16 -0
  8. package/lib/entityTypes.js +96 -0
  9. package/lib/entityTypes.js.map +1 -0
  10. package/lib/index.d.ts +1 -0
  11. package/lib/index.js +18 -0
  12. package/lib/index.js.map +1 -0
  13. package/lib/node/index.d.ts +74 -0
  14. package/lib/node/index.js +1368 -0
  15. package/lib/node/index.js.map +1 -0
  16. package/lib/types.d.ts +95 -0
  17. package/lib/types.js +3 -0
  18. package/lib/types.js.map +1 -0
  19. package/package.json +66 -13
  20. package/public/favicon.png +0 -0
  21. package/public/logo-text.png +0 -0
  22. package/src/App.tsx +62 -0
  23. package/src/api.spec.ts +14 -0
  24. package/src/api.ts +74 -0
  25. package/src/components/tests.spec.ts +134 -0
  26. package/src/components/tests.tsx +301 -0
  27. package/src/components/trees.tsx +202 -0
  28. package/src/components/ui.tsx +660 -0
  29. package/src/components/variables.tsx +700 -0
  30. package/src/components/variations.tsx +450 -0
  31. package/src/context/CatalogContext.tsx +30 -0
  32. package/src/entityTypes.spec.ts +13 -0
  33. package/src/entityTypes.ts +102 -0
  34. package/src/index.ts +1 -0
  35. package/src/main.tsx +26 -0
  36. package/src/node/index.spec.ts +380 -0
  37. package/src/node/index.ts +1906 -0
  38. package/src/pages/EntityDetailPage.tsx +1144 -0
  39. package/src/pages/HistoryPage.tsx +144 -0
  40. package/src/pages/HomePage.tsx +21 -0
  41. package/src/pages/ListPage.tsx +737 -0
  42. package/src/styles.css +59 -0
  43. package/src/testModel.ts +123 -0
  44. package/src/types.ts +112 -0
  45. package/src/vite-env.d.ts +1 -0
  46. package/index.js +0 -1
@@ -0,0 +1,737 @@
1
+ import * as React from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { Link, Navigate, useParams, useSearchParams } from "react-router-dom";
4
+
5
+ import { fetchIndex } from "../api";
6
+ import { entityLabels, entityPathToType, getEntityRoute } from "../entityTypes";
7
+ import type { CatalogEntityType, CatalogIndex, EntityPath, EntitySummary } from "../types";
8
+ import {
9
+ Badge,
10
+ Button,
11
+ EmptyState,
12
+ EntityKey,
13
+ LabelValueBadge,
14
+ PageHeader,
15
+ } from "../components/ui";
16
+
17
+ const LIST_INITIAL_LIMIT = 1000;
18
+
19
+ function isEntityPath(value: string | undefined): value is EntityPath {
20
+ return (
21
+ value === "features" ||
22
+ value === "segments" ||
23
+ value === "attributes" ||
24
+ value === "targets" ||
25
+ value === "groups" ||
26
+ value === "schemas"
27
+ );
28
+ }
29
+
30
+ function getSortDirection(sortValue: string | null) {
31
+ if (!sortValue || sortValue === "name" || sortValue === "name:asc" || sortValue === "asc") {
32
+ return "asc";
33
+ }
34
+
35
+ if (sortValue === "-name" || sortValue === "name:desc" || sortValue === "desc") {
36
+ return "desc";
37
+ }
38
+
39
+ return "asc";
40
+ }
41
+
42
+ function setSearchParam(searchParams: URLSearchParams, key: string, value?: string) {
43
+ const next = new URLSearchParams(searchParams);
44
+
45
+ if (!value) {
46
+ next.delete(key);
47
+ } else {
48
+ next.set(key, value);
49
+ }
50
+
51
+ return next;
52
+ }
53
+
54
+ interface ParsedSearchQuery {
55
+ terms: string[];
56
+ qualifiers: Array<{ key: string; value: string }>;
57
+ }
58
+
59
+ function parseSearchQuery(query: string): ParsedSearchQuery {
60
+ const terms: string[] = [];
61
+ const qualifiers: Array<{ key: string; value: string }> = [];
62
+ const matcher = /(?:(\w+):"([^"]+)")|(?:(\w+):([^\s]+))|(?:"([^"]+)")|(\S+)/g;
63
+ let match: RegExpExecArray | null;
64
+
65
+ while ((match = matcher.exec(query))) {
66
+ const qualifierKey = match[1] || match[3];
67
+ const qualifierValue = match[2] || match[4];
68
+ const term = match[5] || match[6];
69
+
70
+ if (qualifierKey && qualifierValue) {
71
+ qualifiers.push({
72
+ key: qualifierKey.toLowerCase(),
73
+ value: qualifierValue.toLowerCase(),
74
+ });
75
+ } else if (term) {
76
+ terms.push(term.toLowerCase());
77
+ }
78
+ }
79
+
80
+ return { terms, qualifiers };
81
+ }
82
+
83
+ function getFeatureSearchHints(index: CatalogIndex) {
84
+ const features = index.entities.feature || [];
85
+ const tags = sortValues(features.flatMap((feature) => feature.tags || []));
86
+ const environments = sortValues(features.flatMap((feature) => feature.environments || []));
87
+ const variationValues = sortValues(features.flatMap((feature) => feature.variationValues || []));
88
+ const variableKeys = sortValues(features.flatMap((feature) => feature.variableKeys || []));
89
+ const hasVariations = features.some((feature) => feature.hasVariations);
90
+ const hasNoVariations = features.some((feature) => !feature.hasVariations);
91
+ const hasVariables = features.some((feature) => feature.hasVariables);
92
+ const hasNoVariables = features.some((feature) => !feature.hasVariables);
93
+
94
+ return [
95
+ tags[0] ? `tag:${tags[0]}` : undefined,
96
+ environments[0] ? `in:${environments[0]}` : undefined,
97
+ "archived:false",
98
+ hasVariations ? "with:variations" : undefined,
99
+ hasNoVariations ? "without:variations" : undefined,
100
+ variationValues[0] ? `variation:${variationValues[0]}` : undefined,
101
+ hasVariables ? "with:variables" : undefined,
102
+ hasNoVariables ? "without:variables" : undefined,
103
+ variableKeys[0] ? `variable:${variableKeys[0]}` : undefined,
104
+ ].filter((hint): hint is string => Boolean(hint));
105
+ }
106
+
107
+ function listIncludes(values: string[] | undefined, value: string) {
108
+ return (values || []).some((item) => item.toLowerCase().includes(value));
109
+ }
110
+
111
+ function matchesFeatureQualifier(entity: EntitySummary, key: string, value: string) {
112
+ if (key === "tag") {
113
+ return listIncludes(entity.tags, value);
114
+ }
115
+
116
+ if (key === "in") {
117
+ return listIncludes(entity.environments, value);
118
+ }
119
+
120
+ if (key === "archived") {
121
+ return String(Boolean(entity.archived)) === value;
122
+ }
123
+
124
+ if (key === "with") {
125
+ if (value === "variations") return Boolean(entity.hasVariations);
126
+ if (value === "variables") return Boolean(entity.hasVariables);
127
+ }
128
+
129
+ if (key === "without") {
130
+ if (value === "variations") return !entity.hasVariations;
131
+ if (value === "variables") return !entity.hasVariables;
132
+ }
133
+
134
+ if (key === "variation") {
135
+ return listIncludes(entity.variationValues, value);
136
+ }
137
+
138
+ if (key === "variable") {
139
+ return listIncludes(entity.variableKeys, value);
140
+ }
141
+
142
+ return true;
143
+ }
144
+
145
+ function matchesQuery(entity: EntitySummary, query: string, entityPath: EntityPath) {
146
+ const normalizedQuery = query.trim().toLowerCase();
147
+
148
+ if (!normalizedQuery) {
149
+ return true;
150
+ }
151
+
152
+ if (entityPath !== "features") {
153
+ return normalizedQuery.split(/\s+/).every((term) => {
154
+ const haystack = [
155
+ entity.key,
156
+ entity.description || "",
157
+ ...(entity.tags || []),
158
+ ...(entity.targets || []),
159
+ ...(entity.environments || []),
160
+ ]
161
+ .join(" ")
162
+ .toLowerCase();
163
+
164
+ return haystack.includes(term);
165
+ });
166
+ }
167
+
168
+ const parsedQuery = parseSearchQuery(normalizedQuery);
169
+
170
+ if (
171
+ entityPath === "features" &&
172
+ !parsedQuery.qualifiers.every((qualifier) =>
173
+ matchesFeatureQualifier(entity, qualifier.key, qualifier.value),
174
+ )
175
+ ) {
176
+ return false;
177
+ }
178
+
179
+ const haystack = [
180
+ entity.key,
181
+ entity.description || "",
182
+ ...(entity.tags || []),
183
+ ...(entity.targets || []),
184
+ ...(entity.environments || []),
185
+ ]
186
+ .join(" ")
187
+ .toLowerCase();
188
+
189
+ return parsedQuery.terms.every((term) => haystack.includes(term));
190
+ }
191
+
192
+ function getStatusBadges(entity: EntitySummary) {
193
+ return (
194
+ <div className="flex flex-wrap gap-2">
195
+ {entity.archived && <Badge tone="danger">archived</Badge>}
196
+ {entity.deprecated && <Badge tone="warning">deprecated</Badge>}
197
+ {entity.promotable === false && <Badge>not promotable</Badge>}
198
+ </div>
199
+ );
200
+ }
201
+
202
+ function RowTrailingMeta(props: { entity: EntitySummary; type: CatalogEntityType }) {
203
+ const icons = <RowMetadataIcons entity={props.entity} type={props.type} />;
204
+ const usedInBadge =
205
+ props.type === "segment" && (props.entity.usedInFeatureCount ?? 0) > 0 ? (
206
+ <LabelValueBadge
207
+ compact
208
+ label="Used in"
209
+ value={`${props.entity.usedInFeatureCount} ${props.entity.usedInFeatureCount === 1 ? "feature" : "features"}`}
210
+ />
211
+ ) : props.type === "attribute" && (props.entity.usedInSegmentCount ?? 0) > 0 ? (
212
+ <LabelValueBadge
213
+ compact
214
+ label="Used in"
215
+ value={`${props.entity.usedInSegmentCount} ${props.entity.usedInSegmentCount === 1 ? "segment" : "segments"}`}
216
+ />
217
+ ) : null;
218
+
219
+ return (
220
+ <>
221
+ {getStatusBadges(props.entity)}
222
+ {usedInBadge ? (
223
+ <div className="flex items-center gap-1">
224
+ {usedInBadge}
225
+ {icons}
226
+ </div>
227
+ ) : (
228
+ icons
229
+ )}
230
+ </>
231
+ );
232
+ }
233
+
234
+ function ListRow(props: { entity: EntitySummary; type: CatalogEntityType; setKey?: string }) {
235
+ const { entity, type, setKey } = props;
236
+ const description = entity.description || "No description";
237
+ const trailingMeta = <RowTrailingMeta entity={entity} type={type} />;
238
+ const lastModified = (
239
+ <span className="text-right text-[11px] text-faint whitespace-nowrap">
240
+ <LastModified entity={entity} />
241
+ </span>
242
+ );
243
+
244
+ if (type === "feature") {
245
+ return (
246
+ <Link
247
+ to={getEntityRoute(type, entity.key, setKey)}
248
+ className="group block px-6 py-3 hover:bg-elevated"
249
+ >
250
+ <div className="grid grid-cols-[auto_minmax(0,1fr)_auto] grid-rows-[auto_auto] gap-x-3 gap-y-1">
251
+ <div className="col-start-1 row-start-1 flex h-6 shrink-0 items-center justify-center">
252
+ <EnvironmentDot
253
+ status={entity.environmentStatus}
254
+ environment={entity.environmentStatusEnvironment}
255
+ />
256
+ </div>
257
+ <div className="col-start-2 row-start-1 flex min-h-6 min-w-0 items-center">
258
+ <EntityKey value={entity.key} className="text-sm font-semibold text-primary" />
259
+ </div>
260
+ <div className="col-start-3 row-start-1 flex min-h-6 w-full items-center justify-end gap-2">
261
+ {trailingMeta}
262
+ </div>
263
+ <div className="col-start-2 row-start-2 flex min-h-5 min-w-0 items-center overflow-hidden">
264
+ <span className="min-w-0 truncate text-sm text-muted">{description}</span>
265
+ </div>
266
+ <div className="col-start-3 row-start-2 flex min-h-5 w-full items-center justify-end">
267
+ {lastModified}
268
+ </div>
269
+ </div>
270
+ </Link>
271
+ );
272
+ }
273
+
274
+ return (
275
+ <Link
276
+ to={getEntityRoute(type, entity.key, setKey)}
277
+ className="group block px-6 py-3 hover:bg-elevated"
278
+ >
279
+ <div className="grid grid-cols-[minmax(0,1fr)_auto] grid-rows-[auto_auto] gap-x-3 gap-y-1">
280
+ <div className="col-start-1 row-start-1 flex min-h-6 min-w-0 items-center">
281
+ <EntityKey value={entity.key} className="text-sm font-semibold text-primary" />
282
+ </div>
283
+ <div className="col-start-2 row-start-1 flex min-h-6 w-full items-center justify-end gap-2">
284
+ {trailingMeta}
285
+ </div>
286
+ <div className="col-start-1 row-start-2 flex min-h-5 min-w-0 items-center overflow-hidden">
287
+ <span className="min-w-0 truncate text-sm text-muted">{description}</span>
288
+ </div>
289
+ <div className="col-start-2 row-start-2 flex min-h-5 w-full items-center justify-end">
290
+ {lastModified}
291
+ </div>
292
+ </div>
293
+ </Link>
294
+ );
295
+ }
296
+
297
+ function LastModified(props: { entity: EntitySummary }) {
298
+ if (!props.entity.lastModified) {
299
+ return <span>Last modified n/a</span>;
300
+ }
301
+
302
+ const date = new Date(props.entity.lastModified.timestamp);
303
+ const formattedDate = Number.isNaN(date.getTime())
304
+ ? props.entity.lastModified.timestamp
305
+ : new Intl.DateTimeFormat(undefined, {
306
+ month: "short",
307
+ day: "numeric",
308
+ year: "numeric",
309
+ }).format(date);
310
+
311
+ return (
312
+ <span>
313
+ Last modified by <span className="font-medium">{props.entity.lastModified.author}</span> on{" "}
314
+ {formattedDate}
315
+ </span>
316
+ );
317
+ }
318
+
319
+ function EnvironmentDot(props: {
320
+ status?: EntitySummary["environmentStatus"];
321
+ environment?: string;
322
+ className?: string;
323
+ }) {
324
+ const ref = React.useRef<HTMLSpanElement | null>(null);
325
+ const [tooltipPosition, setTooltipPosition] = React.useState<{
326
+ left: number;
327
+ top: number;
328
+ } | null>(null);
329
+
330
+ if (!props.status) {
331
+ return null;
332
+ }
333
+
334
+ const tooltip =
335
+ props.status === "production"
336
+ ? `Enabled in ${props.environment || "production"}`
337
+ : props.status === "other"
338
+ ? "Enabled in non-production environments"
339
+ : "Disabled everywhere";
340
+ const dotClass =
341
+ props.status === "production"
342
+ ? "bg-green-500"
343
+ : props.status === "other"
344
+ ? "bg-amber-500"
345
+ : "bg-slate-300";
346
+
347
+ function showTooltip() {
348
+ const rect = ref.current?.getBoundingClientRect();
349
+
350
+ if (!rect) {
351
+ return;
352
+ }
353
+
354
+ setTooltipPosition({
355
+ left: rect.left + rect.width / 2,
356
+ top: rect.top,
357
+ });
358
+ }
359
+
360
+ function hideTooltip() {
361
+ setTooltipPosition(null);
362
+ }
363
+
364
+ return (
365
+ <span
366
+ ref={ref}
367
+ className={`relative inline-flex ${props.className || ""}`}
368
+ aria-label={tooltip}
369
+ onMouseEnter={showTooltip}
370
+ onMouseLeave={hideTooltip}
371
+ onFocus={showTooltip}
372
+ onBlur={hideTooltip}
373
+ >
374
+ <span className="relative flex h-3 w-3">
375
+ {props.status === "production" && (
376
+ <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
377
+ )}
378
+ <span className={`relative inline-flex h-3 w-3 rounded-full ${dotClass}`} />
379
+ </span>
380
+ {tooltipPosition &&
381
+ createPortal(
382
+ <span
383
+ className="pointer-events-none fixed z-50 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded bg-header px-2 py-1 text-xs font-semibold text-header-text shadow-lg"
384
+ style={{
385
+ left: tooltipPosition.left,
386
+ top: tooltipPosition.top - 8,
387
+ }}
388
+ >
389
+ {tooltip}
390
+ </span>,
391
+ document.body,
392
+ )}
393
+ </span>
394
+ );
395
+ }
396
+
397
+ function HoverTooltip(props: { label: string; children: React.ReactNode; className?: string }) {
398
+ const ref = React.useRef<HTMLSpanElement | null>(null);
399
+ const [tooltipPosition, setTooltipPosition] = React.useState<{
400
+ left: number;
401
+ top: number;
402
+ } | null>(null);
403
+
404
+ function showTooltip() {
405
+ const rect = ref.current?.getBoundingClientRect();
406
+
407
+ if (!rect) {
408
+ return;
409
+ }
410
+
411
+ setTooltipPosition({
412
+ left: rect.left + rect.width / 2,
413
+ top: rect.top,
414
+ });
415
+ }
416
+
417
+ function hideTooltip() {
418
+ setTooltipPosition(null);
419
+ }
420
+
421
+ return (
422
+ <span
423
+ ref={ref}
424
+ className={`relative inline-flex ${props.className || ""}`}
425
+ aria-label={props.label}
426
+ onMouseEnter={showTooltip}
427
+ onMouseLeave={hideTooltip}
428
+ onFocus={showTooltip}
429
+ onBlur={hideTooltip}
430
+ >
431
+ {props.children}
432
+ {tooltipPosition &&
433
+ createPortal(
434
+ <span
435
+ className="pointer-events-none fixed z-50 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded bg-header px-2 py-1 text-xs font-semibold text-header-text shadow-lg"
436
+ style={{
437
+ left: tooltipPosition.left,
438
+ top: tooltipPosition.top - 8,
439
+ }}
440
+ >
441
+ {props.label}
442
+ </span>,
443
+ document.body,
444
+ )}
445
+ </span>
446
+ );
447
+ }
448
+
449
+ function VariationsIcon() {
450
+ return (
451
+ <span
452
+ aria-hidden="true"
453
+ className="inline-flex items-center justify-center font-mono text-[9px] font-semibold leading-none tracking-tight"
454
+ >
455
+ a/b
456
+ </span>
457
+ );
458
+ }
459
+
460
+ function VariablesIcon() {
461
+ return (
462
+ <span
463
+ aria-hidden="true"
464
+ className="inline-flex items-center justify-center font-mono text-[9px] font-semibold leading-none"
465
+ >
466
+ {"{ }"}
467
+ </span>
468
+ );
469
+ }
470
+
471
+ function sortValues(values?: string[]) {
472
+ return Array.from(new Set(values || [])).sort((left, right) => left.localeCompare(right));
473
+ }
474
+
475
+ const rowMetadataItemClassName =
476
+ "h-full min-w-5 items-center justify-center px-1 text-faint transition-colors hover:bg-elevated hover:text-muted";
477
+
478
+ function RowMetadataIcons(props: { entity: EntitySummary; type: CatalogEntityType }) {
479
+ const targets = sortValues(props.entity.targets);
480
+ const isFeature = props.type === "feature";
481
+ const showVariations = isFeature && Boolean(props.entity.hasVariations);
482
+ const showVariables = isFeature && Boolean(props.entity.hasVariables);
483
+
484
+ if (targets.length === 0 && !showVariations && !showVariables) {
485
+ return null;
486
+ }
487
+
488
+ return (
489
+ <div className="flex h-5 shrink-0 divide-x divide-border/70 overflow-hidden rounded-md border border-border/70 bg-surface">
490
+ {showVariations && (
491
+ <HoverTooltip label="Has variations" className={rowMetadataItemClassName}>
492
+ <VariationsIcon />
493
+ </HoverTooltip>
494
+ )}
495
+ {showVariables && (
496
+ <HoverTooltip label="Has variables" className={rowMetadataItemClassName}>
497
+ <VariablesIcon />
498
+ </HoverTooltip>
499
+ )}
500
+ {targets.length > 0 && (
501
+ <HoverTooltip
502
+ label={`Targets: ${targets.join(", ")}`}
503
+ className={`${rowMetadataItemClassName} px-1.5`}
504
+ >
505
+ <span className="inline-flex items-baseline gap-1 text-[10px] leading-none">
506
+ <span>Targets</span>
507
+ <span className="font-semibold tabular-nums text-muted">{targets.length}</span>
508
+ </span>
509
+ </HoverTooltip>
510
+ )}
511
+ </div>
512
+ );
513
+ }
514
+
515
+ function QueryHints(props: {
516
+ query: string;
517
+ hints: string[];
518
+ onHintClick: (hint: string) => void;
519
+ }) {
520
+ if (props.hints.length === 0) {
521
+ return null;
522
+ }
523
+
524
+ const tokens = props.query.trim().split(/\s+/);
525
+
526
+ return (
527
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1.5 pt-2 text-xs text-muted">
528
+ <span className="shrink-0">Try:</span>
529
+ {props.hints.map((hint) => {
530
+ const isActive = tokens.some((token) => token.toLowerCase() === hint.toLowerCase());
531
+
532
+ return (
533
+ <button
534
+ key={hint}
535
+ type="button"
536
+ onClick={() => props.onHintClick(hint)}
537
+ className={[
538
+ "cursor-pointer rounded px-1.5 py-0.5 font-mono transition-colors",
539
+ isActive ? "bg-primary/10 text-primary" : "bg-elevated text-muted hover:text-text",
540
+ ].join(" ")}
541
+ >
542
+ {hint}
543
+ </button>
544
+ );
545
+ })}
546
+ </div>
547
+ );
548
+ }
549
+
550
+ function SearchControls(props: {
551
+ query: string;
552
+ label: string;
553
+ hints: string[];
554
+ searchParams: URLSearchParams;
555
+ setSearchParams: (params: URLSearchParams) => void;
556
+ }) {
557
+ const hasHints = props.hints.length > 0;
558
+ const showHints = hasHints && props.searchParams.get("hints") === "1";
559
+
560
+ function setQuery(value?: string) {
561
+ props.setSearchParams(setSearchParam(props.searchParams, "q", value || undefined));
562
+ }
563
+
564
+ function setShowHints(value: boolean) {
565
+ props.setSearchParams(setSearchParam(props.searchParams, "hints", value ? "1" : undefined));
566
+ }
567
+
568
+ function handleHintClick(hint: string) {
569
+ const current = props.query.trim();
570
+ const tokens = current.split(/\s+/).filter(Boolean);
571
+ const existingIndex = tokens.findIndex((token) => token.toLowerCase() === hint.toLowerCase());
572
+ const next =
573
+ existingIndex >= 0
574
+ ? tokens.filter((_, index) => index !== existingIndex).join(" ")
575
+ : current
576
+ ? `${current} ${hint}`
577
+ : hint;
578
+
579
+ setQuery(next);
580
+ }
581
+
582
+ return (
583
+ <div>
584
+ <div className="relative">
585
+ <input
586
+ value={props.query}
587
+ onChange={(event) => setQuery(event.target.value)}
588
+ placeholder={`Search ${props.label.toLowerCase()}...`}
589
+ className={[
590
+ "w-full rounded-full border border-border bg-surface px-5 py-2 text-xl text-text outline-none placeholder:text-faint focus:border-primary",
591
+ hasHints ? "pr-10" : "",
592
+ ].join(" ")}
593
+ />
594
+ {hasHints && (
595
+ <button
596
+ type="button"
597
+ onClick={() => setShowHints(!showHints)}
598
+ aria-label={showHints ? "Hide advanced search hints" : "Show advanced search hints"}
599
+ aria-pressed={showHints}
600
+ className={[
601
+ "absolute right-3 top-1/2 flex h-4 w-4 -translate-y-1/2 items-center justify-center rounded-full border text-[10px] font-black leading-none transition-all",
602
+ showHints
603
+ ? "scale-105 border-primary bg-primary text-header-text shadow-sm"
604
+ : "border-border bg-surface text-faint hover:border-primary hover:bg-primary/10 hover:text-primary",
605
+ ].join(" ")}
606
+ >
607
+ ?
608
+ </button>
609
+ )}
610
+ </div>
611
+
612
+ <div
613
+ className={[
614
+ "grid transition-all duration-200 ease-in-out",
615
+ showHints ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
616
+ ].join(" ")}
617
+ >
618
+ <div className="overflow-hidden pl-5">
619
+ <QueryHints query={props.query} hints={props.hints} onHintClick={handleHintClick} />
620
+ </div>
621
+ </div>
622
+ </div>
623
+ );
624
+ }
625
+
626
+ export function ListPage() {
627
+ const { entityPath, setKey } = useParams();
628
+ const [searchParams, setSearchParams] = useSearchParams();
629
+ const [index, setIndex] = React.useState<CatalogIndex | null>(null);
630
+ const [error, setError] = React.useState<string | null>(null);
631
+ const [showAll, setShowAll] = React.useState(false);
632
+ const query = searchParams.get("q") || "";
633
+ const sortDirection = getSortDirection(searchParams.get("sort"));
634
+
635
+ React.useEffect(() => {
636
+ setIndex(null);
637
+ setError(null);
638
+ fetchIndex(setKey)
639
+ .then(setIndex)
640
+ .catch((err: Error) => setError(err.message));
641
+ }, [setKey]);
642
+
643
+ React.useEffect(() => {
644
+ setShowAll(false);
645
+ }, [query, sortDirection, entityPath, setKey]);
646
+
647
+ if (!isEntityPath(entityPath)) {
648
+ return <Navigate to="features" replace />;
649
+ }
650
+
651
+ const type = entityPathToType[entityPath];
652
+
653
+ if (error) {
654
+ return <EmptyState title="Unable to load catalog index" description={error} />;
655
+ }
656
+
657
+ if (!index) {
658
+ return (
659
+ <div className="px-6 py-8 text-muted">
660
+ Loading {entityLabels[type].plural.toLowerCase()}...
661
+ </div>
662
+ );
663
+ }
664
+
665
+ const filtered = index.entities[type]
666
+ .filter((entity) => matchesQuery(entity, query, entityPath))
667
+ .slice()
668
+ .sort((left, right) => {
669
+ const result = left.key.localeCompare(left.key === right.key ? "" : right.key);
670
+ return sortDirection === "desc" ? result * -1 : result;
671
+ });
672
+ const visible = showAll ? filtered : filtered.slice(0, LIST_INITIAL_LIMIT);
673
+ const hasHiddenEntities = filtered.length > LIST_INITIAL_LIMIT && !showAll;
674
+ const searchHints = entityPath === "features" ? getFeatureSearchHints(index) : [];
675
+
676
+ return (
677
+ <div className="space-y-4">
678
+ <PageHeader title={entityLabels[type].plural} />
679
+
680
+ <div className="px-6 pt-1">
681
+ <div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
682
+ <SearchControls
683
+ query={query}
684
+ label={entityLabels[type].plural}
685
+ hints={searchHints}
686
+ searchParams={searchParams}
687
+ setSearchParams={setSearchParams}
688
+ />
689
+
690
+ <button
691
+ type="button"
692
+ className="inline-flex h-[46px] w-fit max-w-full cursor-pointer items-center gap-2 self-start rounded-full border border-border bg-surface px-3 py-2 text-left text-sm font-semibold text-muted outline-none focus-visible:ring-2 focus-visible:ring-primary md:justify-self-end"
693
+ onClick={() =>
694
+ setSearchParams(
695
+ setSearchParam(
696
+ searchParams,
697
+ "sort",
698
+ sortDirection === "desc" ? undefined : "-name",
699
+ ),
700
+ )
701
+ }
702
+ >
703
+ <span>Sort</span>
704
+ <span className="whitespace-nowrap font-bold text-text">
705
+ {sortDirection === "desc" ? "Z-A" : "A-Z"}
706
+ </span>
707
+ </button>
708
+ </div>
709
+ </div>
710
+
711
+ {filtered.length === 0 && <EmptyState title="No results found" />}
712
+
713
+ <div className="divide-y divide-border bg-surface">
714
+ {visible.map((entity) => (
715
+ <ListRow key={entity.key} entity={entity} type={type} setKey={setKey} />
716
+ ))}
717
+ </div>
718
+
719
+ <div className="space-y-4 px-6 pb-6">
720
+ <p className="text-center text-sm text-muted">
721
+ {visible.length} of {filtered.length} {entityLabels[type].plural.toLowerCase()}
722
+ {filtered.length !== index.entities[type].length
723
+ ? ` (${index.entities[type].length} total)`
724
+ : ""}
725
+ </p>
726
+
727
+ {hasHiddenEntities && (
728
+ <div className="flex justify-center">
729
+ <Button onClick={() => setShowAll(true)}>
730
+ Load all {filtered.length} {entityLabels[type].plural.toLowerCase()}
731
+ </Button>
732
+ </div>
733
+ )}
734
+ </div>
735
+ </div>
736
+ );
737
+ }