@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,301 @@
1
+ import * as React from "react";
2
+ import { Link, useLocation, useSearchParams } from "react-router-dom";
3
+ import type {
4
+ FeatureAssertion,
5
+ SegmentAssertion,
6
+ Test,
7
+ TestFeature,
8
+ TestSegment,
9
+ } from "@featurevisor/types";
10
+
11
+ import { Badge, EmptyState, EntityKey, LabelValueBadge, MarkdownContent } from "./ui";
12
+ import { expandTestAssertions, getTestAssertionPermalink } from "../testModel";
13
+
14
+ function getAssertionElementId(permalink: string) {
15
+ return `assertion-${encodeURIComponent(permalink)}`;
16
+ }
17
+
18
+ function hasValue(value: unknown) {
19
+ return value !== undefined && value !== null;
20
+ }
21
+
22
+ function ValueDisplay(props: { value: unknown }) {
23
+ if (props.value === undefined || props.value === null) {
24
+ return <span className="text-faint">not set</span>;
25
+ }
26
+
27
+ if (typeof props.value === "boolean") {
28
+ return (
29
+ <Badge tone={props.value ? "success" : "neutral"}>{props.value ? "true" : "false"}</Badge>
30
+ );
31
+ }
32
+
33
+ if (Array.isArray(props.value)) {
34
+ if (props.value.length === 0) {
35
+ return <span className="text-faint">empty</span>;
36
+ }
37
+
38
+ return (
39
+ <div className="flex flex-wrap gap-1.5">
40
+ {props.value.map((value, index) => (
41
+ <span key={index} className="rounded-md border border-border bg-surface px-2 py-1">
42
+ <ValueDisplay value={value} />
43
+ </span>
44
+ ))}
45
+ </div>
46
+ );
47
+ }
48
+
49
+ if (typeof props.value === "object") {
50
+ const entries = Object.entries(props.value as Record<string, unknown>);
51
+ if (entries.length === 0) {
52
+ return <span className="text-faint">empty</span>;
53
+ }
54
+
55
+ return (
56
+ <dl className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-surface">
57
+ {entries.map(([key, value]) => (
58
+ <div key={key} className="grid gap-1 px-3 py-2 sm:grid-cols-[minmax(7rem,0.35fr)_1fr]">
59
+ <dt className="font-mono text-xs font-semibold text-muted [overflow-wrap:anywhere]">
60
+ {key}
61
+ </dt>
62
+ <dd className="min-w-0 text-sm [overflow-wrap:anywhere]">
63
+ <ValueDisplay value={value} />
64
+ </dd>
65
+ </div>
66
+ ))}
67
+ </dl>
68
+ );
69
+ }
70
+
71
+ return <span className="font-mono text-xs [overflow-wrap:anywhere]">{String(props.value)}</span>;
72
+ }
73
+
74
+ function TestDataPanel(props: { title: string; value: unknown; className?: string }) {
75
+ if (!hasValue(props.value)) {
76
+ return null;
77
+ }
78
+
79
+ return (
80
+ <section
81
+ className={`min-w-0 rounded-xl border border-border bg-elevated p-4 ${props.className || ""}`}
82
+ >
83
+ <h4 className="mb-3 text-[10px] font-semibold uppercase tracking-wider text-faint">
84
+ {props.title}
85
+ </h4>
86
+ <ValueDisplay value={props.value} />
87
+ </section>
88
+ );
89
+ }
90
+
91
+ function AssertionPermalink(props: { permalink: string; label: string }) {
92
+ const location = useLocation();
93
+ const search = new URLSearchParams(location.search);
94
+ search.set("assertion", props.permalink);
95
+
96
+ return (
97
+ <Link
98
+ to={{ pathname: location.pathname, search: `?${search.toString()}` }}
99
+ aria-label={`Link to assertion ${props.label}`}
100
+ title="Link to this assertion"
101
+ className="inline-flex rounded p-1 text-muted opacity-100 transition-opacity hover:text-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary md:opacity-0 md:group-hover:opacity-100"
102
+ >
103
+ <svg
104
+ aria-hidden="true"
105
+ viewBox="0 0 24 24"
106
+ className="h-4 w-4"
107
+ fill="none"
108
+ stroke="currentColor"
109
+ strokeLinecap="round"
110
+ strokeLinejoin="round"
111
+ strokeWidth="2"
112
+ >
113
+ <path d="M10 13a5 5 0 0 0 7.07 0l2.83-2.83a5 5 0 0 0-7.07-7.07L11 4" />
114
+ <path d="M14 11a5 5 0 0 0-7.07 0L4.1 13.83a5 5 0 1 0 7.07 7.07L13 20" />
115
+ </svg>
116
+ </Link>
117
+ );
118
+ }
119
+
120
+ function FeatureAssertionContent(props: { assertion: FeatureAssertion }) {
121
+ const assertion = props.assertion;
122
+ const defaults = {
123
+ ...(hasValue(assertion.defaultVariationValue)
124
+ ? { variation: assertion.defaultVariationValue }
125
+ : {}),
126
+ ...(assertion.defaultVariableValues ? { variables: assertion.defaultVariableValues } : {}),
127
+ };
128
+ const expectations = {
129
+ ...(hasValue(assertion.expectedToBeEnabled) ? { enabled: assertion.expectedToBeEnabled } : {}),
130
+ ...(hasValue(assertion.expectedVariation) ? { variation: assertion.expectedVariation } : {}),
131
+ ...(assertion.expectedVariables ? { variables: assertion.expectedVariables } : {}),
132
+ ...(assertion.expectedEvaluations ? { evaluations: assertion.expectedEvaluations } : {}),
133
+ };
134
+
135
+ return (
136
+ <>
137
+ <div className="flex flex-wrap gap-2">
138
+ <LabelValueBadge label="Environment" value={assertion.environment || "none"} compact />
139
+ {assertion.target && <LabelValueBadge label="Target" value={assertion.target} compact />}
140
+ {hasValue(assertion.at) && (
141
+ <LabelValueBadge label="Bucket" value={`${assertion.at}%`} compact />
142
+ )}
143
+ </div>
144
+
145
+ <div className="space-y-4">
146
+ <TestDataPanel title="Context" value={assertion.context || {}} />
147
+ <TestDataPanel title="Expected" value={expectations} />
148
+ {Object.keys(defaults).length > 0 && <TestDataPanel title="Defaults" value={defaults} />}
149
+ {assertion.sticky && <TestDataPanel title="Sticky features" value={assertion.sticky} />}
150
+ </div>
151
+
152
+ {assertion.children?.length ? (
153
+ <section className="space-y-3 rounded-xl border border-border bg-surface p-4">
154
+ <h4 className="text-[10px] font-semibold uppercase tracking-wider text-faint">
155
+ Child instances
156
+ </h4>
157
+ <div className="space-y-3">
158
+ {assertion.children.map((child, index) => (
159
+ <div key={index} className="rounded-lg border border-border bg-elevated p-3">
160
+ <div className="mb-3 text-xs font-semibold text-muted">Child {index + 1}</div>
161
+ <ValueDisplay value={child} />
162
+ </div>
163
+ ))}
164
+ </div>
165
+ </section>
166
+ ) : null}
167
+ </>
168
+ );
169
+ }
170
+
171
+ function SegmentAssertionContent(props: { assertion: SegmentAssertion }) {
172
+ return (
173
+ <div className="space-y-4">
174
+ <TestDataPanel title="Context" value={props.assertion.context} />
175
+ <section className="rounded-xl border border-border bg-elevated p-4">
176
+ <h4 className="mb-3 text-[10px] font-semibold uppercase tracking-wider text-faint">
177
+ Expected
178
+ </h4>
179
+ <Badge tone={props.assertion.expectedToMatch ? "success" : "neutral"}>
180
+ {props.assertion.expectedToMatch ? "Matches segment" : "Does not match segment"}
181
+ </Badge>
182
+ </section>
183
+ </div>
184
+ );
185
+ }
186
+
187
+ function TestSpec(props: { test: Test; index: number; selectedPermalink?: string }) {
188
+ const testKey = props.test.key || `test-${props.index + 1}`;
189
+ const assertions = expandTestAssertions(props.test);
190
+ const authoredCount = props.test.assertions.length;
191
+ const hasMatrix = props.test.assertions.some((assertion) => Boolean(assertion.matrix));
192
+
193
+ return (
194
+ <section className="space-y-5">
195
+ <header className="flex flex-col justify-between gap-3 border-b border-border pb-3 sm:flex-row sm:items-end">
196
+ <div className="min-w-0">
197
+ <div className="text-[10px] font-semibold uppercase tracking-wider text-faint">
198
+ Test spec {props.index + 1}
199
+ </div>
200
+ <h2 className="mt-1 font-mono text-sm font-semibold [overflow-wrap:anywhere]">
201
+ <EntityKey value={testKey} />
202
+ </h2>
203
+ </div>
204
+ <div className="flex flex-wrap gap-2">
205
+ {props.test.promotable === false && <Badge>not promotable</Badge>}
206
+ <Badge>{authoredCount} authored</Badge>
207
+ {hasMatrix && <Badge tone="primary">{assertions.length} applied</Badge>}
208
+ </div>
209
+ </header>
210
+
211
+ {assertions.length === 0 ? (
212
+ <EmptyState title="No assertions found in this test spec" />
213
+ ) : (
214
+ <div className="space-y-5">
215
+ {assertions.map((expanded) => {
216
+ const permalink = getTestAssertionPermalink(testKey, expanded.label);
217
+ const selected = props.selectedPermalink === permalink;
218
+ const assertion = expanded.assertion;
219
+
220
+ return (
221
+ <article
222
+ id={getAssertionElementId(permalink)}
223
+ key={permalink}
224
+ className={[
225
+ "scroll-mt-6 space-y-4 rounded-2xl border bg-surface p-5 transition-shadow",
226
+ selected ? "border-primary ring-2 ring-primary/20" : "border-border",
227
+ ].join(" ")}
228
+ >
229
+ <header className="space-y-2">
230
+ <div className="group flex min-w-0 items-start justify-between gap-3">
231
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
232
+ <h3 className="font-semibold">Assertion {expanded.label}</h3>
233
+ {hasValue(expanded.caseIndex) && (
234
+ <Badge tone="primary">
235
+ Matrix case {(expanded.caseIndex as number) + 1} of {expanded.caseCount}
236
+ </Badge>
237
+ )}
238
+ </div>
239
+ <div className="shrink-0">
240
+ <AssertionPermalink permalink={permalink} label={expanded.label} />
241
+ </div>
242
+ </div>
243
+ {assertion.description && <MarkdownContent value={assertion.description} />}
244
+ </header>
245
+
246
+ {expanded.matrixValues && (
247
+ <div className="flex flex-wrap items-center gap-2">
248
+ <span className="text-[10px] font-semibold uppercase tracking-wider text-faint">
249
+ Matrix values
250
+ </span>
251
+ {Object.entries(expanded.matrixValues).map(([key, value]) => (
252
+ <LabelValueBadge key={key} label={key} value={String(value)} compact />
253
+ ))}
254
+ </div>
255
+ )}
256
+
257
+ {"feature" in props.test ? (
258
+ <FeatureAssertionContent assertion={assertion as FeatureAssertion} />
259
+ ) : (
260
+ <SegmentAssertionContent assertion={assertion as SegmentAssertion} />
261
+ )}
262
+ </article>
263
+ );
264
+ })}
265
+ </div>
266
+ )}
267
+ </section>
268
+ );
269
+ }
270
+
271
+ export function EntityTests(props: { tests: Test[] }) {
272
+ const [searchParams] = useSearchParams();
273
+ const selectedPermalink = searchParams.get("assertion") || undefined;
274
+
275
+ React.useEffect(() => {
276
+ if (!selectedPermalink) {
277
+ return;
278
+ }
279
+
280
+ const frame = window.requestAnimationFrame(() => {
281
+ document
282
+ .getElementById(getAssertionElementId(selectedPermalink))
283
+ ?.scrollIntoView({ block: "start" });
284
+ });
285
+
286
+ return () => window.cancelAnimationFrame(frame);
287
+ }, [selectedPermalink, props.tests]);
288
+
289
+ return (
290
+ <div className="space-y-10">
291
+ {props.tests.map((test, index) => (
292
+ <TestSpec
293
+ key={test.key || index}
294
+ test={test as TestFeature | TestSegment}
295
+ index={index}
296
+ selectedPermalink={selectedPermalink}
297
+ />
298
+ ))}
299
+ </div>
300
+ );
301
+ }
@@ -0,0 +1,202 @@
1
+ import { Link } from "react-router-dom";
2
+ import type { Condition, GroupSegment } from "@featurevisor/types";
3
+
4
+ import { getEntityRoute } from "../entityTypes";
5
+ import { Badge, EntityKey } from "./ui";
6
+
7
+ function formatValue(value: unknown): string {
8
+ if (typeof value === "undefined") return "";
9
+ if (value === null) return "null";
10
+ if (Array.isArray(value)) return value.map((item) => formatValue(item)).join(", ");
11
+ if (typeof value === "object") return JSON.stringify(value);
12
+
13
+ return String(value);
14
+ }
15
+
16
+ function getParentAttributeKey(attributePath: string) {
17
+ return attributePath.split(".")[0];
18
+ }
19
+
20
+ function SegmentLeaf(props: { segmentKey: string; setKey?: string }) {
21
+ return (
22
+ <div className="rounded-lg border border-border bg-surface px-4 py-3 shadow-sm">
23
+ <div className="flex flex-wrap items-center gap-2 text-sm">
24
+ <Badge tone="primary">segment</Badge>
25
+ <Link
26
+ to={getEntityRoute("segment", props.segmentKey, props.setKey)}
27
+ className="font-semibold text-primary hover:underline"
28
+ >
29
+ <EntityKey value={props.segmentKey} className="font-semibold" />
30
+ </Link>
31
+ </div>
32
+ </div>
33
+ );
34
+ }
35
+
36
+ function GroupSegmentNode(props: { segment: GroupSegment; setKey?: string }) {
37
+ if (typeof props.segment === "string") {
38
+ return <SegmentLeaf segmentKey={props.segment} setKey={props.setKey} />;
39
+ }
40
+
41
+ const operator = "and" in props.segment ? "and" : "or" in props.segment ? "or" : "not";
42
+ const rawChildren = props.segment[operator];
43
+ const children = Array.isArray(rawChildren) ? rawChildren : [rawChildren];
44
+
45
+ return (
46
+ <div className="rounded-lg border border-border bg-elevated p-4">
47
+ <div className="mb-3 flex items-center gap-2">
48
+ <Badge tone="neutral">{operator.toUpperCase()}</Badge>
49
+ <span className="text-sm text-muted">
50
+ {children.length} branch{children.length === 1 ? "" : "es"}
51
+ </span>
52
+ </div>
53
+ <div className="ml-3 space-y-3 border-l border-border pl-4">
54
+ {children.map((child, index) => (
55
+ <GroupSegmentNode key={`${operator}-${index}`} segment={child} setKey={props.setKey} />
56
+ ))}
57
+ </div>
58
+ </div>
59
+ );
60
+ }
61
+
62
+ export function GroupSegmentTree(props: {
63
+ segments?: GroupSegment | GroupSegment[] | "*";
64
+ setKey?: string;
65
+ }) {
66
+ if (!props.segments) {
67
+ return <p className="text-sm text-muted">No segments found.</p>;
68
+ }
69
+
70
+ if (props.segments === "*") {
71
+ return (
72
+ <div className="rounded-lg border border-border bg-surface px-4 py-3 text-sm shadow-sm">
73
+ Everyone
74
+ </div>
75
+ );
76
+ }
77
+
78
+ const segments = Array.isArray(props.segments) ? props.segments : [props.segments];
79
+
80
+ return (
81
+ <div className="space-y-3">
82
+ {segments.map((segment, index) => (
83
+ <GroupSegmentNode key={index} segment={segment} setKey={props.setKey} />
84
+ ))}
85
+ </div>
86
+ );
87
+ }
88
+
89
+ function ConditionLeaf(props: { condition: Record<string, any>; setKey?: string }) {
90
+ if ("attribute" in props.condition) {
91
+ const attributePath = String(props.condition.attribute);
92
+
93
+ return (
94
+ <div className="rounded-lg border border-border bg-surface px-4 py-3 shadow-sm">
95
+ <div className="flex flex-wrap items-center gap-2 text-sm">
96
+ <Badge tone="primary">attribute</Badge>
97
+ <Link
98
+ to={getEntityRoute("attribute", getParentAttributeKey(attributePath), props.setKey)}
99
+ className="font-semibold text-primary hover:underline"
100
+ >
101
+ {attributePath}
102
+ </Link>
103
+ <span className="font-medium text-text">{props.condition.operator}</span>
104
+ {"value" in props.condition && (
105
+ <span className="text-muted">{formatValue(props.condition.value)}</span>
106
+ )}
107
+ </div>
108
+ </div>
109
+ );
110
+ }
111
+
112
+ if ("feature" in props.condition) {
113
+ const featureKey = String(props.condition.feature);
114
+
115
+ return (
116
+ <div className="rounded-lg border border-border bg-surface px-4 py-3 shadow-sm">
117
+ <div className="flex flex-wrap items-center gap-2 text-sm">
118
+ <Badge tone="success">feature</Badge>
119
+ <Link
120
+ to={getEntityRoute("feature", featureKey, props.setKey)}
121
+ className="font-semibold text-primary hover:underline"
122
+ >
123
+ <EntityKey value={featureKey} className="font-semibold" />
124
+ </Link>
125
+ <span className="font-medium text-text">{props.condition.operator}</span>
126
+ {"value" in props.condition && (
127
+ <span className="text-muted">{formatValue(props.condition.value)}</span>
128
+ )}
129
+ </div>
130
+ </div>
131
+ );
132
+ }
133
+
134
+ return (
135
+ <div className="rounded-lg border border-border bg-surface px-4 py-3 text-sm text-muted shadow-sm">
136
+ Unsupported condition
137
+ </div>
138
+ );
139
+ }
140
+
141
+ function ConditionNode(props: { condition: Condition; setKey?: string }) {
142
+ const condition = props.condition as any;
143
+
144
+ if (typeof condition === "string") {
145
+ return (
146
+ <div className="rounded-lg border border-border bg-surface px-4 py-3 text-sm shadow-sm">
147
+ {condition === "*" ? "Everyone" : condition}
148
+ </div>
149
+ );
150
+ }
151
+
152
+ if ("and" in condition || "or" in condition || "not" in condition) {
153
+ const operator = "and" in condition ? "and" : "or" in condition ? "or" : "not";
154
+ const rawChildren = condition[operator];
155
+ const children = Array.isArray(rawChildren) ? rawChildren : [rawChildren];
156
+
157
+ return (
158
+ <div className="rounded-lg border border-border bg-elevated p-4">
159
+ <div className="mb-3 flex items-center gap-2">
160
+ <Badge tone="neutral">{operator.toUpperCase()}</Badge>
161
+ <span className="text-sm text-muted">
162
+ {children.length} branch{children.length === 1 ? "" : "es"}
163
+ </span>
164
+ </div>
165
+ <div className="ml-3 space-y-3 border-l border-border pl-4">
166
+ {children.map((child, index) => (
167
+ <ConditionNode key={`${operator}-${index}`} condition={child} setKey={props.setKey} />
168
+ ))}
169
+ </div>
170
+ </div>
171
+ );
172
+ }
173
+
174
+ return <ConditionLeaf condition={condition} setKey={props.setKey} />;
175
+ }
176
+
177
+ export function ConditionTree(props: {
178
+ conditions?: Condition | Condition[] | "*";
179
+ setKey?: string;
180
+ }) {
181
+ if (!props.conditions) {
182
+ return <p className="text-sm text-muted">No conditions found.</p>;
183
+ }
184
+
185
+ if (props.conditions === "*") {
186
+ return (
187
+ <div className="rounded-lg border border-border bg-surface px-4 py-3 text-sm shadow-sm">
188
+ Everyone
189
+ </div>
190
+ );
191
+ }
192
+
193
+ const conditions = Array.isArray(props.conditions) ? props.conditions : [props.conditions];
194
+
195
+ return (
196
+ <div className="space-y-3">
197
+ {conditions.map((condition, index) => (
198
+ <ConditionNode key={index} condition={condition} setKey={props.setKey} />
199
+ ))}
200
+ </div>
201
+ );
202
+ }