@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.
- package/LICENSE +21 -0
- package/dist/assets/index-BVAfG1yl.js +25 -0
- package/dist/assets/index-BrUFr8px.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 +15 -0
- package/lib/entityTypes.js +89 -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 +1352 -0
- package/lib/node/index.js.map +1 -0
- package/lib/types.d.ts +94 -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 +60 -0
- package/src/api.ts +74 -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.ts +95 -0
- package/src/index.ts +1 -0
- package/src/main.tsx +26 -0
- package/src/node/index.spec.ts +236 -0
- package/src/node/index.ts +1888 -0
- package/src/pages/EntityDetailPage.tsx +1004 -0
- package/src/pages/HistoryPage.tsx +144 -0
- package/src/pages/HomePage.tsx +21 -0
- package/src/pages/ListPage.tsx +743 -0
- package/src/styles.css +59 -0
- package/src/types.ts +111 -0
- package/src/vite-env.d.ts +1 -0
- package/index.js +0 -1
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { useLocation, useSearchParams } from "react-router-dom";
|
|
3
|
+
|
|
4
|
+
import { EntityKey } from "./ui";
|
|
5
|
+
import { ConditionTree, GroupSegmentTree } from "./trees";
|
|
6
|
+
import { slugifyFragment, VariablePermalink, VariableValueView } from "./variables";
|
|
7
|
+
|
|
8
|
+
type VariationRecord = Record<string, unknown>;
|
|
9
|
+
type VariableOverrideRecord = {
|
|
10
|
+
value?: unknown;
|
|
11
|
+
segments?: unknown;
|
|
12
|
+
conditions?: unknown;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const VARIABLES_PARAM = "variables";
|
|
16
|
+
const OVERRIDES_PARAM = "overrides";
|
|
17
|
+
|
|
18
|
+
function setSearchParam(searchParams: URLSearchParams, key: string, value?: string) {
|
|
19
|
+
const next = new URLSearchParams(searchParams);
|
|
20
|
+
|
|
21
|
+
if (!value) {
|
|
22
|
+
next.delete(key);
|
|
23
|
+
} else {
|
|
24
|
+
next.set(key, value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return next;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseListParam(value: string | null) {
|
|
31
|
+
if (!value) {
|
|
32
|
+
return new Set<string>();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return new Set(
|
|
36
|
+
value
|
|
37
|
+
.split(",")
|
|
38
|
+
.map((item) => item.trim())
|
|
39
|
+
.filter(Boolean),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function formatListParam(values: Set<string>) {
|
|
44
|
+
if (values.size === 0) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return Array.from(values).sort().join(",");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getVariationSlug(variation: VariationRecord) {
|
|
52
|
+
return slugifyFragment(String(variation.value ?? "variation"));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getVariationVariableId(variationSlug: string, variableName: string) {
|
|
56
|
+
return `${variationSlug}-variables-${slugifyFragment(variableName)}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getVariationOverrideId(variationSlug: string, variableName: string) {
|
|
60
|
+
return `${variationSlug}-overrides-${slugifyFragment(variableName)}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseVariationHash(hash: string) {
|
|
64
|
+
const variablesMatch = hash.match(/^(.+)-variables-(.+)$/);
|
|
65
|
+
|
|
66
|
+
if (variablesMatch) {
|
|
67
|
+
return {
|
|
68
|
+
variationSlug: variablesMatch[1],
|
|
69
|
+
section: VARIABLES_PARAM as typeof VARIABLES_PARAM,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const overridesMatch = hash.match(/^(.+)-overrides-(.+)$/);
|
|
74
|
+
|
|
75
|
+
if (overridesMatch) {
|
|
76
|
+
return {
|
|
77
|
+
variationSlug: overridesMatch[1],
|
|
78
|
+
section: OVERRIDES_PARAM as typeof OVERRIDES_PARAM,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function useScrollToHash(dependencies: React.DependencyList) {
|
|
86
|
+
React.useEffect(() => {
|
|
87
|
+
if (typeof window === "undefined" || !window.location.hash) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const targetId = decodeURIComponent(window.location.hash.slice(1));
|
|
92
|
+
|
|
93
|
+
if (!targetId) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const frame = window.requestAnimationFrame(() => {
|
|
98
|
+
const targetElement = document.getElementById(targetId);
|
|
99
|
+
|
|
100
|
+
if (!targetElement) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
targetElement.scrollIntoView({ block: "start" });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return () => {
|
|
108
|
+
window.cancelAnimationFrame(frame);
|
|
109
|
+
};
|
|
110
|
+
}, dependencies);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function useExpandSectionsFromHash(props: {
|
|
114
|
+
variations: VariationRecord[];
|
|
115
|
+
searchParams: URLSearchParams;
|
|
116
|
+
setSearchParams: (params: URLSearchParams) => void;
|
|
117
|
+
}) {
|
|
118
|
+
const location = useLocation();
|
|
119
|
+
|
|
120
|
+
React.useEffect(() => {
|
|
121
|
+
const hash = decodeURIComponent(location.hash.slice(1));
|
|
122
|
+
const parsed = parseVariationHash(hash);
|
|
123
|
+
|
|
124
|
+
if (!parsed) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const variationExists = props.variations.some(
|
|
129
|
+
(variation) => getVariationSlug(variation) === parsed.variationSlug,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
if (!variationExists) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const expanded = parseListParam(props.searchParams.get(parsed.section));
|
|
137
|
+
|
|
138
|
+
if (expanded.has(parsed.variationSlug)) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const next = new Set(expanded);
|
|
143
|
+
next.add(parsed.variationSlug);
|
|
144
|
+
props.setSearchParams(
|
|
145
|
+
setSearchParam(props.searchParams, parsed.section, formatListParam(next)),
|
|
146
|
+
);
|
|
147
|
+
}, [location.hash, props.searchParams, props.setSearchParams, props.variations]);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function SectionCaret(props: { expanded: boolean }) {
|
|
151
|
+
return (
|
|
152
|
+
<svg
|
|
153
|
+
aria-hidden="true"
|
|
154
|
+
viewBox="0 0 12 12"
|
|
155
|
+
fill="none"
|
|
156
|
+
className={[
|
|
157
|
+
"h-3 w-3 shrink-0 text-muted transition-transform",
|
|
158
|
+
props.expanded ? "rotate-180" : "",
|
|
159
|
+
].join(" ")}
|
|
160
|
+
>
|
|
161
|
+
<path
|
|
162
|
+
d="M3.25 4.5 6 7.25 8.75 4.5"
|
|
163
|
+
stroke="currentColor"
|
|
164
|
+
strokeWidth="1.5"
|
|
165
|
+
strokeLinecap="round"
|
|
166
|
+
strokeLinejoin="round"
|
|
167
|
+
/>
|
|
168
|
+
</svg>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function CollapsibleSection(props: {
|
|
173
|
+
title: string;
|
|
174
|
+
count: number;
|
|
175
|
+
expanded: boolean;
|
|
176
|
+
onToggle: () => void;
|
|
177
|
+
children: React.ReactNode;
|
|
178
|
+
}) {
|
|
179
|
+
return (
|
|
180
|
+
<div className="mt-4 border-t border-border/70 pt-4">
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
className="flex w-full items-center gap-2 rounded-md py-0.5 text-left hover:text-text"
|
|
184
|
+
onClick={props.onToggle}
|
|
185
|
+
aria-expanded={props.expanded}
|
|
186
|
+
>
|
|
187
|
+
<SectionCaret expanded={props.expanded} />
|
|
188
|
+
<span className="text-xs font-semibold uppercase tracking-wide text-muted">
|
|
189
|
+
{props.title}
|
|
190
|
+
<span className="ml-1.5 font-medium text-faint">({props.count})</span>
|
|
191
|
+
</span>
|
|
192
|
+
</button>
|
|
193
|
+
{props.expanded ? (
|
|
194
|
+
<div className="mt-3 border-l border-border/80 pl-4">{props.children}</div>
|
|
195
|
+
) : null}
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function VariationRollout(props: { value: number }) {
|
|
201
|
+
const value = Math.max(0, Math.min(100, props.value));
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<div className="flex min-w-0 max-w-xs shrink-0 items-center gap-2">
|
|
205
|
+
<span className="w-10 shrink-0 text-right text-xs font-semibold text-muted">{value}%</span>
|
|
206
|
+
<div className="h-2 min-w-0 flex-1 overflow-hidden rounded-full bg-pill">
|
|
207
|
+
<div className="h-full rounded-full bg-primary" style={{ width: `${value}%` }} />
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function VariationValue(props: { value: unknown }) {
|
|
214
|
+
if (typeof props.value === "string") {
|
|
215
|
+
return (
|
|
216
|
+
<span className="font-mono text-base font-semibold text-text [overflow-wrap:anywhere]">
|
|
217
|
+
{props.value}
|
|
218
|
+
</span>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return <VariableValueView value={props.value} />;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function VariableAssignment(props: { id: string; name: string; value: unknown }) {
|
|
226
|
+
return (
|
|
227
|
+
<div id={props.id} className="group scroll-mt-6 py-3 first:pt-0 last:pb-0">
|
|
228
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
229
|
+
<div className="min-w-0 font-mono text-sm font-semibold text-text">
|
|
230
|
+
<a
|
|
231
|
+
href={`#${props.id}`}
|
|
232
|
+
className="text-text no-underline hover:text-primary [overflow-wrap:anywhere]"
|
|
233
|
+
>
|
|
234
|
+
<EntityKey value={props.name} className="font-semibold" />
|
|
235
|
+
</a>
|
|
236
|
+
</div>
|
|
237
|
+
<VariablePermalink targetId={props.id} />
|
|
238
|
+
</div>
|
|
239
|
+
<div className="mt-1.5">
|
|
240
|
+
<VariableValueView value={props.value} nested />
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function VariableOverrideEntry(props: { override: VariableOverrideRecord; setKey?: string }) {
|
|
247
|
+
const hasSegments = props.override.segments !== undefined;
|
|
248
|
+
const hasConditions = props.override.conditions !== undefined;
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<div className="rounded-md bg-elevated/60 px-3 py-3">
|
|
252
|
+
{hasSegments && (
|
|
253
|
+
<div className="mb-3">
|
|
254
|
+
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-faint">
|
|
255
|
+
Segments
|
|
256
|
+
</div>
|
|
257
|
+
<GroupSegmentTree segments={props.override.segments as any} setKey={props.setKey} />
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
{hasConditions && (
|
|
261
|
+
<div className="mb-3">
|
|
262
|
+
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-faint">
|
|
263
|
+
Conditions
|
|
264
|
+
</div>
|
|
265
|
+
<ConditionTree conditions={props.override.conditions as any} setKey={props.setKey} />
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
<div>
|
|
269
|
+
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-faint">
|
|
270
|
+
Value
|
|
271
|
+
</div>
|
|
272
|
+
<VariableValueView value={props.override.value} nested />
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function VariableOverrideGroup(props: {
|
|
279
|
+
id: string;
|
|
280
|
+
name: string;
|
|
281
|
+
overrides: VariableOverrideRecord[];
|
|
282
|
+
setKey?: string;
|
|
283
|
+
}) {
|
|
284
|
+
return (
|
|
285
|
+
<div id={props.id} className="group scroll-mt-6 py-3 first:pt-0 last:pb-0">
|
|
286
|
+
<div className="mb-2 flex min-w-0 items-center gap-2">
|
|
287
|
+
<div className="min-w-0 font-mono text-sm font-semibold text-text">
|
|
288
|
+
<a
|
|
289
|
+
href={`#${props.id}`}
|
|
290
|
+
className="text-text no-underline hover:text-primary [overflow-wrap:anywhere]"
|
|
291
|
+
>
|
|
292
|
+
<EntityKey value={props.name} className="font-semibold" />
|
|
293
|
+
</a>
|
|
294
|
+
</div>
|
|
295
|
+
<VariablePermalink targetId={props.id} label="Link to this override" />
|
|
296
|
+
</div>
|
|
297
|
+
<div className="space-y-2">
|
|
298
|
+
{props.overrides.map((override, index) => (
|
|
299
|
+
<VariableOverrideEntry key={index} override={override} setKey={props.setKey} />
|
|
300
|
+
))}
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function sortedRecordEntries(value: Record<string, unknown> | undefined) {
|
|
307
|
+
if (!value || typeof value !== "object") {
|
|
308
|
+
return [];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return Object.entries(value).sort(([left], [right]) => left.localeCompare(right));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function VariationCard(props: {
|
|
315
|
+
variation: VariationRecord;
|
|
316
|
+
variationSlug: string;
|
|
317
|
+
variablesExpanded: boolean;
|
|
318
|
+
overridesExpanded: boolean;
|
|
319
|
+
onToggleVariables: () => void;
|
|
320
|
+
onToggleOverrides: () => void;
|
|
321
|
+
setKey?: string;
|
|
322
|
+
}) {
|
|
323
|
+
const variation = props.variation;
|
|
324
|
+
const variables = sortedRecordEntries(variation.variables as Record<string, unknown> | undefined);
|
|
325
|
+
const variableOverrides = sortedRecordEntries(
|
|
326
|
+
variation.variableOverrides as Record<string, unknown> | undefined,
|
|
327
|
+
);
|
|
328
|
+
const description =
|
|
329
|
+
typeof variation.description === "string" && variation.description.trim().length > 0
|
|
330
|
+
? variation.description.trim()
|
|
331
|
+
: undefined;
|
|
332
|
+
|
|
333
|
+
return (
|
|
334
|
+
<section className="rounded-lg border border-border bg-surface p-4 shadow-sm">
|
|
335
|
+
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
|
336
|
+
<div className="min-w-0">
|
|
337
|
+
<div className="text-xs font-semibold uppercase tracking-wide text-faint">Value</div>
|
|
338
|
+
<div className="mt-1">
|
|
339
|
+
<VariationValue value={variation.value} />
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
{typeof variation.weight === "number" && <VariationRollout value={variation.weight} />}
|
|
343
|
+
</div>
|
|
344
|
+
|
|
345
|
+
{description && <p className="mt-3 text-sm text-muted">{description}</p>}
|
|
346
|
+
|
|
347
|
+
{variables.length > 0 && (
|
|
348
|
+
<CollapsibleSection
|
|
349
|
+
title="Variables"
|
|
350
|
+
count={variables.length}
|
|
351
|
+
expanded={props.variablesExpanded}
|
|
352
|
+
onToggle={props.onToggleVariables}
|
|
353
|
+
>
|
|
354
|
+
<div className="divide-y divide-border/50">
|
|
355
|
+
{variables.map(([name, value]) => (
|
|
356
|
+
<VariableAssignment
|
|
357
|
+
key={name}
|
|
358
|
+
id={getVariationVariableId(props.variationSlug, name)}
|
|
359
|
+
name={name}
|
|
360
|
+
value={value}
|
|
361
|
+
/>
|
|
362
|
+
))}
|
|
363
|
+
</div>
|
|
364
|
+
</CollapsibleSection>
|
|
365
|
+
)}
|
|
366
|
+
|
|
367
|
+
{variableOverrides.length > 0 && (
|
|
368
|
+
<CollapsibleSection
|
|
369
|
+
title="Variable overrides"
|
|
370
|
+
count={variableOverrides.length}
|
|
371
|
+
expanded={props.overridesExpanded}
|
|
372
|
+
onToggle={props.onToggleOverrides}
|
|
373
|
+
>
|
|
374
|
+
<div className="divide-y divide-border/50">
|
|
375
|
+
{variableOverrides.map(([name, overrides]) => (
|
|
376
|
+
<VariableOverrideGroup
|
|
377
|
+
key={name}
|
|
378
|
+
id={getVariationOverrideId(props.variationSlug, name)}
|
|
379
|
+
name={name}
|
|
380
|
+
overrides={Array.isArray(overrides) ? (overrides as VariableOverrideRecord[]) : []}
|
|
381
|
+
setKey={props.setKey}
|
|
382
|
+
/>
|
|
383
|
+
))}
|
|
384
|
+
</div>
|
|
385
|
+
</CollapsibleSection>
|
|
386
|
+
)}
|
|
387
|
+
</section>
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function FeatureVariationsList(props: { variations: VariationRecord[]; setKey?: string }) {
|
|
392
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
393
|
+
const location = useLocation();
|
|
394
|
+
const expandedVariables = React.useMemo(
|
|
395
|
+
() => parseListParam(searchParams.get(VARIABLES_PARAM)),
|
|
396
|
+
[searchParams],
|
|
397
|
+
);
|
|
398
|
+
const expandedOverrides = React.useMemo(
|
|
399
|
+
() => parseListParam(searchParams.get(OVERRIDES_PARAM)),
|
|
400
|
+
[searchParams],
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
useExpandSectionsFromHash({
|
|
404
|
+
variations: props.variations,
|
|
405
|
+
searchParams,
|
|
406
|
+
setSearchParams,
|
|
407
|
+
});
|
|
408
|
+
useScrollToHash([props.variations.length, searchParams.toString(), location.hash]);
|
|
409
|
+
|
|
410
|
+
if (props.variations.length === 0) {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function toggleSection(param: string, slug: string, expanded: Set<string>) {
|
|
415
|
+
const next = new Set(expanded);
|
|
416
|
+
|
|
417
|
+
if (next.has(slug)) {
|
|
418
|
+
next.delete(slug);
|
|
419
|
+
} else {
|
|
420
|
+
next.add(slug);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
setSearchParams(setSearchParam(searchParams, param, formatListParam(next)));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return (
|
|
427
|
+
<div className="space-y-4">
|
|
428
|
+
{props.variations.map((variation, index) => {
|
|
429
|
+
const variationSlug = getVariationSlug(variation);
|
|
430
|
+
|
|
431
|
+
return (
|
|
432
|
+
<VariationCard
|
|
433
|
+
key={String(variation.value ?? index)}
|
|
434
|
+
variation={variation}
|
|
435
|
+
variationSlug={variationSlug}
|
|
436
|
+
variablesExpanded={expandedVariables.has(variationSlug)}
|
|
437
|
+
overridesExpanded={expandedOverrides.has(variationSlug)}
|
|
438
|
+
onToggleVariables={() =>
|
|
439
|
+
toggleSection(VARIABLES_PARAM, variationSlug, expandedVariables)
|
|
440
|
+
}
|
|
441
|
+
onToggleOverrides={() =>
|
|
442
|
+
toggleSection(OVERRIDES_PARAM, variationSlug, expandedOverrides)
|
|
443
|
+
}
|
|
444
|
+
setKey={props.setKey}
|
|
445
|
+
/>
|
|
446
|
+
);
|
|
447
|
+
})}
|
|
448
|
+
</div>
|
|
449
|
+
);
|
|
450
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import type { CatalogManifest } from "../types";
|
|
4
|
+
|
|
5
|
+
interface CatalogContextValue {
|
|
6
|
+
manifest: CatalogManifest;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const CatalogContext = React.createContext<CatalogContextValue | null>(null);
|
|
10
|
+
|
|
11
|
+
export function CatalogProvider(props: {
|
|
12
|
+
children: React.ReactNode;
|
|
13
|
+
initialManifest: CatalogManifest;
|
|
14
|
+
}) {
|
|
15
|
+
return (
|
|
16
|
+
<CatalogContext.Provider value={{ manifest: props.initialManifest }}>
|
|
17
|
+
{props.children}
|
|
18
|
+
</CatalogContext.Provider>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useCatalog() {
|
|
23
|
+
const context = React.useContext(CatalogContext);
|
|
24
|
+
|
|
25
|
+
if (!context) {
|
|
26
|
+
throw new Error("useCatalog must be used inside CatalogProvider.");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return context;
|
|
30
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { CatalogEntityType, EntityPath } from "./types";
|
|
2
|
+
|
|
3
|
+
export const entityPaths: EntityPath[] = [
|
|
4
|
+
"features",
|
|
5
|
+
"segments",
|
|
6
|
+
"attributes",
|
|
7
|
+
"targets",
|
|
8
|
+
"groups",
|
|
9
|
+
"schemas",
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export const entityPathToType: Record<EntityPath, CatalogEntityType> = {
|
|
13
|
+
features: "feature",
|
|
14
|
+
segments: "segment",
|
|
15
|
+
attributes: "attribute",
|
|
16
|
+
targets: "target",
|
|
17
|
+
groups: "group",
|
|
18
|
+
schemas: "schema",
|
|
19
|
+
tests: "test",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const entityTypeToPath: Record<CatalogEntityType, EntityPath> = {
|
|
23
|
+
feature: "features",
|
|
24
|
+
segment: "segments",
|
|
25
|
+
attribute: "attributes",
|
|
26
|
+
target: "targets",
|
|
27
|
+
group: "groups",
|
|
28
|
+
schema: "schemas",
|
|
29
|
+
test: "tests",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const entityLabels: Record<CatalogEntityType, { singular: string; plural: string }> = {
|
|
33
|
+
feature: { singular: "Feature", plural: "Features" },
|
|
34
|
+
segment: { singular: "Segment", plural: "Segments" },
|
|
35
|
+
attribute: { singular: "Attribute", plural: "Attributes" },
|
|
36
|
+
target: { singular: "Target", plural: "Targets" },
|
|
37
|
+
group: { singular: "Group", plural: "Groups" },
|
|
38
|
+
schema: { singular: "Schema", plural: "Schemas" },
|
|
39
|
+
test: { singular: "Test", plural: "Tests" },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function encodeRouteSegment(value: string) {
|
|
43
|
+
return encodeURIComponent(value).replace(/%2F/gi, "%252F");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function decodeRouteSegment(value: string) {
|
|
47
|
+
try {
|
|
48
|
+
return decodeURIComponent(value);
|
|
49
|
+
} catch {
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function encodeDataSegment(value: string) {
|
|
55
|
+
return encodeURIComponent(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getBasePath(setKey?: string) {
|
|
59
|
+
return setKey ? `/sets/${encodeRouteSegment(setKey)}` : "";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getEntityRoute(type: CatalogEntityType, key: string, setKey?: string) {
|
|
63
|
+
return `${getBasePath(setKey)}/${entityTypeToPath[type]}/${encodeRouteSegment(key)}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getDataBasePath(setKey?: string) {
|
|
67
|
+
return setKey ? `/data/sets/${encodeDataSegment(setKey)}` : "/data/root";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getSetSortGroup(value: string) {
|
|
71
|
+
const normalized = value.toLowerCase();
|
|
72
|
+
|
|
73
|
+
if (normalized.startsWith("dev")) {
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (normalized.startsWith("prod")) {
|
|
78
|
+
return 2;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function sortSetKeys(setKeys: string[]) {
|
|
85
|
+
return setKeys.slice().sort((left, right) => {
|
|
86
|
+
const leftGroup = getSetSortGroup(left);
|
|
87
|
+
const rightGroup = getSetSortGroup(right);
|
|
88
|
+
|
|
89
|
+
if (leftGroup !== rightGroup) {
|
|
90
|
+
return leftGroup - rightGroup;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return left.localeCompare(right);
|
|
94
|
+
});
|
|
95
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./node";
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { BrowserRouter, HashRouter } from "react-router-dom";
|
|
4
|
+
|
|
5
|
+
import { fetchManifest, setCatalogRouterMode } from "./api";
|
|
6
|
+
import { App } from "./App";
|
|
7
|
+
import "./styles.css";
|
|
8
|
+
|
|
9
|
+
const root = createRoot(document.getElementById("root") as HTMLElement);
|
|
10
|
+
|
|
11
|
+
fetchManifest()
|
|
12
|
+
.then((manifest) => {
|
|
13
|
+
setCatalogRouterMode(manifest.router);
|
|
14
|
+
const Router = manifest.router === "hash" ? HashRouter : BrowserRouter;
|
|
15
|
+
|
|
16
|
+
root.render(
|
|
17
|
+
<React.StrictMode>
|
|
18
|
+
<Router>
|
|
19
|
+
<App manifest={manifest} />
|
|
20
|
+
</Router>
|
|
21
|
+
</React.StrictMode>,
|
|
22
|
+
);
|
|
23
|
+
})
|
|
24
|
+
.catch((error: Error) => {
|
|
25
|
+
root.render(<div className="p-8 text-danger">{error.message}</div>);
|
|
26
|
+
});
|