@featurevisor/catalog 0.0.1 → 3.0.1

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.
@@ -0,0 +1,1004 @@
1
+ import * as React from "react";
2
+ import { Link, Navigate, Outlet, useOutletContext, useParams } from "react-router-dom";
3
+
4
+ import { fetchEntityDetail, fetchHistoryPage } from "../api";
5
+ import { decodeRouteSegment, entityLabels, entityPathToType, getEntityRoute } from "../entityTypes";
6
+ import type {
7
+ CatalogEntityType,
8
+ DevEditor,
9
+ EntityDetail,
10
+ EntityPath,
11
+ HistoryPage as HistoryPageData,
12
+ } from "../types";
13
+ import { useCatalog } from "../context/CatalogContext";
14
+ import {
15
+ Badge,
16
+ EmptyState,
17
+ EntityKey,
18
+ DescriptionField,
19
+ OverviewChip,
20
+ OverviewChipLink,
21
+ OverviewMetaPanel,
22
+ OverviewMetaRow,
23
+ OverviewSection,
24
+ MarkdownContent,
25
+ PageHeader,
26
+ Tabs,
27
+ } from "../components/ui";
28
+ import { ConditionTree, GroupSegmentTree } from "../components/trees";
29
+ import {
30
+ FeatureVariablesList,
31
+ SchemaPropertiesOverview,
32
+ SchemaTable,
33
+ hasSchemaTableRows,
34
+ usesSchemaStructureTable,
35
+ type SchemaLike,
36
+ } from "../components/variables";
37
+ import { FeatureVariationsList } from "../components/variations";
38
+
39
+ function isEntityPath(value: string | undefined): value is EntityPath {
40
+ return (
41
+ value === "features" ||
42
+ value === "segments" ||
43
+ value === "attributes" ||
44
+ value === "targets" ||
45
+ value === "groups" ||
46
+ value === "schemas"
47
+ );
48
+ }
49
+
50
+ function slugifyFragment(value: string) {
51
+ return value
52
+ .toLowerCase()
53
+ .replace(/[^a-z0-9]+/g, "-")
54
+ .replace(/^-+|-+$/g, "");
55
+ }
56
+
57
+ function RulePermalink(props: { targetId: string }) {
58
+ return (
59
+ <a
60
+ href={`#${props.targetId}`}
61
+ aria-label="Link to this rule"
62
+ className="inline-flex rounded p-1 text-muted opacity-0 transition-opacity hover:text-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary group-hover:opacity-100"
63
+ >
64
+ <svg
65
+ aria-hidden="true"
66
+ viewBox="0 0 24 24"
67
+ className="h-4 w-4"
68
+ fill="none"
69
+ stroke="currentColor"
70
+ strokeLinecap="round"
71
+ strokeLinejoin="round"
72
+ strokeWidth="2"
73
+ >
74
+ <path d="M10 13a5 5 0 0 0 7.07 0l2.83-2.83a5 5 0 0 0-7.07-7.07L11 4" />
75
+ <path d="M14 11a5 5 0 0 0-7.07 0L4.1 13.83a5 5 0 1 0 7.07 7.07L13 20" />
76
+ </svg>
77
+ </a>
78
+ );
79
+ }
80
+
81
+ function useScrollToHash(dependencies: React.DependencyList) {
82
+ React.useEffect(() => {
83
+ if (typeof window === "undefined" || !window.location.hash) {
84
+ return;
85
+ }
86
+
87
+ const targetId = decodeURIComponent(window.location.hash.slice(1));
88
+
89
+ if (!targetId) {
90
+ return;
91
+ }
92
+
93
+ const frame = window.requestAnimationFrame(() => {
94
+ const targetElement = document.getElementById(targetId);
95
+
96
+ if (!targetElement) {
97
+ return;
98
+ }
99
+
100
+ targetElement.scrollIntoView({ block: "start" });
101
+ });
102
+
103
+ return () => {
104
+ window.cancelAnimationFrame(frame);
105
+ };
106
+ }, dependencies);
107
+ }
108
+
109
+ export function useEntityDetail() {
110
+ return useOutletContext<{ detail: EntityDetail; setKey?: string }>();
111
+ }
112
+
113
+ function formatScalar(value: unknown) {
114
+ if (value === undefined || value === null || value === "") {
115
+ return <span className="text-muted">n/a</span>;
116
+ }
117
+
118
+ if (typeof value === "boolean") {
119
+ return value ? "Yes" : "No";
120
+ }
121
+
122
+ return String(value);
123
+ }
124
+
125
+ function FormattedValue(props: { value: unknown }) {
126
+ const value = props.value;
127
+
128
+ if (value === undefined || value === null || value === "") {
129
+ return <span className="text-muted">n/a</span>;
130
+ }
131
+
132
+ if (Array.isArray(value)) {
133
+ if (value.length === 0) {
134
+ return <span className="text-muted">empty</span>;
135
+ }
136
+
137
+ return (
138
+ <div className="flex flex-wrap gap-2">
139
+ {value.map((item, index) => (
140
+ <span key={index} className="rounded border border-border bg-elevated px-2 py-1 text-xs">
141
+ <FormattedValue value={item} />
142
+ </span>
143
+ ))}
144
+ </div>
145
+ );
146
+ }
147
+
148
+ if (typeof value === "object") {
149
+ const entries = Object.entries(value as Record<string, unknown>);
150
+
151
+ if (entries.length === 0) {
152
+ return <span className="text-muted">empty</span>;
153
+ }
154
+
155
+ return (
156
+ <dl className="space-y-2">
157
+ {entries.map(([key, item]) => (
158
+ <div
159
+ key={key}
160
+ className="grid gap-1 rounded border border-border bg-elevated p-2 sm:grid-cols-3"
161
+ >
162
+ <dt className="font-mono text-xs font-semibold text-muted">{key}</dt>
163
+ <dd className="min-w-0 sm:col-span-2">
164
+ <FormattedValue value={item} />
165
+ </dd>
166
+ </div>
167
+ ))}
168
+ </dl>
169
+ );
170
+ }
171
+
172
+ return <span>{formatScalar(value)}</span>;
173
+ }
174
+
175
+ function CaretIcon() {
176
+ return (
177
+ <svg aria-hidden="true" viewBox="0 0 12 12" fill="none" className="h-3 w-3">
178
+ <path
179
+ d="M3.25 4.5 6 7.25 8.75 4.5"
180
+ stroke="currentColor"
181
+ strokeWidth="1.5"
182
+ strokeLinecap="round"
183
+ strokeLinejoin="round"
184
+ />
185
+ </svg>
186
+ );
187
+ }
188
+
189
+ function EditorIcon(props: { icon: DevEditor["icon"] }) {
190
+ if (props.icon === "cursor") {
191
+ return (
192
+ <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" className="h-4 w-4">
193
+ <path d="M4 3l16 9-7 2-3 7L4 3Z" fill="#111827" />
194
+ <path d="M8.2 8.6 15 12.6" stroke="#ffffff" strokeWidth="1.4" strokeLinecap="round" />
195
+ <path d="M10.2 12.9 12.6 14.3 10.5 18.8Z" fill="#ffffff" opacity=".92" />
196
+ </svg>
197
+ );
198
+ }
199
+
200
+ return (
201
+ <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" className="h-4 w-4">
202
+ <path
203
+ d="M17.2 3.2 9.4 10 5.1 6.7 3 7.8v8.4l2.1 1.1 4.3-3.3 7.8 6.8L21 19V5l-3.8-1.8Z"
204
+ fill="#007ACC"
205
+ />
206
+ <path d="M17.2 8.2v7.6L12.5 12l4.7-3.8Z" fill="#ffffff" opacity=".35" />
207
+ </svg>
208
+ );
209
+ }
210
+
211
+ function EditLink(props: { sourcePath?: string; editLinks?: EntityDetail["editLinks"] }) {
212
+ const { manifest } = useCatalog();
213
+ const [open, setOpen] = React.useState(false);
214
+ const containerRef = React.useRef<HTMLDivElement | null>(null);
215
+
216
+ React.useEffect(() => {
217
+ if (!open) {
218
+ return;
219
+ }
220
+
221
+ function handlePointerDown(event: PointerEvent) {
222
+ if (!containerRef.current?.contains(event.target as Node)) {
223
+ setOpen(false);
224
+ }
225
+ }
226
+
227
+ function handleKeyDown(event: KeyboardEvent) {
228
+ if (event.key === "Escape") {
229
+ setOpen(false);
230
+ }
231
+ }
232
+
233
+ document.addEventListener("pointerdown", handlePointerDown);
234
+ document.addEventListener("keydown", handleKeyDown);
235
+
236
+ return () => {
237
+ document.removeEventListener("pointerdown", handlePointerDown);
238
+ document.removeEventListener("keydown", handleKeyDown);
239
+ };
240
+ }, [open]);
241
+
242
+ const sourceHref =
243
+ props.sourcePath && manifest.links?.source
244
+ ? manifest.links.source.replace("{{path}}", props.sourcePath)
245
+ : undefined;
246
+ const editors = (manifest.dev?.editors || []).filter((editor) => props.editLinks?.[editor.id]);
247
+ const hasEditorLinks = editors.length > 0;
248
+
249
+ if (!sourceHref && !hasEditorLinks) {
250
+ return null;
251
+ }
252
+
253
+ const buttonClass =
254
+ "rounded border border-border bg-elevated px-4 py-2 text-sm font-bold text-muted shadow-sm hover:bg-background";
255
+ const splitButtonClass =
256
+ "border border-border bg-elevated px-4 py-2 text-sm font-bold text-muted shadow-sm hover:bg-background";
257
+ const menuButtonClass =
258
+ "border border-border bg-elevated py-2 text-sm font-bold text-muted shadow-sm hover:bg-background";
259
+
260
+ const dropdown = hasEditorLinks ? (
261
+ <div
262
+ role="menu"
263
+ className="absolute right-0 top-full z-20 mt-px min-w-48 overflow-hidden rounded border border-border bg-surface py-1 shadow-lg"
264
+ >
265
+ {editors.map((editor) => (
266
+ <a
267
+ key={editor.id}
268
+ role="menuitem"
269
+ href={props.editLinks?.[editor.id]}
270
+ className="flex items-center gap-2 px-3 py-2 text-sm font-semibold text-muted hover:bg-elevated hover:text-text"
271
+ onClick={() => setOpen(false)}
272
+ >
273
+ <EditorIcon icon={editor.icon} />
274
+ <span>Open in {editor.label}</span>
275
+ </a>
276
+ ))}
277
+ </div>
278
+ ) : null;
279
+
280
+ if (!hasEditorLinks) {
281
+ return sourceHref ? (
282
+ <a href={sourceHref} target="_blank" rel="noreferrer" className={buttonClass}>
283
+ Edit
284
+ </a>
285
+ ) : null;
286
+ }
287
+
288
+ return (
289
+ <div ref={containerRef} className="relative inline-flex">
290
+ {sourceHref ? (
291
+ <a
292
+ href={sourceHref}
293
+ target="_blank"
294
+ rel="noreferrer"
295
+ className={`${splitButtonClass} rounded-l`}
296
+ >
297
+ Edit
298
+ </a>
299
+ ) : (
300
+ <button
301
+ type="button"
302
+ className={`${splitButtonClass} rounded-l`}
303
+ onClick={() => setOpen((current) => !current)}
304
+ >
305
+ Edit
306
+ </button>
307
+ )}
308
+ <button
309
+ type="button"
310
+ className={`${menuButtonClass} -ml-px rounded-r px-2`}
311
+ aria-label="Open edit options"
312
+ aria-expanded={open}
313
+ onClick={() => setOpen((current) => !current)}
314
+ >
315
+ <CaretIcon />
316
+ </button>
317
+ {open ? dropdown : null}
318
+ </div>
319
+ );
320
+ }
321
+
322
+ export function EntityDetailPage() {
323
+ const { entityPath, entityKey, setKey } = useParams();
324
+ const decodedEntityKey = entityKey ? decodeRouteSegment(entityKey) : undefined;
325
+ const [detail, setDetail] = React.useState<EntityDetail | null>(null);
326
+ const [error, setError] = React.useState<string | null>(null);
327
+
328
+ React.useEffect(() => {
329
+ setDetail(null);
330
+ setError(null);
331
+
332
+ if (!isEntityPath(entityPath) || !decodedEntityKey) {
333
+ return;
334
+ }
335
+
336
+ fetchEntityDetail(entityPathToType[entityPath], decodedEntityKey, setKey)
337
+ .then(setDetail)
338
+ .catch((err: Error) => setError(err.message));
339
+ }, [entityPath, decodedEntityKey, setKey]);
340
+
341
+ if (!isEntityPath(entityPath) || !decodedEntityKey) {
342
+ return <Navigate to="/features" replace />;
343
+ }
344
+
345
+ const type = entityPathToType[entityPath];
346
+
347
+ if (error) {
348
+ return <EmptyState title="Unable to load entity" description={error} />;
349
+ }
350
+
351
+ if (!detail) {
352
+ return <div className="text-muted">Loading {entityLabels[type].singular.toLowerCase()}...</div>;
353
+ }
354
+
355
+ const tabs = [
356
+ { to: ".", label: "Overview" },
357
+ ...(type === "feature"
358
+ ? [
359
+ { to: "variations", label: "Variations" },
360
+ { to: "variables", label: "Variables" },
361
+ { to: "rules", label: "Rules" },
362
+ { to: "force", label: "Force" },
363
+ ]
364
+ : []),
365
+ ...(type !== "test" ? [{ to: "usage", label: "Usage" }] : []),
366
+ { to: "history", label: "History" },
367
+ ];
368
+
369
+ return (
370
+ <div>
371
+ <PageHeader
372
+ title={detail.key}
373
+ description={entityLabels[type].singular}
374
+ actions={<EditLink sourcePath={detail.sourcePath} editLinks={detail.editLinks} />}
375
+ />
376
+ <Tabs items={tabs} />
377
+ <div className="px-6 py-6">
378
+ <Outlet context={{ detail, setKey }} />
379
+ </div>
380
+ </div>
381
+ );
382
+ }
383
+
384
+ export function OverviewTab() {
385
+ const { detail, setKey } = useEntityDetail();
386
+ const entity = detail.entity as Record<string, unknown>;
387
+ const schema = entity as SchemaLike;
388
+ const hasStructureTable =
389
+ (detail.type === "schema" && usesSchemaStructureTable(schema) && hasSchemaTableRows(schema)) ||
390
+ (detail.type === "attribute" && hasSchemaTableRows(schema));
391
+
392
+ return (
393
+ <div className="space-y-6">
394
+ <EntityOverviewMeta detail={detail} entity={entity} setKey={setKey} />
395
+
396
+ {detail.type === "segment" && (
397
+ <OverviewSection title="Conditions">
398
+ <ConditionTree conditions={entity.conditions as any} />
399
+ </OverviewSection>
400
+ )}
401
+
402
+ {detail.type === "schema" && !usesSchemaStructureTable(entity as SchemaLike) && (
403
+ <SchemaPropertiesOverview schema={entity as SchemaLike} setKey={setKey} />
404
+ )}
405
+
406
+ {detail.type === "schema" &&
407
+ usesSchemaStructureTable(entity as SchemaLike) &&
408
+ hasSchemaTableRows(entity as SchemaLike) && (
409
+ <OverviewSection title="Structure">
410
+ <SchemaTable schema={entity as SchemaLike} setKey={setKey} />
411
+ </OverviewSection>
412
+ )}
413
+
414
+ {detail.type === "attribute" && hasSchemaTableRows(entity as SchemaLike) && (
415
+ <OverviewSection title="Structure">
416
+ <SchemaTable schema={entity as SchemaLike} setKey={setKey} />
417
+ </OverviewSection>
418
+ )}
419
+
420
+ <DescriptionField value={entity.description} showTopDivider={!hasStructureTable} />
421
+ </div>
422
+ );
423
+ }
424
+
425
+ function EntityStatusBadges(props: { entity: Record<string, unknown> }) {
426
+ const hasBadges =
427
+ props.entity.archived === true ||
428
+ props.entity.deprecated === true ||
429
+ props.entity.promotable === false;
430
+
431
+ if (!hasBadges) {
432
+ return null;
433
+ }
434
+
435
+ return (
436
+ <div className="flex flex-wrap gap-2">
437
+ {props.entity.archived === true && <Badge tone="danger">archived</Badge>}
438
+ {props.entity.deprecated === true && <Badge tone="warning">deprecated</Badge>}
439
+ {props.entity.promotable === false && <Badge>not promotable</Badge>}
440
+ </div>
441
+ );
442
+ }
443
+
444
+ function LinkedEntityChips(props: { type: CatalogEntityType; values?: string[]; setKey?: string }) {
445
+ if (!props.values?.length) {
446
+ return null;
447
+ }
448
+
449
+ return (
450
+ <>
451
+ {props.values.map((value) => (
452
+ <OverviewChipLink key={value} to={getEntityRoute(props.type, value, props.setKey)}>
453
+ {value}
454
+ </OverviewChipLink>
455
+ ))}
456
+ </>
457
+ );
458
+ }
459
+
460
+ function hasBucketBy(value: unknown) {
461
+ if (value === undefined || value === null || value === "") {
462
+ return false;
463
+ }
464
+
465
+ if (Array.isArray(value)) {
466
+ return value.length > 0;
467
+ }
468
+
469
+ return true;
470
+ }
471
+
472
+ function getAttributeEntityKey(attributePath: string) {
473
+ return attributePath.split(".")[0];
474
+ }
475
+
476
+ function BucketByDisplay(props: { value: unknown; setKey?: string }) {
477
+ const value = props.value;
478
+
479
+ if (!hasBucketBy(value)) {
480
+ return null;
481
+ }
482
+
483
+ if (typeof value === "string") {
484
+ return (
485
+ <OverviewChipLink
486
+ to={getEntityRoute("attribute", getAttributeEntityKey(value), props.setKey)}
487
+ >
488
+ <EntityKey value={value} className="font-mono" />
489
+ </OverviewChipLink>
490
+ );
491
+ }
492
+
493
+ if (Array.isArray(value)) {
494
+ return (
495
+ <>
496
+ {value.map((item, index) => (
497
+ <React.Fragment key={index}>
498
+ {index > 0 && <span className="text-xs text-faint">and</span>}
499
+ <BucketByDisplay setKey={props.setKey} value={item} />
500
+ </React.Fragment>
501
+ ))}
502
+ </>
503
+ );
504
+ }
505
+
506
+ if (typeof value === "object" && value !== null && "or" in value) {
507
+ const orValue = (value as { or: unknown }).or;
508
+ const orItems = Array.isArray(orValue) ? orValue : [orValue];
509
+
510
+ return (
511
+ <>
512
+ {orItems.map((item, index) => (
513
+ <React.Fragment key={index}>
514
+ {index > 0 && <span className="text-xs text-faint">or</span>}
515
+ <BucketByDisplay setKey={props.setKey} value={item} />
516
+ </React.Fragment>
517
+ ))}
518
+ </>
519
+ );
520
+ }
521
+
522
+ return (
523
+ <OverviewChip>
524
+ <span className="font-mono text-xs">
525
+ <FormattedValue value={value} />
526
+ </span>
527
+ </OverviewChip>
528
+ );
529
+ }
530
+
531
+ function asStringArray(value: unknown) {
532
+ if (!value) {
533
+ return undefined;
534
+ }
535
+
536
+ if (Array.isArray(value)) {
537
+ const items = value.map(String).filter(Boolean);
538
+ return items.length > 0 ? items : undefined;
539
+ }
540
+
541
+ if (typeof value === "string") {
542
+ return [value];
543
+ }
544
+
545
+ return undefined;
546
+ }
547
+
548
+ function EntityOverviewMeta(props: {
549
+ detail: EntityDetail;
550
+ entity: Record<string, unknown>;
551
+ setKey?: string;
552
+ }) {
553
+ const { detail, entity, setKey } = props;
554
+ const tags = asStringArray(entity.tags);
555
+ const targets = detail.relationships?.targets;
556
+ const required = asStringArray(entity.required);
557
+ const hasStatus =
558
+ entity.archived === true || entity.deprecated === true || entity.promotable === false;
559
+
560
+ if (detail.type === "feature") {
561
+ const showBucketBy = hasBucketBy(entity.bucketBy);
562
+ const hasFacts = showBucketBy || Boolean(required?.length);
563
+ const hasRelations = Boolean(tags?.length) || Boolean(targets?.length);
564
+
565
+ if (!hasStatus && !hasFacts && !hasRelations) {
566
+ return null;
567
+ }
568
+
569
+ return (
570
+ <OverviewMetaPanel>
571
+ {hasStatus && (
572
+ <OverviewMetaRow label="Status">
573
+ <EntityStatusBadges entity={entity} />
574
+ </OverviewMetaRow>
575
+ )}
576
+ {showBucketBy && (
577
+ <OverviewMetaRow label="Bucket by">
578
+ <BucketByDisplay value={entity.bucketBy} setKey={setKey} />
579
+ </OverviewMetaRow>
580
+ )}
581
+ {required?.length ? (
582
+ <OverviewMetaRow label="Required">
583
+ {required.map((key) => (
584
+ <OverviewChipLink key={key} to={getEntityRoute("feature", key, setKey)}>
585
+ {key}
586
+ </OverviewChipLink>
587
+ ))}
588
+ </OverviewMetaRow>
589
+ ) : null}
590
+ {tags?.length ? (
591
+ <OverviewMetaRow label="Tags">
592
+ {tags.map((tag) => (
593
+ <OverviewChip key={tag}>{tag}</OverviewChip>
594
+ ))}
595
+ </OverviewMetaRow>
596
+ ) : null}
597
+ {targets?.length ? (
598
+ <OverviewMetaRow label="Targets">
599
+ <LinkedEntityChips type="target" values={targets} setKey={setKey} />
600
+ </OverviewMetaRow>
601
+ ) : null}
602
+ </OverviewMetaPanel>
603
+ );
604
+ }
605
+
606
+ if (
607
+ (detail.type === "attribute" || detail.type === "schema") &&
608
+ (entity.type || (Array.isArray(entity.oneOf) && entity.oneOf.length > 0))
609
+ ) {
610
+ const typeLabel = entity.type ? String(entity.type) : "oneOf";
611
+
612
+ return (
613
+ <OverviewMetaPanel>
614
+ {hasStatus && (
615
+ <OverviewMetaRow label="Status">
616
+ <EntityStatusBadges entity={entity} />
617
+ </OverviewMetaRow>
618
+ )}
619
+ <OverviewMetaRow label="Type">
620
+ <OverviewChip>{typeLabel}</OverviewChip>
621
+ </OverviewMetaRow>
622
+ </OverviewMetaPanel>
623
+ );
624
+ }
625
+
626
+ if (!hasStatus) {
627
+ return null;
628
+ }
629
+
630
+ return (
631
+ <OverviewMetaPanel>
632
+ <OverviewMetaRow label="Status">
633
+ <EntityStatusBadges entity={entity} />
634
+ </OverviewMetaRow>
635
+ </OverviewMetaPanel>
636
+ );
637
+ }
638
+
639
+ function getEnvironmentItems(detail: EntityDetail, tab: "rules" | "force") {
640
+ const entity = detail.entity as Record<string, any>;
641
+ const value = entity[tab];
642
+
643
+ if (!value || Array.isArray(value)) {
644
+ return [];
645
+ }
646
+
647
+ return Object.keys(value).sort(sortEnvironmentKeys);
648
+ }
649
+
650
+ function getEnvironmentSortGroup(value: string) {
651
+ const normalized = value.toLowerCase();
652
+
653
+ if (normalized.startsWith("dev")) {
654
+ return 0;
655
+ }
656
+
657
+ if (normalized.startsWith("prod")) {
658
+ return 2;
659
+ }
660
+
661
+ return 1;
662
+ }
663
+
664
+ function sortEnvironmentKeys(left: string, right: string) {
665
+ const leftGroup = getEnvironmentSortGroup(left);
666
+ const rightGroup = getEnvironmentSortGroup(right);
667
+
668
+ if (leftGroup !== rightGroup) {
669
+ return leftGroup - rightGroup;
670
+ }
671
+
672
+ return left.localeCompare(right);
673
+ }
674
+
675
+ export function FeatureRulesTab() {
676
+ const { detail, setKey } = useEntityDetail();
677
+ const { environmentKey } = useParams();
678
+ const entity = detail.entity as Record<string, any>;
679
+ const environments = getEnvironmentItems(detail, "rules");
680
+ const selectedEnvironment = environmentKey || environments[0];
681
+ const rules = environments.length > 0 ? entity.rules?.[selectedEnvironment] : entity.rules;
682
+ const expose = environments.length > 0 ? entity.expose?.[selectedEnvironment] : entity.expose;
683
+
684
+ if (detail.type !== "feature") return <Navigate to=".." replace />;
685
+
686
+ return (
687
+ <FeatureRows
688
+ title="Rules"
689
+ base="rules"
690
+ environments={environments}
691
+ selectedEnvironment={selectedEnvironment}
692
+ rows={rules || []}
693
+ expose={expose}
694
+ showConditions={false}
695
+ setKey={setKey}
696
+ />
697
+ );
698
+ }
699
+
700
+ export function FeatureForceTab() {
701
+ const { detail, setKey } = useEntityDetail();
702
+ const { environmentKey } = useParams();
703
+ const entity = detail.entity as Record<string, any>;
704
+ const environments = getEnvironmentItems(detail, "force");
705
+ const selectedEnvironment = environmentKey || environments[0];
706
+ const rows = environments.length > 0 ? entity.force?.[selectedEnvironment] : entity.force;
707
+
708
+ if (detail.type !== "feature") return <Navigate to=".." replace />;
709
+
710
+ return (
711
+ <FeatureRows
712
+ title="Force"
713
+ emptyTitle="No forced rules found"
714
+ base="force"
715
+ environments={environments}
716
+ selectedEnvironment={selectedEnvironment}
717
+ rows={rows || []}
718
+ showConditions
719
+ setKey={setKey}
720
+ />
721
+ );
722
+ }
723
+
724
+ function FeatureRows(props: {
725
+ title: string;
726
+ emptyTitle?: string;
727
+ base: string;
728
+ environments: string[];
729
+ selectedEnvironment?: string;
730
+ rows: any[];
731
+ expose?: unknown;
732
+ showConditions: boolean;
733
+ setKey?: string;
734
+ }) {
735
+ useScrollToHash([props.base, props.selectedEnvironment, props.rows.length]);
736
+
737
+ if (props.environments.length > 0 && props.selectedEnvironment) {
738
+ const isKnown = props.environments.includes(props.selectedEnvironment);
739
+ if (!isKnown) {
740
+ return <Navigate to={`../${props.base}/${props.environments[0]}`} replace />;
741
+ }
742
+ }
743
+
744
+ return (
745
+ <div className="space-y-4">
746
+ {props.environments.length > 0 && (
747
+ <nav className="flex flex-wrap gap-2">
748
+ {props.environments.map((environment) => (
749
+ <Link
750
+ key={environment}
751
+ to={`../${props.base}/${environment}`}
752
+ className={[
753
+ "inline-flex rounded-full border px-3 py-1 text-xs font-semibold transition-colors",
754
+ environment === props.selectedEnvironment
755
+ ? "border-primary bg-header-active !text-header-text"
756
+ : "border-pill bg-transparent text-text hover:bg-elevated",
757
+ ].join(" ")}
758
+ >
759
+ {environment}
760
+ </Link>
761
+ ))}
762
+ </nav>
763
+ )}
764
+
765
+ {typeof props.expose !== "undefined" && (
766
+ <div className="rounded border border-border bg-warning-surface p-3 text-sm">
767
+ <span className="font-semibold">Expose</span>
768
+ <div className="mt-2">
769
+ <FormattedValue value={props.expose} />
770
+ </div>
771
+ </div>
772
+ )}
773
+
774
+ {props.rows.length === 0 && (
775
+ <EmptyState title={props.emptyTitle || `No ${props.title.toLowerCase()} found`} />
776
+ )}
777
+ <div className="space-y-8">
778
+ {props.rows.map((row, index) => {
779
+ const ruleKey = String(row.key || `#${index + 1}`);
780
+ const ruleId = slugifyFragment(
781
+ [props.base, props.selectedEnvironment, ruleKey].filter(Boolean).join("-"),
782
+ );
783
+
784
+ return (
785
+ <section key={row.key || index} className="space-y-4">
786
+ <div className="space-y-3">
787
+ <div className="flex min-w-0 items-center justify-between gap-4">
788
+ <div className="group flex min-w-0 flex-wrap items-center gap-2">
789
+ <h2 id={ruleId} className="font-semibold [overflow-wrap:anywhere]">
790
+ <EntityKey value={ruleKey} className="font-semibold" />
791
+ </h2>
792
+ <RulePermalink targetId={ruleId} />
793
+ {row.enabled === false && <Badge tone="danger">disabled</Badge>}
794
+ {row.promotable === false && <Badge>not promotable</Badge>}
795
+ </div>
796
+ {typeof row.percentage === "number" && <RuleProgress value={row.percentage} />}
797
+ </div>
798
+ {row.summary && <p className="text-sm text-muted">{row.summary}</p>}
799
+ {row.description && <MarkdownContent value={row.description} />}
800
+ </div>
801
+
802
+ <div className={`grid gap-4 ${props.showConditions ? "md:grid-cols-2" : ""}`}>
803
+ <div className="space-y-2 rounded-xl border border-border bg-elevated p-4">
804
+ <h3 className="text-sm font-semibold text-muted">Segments</h3>
805
+ <GroupSegmentTree segments={row.segments} setKey={props.setKey} />
806
+ </div>
807
+ {props.showConditions && (
808
+ <div className="space-y-2 rounded-xl border border-border bg-elevated p-4">
809
+ <h3 className="text-sm font-semibold text-muted">Conditions</h3>
810
+ <ConditionTree conditions={row.conditions} setKey={props.setKey} />
811
+ </div>
812
+ )}
813
+ </div>
814
+ </section>
815
+ );
816
+ })}
817
+ </div>
818
+ </div>
819
+ );
820
+ }
821
+
822
+ function RuleProgress(props: { value: number }) {
823
+ const value = Math.max(0, Math.min(100, props.value));
824
+
825
+ return (
826
+ <div className="flex w-2/5 min-w-0 max-w-sm shrink-0 items-center gap-2">
827
+ <span className="w-10 shrink-0 text-right text-xs font-semibold text-muted">{value}%</span>
828
+ <div className="h-2 min-w-0 flex-1 overflow-hidden rounded-full bg-slate-200">
829
+ <div className="h-full rounded-full bg-green-500" style={{ width: `${value}%` }} />
830
+ </div>
831
+ </div>
832
+ );
833
+ }
834
+
835
+ export function FeatureVariationsTab() {
836
+ const { detail, setKey } = useEntityDetail();
837
+ const entity = detail.entity as Record<string, any>;
838
+ const variations = entity.variations || [];
839
+
840
+ if (detail.type !== "feature") return <Navigate to=".." replace />;
841
+
842
+ if (variations.length === 0) {
843
+ return <EmptyState title="No variations found" />;
844
+ }
845
+
846
+ return <FeatureVariationsList variations={variations} setKey={setKey} />;
847
+ }
848
+
849
+ export function FeatureVariablesTab() {
850
+ const { detail, setKey } = useEntityDetail();
851
+ const entity = detail.entity as Record<string, any>;
852
+ const variablesSchema = entity.variablesSchema || {};
853
+
854
+ if (detail.type !== "feature") return <Navigate to=".." replace />;
855
+
856
+ if (Object.keys(variablesSchema).length === 0) {
857
+ return <EmptyState title="No variables found" />;
858
+ }
859
+
860
+ return <FeatureVariablesList variablesSchema={variablesSchema} setKey={setKey} />;
861
+ }
862
+
863
+ function getUsageEntityType(label: string): CatalogEntityType {
864
+ if (label.includes("segment")) {
865
+ return "segment";
866
+ }
867
+
868
+ if (label.includes("target")) {
869
+ return "target";
870
+ }
871
+
872
+ if (label.includes("attribute")) {
873
+ return "attribute";
874
+ }
875
+
876
+ if (label.includes("schema")) {
877
+ return "schema";
878
+ }
879
+
880
+ if (label.includes("group")) {
881
+ return "group";
882
+ }
883
+
884
+ if (label.includes("test")) {
885
+ return "test";
886
+ }
887
+
888
+ return "feature";
889
+ }
890
+
891
+ function getUsageTitle(label: string) {
892
+ return label.charAt(0).toUpperCase() + label.slice(1);
893
+ }
894
+
895
+ function UsageSection(props: {
896
+ title: string;
897
+ type: CatalogEntityType;
898
+ values: string[];
899
+ setKey?: string;
900
+ }) {
901
+ return (
902
+ <section>
903
+ <h2 className="mb-2 text-sm font-semibold text-text">{props.title}</h2>
904
+ <ul className="list-inside list-disc space-y-1 text-sm">
905
+ {props.values.map((value) => (
906
+ <li key={value} className="[overflow-wrap:anywhere]">
907
+ <Link
908
+ className="text-primary hover:underline"
909
+ to={getEntityRoute(props.type, value, props.setKey)}
910
+ >
911
+ <EntityKey value={value} className="font-medium" />
912
+ </Link>
913
+ </li>
914
+ ))}
915
+ </ul>
916
+ </section>
917
+ );
918
+ }
919
+
920
+ export function UsageTab() {
921
+ const { detail, setKey } = useEntityDetail();
922
+ const relationships = detail.relationships || {};
923
+ const entries: Array<[string, string[]]> =
924
+ detail.type === "feature"
925
+ ? [
926
+ ["targets", relationships.targets || []],
927
+ ["features", relationships.requiredBy || []],
928
+ ]
929
+ : Object.entries(relationships).filter(([label]) => label !== "tests");
930
+ const visibleEntries = entries.filter(([, values]) => values.length > 0);
931
+
932
+ if (visibleEntries.length === 0) {
933
+ return <EmptyState title="No usage found" />;
934
+ }
935
+
936
+ return (
937
+ <div className="space-y-6">
938
+ {visibleEntries.map(([label, values]) => (
939
+ <UsageSection
940
+ key={label}
941
+ title={getUsageTitle(label)}
942
+ type={getUsageEntityType(label)}
943
+ values={values}
944
+ setKey={setKey}
945
+ />
946
+ ))}
947
+ </div>
948
+ );
949
+ }
950
+
951
+ export function HistoryTab() {
952
+ const { detail } = useEntityDetail();
953
+ const { manifest } = useCatalog();
954
+ const [page, setPage] = React.useState<HistoryPageData | null>(null);
955
+
956
+ React.useEffect(() => {
957
+ if (!detail.historyPath) {
958
+ setPage({ page: 1, pageSize: 50, totalPages: 1, entries: [] });
959
+ return;
960
+ }
961
+
962
+ fetchHistoryPage(detail.historyPath, 1).then(setPage);
963
+ }, [detail.historyPath]);
964
+
965
+ if (!page) {
966
+ return <div className="text-muted">Loading history...</div>;
967
+ }
968
+
969
+ return (
970
+ <div className="space-y-3">
971
+ {page.entries.length === 0 && <EmptyState title="No history found" />}
972
+ {page.entries.map((entry) => {
973
+ const commitHref = manifest.links?.commit?.replace("{{hash}}", entry.commit);
974
+ const content = (
975
+ <>
976
+ <div className="font-mono text-sm">{entry.commit.slice(0, 10)}</div>
977
+ <div className="text-sm text-muted">
978
+ {entry.author} · {new Date(entry.timestamp).toLocaleString()}
979
+ </div>
980
+ </>
981
+ );
982
+
983
+ return commitHref ? (
984
+ <a
985
+ key={entry.commit}
986
+ href={commitHref}
987
+ target="_blank"
988
+ rel="noreferrer"
989
+ className="block rounded-lg border border-border bg-surface p-4 shadow-sm ring-1 ring-black/5 hover:bg-elevated"
990
+ >
991
+ {content}
992
+ </a>
993
+ ) : (
994
+ <div
995
+ key={entry.commit}
996
+ className="rounded-lg border border-border bg-surface p-4 shadow-sm ring-1 ring-black/5"
997
+ >
998
+ {content}
999
+ </div>
1000
+ );
1001
+ })}
1002
+ </div>
1003
+ );
1004
+ }