@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,1004 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Link, Navigate, Outlet, useOutletContext, useParams } from "react-router-dom";
|
|
3
|
+
|
|
4
|
+
import { fetchEntityDetail, fetchHistoryPage } from "../api";
|
|
5
|
+
import { decodeRouteSegment, entityLabels, entityPathToType, getEntityRoute } from "../entityTypes";
|
|
6
|
+
import type {
|
|
7
|
+
CatalogEntityType,
|
|
8
|
+
DevEditor,
|
|
9
|
+
EntityDetail,
|
|
10
|
+
EntityPath,
|
|
11
|
+
HistoryPage as HistoryPageData,
|
|
12
|
+
} from "../types";
|
|
13
|
+
import { useCatalog } from "../context/CatalogContext";
|
|
14
|
+
import {
|
|
15
|
+
Badge,
|
|
16
|
+
EmptyState,
|
|
17
|
+
EntityKey,
|
|
18
|
+
DescriptionField,
|
|
19
|
+
OverviewChip,
|
|
20
|
+
OverviewChipLink,
|
|
21
|
+
OverviewMetaPanel,
|
|
22
|
+
OverviewMetaRow,
|
|
23
|
+
OverviewSection,
|
|
24
|
+
MarkdownContent,
|
|
25
|
+
PageHeader,
|
|
26
|
+
Tabs,
|
|
27
|
+
} from "../components/ui";
|
|
28
|
+
import { ConditionTree, GroupSegmentTree } from "../components/trees";
|
|
29
|
+
import {
|
|
30
|
+
FeatureVariablesList,
|
|
31
|
+
SchemaPropertiesOverview,
|
|
32
|
+
SchemaTable,
|
|
33
|
+
hasSchemaTableRows,
|
|
34
|
+
usesSchemaStructureTable,
|
|
35
|
+
type SchemaLike,
|
|
36
|
+
} from "../components/variables";
|
|
37
|
+
import { FeatureVariationsList } from "../components/variations";
|
|
38
|
+
|
|
39
|
+
function isEntityPath(value: string | undefined): value is EntityPath {
|
|
40
|
+
return (
|
|
41
|
+
value === "features" ||
|
|
42
|
+
value === "segments" ||
|
|
43
|
+
value === "attributes" ||
|
|
44
|
+
value === "targets" ||
|
|
45
|
+
value === "groups" ||
|
|
46
|
+
value === "schemas"
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function slugifyFragment(value: string) {
|
|
51
|
+
return value
|
|
52
|
+
.toLowerCase()
|
|
53
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
54
|
+
.replace(/^-+|-+$/g, "");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function RulePermalink(props: { targetId: string }) {
|
|
58
|
+
return (
|
|
59
|
+
<a
|
|
60
|
+
href={`#${props.targetId}`}
|
|
61
|
+
aria-label="Link to this rule"
|
|
62
|
+
className="inline-flex rounded p-1 text-muted opacity-0 transition-opacity hover:text-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary group-hover:opacity-100"
|
|
63
|
+
>
|
|
64
|
+
<svg
|
|
65
|
+
aria-hidden="true"
|
|
66
|
+
viewBox="0 0 24 24"
|
|
67
|
+
className="h-4 w-4"
|
|
68
|
+
fill="none"
|
|
69
|
+
stroke="currentColor"
|
|
70
|
+
strokeLinecap="round"
|
|
71
|
+
strokeLinejoin="round"
|
|
72
|
+
strokeWidth="2"
|
|
73
|
+
>
|
|
74
|
+
<path d="M10 13a5 5 0 0 0 7.07 0l2.83-2.83a5 5 0 0 0-7.07-7.07L11 4" />
|
|
75
|
+
<path d="M14 11a5 5 0 0 0-7.07 0L4.1 13.83a5 5 0 1 0 7.07 7.07L13 20" />
|
|
76
|
+
</svg>
|
|
77
|
+
</a>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function useScrollToHash(dependencies: React.DependencyList) {
|
|
82
|
+
React.useEffect(() => {
|
|
83
|
+
if (typeof window === "undefined" || !window.location.hash) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const targetId = decodeURIComponent(window.location.hash.slice(1));
|
|
88
|
+
|
|
89
|
+
if (!targetId) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const frame = window.requestAnimationFrame(() => {
|
|
94
|
+
const targetElement = document.getElementById(targetId);
|
|
95
|
+
|
|
96
|
+
if (!targetElement) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
targetElement.scrollIntoView({ block: "start" });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return () => {
|
|
104
|
+
window.cancelAnimationFrame(frame);
|
|
105
|
+
};
|
|
106
|
+
}, dependencies);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function useEntityDetail() {
|
|
110
|
+
return useOutletContext<{ detail: EntityDetail; setKey?: string }>();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function formatScalar(value: unknown) {
|
|
114
|
+
if (value === undefined || value === null || value === "") {
|
|
115
|
+
return <span className="text-muted">n/a</span>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (typeof value === "boolean") {
|
|
119
|
+
return value ? "Yes" : "No";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return String(value);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function FormattedValue(props: { value: unknown }) {
|
|
126
|
+
const value = props.value;
|
|
127
|
+
|
|
128
|
+
if (value === undefined || value === null || value === "") {
|
|
129
|
+
return <span className="text-muted">n/a</span>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (Array.isArray(value)) {
|
|
133
|
+
if (value.length === 0) {
|
|
134
|
+
return <span className="text-muted">empty</span>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div className="flex flex-wrap gap-2">
|
|
139
|
+
{value.map((item, index) => (
|
|
140
|
+
<span key={index} className="rounded border border-border bg-elevated px-2 py-1 text-xs">
|
|
141
|
+
<FormattedValue value={item} />
|
|
142
|
+
</span>
|
|
143
|
+
))}
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (typeof value === "object") {
|
|
149
|
+
const entries = Object.entries(value as Record<string, unknown>);
|
|
150
|
+
|
|
151
|
+
if (entries.length === 0) {
|
|
152
|
+
return <span className="text-muted">empty</span>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<dl className="space-y-2">
|
|
157
|
+
{entries.map(([key, item]) => (
|
|
158
|
+
<div
|
|
159
|
+
key={key}
|
|
160
|
+
className="grid gap-1 rounded border border-border bg-elevated p-2 sm:grid-cols-3"
|
|
161
|
+
>
|
|
162
|
+
<dt className="font-mono text-xs font-semibold text-muted">{key}</dt>
|
|
163
|
+
<dd className="min-w-0 sm:col-span-2">
|
|
164
|
+
<FormattedValue value={item} />
|
|
165
|
+
</dd>
|
|
166
|
+
</div>
|
|
167
|
+
))}
|
|
168
|
+
</dl>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return <span>{formatScalar(value)}</span>;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function CaretIcon() {
|
|
176
|
+
return (
|
|
177
|
+
<svg aria-hidden="true" viewBox="0 0 12 12" fill="none" className="h-3 w-3">
|
|
178
|
+
<path
|
|
179
|
+
d="M3.25 4.5 6 7.25 8.75 4.5"
|
|
180
|
+
stroke="currentColor"
|
|
181
|
+
strokeWidth="1.5"
|
|
182
|
+
strokeLinecap="round"
|
|
183
|
+
strokeLinejoin="round"
|
|
184
|
+
/>
|
|
185
|
+
</svg>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function EditorIcon(props: { icon: DevEditor["icon"] }) {
|
|
190
|
+
if (props.icon === "cursor") {
|
|
191
|
+
return (
|
|
192
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" className="h-4 w-4">
|
|
193
|
+
<path d="M4 3l16 9-7 2-3 7L4 3Z" fill="#111827" />
|
|
194
|
+
<path d="M8.2 8.6 15 12.6" stroke="#ffffff" strokeWidth="1.4" strokeLinecap="round" />
|
|
195
|
+
<path d="M10.2 12.9 12.6 14.3 10.5 18.8Z" fill="#ffffff" opacity=".92" />
|
|
196
|
+
</svg>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" className="h-4 w-4">
|
|
202
|
+
<path
|
|
203
|
+
d="M17.2 3.2 9.4 10 5.1 6.7 3 7.8v8.4l2.1 1.1 4.3-3.3 7.8 6.8L21 19V5l-3.8-1.8Z"
|
|
204
|
+
fill="#007ACC"
|
|
205
|
+
/>
|
|
206
|
+
<path d="M17.2 8.2v7.6L12.5 12l4.7-3.8Z" fill="#ffffff" opacity=".35" />
|
|
207
|
+
</svg>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function EditLink(props: { sourcePath?: string; editLinks?: EntityDetail["editLinks"] }) {
|
|
212
|
+
const { manifest } = useCatalog();
|
|
213
|
+
const [open, setOpen] = React.useState(false);
|
|
214
|
+
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
|
215
|
+
|
|
216
|
+
React.useEffect(() => {
|
|
217
|
+
if (!open) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function handlePointerDown(event: PointerEvent) {
|
|
222
|
+
if (!containerRef.current?.contains(event.target as Node)) {
|
|
223
|
+
setOpen(false);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
228
|
+
if (event.key === "Escape") {
|
|
229
|
+
setOpen(false);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
document.addEventListener("pointerdown", handlePointerDown);
|
|
234
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
235
|
+
|
|
236
|
+
return () => {
|
|
237
|
+
document.removeEventListener("pointerdown", handlePointerDown);
|
|
238
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
239
|
+
};
|
|
240
|
+
}, [open]);
|
|
241
|
+
|
|
242
|
+
const sourceHref =
|
|
243
|
+
props.sourcePath && manifest.links?.source
|
|
244
|
+
? manifest.links.source.replace("{{path}}", props.sourcePath)
|
|
245
|
+
: undefined;
|
|
246
|
+
const editors = (manifest.dev?.editors || []).filter((editor) => props.editLinks?.[editor.id]);
|
|
247
|
+
const hasEditorLinks = editors.length > 0;
|
|
248
|
+
|
|
249
|
+
if (!sourceHref && !hasEditorLinks) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const buttonClass =
|
|
254
|
+
"rounded border border-border bg-elevated px-4 py-2 text-sm font-bold text-muted shadow-sm hover:bg-background";
|
|
255
|
+
const splitButtonClass =
|
|
256
|
+
"border border-border bg-elevated px-4 py-2 text-sm font-bold text-muted shadow-sm hover:bg-background";
|
|
257
|
+
const menuButtonClass =
|
|
258
|
+
"border border-border bg-elevated py-2 text-sm font-bold text-muted shadow-sm hover:bg-background";
|
|
259
|
+
|
|
260
|
+
const dropdown = hasEditorLinks ? (
|
|
261
|
+
<div
|
|
262
|
+
role="menu"
|
|
263
|
+
className="absolute right-0 top-full z-20 mt-px min-w-48 overflow-hidden rounded border border-border bg-surface py-1 shadow-lg"
|
|
264
|
+
>
|
|
265
|
+
{editors.map((editor) => (
|
|
266
|
+
<a
|
|
267
|
+
key={editor.id}
|
|
268
|
+
role="menuitem"
|
|
269
|
+
href={props.editLinks?.[editor.id]}
|
|
270
|
+
className="flex items-center gap-2 px-3 py-2 text-sm font-semibold text-muted hover:bg-elevated hover:text-text"
|
|
271
|
+
onClick={() => setOpen(false)}
|
|
272
|
+
>
|
|
273
|
+
<EditorIcon icon={editor.icon} />
|
|
274
|
+
<span>Open in {editor.label}</span>
|
|
275
|
+
</a>
|
|
276
|
+
))}
|
|
277
|
+
</div>
|
|
278
|
+
) : null;
|
|
279
|
+
|
|
280
|
+
if (!hasEditorLinks) {
|
|
281
|
+
return sourceHref ? (
|
|
282
|
+
<a href={sourceHref} target="_blank" rel="noreferrer" className={buttonClass}>
|
|
283
|
+
Edit
|
|
284
|
+
</a>
|
|
285
|
+
) : null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return (
|
|
289
|
+
<div ref={containerRef} className="relative inline-flex">
|
|
290
|
+
{sourceHref ? (
|
|
291
|
+
<a
|
|
292
|
+
href={sourceHref}
|
|
293
|
+
target="_blank"
|
|
294
|
+
rel="noreferrer"
|
|
295
|
+
className={`${splitButtonClass} rounded-l`}
|
|
296
|
+
>
|
|
297
|
+
Edit
|
|
298
|
+
</a>
|
|
299
|
+
) : (
|
|
300
|
+
<button
|
|
301
|
+
type="button"
|
|
302
|
+
className={`${splitButtonClass} rounded-l`}
|
|
303
|
+
onClick={() => setOpen((current) => !current)}
|
|
304
|
+
>
|
|
305
|
+
Edit
|
|
306
|
+
</button>
|
|
307
|
+
)}
|
|
308
|
+
<button
|
|
309
|
+
type="button"
|
|
310
|
+
className={`${menuButtonClass} -ml-px rounded-r px-2`}
|
|
311
|
+
aria-label="Open edit options"
|
|
312
|
+
aria-expanded={open}
|
|
313
|
+
onClick={() => setOpen((current) => !current)}
|
|
314
|
+
>
|
|
315
|
+
<CaretIcon />
|
|
316
|
+
</button>
|
|
317
|
+
{open ? dropdown : null}
|
|
318
|
+
</div>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function EntityDetailPage() {
|
|
323
|
+
const { entityPath, entityKey, setKey } = useParams();
|
|
324
|
+
const decodedEntityKey = entityKey ? decodeRouteSegment(entityKey) : undefined;
|
|
325
|
+
const [detail, setDetail] = React.useState<EntityDetail | null>(null);
|
|
326
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
327
|
+
|
|
328
|
+
React.useEffect(() => {
|
|
329
|
+
setDetail(null);
|
|
330
|
+
setError(null);
|
|
331
|
+
|
|
332
|
+
if (!isEntityPath(entityPath) || !decodedEntityKey) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
fetchEntityDetail(entityPathToType[entityPath], decodedEntityKey, setKey)
|
|
337
|
+
.then(setDetail)
|
|
338
|
+
.catch((err: Error) => setError(err.message));
|
|
339
|
+
}, [entityPath, decodedEntityKey, setKey]);
|
|
340
|
+
|
|
341
|
+
if (!isEntityPath(entityPath) || !decodedEntityKey) {
|
|
342
|
+
return <Navigate to="/features" replace />;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const type = entityPathToType[entityPath];
|
|
346
|
+
|
|
347
|
+
if (error) {
|
|
348
|
+
return <EmptyState title="Unable to load entity" description={error} />;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (!detail) {
|
|
352
|
+
return <div className="text-muted">Loading {entityLabels[type].singular.toLowerCase()}...</div>;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const tabs = [
|
|
356
|
+
{ to: ".", label: "Overview" },
|
|
357
|
+
...(type === "feature"
|
|
358
|
+
? [
|
|
359
|
+
{ to: "variations", label: "Variations" },
|
|
360
|
+
{ to: "variables", label: "Variables" },
|
|
361
|
+
{ to: "rules", label: "Rules" },
|
|
362
|
+
{ to: "force", label: "Force" },
|
|
363
|
+
]
|
|
364
|
+
: []),
|
|
365
|
+
...(type !== "test" ? [{ to: "usage", label: "Usage" }] : []),
|
|
366
|
+
{ to: "history", label: "History" },
|
|
367
|
+
];
|
|
368
|
+
|
|
369
|
+
return (
|
|
370
|
+
<div>
|
|
371
|
+
<PageHeader
|
|
372
|
+
title={detail.key}
|
|
373
|
+
description={entityLabels[type].singular}
|
|
374
|
+
actions={<EditLink sourcePath={detail.sourcePath} editLinks={detail.editLinks} />}
|
|
375
|
+
/>
|
|
376
|
+
<Tabs items={tabs} />
|
|
377
|
+
<div className="px-6 py-6">
|
|
378
|
+
<Outlet context={{ detail, setKey }} />
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function OverviewTab() {
|
|
385
|
+
const { detail, setKey } = useEntityDetail();
|
|
386
|
+
const entity = detail.entity as Record<string, unknown>;
|
|
387
|
+
const schema = entity as SchemaLike;
|
|
388
|
+
const hasStructureTable =
|
|
389
|
+
(detail.type === "schema" && usesSchemaStructureTable(schema) && hasSchemaTableRows(schema)) ||
|
|
390
|
+
(detail.type === "attribute" && hasSchemaTableRows(schema));
|
|
391
|
+
|
|
392
|
+
return (
|
|
393
|
+
<div className="space-y-6">
|
|
394
|
+
<EntityOverviewMeta detail={detail} entity={entity} setKey={setKey} />
|
|
395
|
+
|
|
396
|
+
{detail.type === "segment" && (
|
|
397
|
+
<OverviewSection title="Conditions">
|
|
398
|
+
<ConditionTree conditions={entity.conditions as any} />
|
|
399
|
+
</OverviewSection>
|
|
400
|
+
)}
|
|
401
|
+
|
|
402
|
+
{detail.type === "schema" && !usesSchemaStructureTable(entity as SchemaLike) && (
|
|
403
|
+
<SchemaPropertiesOverview schema={entity as SchemaLike} setKey={setKey} />
|
|
404
|
+
)}
|
|
405
|
+
|
|
406
|
+
{detail.type === "schema" &&
|
|
407
|
+
usesSchemaStructureTable(entity as SchemaLike) &&
|
|
408
|
+
hasSchemaTableRows(entity as SchemaLike) && (
|
|
409
|
+
<OverviewSection title="Structure">
|
|
410
|
+
<SchemaTable schema={entity as SchemaLike} setKey={setKey} />
|
|
411
|
+
</OverviewSection>
|
|
412
|
+
)}
|
|
413
|
+
|
|
414
|
+
{detail.type === "attribute" && hasSchemaTableRows(entity as SchemaLike) && (
|
|
415
|
+
<OverviewSection title="Structure">
|
|
416
|
+
<SchemaTable schema={entity as SchemaLike} setKey={setKey} />
|
|
417
|
+
</OverviewSection>
|
|
418
|
+
)}
|
|
419
|
+
|
|
420
|
+
<DescriptionField value={entity.description} showTopDivider={!hasStructureTable} />
|
|
421
|
+
</div>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function EntityStatusBadges(props: { entity: Record<string, unknown> }) {
|
|
426
|
+
const hasBadges =
|
|
427
|
+
props.entity.archived === true ||
|
|
428
|
+
props.entity.deprecated === true ||
|
|
429
|
+
props.entity.promotable === false;
|
|
430
|
+
|
|
431
|
+
if (!hasBadges) {
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return (
|
|
436
|
+
<div className="flex flex-wrap gap-2">
|
|
437
|
+
{props.entity.archived === true && <Badge tone="danger">archived</Badge>}
|
|
438
|
+
{props.entity.deprecated === true && <Badge tone="warning">deprecated</Badge>}
|
|
439
|
+
{props.entity.promotable === false && <Badge>not promotable</Badge>}
|
|
440
|
+
</div>
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function LinkedEntityChips(props: { type: CatalogEntityType; values?: string[]; setKey?: string }) {
|
|
445
|
+
if (!props.values?.length) {
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return (
|
|
450
|
+
<>
|
|
451
|
+
{props.values.map((value) => (
|
|
452
|
+
<OverviewChipLink key={value} to={getEntityRoute(props.type, value, props.setKey)}>
|
|
453
|
+
{value}
|
|
454
|
+
</OverviewChipLink>
|
|
455
|
+
))}
|
|
456
|
+
</>
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function hasBucketBy(value: unknown) {
|
|
461
|
+
if (value === undefined || value === null || value === "") {
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (Array.isArray(value)) {
|
|
466
|
+
return value.length > 0;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function getAttributeEntityKey(attributePath: string) {
|
|
473
|
+
return attributePath.split(".")[0];
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function BucketByDisplay(props: { value: unknown; setKey?: string }) {
|
|
477
|
+
const value = props.value;
|
|
478
|
+
|
|
479
|
+
if (!hasBucketBy(value)) {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (typeof value === "string") {
|
|
484
|
+
return (
|
|
485
|
+
<OverviewChipLink
|
|
486
|
+
to={getEntityRoute("attribute", getAttributeEntityKey(value), props.setKey)}
|
|
487
|
+
>
|
|
488
|
+
<EntityKey value={value} className="font-mono" />
|
|
489
|
+
</OverviewChipLink>
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (Array.isArray(value)) {
|
|
494
|
+
return (
|
|
495
|
+
<>
|
|
496
|
+
{value.map((item, index) => (
|
|
497
|
+
<React.Fragment key={index}>
|
|
498
|
+
{index > 0 && <span className="text-xs text-faint">and</span>}
|
|
499
|
+
<BucketByDisplay setKey={props.setKey} value={item} />
|
|
500
|
+
</React.Fragment>
|
|
501
|
+
))}
|
|
502
|
+
</>
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (typeof value === "object" && value !== null && "or" in value) {
|
|
507
|
+
const orValue = (value as { or: unknown }).or;
|
|
508
|
+
const orItems = Array.isArray(orValue) ? orValue : [orValue];
|
|
509
|
+
|
|
510
|
+
return (
|
|
511
|
+
<>
|
|
512
|
+
{orItems.map((item, index) => (
|
|
513
|
+
<React.Fragment key={index}>
|
|
514
|
+
{index > 0 && <span className="text-xs text-faint">or</span>}
|
|
515
|
+
<BucketByDisplay setKey={props.setKey} value={item} />
|
|
516
|
+
</React.Fragment>
|
|
517
|
+
))}
|
|
518
|
+
</>
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return (
|
|
523
|
+
<OverviewChip>
|
|
524
|
+
<span className="font-mono text-xs">
|
|
525
|
+
<FormattedValue value={value} />
|
|
526
|
+
</span>
|
|
527
|
+
</OverviewChip>
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function asStringArray(value: unknown) {
|
|
532
|
+
if (!value) {
|
|
533
|
+
return undefined;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (Array.isArray(value)) {
|
|
537
|
+
const items = value.map(String).filter(Boolean);
|
|
538
|
+
return items.length > 0 ? items : undefined;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (typeof value === "string") {
|
|
542
|
+
return [value];
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return undefined;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function EntityOverviewMeta(props: {
|
|
549
|
+
detail: EntityDetail;
|
|
550
|
+
entity: Record<string, unknown>;
|
|
551
|
+
setKey?: string;
|
|
552
|
+
}) {
|
|
553
|
+
const { detail, entity, setKey } = props;
|
|
554
|
+
const tags = asStringArray(entity.tags);
|
|
555
|
+
const targets = detail.relationships?.targets;
|
|
556
|
+
const required = asStringArray(entity.required);
|
|
557
|
+
const hasStatus =
|
|
558
|
+
entity.archived === true || entity.deprecated === true || entity.promotable === false;
|
|
559
|
+
|
|
560
|
+
if (detail.type === "feature") {
|
|
561
|
+
const showBucketBy = hasBucketBy(entity.bucketBy);
|
|
562
|
+
const hasFacts = showBucketBy || Boolean(required?.length);
|
|
563
|
+
const hasRelations = Boolean(tags?.length) || Boolean(targets?.length);
|
|
564
|
+
|
|
565
|
+
if (!hasStatus && !hasFacts && !hasRelations) {
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return (
|
|
570
|
+
<OverviewMetaPanel>
|
|
571
|
+
{hasStatus && (
|
|
572
|
+
<OverviewMetaRow label="Status">
|
|
573
|
+
<EntityStatusBadges entity={entity} />
|
|
574
|
+
</OverviewMetaRow>
|
|
575
|
+
)}
|
|
576
|
+
{showBucketBy && (
|
|
577
|
+
<OverviewMetaRow label="Bucket by">
|
|
578
|
+
<BucketByDisplay value={entity.bucketBy} setKey={setKey} />
|
|
579
|
+
</OverviewMetaRow>
|
|
580
|
+
)}
|
|
581
|
+
{required?.length ? (
|
|
582
|
+
<OverviewMetaRow label="Required">
|
|
583
|
+
{required.map((key) => (
|
|
584
|
+
<OverviewChipLink key={key} to={getEntityRoute("feature", key, setKey)}>
|
|
585
|
+
{key}
|
|
586
|
+
</OverviewChipLink>
|
|
587
|
+
))}
|
|
588
|
+
</OverviewMetaRow>
|
|
589
|
+
) : null}
|
|
590
|
+
{tags?.length ? (
|
|
591
|
+
<OverviewMetaRow label="Tags">
|
|
592
|
+
{tags.map((tag) => (
|
|
593
|
+
<OverviewChip key={tag}>{tag}</OverviewChip>
|
|
594
|
+
))}
|
|
595
|
+
</OverviewMetaRow>
|
|
596
|
+
) : null}
|
|
597
|
+
{targets?.length ? (
|
|
598
|
+
<OverviewMetaRow label="Targets">
|
|
599
|
+
<LinkedEntityChips type="target" values={targets} setKey={setKey} />
|
|
600
|
+
</OverviewMetaRow>
|
|
601
|
+
) : null}
|
|
602
|
+
</OverviewMetaPanel>
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (
|
|
607
|
+
(detail.type === "attribute" || detail.type === "schema") &&
|
|
608
|
+
(entity.type || (Array.isArray(entity.oneOf) && entity.oneOf.length > 0))
|
|
609
|
+
) {
|
|
610
|
+
const typeLabel = entity.type ? String(entity.type) : "oneOf";
|
|
611
|
+
|
|
612
|
+
return (
|
|
613
|
+
<OverviewMetaPanel>
|
|
614
|
+
{hasStatus && (
|
|
615
|
+
<OverviewMetaRow label="Status">
|
|
616
|
+
<EntityStatusBadges entity={entity} />
|
|
617
|
+
</OverviewMetaRow>
|
|
618
|
+
)}
|
|
619
|
+
<OverviewMetaRow label="Type">
|
|
620
|
+
<OverviewChip>{typeLabel}</OverviewChip>
|
|
621
|
+
</OverviewMetaRow>
|
|
622
|
+
</OverviewMetaPanel>
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (!hasStatus) {
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return (
|
|
631
|
+
<OverviewMetaPanel>
|
|
632
|
+
<OverviewMetaRow label="Status">
|
|
633
|
+
<EntityStatusBadges entity={entity} />
|
|
634
|
+
</OverviewMetaRow>
|
|
635
|
+
</OverviewMetaPanel>
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function getEnvironmentItems(detail: EntityDetail, tab: "rules" | "force") {
|
|
640
|
+
const entity = detail.entity as Record<string, any>;
|
|
641
|
+
const value = entity[tab];
|
|
642
|
+
|
|
643
|
+
if (!value || Array.isArray(value)) {
|
|
644
|
+
return [];
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return Object.keys(value).sort(sortEnvironmentKeys);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function getEnvironmentSortGroup(value: string) {
|
|
651
|
+
const normalized = value.toLowerCase();
|
|
652
|
+
|
|
653
|
+
if (normalized.startsWith("dev")) {
|
|
654
|
+
return 0;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (normalized.startsWith("prod")) {
|
|
658
|
+
return 2;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
return 1;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function sortEnvironmentKeys(left: string, right: string) {
|
|
665
|
+
const leftGroup = getEnvironmentSortGroup(left);
|
|
666
|
+
const rightGroup = getEnvironmentSortGroup(right);
|
|
667
|
+
|
|
668
|
+
if (leftGroup !== rightGroup) {
|
|
669
|
+
return leftGroup - rightGroup;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return left.localeCompare(right);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
export function FeatureRulesTab() {
|
|
676
|
+
const { detail, setKey } = useEntityDetail();
|
|
677
|
+
const { environmentKey } = useParams();
|
|
678
|
+
const entity = detail.entity as Record<string, any>;
|
|
679
|
+
const environments = getEnvironmentItems(detail, "rules");
|
|
680
|
+
const selectedEnvironment = environmentKey || environments[0];
|
|
681
|
+
const rules = environments.length > 0 ? entity.rules?.[selectedEnvironment] : entity.rules;
|
|
682
|
+
const expose = environments.length > 0 ? entity.expose?.[selectedEnvironment] : entity.expose;
|
|
683
|
+
|
|
684
|
+
if (detail.type !== "feature") return <Navigate to=".." replace />;
|
|
685
|
+
|
|
686
|
+
return (
|
|
687
|
+
<FeatureRows
|
|
688
|
+
title="Rules"
|
|
689
|
+
base="rules"
|
|
690
|
+
environments={environments}
|
|
691
|
+
selectedEnvironment={selectedEnvironment}
|
|
692
|
+
rows={rules || []}
|
|
693
|
+
expose={expose}
|
|
694
|
+
showConditions={false}
|
|
695
|
+
setKey={setKey}
|
|
696
|
+
/>
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
export function FeatureForceTab() {
|
|
701
|
+
const { detail, setKey } = useEntityDetail();
|
|
702
|
+
const { environmentKey } = useParams();
|
|
703
|
+
const entity = detail.entity as Record<string, any>;
|
|
704
|
+
const environments = getEnvironmentItems(detail, "force");
|
|
705
|
+
const selectedEnvironment = environmentKey || environments[0];
|
|
706
|
+
const rows = environments.length > 0 ? entity.force?.[selectedEnvironment] : entity.force;
|
|
707
|
+
|
|
708
|
+
if (detail.type !== "feature") return <Navigate to=".." replace />;
|
|
709
|
+
|
|
710
|
+
return (
|
|
711
|
+
<FeatureRows
|
|
712
|
+
title="Force"
|
|
713
|
+
emptyTitle="No forced rules found"
|
|
714
|
+
base="force"
|
|
715
|
+
environments={environments}
|
|
716
|
+
selectedEnvironment={selectedEnvironment}
|
|
717
|
+
rows={rows || []}
|
|
718
|
+
showConditions
|
|
719
|
+
setKey={setKey}
|
|
720
|
+
/>
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function FeatureRows(props: {
|
|
725
|
+
title: string;
|
|
726
|
+
emptyTitle?: string;
|
|
727
|
+
base: string;
|
|
728
|
+
environments: string[];
|
|
729
|
+
selectedEnvironment?: string;
|
|
730
|
+
rows: any[];
|
|
731
|
+
expose?: unknown;
|
|
732
|
+
showConditions: boolean;
|
|
733
|
+
setKey?: string;
|
|
734
|
+
}) {
|
|
735
|
+
useScrollToHash([props.base, props.selectedEnvironment, props.rows.length]);
|
|
736
|
+
|
|
737
|
+
if (props.environments.length > 0 && props.selectedEnvironment) {
|
|
738
|
+
const isKnown = props.environments.includes(props.selectedEnvironment);
|
|
739
|
+
if (!isKnown) {
|
|
740
|
+
return <Navigate to={`../${props.base}/${props.environments[0]}`} replace />;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
return (
|
|
745
|
+
<div className="space-y-4">
|
|
746
|
+
{props.environments.length > 0 && (
|
|
747
|
+
<nav className="flex flex-wrap gap-2">
|
|
748
|
+
{props.environments.map((environment) => (
|
|
749
|
+
<Link
|
|
750
|
+
key={environment}
|
|
751
|
+
to={`../${props.base}/${environment}`}
|
|
752
|
+
className={[
|
|
753
|
+
"inline-flex rounded-full border px-3 py-1 text-xs font-semibold transition-colors",
|
|
754
|
+
environment === props.selectedEnvironment
|
|
755
|
+
? "border-primary bg-header-active !text-header-text"
|
|
756
|
+
: "border-pill bg-transparent text-text hover:bg-elevated",
|
|
757
|
+
].join(" ")}
|
|
758
|
+
>
|
|
759
|
+
{environment}
|
|
760
|
+
</Link>
|
|
761
|
+
))}
|
|
762
|
+
</nav>
|
|
763
|
+
)}
|
|
764
|
+
|
|
765
|
+
{typeof props.expose !== "undefined" && (
|
|
766
|
+
<div className="rounded border border-border bg-warning-surface p-3 text-sm">
|
|
767
|
+
<span className="font-semibold">Expose</span>
|
|
768
|
+
<div className="mt-2">
|
|
769
|
+
<FormattedValue value={props.expose} />
|
|
770
|
+
</div>
|
|
771
|
+
</div>
|
|
772
|
+
)}
|
|
773
|
+
|
|
774
|
+
{props.rows.length === 0 && (
|
|
775
|
+
<EmptyState title={props.emptyTitle || `No ${props.title.toLowerCase()} found`} />
|
|
776
|
+
)}
|
|
777
|
+
<div className="space-y-8">
|
|
778
|
+
{props.rows.map((row, index) => {
|
|
779
|
+
const ruleKey = String(row.key || `#${index + 1}`);
|
|
780
|
+
const ruleId = slugifyFragment(
|
|
781
|
+
[props.base, props.selectedEnvironment, ruleKey].filter(Boolean).join("-"),
|
|
782
|
+
);
|
|
783
|
+
|
|
784
|
+
return (
|
|
785
|
+
<section key={row.key || index} className="space-y-4">
|
|
786
|
+
<div className="space-y-3">
|
|
787
|
+
<div className="flex min-w-0 items-center justify-between gap-4">
|
|
788
|
+
<div className="group flex min-w-0 flex-wrap items-center gap-2">
|
|
789
|
+
<h2 id={ruleId} className="font-semibold [overflow-wrap:anywhere]">
|
|
790
|
+
<EntityKey value={ruleKey} className="font-semibold" />
|
|
791
|
+
</h2>
|
|
792
|
+
<RulePermalink targetId={ruleId} />
|
|
793
|
+
{row.enabled === false && <Badge tone="danger">disabled</Badge>}
|
|
794
|
+
{row.promotable === false && <Badge>not promotable</Badge>}
|
|
795
|
+
</div>
|
|
796
|
+
{typeof row.percentage === "number" && <RuleProgress value={row.percentage} />}
|
|
797
|
+
</div>
|
|
798
|
+
{row.summary && <p className="text-sm text-muted">{row.summary}</p>}
|
|
799
|
+
{row.description && <MarkdownContent value={row.description} />}
|
|
800
|
+
</div>
|
|
801
|
+
|
|
802
|
+
<div className={`grid gap-4 ${props.showConditions ? "md:grid-cols-2" : ""}`}>
|
|
803
|
+
<div className="space-y-2 rounded-xl border border-border bg-elevated p-4">
|
|
804
|
+
<h3 className="text-sm font-semibold text-muted">Segments</h3>
|
|
805
|
+
<GroupSegmentTree segments={row.segments} setKey={props.setKey} />
|
|
806
|
+
</div>
|
|
807
|
+
{props.showConditions && (
|
|
808
|
+
<div className="space-y-2 rounded-xl border border-border bg-elevated p-4">
|
|
809
|
+
<h3 className="text-sm font-semibold text-muted">Conditions</h3>
|
|
810
|
+
<ConditionTree conditions={row.conditions} setKey={props.setKey} />
|
|
811
|
+
</div>
|
|
812
|
+
)}
|
|
813
|
+
</div>
|
|
814
|
+
</section>
|
|
815
|
+
);
|
|
816
|
+
})}
|
|
817
|
+
</div>
|
|
818
|
+
</div>
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function RuleProgress(props: { value: number }) {
|
|
823
|
+
const value = Math.max(0, Math.min(100, props.value));
|
|
824
|
+
|
|
825
|
+
return (
|
|
826
|
+
<div className="flex w-2/5 min-w-0 max-w-sm shrink-0 items-center gap-2">
|
|
827
|
+
<span className="w-10 shrink-0 text-right text-xs font-semibold text-muted">{value}%</span>
|
|
828
|
+
<div className="h-2 min-w-0 flex-1 overflow-hidden rounded-full bg-slate-200">
|
|
829
|
+
<div className="h-full rounded-full bg-green-500" style={{ width: `${value}%` }} />
|
|
830
|
+
</div>
|
|
831
|
+
</div>
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
export function FeatureVariationsTab() {
|
|
836
|
+
const { detail, setKey } = useEntityDetail();
|
|
837
|
+
const entity = detail.entity as Record<string, any>;
|
|
838
|
+
const variations = entity.variations || [];
|
|
839
|
+
|
|
840
|
+
if (detail.type !== "feature") return <Navigate to=".." replace />;
|
|
841
|
+
|
|
842
|
+
if (variations.length === 0) {
|
|
843
|
+
return <EmptyState title="No variations found" />;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
return <FeatureVariationsList variations={variations} setKey={setKey} />;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
export function FeatureVariablesTab() {
|
|
850
|
+
const { detail, setKey } = useEntityDetail();
|
|
851
|
+
const entity = detail.entity as Record<string, any>;
|
|
852
|
+
const variablesSchema = entity.variablesSchema || {};
|
|
853
|
+
|
|
854
|
+
if (detail.type !== "feature") return <Navigate to=".." replace />;
|
|
855
|
+
|
|
856
|
+
if (Object.keys(variablesSchema).length === 0) {
|
|
857
|
+
return <EmptyState title="No variables found" />;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
return <FeatureVariablesList variablesSchema={variablesSchema} setKey={setKey} />;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function getUsageEntityType(label: string): CatalogEntityType {
|
|
864
|
+
if (label.includes("segment")) {
|
|
865
|
+
return "segment";
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (label.includes("target")) {
|
|
869
|
+
return "target";
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (label.includes("attribute")) {
|
|
873
|
+
return "attribute";
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (label.includes("schema")) {
|
|
877
|
+
return "schema";
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
if (label.includes("group")) {
|
|
881
|
+
return "group";
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
if (label.includes("test")) {
|
|
885
|
+
return "test";
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
return "feature";
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function getUsageTitle(label: string) {
|
|
892
|
+
return label.charAt(0).toUpperCase() + label.slice(1);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function UsageSection(props: {
|
|
896
|
+
title: string;
|
|
897
|
+
type: CatalogEntityType;
|
|
898
|
+
values: string[];
|
|
899
|
+
setKey?: string;
|
|
900
|
+
}) {
|
|
901
|
+
return (
|
|
902
|
+
<section>
|
|
903
|
+
<h2 className="mb-2 text-sm font-semibold text-text">{props.title}</h2>
|
|
904
|
+
<ul className="list-inside list-disc space-y-1 text-sm">
|
|
905
|
+
{props.values.map((value) => (
|
|
906
|
+
<li key={value} className="[overflow-wrap:anywhere]">
|
|
907
|
+
<Link
|
|
908
|
+
className="text-primary hover:underline"
|
|
909
|
+
to={getEntityRoute(props.type, value, props.setKey)}
|
|
910
|
+
>
|
|
911
|
+
<EntityKey value={value} className="font-medium" />
|
|
912
|
+
</Link>
|
|
913
|
+
</li>
|
|
914
|
+
))}
|
|
915
|
+
</ul>
|
|
916
|
+
</section>
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
export function UsageTab() {
|
|
921
|
+
const { detail, setKey } = useEntityDetail();
|
|
922
|
+
const relationships = detail.relationships || {};
|
|
923
|
+
const entries: Array<[string, string[]]> =
|
|
924
|
+
detail.type === "feature"
|
|
925
|
+
? [
|
|
926
|
+
["targets", relationships.targets || []],
|
|
927
|
+
["features", relationships.requiredBy || []],
|
|
928
|
+
]
|
|
929
|
+
: Object.entries(relationships).filter(([label]) => label !== "tests");
|
|
930
|
+
const visibleEntries = entries.filter(([, values]) => values.length > 0);
|
|
931
|
+
|
|
932
|
+
if (visibleEntries.length === 0) {
|
|
933
|
+
return <EmptyState title="No usage found" />;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
return (
|
|
937
|
+
<div className="space-y-6">
|
|
938
|
+
{visibleEntries.map(([label, values]) => (
|
|
939
|
+
<UsageSection
|
|
940
|
+
key={label}
|
|
941
|
+
title={getUsageTitle(label)}
|
|
942
|
+
type={getUsageEntityType(label)}
|
|
943
|
+
values={values}
|
|
944
|
+
setKey={setKey}
|
|
945
|
+
/>
|
|
946
|
+
))}
|
|
947
|
+
</div>
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
export function HistoryTab() {
|
|
952
|
+
const { detail } = useEntityDetail();
|
|
953
|
+
const { manifest } = useCatalog();
|
|
954
|
+
const [page, setPage] = React.useState<HistoryPageData | null>(null);
|
|
955
|
+
|
|
956
|
+
React.useEffect(() => {
|
|
957
|
+
if (!detail.historyPath) {
|
|
958
|
+
setPage({ page: 1, pageSize: 50, totalPages: 1, entries: [] });
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
fetchHistoryPage(detail.historyPath, 1).then(setPage);
|
|
963
|
+
}, [detail.historyPath]);
|
|
964
|
+
|
|
965
|
+
if (!page) {
|
|
966
|
+
return <div className="text-muted">Loading history...</div>;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
return (
|
|
970
|
+
<div className="space-y-3">
|
|
971
|
+
{page.entries.length === 0 && <EmptyState title="No history found" />}
|
|
972
|
+
{page.entries.map((entry) => {
|
|
973
|
+
const commitHref = manifest.links?.commit?.replace("{{hash}}", entry.commit);
|
|
974
|
+
const content = (
|
|
975
|
+
<>
|
|
976
|
+
<div className="font-mono text-sm">{entry.commit.slice(0, 10)}</div>
|
|
977
|
+
<div className="text-sm text-muted">
|
|
978
|
+
{entry.author} · {new Date(entry.timestamp).toLocaleString()}
|
|
979
|
+
</div>
|
|
980
|
+
</>
|
|
981
|
+
);
|
|
982
|
+
|
|
983
|
+
return commitHref ? (
|
|
984
|
+
<a
|
|
985
|
+
key={entry.commit}
|
|
986
|
+
href={commitHref}
|
|
987
|
+
target="_blank"
|
|
988
|
+
rel="noreferrer"
|
|
989
|
+
className="block rounded-lg border border-border bg-surface p-4 shadow-sm ring-1 ring-black/5 hover:bg-elevated"
|
|
990
|
+
>
|
|
991
|
+
{content}
|
|
992
|
+
</a>
|
|
993
|
+
) : (
|
|
994
|
+
<div
|
|
995
|
+
key={entry.commit}
|
|
996
|
+
className="rounded-lg border border-border bg-surface p-4 shadow-sm ring-1 ring-black/5"
|
|
997
|
+
>
|
|
998
|
+
{content}
|
|
999
|
+
</div>
|
|
1000
|
+
);
|
|
1001
|
+
})}
|
|
1002
|
+
</div>
|
|
1003
|
+
);
|
|
1004
|
+
}
|