@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.
- package/LICENSE +21 -0
- package/dist/assets/index-DB7fEbI6.js +25 -0
- package/dist/assets/index-DgA0Oqrg.css +2 -0
- package/dist/favicon.png +0 -0
- package/dist/index.html +14 -0
- package/dist/logo-text.png +0 -0
- package/lib/entityTypes.d.ts +16 -0
- package/lib/entityTypes.js +96 -0
- package/lib/entityTypes.js.map +1 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +18 -0
- package/lib/index.js.map +1 -0
- package/lib/node/index.d.ts +74 -0
- package/lib/node/index.js +1368 -0
- package/lib/node/index.js.map +1 -0
- package/lib/types.d.ts +95 -0
- package/lib/types.js +3 -0
- package/lib/types.js.map +1 -0
- package/package.json +66 -13
- package/public/favicon.png +0 -0
- package/public/logo-text.png +0 -0
- package/src/App.tsx +62 -0
- package/src/api.spec.ts +14 -0
- package/src/api.ts +74 -0
- package/src/components/tests.spec.ts +134 -0
- package/src/components/tests.tsx +301 -0
- package/src/components/trees.tsx +202 -0
- package/src/components/ui.tsx +660 -0
- package/src/components/variables.tsx +700 -0
- package/src/components/variations.tsx +450 -0
- package/src/context/CatalogContext.tsx +30 -0
- package/src/entityTypes.spec.ts +13 -0
- package/src/entityTypes.ts +102 -0
- package/src/index.ts +1 -0
- package/src/main.tsx +26 -0
- package/src/node/index.spec.ts +380 -0
- package/src/node/index.ts +1906 -0
- package/src/pages/EntityDetailPage.tsx +1144 -0
- package/src/pages/HistoryPage.tsx +144 -0
- package/src/pages/HomePage.tsx +21 -0
- package/src/pages/ListPage.tsx +737 -0
- package/src/styles.css +59 -0
- package/src/testModel.ts +123 -0
- package/src/types.ts +112 -0
- package/src/vite-env.d.ts +1 -0
- 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
|
+
}
|