@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,743 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { Link, Navigate, useParams, useSearchParams } from "react-router-dom";
|
|
4
|
+
|
|
5
|
+
import { fetchIndex } from "../api";
|
|
6
|
+
import { entityLabels, entityPathToType, getEntityRoute } from "../entityTypes";
|
|
7
|
+
import type { CatalogEntityType, CatalogIndex, EntityPath, EntitySummary } from "../types";
|
|
8
|
+
import {
|
|
9
|
+
Badge,
|
|
10
|
+
Button,
|
|
11
|
+
EmptyState,
|
|
12
|
+
EntityKey,
|
|
13
|
+
LabelValueBadge,
|
|
14
|
+
PageHeader,
|
|
15
|
+
} from "../components/ui";
|
|
16
|
+
|
|
17
|
+
const LIST_INITIAL_LIMIT = 1000;
|
|
18
|
+
|
|
19
|
+
function isEntityPath(value: string | undefined): value is EntityPath {
|
|
20
|
+
return (
|
|
21
|
+
value === "features" ||
|
|
22
|
+
value === "segments" ||
|
|
23
|
+
value === "attributes" ||
|
|
24
|
+
value === "targets" ||
|
|
25
|
+
value === "groups" ||
|
|
26
|
+
value === "schemas"
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getSortDirection(sortValue: string | null) {
|
|
31
|
+
if (!sortValue || sortValue === "name" || sortValue === "name:asc" || sortValue === "asc") {
|
|
32
|
+
return "asc";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (sortValue === "-name" || sortValue === "name:desc" || sortValue === "desc") {
|
|
36
|
+
return "desc";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return "asc";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function setSearchParam(searchParams: URLSearchParams, key: string, value?: string) {
|
|
43
|
+
const next = new URLSearchParams(searchParams);
|
|
44
|
+
|
|
45
|
+
if (!value) {
|
|
46
|
+
next.delete(key);
|
|
47
|
+
} else {
|
|
48
|
+
next.set(key, value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return next;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface ParsedSearchQuery {
|
|
55
|
+
terms: string[];
|
|
56
|
+
qualifiers: Array<{ key: string; value: string }>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseSearchQuery(query: string): ParsedSearchQuery {
|
|
60
|
+
const terms: string[] = [];
|
|
61
|
+
const qualifiers: Array<{ key: string; value: string }> = [];
|
|
62
|
+
const matcher = /(?:(\w+):"([^"]+)")|(?:(\w+):([^\s]+))|(?:"([^"]+)")|(\S+)/g;
|
|
63
|
+
let match: RegExpExecArray | null;
|
|
64
|
+
|
|
65
|
+
while ((match = matcher.exec(query))) {
|
|
66
|
+
const qualifierKey = match[1] || match[3];
|
|
67
|
+
const qualifierValue = match[2] || match[4];
|
|
68
|
+
const term = match[5] || match[6];
|
|
69
|
+
|
|
70
|
+
if (qualifierKey && qualifierValue) {
|
|
71
|
+
qualifiers.push({
|
|
72
|
+
key: qualifierKey.toLowerCase(),
|
|
73
|
+
value: qualifierValue.toLowerCase(),
|
|
74
|
+
});
|
|
75
|
+
} else if (term) {
|
|
76
|
+
terms.push(term.toLowerCase());
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { terms, qualifiers };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getFeatureSearchHints(index: CatalogIndex) {
|
|
84
|
+
const features = index.entities.feature || [];
|
|
85
|
+
const tags = sortValues(features.flatMap((feature) => feature.tags || []));
|
|
86
|
+
const environments = sortValues(features.flatMap((feature) => feature.environments || []));
|
|
87
|
+
const variationValues = sortValues(features.flatMap((feature) => feature.variationValues || []));
|
|
88
|
+
const variableKeys = sortValues(features.flatMap((feature) => feature.variableKeys || []));
|
|
89
|
+
const hasVariations = features.some((feature) => feature.hasVariations);
|
|
90
|
+
const hasNoVariations = features.some((feature) => !feature.hasVariations);
|
|
91
|
+
const hasVariables = features.some((feature) => feature.hasVariables);
|
|
92
|
+
const hasNoVariables = features.some((feature) => !feature.hasVariables);
|
|
93
|
+
|
|
94
|
+
return [
|
|
95
|
+
tags[0] ? `tag:${tags[0]}` : undefined,
|
|
96
|
+
environments[0] ? `in:${environments[0]}` : undefined,
|
|
97
|
+
"archived:false",
|
|
98
|
+
hasVariations ? "with:variations" : undefined,
|
|
99
|
+
hasNoVariations ? "without:variations" : undefined,
|
|
100
|
+
variationValues[0] ? `variation:${variationValues[0]}` : undefined,
|
|
101
|
+
hasVariables ? "with:variables" : undefined,
|
|
102
|
+
hasNoVariables ? "without:variables" : undefined,
|
|
103
|
+
variableKeys[0] ? `variable:${variableKeys[0]}` : undefined,
|
|
104
|
+
].filter((hint): hint is string => Boolean(hint));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function listIncludes(values: string[] | undefined, value: string) {
|
|
108
|
+
return (values || []).some((item) => item.toLowerCase().includes(value));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function matchesFeatureQualifier(entity: EntitySummary, key: string, value: string) {
|
|
112
|
+
if (key === "tag") {
|
|
113
|
+
return listIncludes(entity.tags, value);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (key === "in") {
|
|
117
|
+
return listIncludes(entity.environments, value);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (key === "archived") {
|
|
121
|
+
return String(Boolean(entity.archived)) === value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (key === "with") {
|
|
125
|
+
if (value === "variations") return Boolean(entity.hasVariations);
|
|
126
|
+
if (value === "variables") return Boolean(entity.hasVariables);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (key === "without") {
|
|
130
|
+
if (value === "variations") return !entity.hasVariations;
|
|
131
|
+
if (value === "variables") return !entity.hasVariables;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (key === "variation") {
|
|
135
|
+
return listIncludes(entity.variationValues, value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (key === "variable") {
|
|
139
|
+
return listIncludes(entity.variableKeys, value);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function matchesQuery(entity: EntitySummary, query: string, entityPath: EntityPath) {
|
|
146
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
147
|
+
|
|
148
|
+
if (!normalizedQuery) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (entityPath !== "features") {
|
|
153
|
+
return normalizedQuery.split(/\s+/).every((term) => {
|
|
154
|
+
const haystack = [
|
|
155
|
+
entity.key,
|
|
156
|
+
entity.description || "",
|
|
157
|
+
...(entity.tags || []),
|
|
158
|
+
...(entity.targets || []),
|
|
159
|
+
...(entity.environments || []),
|
|
160
|
+
]
|
|
161
|
+
.join(" ")
|
|
162
|
+
.toLowerCase();
|
|
163
|
+
|
|
164
|
+
return haystack.includes(term);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const parsedQuery = parseSearchQuery(normalizedQuery);
|
|
169
|
+
|
|
170
|
+
if (
|
|
171
|
+
entityPath === "features" &&
|
|
172
|
+
!parsedQuery.qualifiers.every((qualifier) =>
|
|
173
|
+
matchesFeatureQualifier(entity, qualifier.key, qualifier.value),
|
|
174
|
+
)
|
|
175
|
+
) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const haystack = [
|
|
180
|
+
entity.key,
|
|
181
|
+
entity.description || "",
|
|
182
|
+
...(entity.tags || []),
|
|
183
|
+
...(entity.targets || []),
|
|
184
|
+
...(entity.environments || []),
|
|
185
|
+
]
|
|
186
|
+
.join(" ")
|
|
187
|
+
.toLowerCase();
|
|
188
|
+
|
|
189
|
+
return parsedQuery.terms.every((term) => haystack.includes(term));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function getStatusBadges(entity: EntitySummary) {
|
|
193
|
+
return (
|
|
194
|
+
<div className="flex flex-wrap gap-2">
|
|
195
|
+
{entity.archived && <Badge tone="danger">archived</Badge>}
|
|
196
|
+
{entity.deprecated && <Badge tone="warning">deprecated</Badge>}
|
|
197
|
+
{entity.promotable === false && <Badge>not promotable</Badge>}
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function RowTrailingMeta(props: { entity: EntitySummary; type: CatalogEntityType }) {
|
|
203
|
+
const icons = <RowMetadataIcons entity={props.entity} type={props.type} />;
|
|
204
|
+
const usedInBadge =
|
|
205
|
+
props.type === "segment" && (props.entity.usedInFeatureCount ?? 0) > 0 ? (
|
|
206
|
+
<LabelValueBadge
|
|
207
|
+
compact
|
|
208
|
+
label="Used in"
|
|
209
|
+
value={`${props.entity.usedInFeatureCount} ${props.entity.usedInFeatureCount === 1 ? "feature" : "features"}`}
|
|
210
|
+
/>
|
|
211
|
+
) : props.type === "attribute" && (props.entity.usedInSegmentCount ?? 0) > 0 ? (
|
|
212
|
+
<LabelValueBadge
|
|
213
|
+
compact
|
|
214
|
+
label="Used in"
|
|
215
|
+
value={`${props.entity.usedInSegmentCount} ${props.entity.usedInSegmentCount === 1 ? "segment" : "segments"}`}
|
|
216
|
+
/>
|
|
217
|
+
) : null;
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<>
|
|
221
|
+
{getStatusBadges(props.entity)}
|
|
222
|
+
{usedInBadge ? (
|
|
223
|
+
<div className="flex items-center gap-1">
|
|
224
|
+
{usedInBadge}
|
|
225
|
+
{icons}
|
|
226
|
+
</div>
|
|
227
|
+
) : (
|
|
228
|
+
icons
|
|
229
|
+
)}
|
|
230
|
+
</>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function ListRow(props: { entity: EntitySummary; type: CatalogEntityType; setKey?: string }) {
|
|
235
|
+
const { entity, type, setKey } = props;
|
|
236
|
+
const description = entity.description || "No description";
|
|
237
|
+
const trailingMeta = <RowTrailingMeta entity={entity} type={type} />;
|
|
238
|
+
const lastModified = (
|
|
239
|
+
<span className="text-right text-[11px] text-faint whitespace-nowrap">
|
|
240
|
+
<LastModified entity={entity} />
|
|
241
|
+
</span>
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
if (type === "feature") {
|
|
245
|
+
return (
|
|
246
|
+
<Link
|
|
247
|
+
to={getEntityRoute(type, entity.key, setKey)}
|
|
248
|
+
className="group block px-6 py-3 hover:bg-elevated"
|
|
249
|
+
>
|
|
250
|
+
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] grid-rows-[auto_auto] gap-x-3 gap-y-1">
|
|
251
|
+
<div className="col-start-1 row-start-1 flex h-6 shrink-0 items-center justify-center">
|
|
252
|
+
<EnvironmentDot
|
|
253
|
+
status={entity.environmentStatus}
|
|
254
|
+
environment={entity.environmentStatusEnvironment}
|
|
255
|
+
/>
|
|
256
|
+
</div>
|
|
257
|
+
<div className="col-start-2 row-start-1 flex min-h-6 min-w-0 items-center">
|
|
258
|
+
<EntityKey value={entity.key} className="text-sm font-semibold text-primary" />
|
|
259
|
+
</div>
|
|
260
|
+
<div className="col-start-3 row-start-1 flex min-h-6 w-full items-center justify-end gap-2">
|
|
261
|
+
{trailingMeta}
|
|
262
|
+
</div>
|
|
263
|
+
<div className="col-start-2 row-start-2 flex min-h-5 min-w-0 items-center overflow-hidden">
|
|
264
|
+
<span className="min-w-0 truncate text-sm text-muted">{description}</span>
|
|
265
|
+
</div>
|
|
266
|
+
<div className="col-start-3 row-start-2 flex min-h-5 w-full items-center justify-end">
|
|
267
|
+
{lastModified}
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
</Link>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<Link
|
|
276
|
+
to={getEntityRoute(type, entity.key, setKey)}
|
|
277
|
+
className="group block px-6 py-3 hover:bg-elevated"
|
|
278
|
+
>
|
|
279
|
+
<div className="grid grid-cols-[minmax(0,1fr)_auto] grid-rows-[auto_auto] gap-x-3 gap-y-1">
|
|
280
|
+
<div className="col-start-1 row-start-1 flex min-h-6 min-w-0 items-center">
|
|
281
|
+
<EntityKey value={entity.key} className="text-sm font-semibold text-primary" />
|
|
282
|
+
</div>
|
|
283
|
+
<div className="col-start-2 row-start-1 flex min-h-6 w-full items-center justify-end gap-2">
|
|
284
|
+
{trailingMeta}
|
|
285
|
+
</div>
|
|
286
|
+
<div className="col-start-1 row-start-2 flex min-h-5 min-w-0 items-center overflow-hidden">
|
|
287
|
+
<span className="min-w-0 truncate text-sm text-muted">{description}</span>
|
|
288
|
+
</div>
|
|
289
|
+
<div className="col-start-2 row-start-2 flex min-h-5 w-full items-center justify-end">
|
|
290
|
+
{lastModified}
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
</Link>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function LastModified(props: { entity: EntitySummary }) {
|
|
298
|
+
if (!props.entity.lastModified) {
|
|
299
|
+
return <span>Last modified n/a</span>;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const date = new Date(props.entity.lastModified.timestamp);
|
|
303
|
+
const formattedDate = Number.isNaN(date.getTime())
|
|
304
|
+
? props.entity.lastModified.timestamp
|
|
305
|
+
: new Intl.DateTimeFormat(undefined, {
|
|
306
|
+
month: "short",
|
|
307
|
+
day: "numeric",
|
|
308
|
+
year: "numeric",
|
|
309
|
+
}).format(date);
|
|
310
|
+
|
|
311
|
+
return (
|
|
312
|
+
<span>
|
|
313
|
+
Last modified by <span className="font-medium">{props.entity.lastModified.author}</span> on{" "}
|
|
314
|
+
{formattedDate}
|
|
315
|
+
</span>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function EnvironmentDot(props: {
|
|
320
|
+
status?: EntitySummary["environmentStatus"];
|
|
321
|
+
environment?: string;
|
|
322
|
+
className?: string;
|
|
323
|
+
}) {
|
|
324
|
+
const ref = React.useRef<HTMLSpanElement | null>(null);
|
|
325
|
+
const [tooltipPosition, setTooltipPosition] = React.useState<{
|
|
326
|
+
left: number;
|
|
327
|
+
top: number;
|
|
328
|
+
} | null>(null);
|
|
329
|
+
|
|
330
|
+
if (!props.status) {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const tooltip =
|
|
335
|
+
props.status === "production"
|
|
336
|
+
? `Enabled in ${props.environment || "production"}`
|
|
337
|
+
: props.status === "other"
|
|
338
|
+
? "Enabled in non-production environments"
|
|
339
|
+
: "Disabled everywhere";
|
|
340
|
+
const dotClass =
|
|
341
|
+
props.status === "production"
|
|
342
|
+
? "bg-green-500"
|
|
343
|
+
: props.status === "other"
|
|
344
|
+
? "bg-amber-500"
|
|
345
|
+
: "bg-slate-300";
|
|
346
|
+
|
|
347
|
+
function showTooltip() {
|
|
348
|
+
const rect = ref.current?.getBoundingClientRect();
|
|
349
|
+
|
|
350
|
+
if (!rect) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
setTooltipPosition({
|
|
355
|
+
left: rect.left + rect.width / 2,
|
|
356
|
+
top: rect.top,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function hideTooltip() {
|
|
361
|
+
setTooltipPosition(null);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return (
|
|
365
|
+
<span
|
|
366
|
+
ref={ref}
|
|
367
|
+
className={`relative inline-flex ${props.className || ""}`}
|
|
368
|
+
aria-label={tooltip}
|
|
369
|
+
onMouseEnter={showTooltip}
|
|
370
|
+
onMouseLeave={hideTooltip}
|
|
371
|
+
onFocus={showTooltip}
|
|
372
|
+
onBlur={hideTooltip}
|
|
373
|
+
>
|
|
374
|
+
<span className="relative flex h-3 w-3">
|
|
375
|
+
{props.status === "production" && (
|
|
376
|
+
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
|
|
377
|
+
)}
|
|
378
|
+
<span className={`relative inline-flex h-3 w-3 rounded-full ${dotClass}`} />
|
|
379
|
+
</span>
|
|
380
|
+
{tooltipPosition &&
|
|
381
|
+
createPortal(
|
|
382
|
+
<span
|
|
383
|
+
className="pointer-events-none fixed z-50 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded bg-header px-2 py-1 text-xs font-semibold text-header-text shadow-lg"
|
|
384
|
+
style={{
|
|
385
|
+
left: tooltipPosition.left,
|
|
386
|
+
top: tooltipPosition.top - 8,
|
|
387
|
+
}}
|
|
388
|
+
>
|
|
389
|
+
{tooltip}
|
|
390
|
+
</span>,
|
|
391
|
+
document.body,
|
|
392
|
+
)}
|
|
393
|
+
</span>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function HoverTooltip(props: { label: string; children: React.ReactNode; className?: string }) {
|
|
398
|
+
const ref = React.useRef<HTMLSpanElement | null>(null);
|
|
399
|
+
const [tooltipPosition, setTooltipPosition] = React.useState<{
|
|
400
|
+
left: number;
|
|
401
|
+
top: number;
|
|
402
|
+
} | null>(null);
|
|
403
|
+
|
|
404
|
+
function showTooltip() {
|
|
405
|
+
const rect = ref.current?.getBoundingClientRect();
|
|
406
|
+
|
|
407
|
+
if (!rect) {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
setTooltipPosition({
|
|
412
|
+
left: rect.left + rect.width / 2,
|
|
413
|
+
top: rect.top,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function hideTooltip() {
|
|
418
|
+
setTooltipPosition(null);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return (
|
|
422
|
+
<span
|
|
423
|
+
ref={ref}
|
|
424
|
+
className={`relative inline-flex ${props.className || ""}`}
|
|
425
|
+
aria-label={props.label}
|
|
426
|
+
onMouseEnter={showTooltip}
|
|
427
|
+
onMouseLeave={hideTooltip}
|
|
428
|
+
onFocus={showTooltip}
|
|
429
|
+
onBlur={hideTooltip}
|
|
430
|
+
>
|
|
431
|
+
{props.children}
|
|
432
|
+
{tooltipPosition &&
|
|
433
|
+
createPortal(
|
|
434
|
+
<span
|
|
435
|
+
className="pointer-events-none fixed z-50 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded bg-header px-2 py-1 text-xs font-semibold text-header-text shadow-lg"
|
|
436
|
+
style={{
|
|
437
|
+
left: tooltipPosition.left,
|
|
438
|
+
top: tooltipPosition.top - 8,
|
|
439
|
+
}}
|
|
440
|
+
>
|
|
441
|
+
{props.label}
|
|
442
|
+
</span>,
|
|
443
|
+
document.body,
|
|
444
|
+
)}
|
|
445
|
+
</span>
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function VariationsIcon() {
|
|
450
|
+
return (
|
|
451
|
+
<span
|
|
452
|
+
aria-hidden="true"
|
|
453
|
+
className="inline-flex h-4 min-w-4 items-center justify-center font-mono text-[9px] font-bold leading-none tracking-tight"
|
|
454
|
+
>
|
|
455
|
+
a/b
|
|
456
|
+
</span>
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function VariablesIcon() {
|
|
461
|
+
return (
|
|
462
|
+
<span
|
|
463
|
+
aria-hidden="true"
|
|
464
|
+
className="inline-flex h-4 min-w-4 items-center justify-center font-mono text-[9px] font-bold leading-none"
|
|
465
|
+
>
|
|
466
|
+
{"{ }"}
|
|
467
|
+
</span>
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function TargetIcon() {
|
|
472
|
+
return (
|
|
473
|
+
<svg aria-hidden="true" viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4">
|
|
474
|
+
<path
|
|
475
|
+
fillRule="evenodd"
|
|
476
|
+
d="M10 2.75a7.25 7.25 0 1 0 0 14.5 7.25 7.25 0 0 0 0-14.5ZM4.25 10a5.75 5.75 0 1 1 11.5 0 5.75 5.75 0 0 1-11.5 0ZM10 6.25a3.75 3.75 0 1 0 0 7.5 3.75 3.75 0 0 0 0-7.5ZM7.75 10a2.25 2.25 0 1 1 4.5 0 2.25 2.25 0 0 1-4.5 0Z"
|
|
477
|
+
clipRule="evenodd"
|
|
478
|
+
/>
|
|
479
|
+
</svg>
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function sortValues(values?: string[]) {
|
|
484
|
+
return Array.from(new Set(values || [])).sort((left, right) => left.localeCompare(right));
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const rowMetadataIconClassName =
|
|
488
|
+
"rounded-full bg-slate-100 p-1 text-slate-400 hover:bg-slate-200 hover:text-slate-600";
|
|
489
|
+
|
|
490
|
+
function RowMetadataIcons(props: { entity: EntitySummary; type: CatalogEntityType }) {
|
|
491
|
+
const targets = sortValues(props.entity.targets);
|
|
492
|
+
const isFeature = props.type === "feature";
|
|
493
|
+
const showVariations = isFeature && Boolean(props.entity.hasVariations);
|
|
494
|
+
const showVariables = isFeature && Boolean(props.entity.hasVariables);
|
|
495
|
+
|
|
496
|
+
if (targets.length === 0 && !showVariations && !showVariables) {
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return (
|
|
501
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
502
|
+
{showVariations && (
|
|
503
|
+
<HoverTooltip label="Has variations" className={rowMetadataIconClassName}>
|
|
504
|
+
<VariationsIcon />
|
|
505
|
+
</HoverTooltip>
|
|
506
|
+
)}
|
|
507
|
+
{showVariables && (
|
|
508
|
+
<HoverTooltip label="Has variables" className={rowMetadataIconClassName}>
|
|
509
|
+
<VariablesIcon />
|
|
510
|
+
</HoverTooltip>
|
|
511
|
+
)}
|
|
512
|
+
{targets.length > 0 && (
|
|
513
|
+
<HoverTooltip label={`Targets: ${targets.join(", ")}`} className={rowMetadataIconClassName}>
|
|
514
|
+
<TargetIcon />
|
|
515
|
+
</HoverTooltip>
|
|
516
|
+
)}
|
|
517
|
+
</div>
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function QueryHints(props: {
|
|
522
|
+
query: string;
|
|
523
|
+
hints: string[];
|
|
524
|
+
onHintClick: (hint: string) => void;
|
|
525
|
+
}) {
|
|
526
|
+
if (props.hints.length === 0) {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const tokens = props.query.trim().split(/\s+/);
|
|
531
|
+
|
|
532
|
+
return (
|
|
533
|
+
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5 pt-2 text-xs text-muted">
|
|
534
|
+
<span className="shrink-0">Try:</span>
|
|
535
|
+
{props.hints.map((hint) => {
|
|
536
|
+
const isActive = tokens.some((token) => token.toLowerCase() === hint.toLowerCase());
|
|
537
|
+
|
|
538
|
+
return (
|
|
539
|
+
<button
|
|
540
|
+
key={hint}
|
|
541
|
+
type="button"
|
|
542
|
+
onClick={() => props.onHintClick(hint)}
|
|
543
|
+
className={[
|
|
544
|
+
"cursor-pointer rounded px-1.5 py-0.5 font-mono transition-colors",
|
|
545
|
+
isActive ? "bg-primary/10 text-primary" : "bg-elevated text-muted hover:text-text",
|
|
546
|
+
].join(" ")}
|
|
547
|
+
>
|
|
548
|
+
{hint}
|
|
549
|
+
</button>
|
|
550
|
+
);
|
|
551
|
+
})}
|
|
552
|
+
</div>
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function SearchControls(props: {
|
|
557
|
+
query: string;
|
|
558
|
+
label: string;
|
|
559
|
+
hints: string[];
|
|
560
|
+
searchParams: URLSearchParams;
|
|
561
|
+
setSearchParams: (params: URLSearchParams) => void;
|
|
562
|
+
}) {
|
|
563
|
+
const hasHints = props.hints.length > 0;
|
|
564
|
+
const showHints = hasHints && props.searchParams.get("hints") === "1";
|
|
565
|
+
|
|
566
|
+
function setQuery(value?: string) {
|
|
567
|
+
props.setSearchParams(setSearchParam(props.searchParams, "q", value || undefined));
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function setShowHints(value: boolean) {
|
|
571
|
+
props.setSearchParams(setSearchParam(props.searchParams, "hints", value ? "1" : undefined));
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function handleHintClick(hint: string) {
|
|
575
|
+
const current = props.query.trim();
|
|
576
|
+
const tokens = current.split(/\s+/).filter(Boolean);
|
|
577
|
+
const existingIndex = tokens.findIndex((token) => token.toLowerCase() === hint.toLowerCase());
|
|
578
|
+
const next =
|
|
579
|
+
existingIndex >= 0
|
|
580
|
+
? tokens.filter((_, index) => index !== existingIndex).join(" ")
|
|
581
|
+
: current
|
|
582
|
+
? `${current} ${hint}`
|
|
583
|
+
: hint;
|
|
584
|
+
|
|
585
|
+
setQuery(next);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return (
|
|
589
|
+
<div>
|
|
590
|
+
<div className="relative">
|
|
591
|
+
<input
|
|
592
|
+
value={props.query}
|
|
593
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
594
|
+
placeholder={`Search ${props.label.toLowerCase()}...`}
|
|
595
|
+
className={[
|
|
596
|
+
"w-full rounded-full border border-border bg-surface px-5 py-2 text-xl text-text outline-none placeholder:text-faint focus:border-primary",
|
|
597
|
+
hasHints ? "pr-10" : "",
|
|
598
|
+
].join(" ")}
|
|
599
|
+
/>
|
|
600
|
+
{hasHints && (
|
|
601
|
+
<button
|
|
602
|
+
type="button"
|
|
603
|
+
onClick={() => setShowHints(!showHints)}
|
|
604
|
+
aria-label={showHints ? "Hide advanced search hints" : "Show advanced search hints"}
|
|
605
|
+
aria-pressed={showHints}
|
|
606
|
+
className={[
|
|
607
|
+
"absolute right-3 top-1/2 flex h-4 w-4 -translate-y-1/2 items-center justify-center rounded-full border text-[10px] font-black leading-none transition-all",
|
|
608
|
+
showHints
|
|
609
|
+
? "scale-105 border-primary bg-primary text-header-text shadow-sm"
|
|
610
|
+
: "border-border bg-surface text-faint hover:border-primary hover:bg-primary/10 hover:text-primary",
|
|
611
|
+
].join(" ")}
|
|
612
|
+
>
|
|
613
|
+
?
|
|
614
|
+
</button>
|
|
615
|
+
)}
|
|
616
|
+
</div>
|
|
617
|
+
|
|
618
|
+
<div
|
|
619
|
+
className={[
|
|
620
|
+
"grid transition-all duration-200 ease-in-out",
|
|
621
|
+
showHints ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
|
|
622
|
+
].join(" ")}
|
|
623
|
+
>
|
|
624
|
+
<div className="overflow-hidden pl-5">
|
|
625
|
+
<QueryHints query={props.query} hints={props.hints} onHintClick={handleHintClick} />
|
|
626
|
+
</div>
|
|
627
|
+
</div>
|
|
628
|
+
</div>
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
export function ListPage() {
|
|
633
|
+
const { entityPath, setKey } = useParams();
|
|
634
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
635
|
+
const [index, setIndex] = React.useState<CatalogIndex | null>(null);
|
|
636
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
637
|
+
const [showAll, setShowAll] = React.useState(false);
|
|
638
|
+
const query = searchParams.get("q") || "";
|
|
639
|
+
const sortDirection = getSortDirection(searchParams.get("sort"));
|
|
640
|
+
|
|
641
|
+
React.useEffect(() => {
|
|
642
|
+
setIndex(null);
|
|
643
|
+
setError(null);
|
|
644
|
+
fetchIndex(setKey)
|
|
645
|
+
.then(setIndex)
|
|
646
|
+
.catch((err: Error) => setError(err.message));
|
|
647
|
+
}, [setKey]);
|
|
648
|
+
|
|
649
|
+
React.useEffect(() => {
|
|
650
|
+
setShowAll(false);
|
|
651
|
+
}, [query, sortDirection, entityPath, setKey]);
|
|
652
|
+
|
|
653
|
+
if (!isEntityPath(entityPath)) {
|
|
654
|
+
return <Navigate to="features" replace />;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const type = entityPathToType[entityPath];
|
|
658
|
+
|
|
659
|
+
if (error) {
|
|
660
|
+
return <EmptyState title="Unable to load catalog index" description={error} />;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (!index) {
|
|
664
|
+
return (
|
|
665
|
+
<div className="px-6 py-8 text-muted">
|
|
666
|
+
Loading {entityLabels[type].plural.toLowerCase()}...
|
|
667
|
+
</div>
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const filtered = index.entities[type]
|
|
672
|
+
.filter((entity) => matchesQuery(entity, query, entityPath))
|
|
673
|
+
.slice()
|
|
674
|
+
.sort((left, right) => {
|
|
675
|
+
const result = left.key.localeCompare(left.key === right.key ? "" : right.key);
|
|
676
|
+
return sortDirection === "desc" ? result * -1 : result;
|
|
677
|
+
});
|
|
678
|
+
const visible = showAll ? filtered : filtered.slice(0, LIST_INITIAL_LIMIT);
|
|
679
|
+
const hasHiddenEntities = filtered.length > LIST_INITIAL_LIMIT && !showAll;
|
|
680
|
+
const searchHints = entityPath === "features" ? getFeatureSearchHints(index) : [];
|
|
681
|
+
|
|
682
|
+
return (
|
|
683
|
+
<div className="space-y-4">
|
|
684
|
+
<PageHeader title={entityLabels[type].plural} />
|
|
685
|
+
|
|
686
|
+
<div className="px-6 pt-1">
|
|
687
|
+
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
|
|
688
|
+
<SearchControls
|
|
689
|
+
query={query}
|
|
690
|
+
label={entityLabels[type].plural}
|
|
691
|
+
hints={searchHints}
|
|
692
|
+
searchParams={searchParams}
|
|
693
|
+
setSearchParams={setSearchParams}
|
|
694
|
+
/>
|
|
695
|
+
|
|
696
|
+
<button
|
|
697
|
+
type="button"
|
|
698
|
+
className="inline-flex h-[46px] w-fit max-w-full cursor-pointer items-center gap-2 self-start rounded-full border border-border bg-surface px-3 py-2 text-left text-sm font-semibold text-muted outline-none focus-visible:ring-2 focus-visible:ring-primary md:justify-self-end"
|
|
699
|
+
onClick={() =>
|
|
700
|
+
setSearchParams(
|
|
701
|
+
setSearchParam(
|
|
702
|
+
searchParams,
|
|
703
|
+
"sort",
|
|
704
|
+
sortDirection === "desc" ? undefined : "-name",
|
|
705
|
+
),
|
|
706
|
+
)
|
|
707
|
+
}
|
|
708
|
+
>
|
|
709
|
+
<span>Sort</span>
|
|
710
|
+
<span className="whitespace-nowrap font-bold text-text">
|
|
711
|
+
{sortDirection === "desc" ? "Z-A" : "A-Z"}
|
|
712
|
+
</span>
|
|
713
|
+
</button>
|
|
714
|
+
</div>
|
|
715
|
+
</div>
|
|
716
|
+
|
|
717
|
+
{filtered.length === 0 && <EmptyState title="No results found" />}
|
|
718
|
+
|
|
719
|
+
<div className="divide-y divide-border bg-surface">
|
|
720
|
+
{visible.map((entity) => (
|
|
721
|
+
<ListRow key={entity.key} entity={entity} type={type} setKey={setKey} />
|
|
722
|
+
))}
|
|
723
|
+
</div>
|
|
724
|
+
|
|
725
|
+
<div className="space-y-4 px-6 pb-6">
|
|
726
|
+
<p className="text-center text-sm text-muted">
|
|
727
|
+
{visible.length} of {filtered.length} {entityLabels[type].plural.toLowerCase()}
|
|
728
|
+
{filtered.length !== index.entities[type].length
|
|
729
|
+
? ` (${index.entities[type].length} total)`
|
|
730
|
+
: ""}
|
|
731
|
+
</p>
|
|
732
|
+
|
|
733
|
+
{hasHiddenEntities && (
|
|
734
|
+
<div className="flex justify-center">
|
|
735
|
+
<Button onClick={() => setShowAll(true)}>
|
|
736
|
+
Load all {filtered.length} {entityLabels[type].plural.toLowerCase()}
|
|
737
|
+
</Button>
|
|
738
|
+
</div>
|
|
739
|
+
)}
|
|
740
|
+
</div>
|
|
741
|
+
</div>
|
|
742
|
+
);
|
|
743
|
+
}
|