@featurevisor/catalog 3.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/dist/assets/index-DB7fEbI6.js +25 -0
- package/dist/assets/index-DgA0Oqrg.css +2 -0
- package/dist/index.html +2 -2
- package/lib/entityTypes.d.ts +1 -0
- package/lib/entityTypes.js +7 -0
- package/lib/entityTypes.js.map +1 -1
- package/lib/node/index.js +22 -6
- package/lib/node/index.js.map +1 -1
- package/lib/types.d.ts +2 -1
- package/package.json +2 -2
- package/src/App.tsx +2 -0
- package/src/api.spec.ts +14 -0
- package/src/api.ts +2 -2
- package/src/components/tests.spec.ts +134 -0
- package/src/components/tests.tsx +301 -0
- package/src/components/ui.tsx +11 -11
- package/src/entityTypes.spec.ts +13 -0
- package/src/entityTypes.ts +7 -0
- package/src/node/index.spec.ts +147 -3
- package/src/node/index.ts +24 -6
- package/src/pages/EntityDetailPage.tsx +140 -0
- package/src/pages/ListPage.tsx +15 -21
- package/src/testModel.ts +123 -0
- package/src/types.ts +2 -1
- package/dist/assets/index-BVAfG1yl.js +0 -25
- package/dist/assets/index-BrUFr8px.css +0 -2
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { TestFeature, TestSegment } from "@featurevisor/types";
|
|
2
|
+
|
|
3
|
+
import { expandTestAssertions, getTestAssertionPermalink } from "../testModel";
|
|
4
|
+
|
|
5
|
+
describe("catalog test presentation", () => {
|
|
6
|
+
it("keeps authored assertions without matrices as one numbered assertion", () => {
|
|
7
|
+
const test: TestFeature = {
|
|
8
|
+
key: "checkout-primary",
|
|
9
|
+
feature: "checkout",
|
|
10
|
+
assertions: [
|
|
11
|
+
{
|
|
12
|
+
environment: "staging",
|
|
13
|
+
context: { country: "nl" },
|
|
14
|
+
expectedToBeEnabled: true,
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
expect(expandTestAssertions(test)).toEqual([
|
|
20
|
+
expect.objectContaining({
|
|
21
|
+
assertionIndex: 0,
|
|
22
|
+
label: "1",
|
|
23
|
+
assertion: test.assertions[0],
|
|
24
|
+
}),
|
|
25
|
+
]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("expands feature matrices in deterministic order with dotted case numbers", () => {
|
|
29
|
+
const test: TestFeature = {
|
|
30
|
+
key: "checkout-matrix",
|
|
31
|
+
feature: "checkout",
|
|
32
|
+
assertions: [
|
|
33
|
+
{
|
|
34
|
+
environment: "staging",
|
|
35
|
+
context: {},
|
|
36
|
+
expectedToBeEnabled: true,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
matrix: {
|
|
40
|
+
environment: ["staging", "production"],
|
|
41
|
+
at: [10, 90],
|
|
42
|
+
country: ["nl"],
|
|
43
|
+
},
|
|
44
|
+
description: "${{ country }} in ${{ environment }} at ${{ at }}%",
|
|
45
|
+
environment: "${{ environment }}",
|
|
46
|
+
target: "${{ environment }}-web",
|
|
47
|
+
at: "${{ at }}" as never,
|
|
48
|
+
context: {
|
|
49
|
+
country: "${{ country }}",
|
|
50
|
+
label: "${{ country }}-${{ environment }}",
|
|
51
|
+
},
|
|
52
|
+
expectedToBeEnabled: true,
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const expanded = expandTestAssertions(test);
|
|
58
|
+
|
|
59
|
+
expect(expanded.map((item) => item.label)).toEqual(["1", "2.1", "2.2", "2.3", "2.4"]);
|
|
60
|
+
expect(expanded[1]).toMatchObject({
|
|
61
|
+
assertionIndex: 1,
|
|
62
|
+
caseIndex: 0,
|
|
63
|
+
caseCount: 4,
|
|
64
|
+
matrixValues: { environment: "staging", at: 10, country: "nl" },
|
|
65
|
+
assertion: {
|
|
66
|
+
description: "nl in staging at 10%",
|
|
67
|
+
environment: "staging",
|
|
68
|
+
target: "staging-web",
|
|
69
|
+
at: 10,
|
|
70
|
+
context: { country: "nl", label: "nl-staging" },
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
expect(expanded[4]).toMatchObject({
|
|
74
|
+
matrixValues: { environment: "production", at: 90, country: "nl" },
|
|
75
|
+
assertion: { environment: "production", at: 90 },
|
|
76
|
+
});
|
|
77
|
+
expect("matrix" in expanded[1].assertion).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("expands segment contexts and descriptions without changing expectations", () => {
|
|
81
|
+
const test: TestSegment = {
|
|
82
|
+
key: "countries-germany",
|
|
83
|
+
segment: "countries.germany",
|
|
84
|
+
assertions: [
|
|
85
|
+
{
|
|
86
|
+
matrix: { country: ["de"], city: ["berlin", "hamburg"] },
|
|
87
|
+
description: "${{ city }}, ${{ country }}",
|
|
88
|
+
context: { country: "${{ country }}", city: "${{ city }}" },
|
|
89
|
+
expectedToMatch: false,
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
expect(expandTestAssertions(test)).toEqual([
|
|
95
|
+
expect.objectContaining({
|
|
96
|
+
label: "1.1",
|
|
97
|
+
assertion: expect.objectContaining({
|
|
98
|
+
description: "berlin, de",
|
|
99
|
+
context: { country: "de", city: "berlin" },
|
|
100
|
+
expectedToMatch: false,
|
|
101
|
+
}),
|
|
102
|
+
}),
|
|
103
|
+
expect.objectContaining({
|
|
104
|
+
label: "1.2",
|
|
105
|
+
assertion: expect.objectContaining({
|
|
106
|
+
description: "hamburg, de",
|
|
107
|
+
context: { country: "de", city: "hamburg" },
|
|
108
|
+
expectedToMatch: false,
|
|
109
|
+
}),
|
|
110
|
+
}),
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("uses the test key and dotted case number for stable permalink identities", () => {
|
|
115
|
+
expect(getTestAssertionPermalink("features/checkout/redesign.spec", "2.3")).toBe(
|
|
116
|
+
"features/checkout/redesign.spec:2.3",
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("matches the tester by producing no cases for an empty matrix", () => {
|
|
121
|
+
const test: TestSegment = {
|
|
122
|
+
segment: "everyone",
|
|
123
|
+
assertions: [
|
|
124
|
+
{
|
|
125
|
+
matrix: {},
|
|
126
|
+
context: {},
|
|
127
|
+
expectedToMatch: true,
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
expect(expandTestAssertions(test)).toEqual([]);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -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
|
+
}
|
package/src/components/ui.tsx
CHANGED
|
@@ -123,18 +123,14 @@ export function LabelValueBadge(props: {
|
|
|
123
123
|
{props.value}
|
|
124
124
|
</Link>
|
|
125
125
|
) : (
|
|
126
|
-
<span className=
|
|
126
|
+
<span className="font-medium text-text">{props.value}</span>
|
|
127
127
|
);
|
|
128
128
|
|
|
129
129
|
if (props.compact) {
|
|
130
130
|
return (
|
|
131
|
-
<span className="inline-flex h-
|
|
132
|
-
<span className="flex items-center bg-
|
|
133
|
-
|
|
134
|
-
</span>
|
|
135
|
-
<span className="flex items-center bg-surface pl-1.5 pr-1.5 transition-colors group-hover:bg-slate-100">
|
|
136
|
-
{valueContent}
|
|
137
|
-
</span>
|
|
131
|
+
<span className="inline-flex h-5 shrink-0 overflow-hidden rounded-md border border-border/70 text-[10px] leading-none">
|
|
132
|
+
<span className="flex items-center bg-elevated px-1.5 text-muted">{props.label}</span>
|
|
133
|
+
<span className="flex items-center bg-surface px-1.5 text-text">{valueContent}</span>
|
|
138
134
|
</span>
|
|
139
135
|
);
|
|
140
136
|
}
|
|
@@ -241,15 +237,19 @@ export function EmptyState(props: { title: string; description?: string }) {
|
|
|
241
237
|
|
|
242
238
|
export function PageHeader(props: {
|
|
243
239
|
title: React.ReactNode;
|
|
240
|
+
titleAction?: React.ReactNode;
|
|
244
241
|
description?: React.ReactNode;
|
|
245
242
|
actions?: React.ReactNode;
|
|
246
243
|
}) {
|
|
247
244
|
return (
|
|
248
245
|
<div className="mb-6 flex flex-col justify-between gap-4 border-b border-border px-6 pb-4 pt-8 md:flex-row md:items-start">
|
|
249
246
|
<div className="min-w-0 flex-1">
|
|
250
|
-
<
|
|
251
|
-
|
|
252
|
-
|
|
247
|
+
<div className="group flex min-w-0 items-center gap-2">
|
|
248
|
+
<h1 className="min-w-0 text-3xl font-black text-text [overflow-wrap:anywhere]">
|
|
249
|
+
{props.title}
|
|
250
|
+
</h1>
|
|
251
|
+
{props.titleAction ? <div className="shrink-0">{props.titleAction}</div> : null}
|
|
252
|
+
</div>
|
|
253
253
|
{props.description && (
|
|
254
254
|
<div className="mt-2 min-w-0 text-sm text-muted [overflow-wrap:anywhere]">
|
|
255
255
|
{props.description}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { decodeRouteSegment, encodeDataPath, encodeRouteSegment } from "./entityTypes";
|
|
2
|
+
|
|
3
|
+
describe("catalog entity path encoding", () => {
|
|
4
|
+
it("keeps slash-namespaced keys in one browser route segment", () => {
|
|
5
|
+
expect(encodeRouteSegment("checkout/redesign")).toBe("checkout%252Fredesign");
|
|
6
|
+
expect(decodeRouteSegment("checkout%2Fredesign")).toBe("checkout/redesign");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("maps slash-namespaced keys to nested data paths", () => {
|
|
10
|
+
expect(encodeDataPath("checkout/redesign")).toBe("checkout/redesign");
|
|
11
|
+
expect(encodeDataPath("checkout/new design")).toBe("checkout/new%20design");
|
|
12
|
+
});
|
|
13
|
+
});
|
package/src/entityTypes.ts
CHANGED
|
@@ -55,6 +55,13 @@ export function encodeDataSegment(value: string) {
|
|
|
55
55
|
return encodeURIComponent(value);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export function encodeDataPath(value: string) {
|
|
59
|
+
return value
|
|
60
|
+
.split("/")
|
|
61
|
+
.map((segment) => encodeURIComponent(segment))
|
|
62
|
+
.join("/");
|
|
63
|
+
}
|
|
64
|
+
|
|
58
65
|
export function getBasePath(setKey?: string) {
|
|
59
66
|
return setKey ? `/sets/${encodeRouteSegment(setKey)}` : "";
|
|
60
67
|
}
|
package/src/node/index.spec.ts
CHANGED
|
@@ -85,8 +85,51 @@ function createDatasource(set = "") {
|
|
|
85
85
|
readGroup: async () => undefined,
|
|
86
86
|
listSchemas: async () => [],
|
|
87
87
|
readSchema: async () => undefined,
|
|
88
|
-
listTests: async () => ["checkout"],
|
|
89
|
-
readTest: async () =>
|
|
88
|
+
listTests: async () => ["checkout-primary", "checkout-matrix", "premium-users"],
|
|
89
|
+
readTest: async (key: string) => {
|
|
90
|
+
if (key === "premium-users") {
|
|
91
|
+
return {
|
|
92
|
+
key,
|
|
93
|
+
segment: segmentKey,
|
|
94
|
+
assertions: [
|
|
95
|
+
{
|
|
96
|
+
context: { plan: "premium" },
|
|
97
|
+
expectedToMatch: true,
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (key === "checkout-matrix") {
|
|
104
|
+
return {
|
|
105
|
+
key,
|
|
106
|
+
feature: featureKey,
|
|
107
|
+
assertions: [
|
|
108
|
+
{
|
|
109
|
+
matrix: { country: ["nl", "de"], at: [10, 90] },
|
|
110
|
+
description: "${{ country }} at ${{ at }}%",
|
|
111
|
+
environment: "production",
|
|
112
|
+
at: "${{ at }}",
|
|
113
|
+
context: { country: "${{ country }}" },
|
|
114
|
+
expectedToBeEnabled: true,
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
key,
|
|
122
|
+
feature: featureKey,
|
|
123
|
+
assertions: [
|
|
124
|
+
{
|
|
125
|
+
description: "Enabled in staging",
|
|
126
|
+
environment: "staging",
|
|
127
|
+
context: { country: "nl" },
|
|
128
|
+
expectedToBeEnabled: true,
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
};
|
|
132
|
+
},
|
|
90
133
|
};
|
|
91
134
|
}
|
|
92
135
|
|
|
@@ -161,8 +204,27 @@ describe("catalog export", () => {
|
|
|
161
204
|
segments: ["premiumUsers"],
|
|
162
205
|
attributes: ["country"],
|
|
163
206
|
targets: ["premiumWeb"],
|
|
164
|
-
tests: ["checkout"],
|
|
207
|
+
tests: ["checkout-matrix", "checkout-primary"],
|
|
165
208
|
});
|
|
209
|
+
expect(detail.tests).toHaveLength(2);
|
|
210
|
+
expect(detail.tests.map((test: any) => test.key)).toEqual([
|
|
211
|
+
"checkout-matrix",
|
|
212
|
+
"checkout-primary",
|
|
213
|
+
]);
|
|
214
|
+
expect(detail.tests[0].assertions[0].matrix).toEqual({
|
|
215
|
+
country: ["nl", "de"],
|
|
216
|
+
at: [10, 90],
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const segmentDetail = JSON.parse(
|
|
220
|
+
fs.readFileSync(
|
|
221
|
+
path.join(root, "catalog", "data", "root", "entities", "segment", "premiumUsers.json"),
|
|
222
|
+
"utf8",
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
expect(segmentDetail.tests).toEqual([
|
|
226
|
+
expect.objectContaining({ key: "premium-users", segment: "premiumUsers" }),
|
|
227
|
+
]);
|
|
166
228
|
});
|
|
167
229
|
|
|
168
230
|
it("writes per-set catalog files for sets-enabled projects", async () => {
|
|
@@ -200,6 +262,88 @@ describe("catalog export", () => {
|
|
|
200
262
|
).toBe(true);
|
|
201
263
|
});
|
|
202
264
|
|
|
265
|
+
it("writes slash-namespaced entity details as nested data paths", async () => {
|
|
266
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "featurevisor-catalog-"));
|
|
267
|
+
const projectConfig = {
|
|
268
|
+
...createProjectConfig(root),
|
|
269
|
+
namespaceCharacter: "/",
|
|
270
|
+
};
|
|
271
|
+
const baseDatasource = createDatasource();
|
|
272
|
+
const featureKey = "checkout/redesign";
|
|
273
|
+
const datasource = {
|
|
274
|
+
...baseDatasource,
|
|
275
|
+
listHistoryEntries: async () => [
|
|
276
|
+
{
|
|
277
|
+
commit: "root123456789",
|
|
278
|
+
author: "Test",
|
|
279
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
280
|
+
entities: [{ type: "feature", key: featureKey }],
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
listFeatures: async () => [featureKey],
|
|
284
|
+
readFeature: async () => ({
|
|
285
|
+
...(await baseDatasource.readFeature()),
|
|
286
|
+
key: featureKey,
|
|
287
|
+
}),
|
|
288
|
+
listTests: async () => ["features/checkout/redesign.spec"],
|
|
289
|
+
readTest: async () => ({
|
|
290
|
+
key: "features/checkout/redesign.spec",
|
|
291
|
+
feature: featureKey,
|
|
292
|
+
assertions: [],
|
|
293
|
+
}),
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
await exportCatalog(createRuntime(), root, projectConfig, datasource, {
|
|
297
|
+
copyAssets: false,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const nestedDetailPath = path.join(
|
|
301
|
+
root,
|
|
302
|
+
"catalog",
|
|
303
|
+
"data",
|
|
304
|
+
"root",
|
|
305
|
+
"entities",
|
|
306
|
+
"feature",
|
|
307
|
+
"checkout",
|
|
308
|
+
"redesign.json",
|
|
309
|
+
);
|
|
310
|
+
const legacyFlatDetailPath = path.join(
|
|
311
|
+
root,
|
|
312
|
+
"catalog",
|
|
313
|
+
"data",
|
|
314
|
+
"root",
|
|
315
|
+
"entities",
|
|
316
|
+
"feature",
|
|
317
|
+
"checkout%2Fredesign.json",
|
|
318
|
+
);
|
|
319
|
+
const detail = JSON.parse(fs.readFileSync(nestedDetailPath, "utf8"));
|
|
320
|
+
const index = JSON.parse(
|
|
321
|
+
fs.readFileSync(path.join(root, "catalog", "data", "root", "index.json"), "utf8"),
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
expect(fs.existsSync(legacyFlatDetailPath)).toBe(false);
|
|
325
|
+
expect(
|
|
326
|
+
fs.existsSync(
|
|
327
|
+
path.join(
|
|
328
|
+
root,
|
|
329
|
+
"catalog",
|
|
330
|
+
"data",
|
|
331
|
+
"root",
|
|
332
|
+
"entities",
|
|
333
|
+
"feature",
|
|
334
|
+
"checkout",
|
|
335
|
+
"redesign",
|
|
336
|
+
"history",
|
|
337
|
+
"page-1.json",
|
|
338
|
+
),
|
|
339
|
+
),
|
|
340
|
+
).toBe(true);
|
|
341
|
+
expect(index.entities.feature[0].key).toBe(featureKey);
|
|
342
|
+
expect(index.entities.feature[0].href).toBe("entities/feature/checkout/redesign.json");
|
|
343
|
+
expect(detail.sourcePath).toBe("features/checkout/redesign.yml");
|
|
344
|
+
expect(detail.historyPath).toBe("data/root/entities/feature/checkout/redesign/history");
|
|
345
|
+
});
|
|
346
|
+
|
|
203
347
|
it("exports repository source links and dev editor links", async () => {
|
|
204
348
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "featurevisor-catalog-"));
|
|
205
349
|
|