@featurevisor/catalog 3.0.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/node/index.ts CHANGED
@@ -242,8 +242,18 @@ class CatalogProgressReporter {
242
242
  }
243
243
  }
244
244
 
245
- function encodeKey(key: string) {
246
- return encodeURIComponent(key);
245
+ function encodeKeyPath(key: string) {
246
+ return key
247
+ .split("/")
248
+ .map((segment) => encodeURIComponent(segment))
249
+ .join(path.sep);
250
+ }
251
+
252
+ function encodeKeyUrlPath(key: string) {
253
+ return key
254
+ .split("/")
255
+ .map((segment) => encodeURIComponent(segment))
256
+ .join("/");
247
257
  }
248
258
 
249
259
  function toPosixPath(value: string) {
@@ -448,7 +458,7 @@ function getEntitySummary(
448
458
  promotable: entity.promotable,
449
459
  ...extra,
450
460
  lastModified: getLastModified(historyIndex, type, key, set),
451
- href: `entities/${type}/${encodeKey(key)}.json`,
461
+ href: `entities/${type}/${encodeKeyUrlPath(key)}.json`,
452
462
  };
453
463
  }
454
464
 
@@ -1193,6 +1203,13 @@ async function buildSetCatalog(
1193
1203
  );
1194
1204
  const lastModified = getLastModified(context.historyIndex, plan.type, key, set);
1195
1205
  const entityRelationships = getEntityRelationships(plan.type, key, relationships);
1206
+ const entityTests =
1207
+ plan.type === "feature" || plan.type === "segment"
1208
+ ? (entityRelationships.tests || []).map((testKey) => ({
1209
+ ...maps.test[testKey],
1210
+ key: maps.test[testKey].key || testKey,
1211
+ }))
1212
+ : undefined;
1196
1213
  const detail: EntityDetail = {
1197
1214
  type: plan.type,
1198
1215
  key,
@@ -1201,25 +1218,26 @@ async function buildSetCatalog(
1201
1218
  editLinks: getEditorLinks(context.devEditors, sourceFileInfo),
1202
1219
  lastModified,
1203
1220
  relationships: entityRelationships,
1221
+ tests: entityTests?.length ? entityTests : undefined,
1204
1222
  environments: projectConfig.environments,
1205
1223
  historyPath: `${path.posix.join(
1206
1224
  "data",
1207
1225
  outputRelativeDirectory.split(path.sep).join(path.posix.sep),
1208
1226
  "entities",
1209
1227
  plan.type,
1210
- encodeKey(key),
1228
+ encodeKeyUrlPath(key),
1211
1229
  "history",
1212
1230
  )}`,
1213
1231
  };
1214
1232
 
1215
1233
  await context.writer.write(
1216
- path.join(outputDirectoryPath, "entities", plan.type, `${encodeKey(key)}.json`),
1234
+ path.join(outputDirectoryPath, "entities", plan.type, `${encodeKeyPath(key)}.json`),
1217
1235
  detail,
1218
1236
  );
1219
1237
 
1220
1238
  await writeHistoryPages(
1221
1239
  context.writer,
1222
- path.join(outputDirectoryPath, "entities", plan.type, encodeKey(key), "history"),
1240
+ path.join(outputDirectoryPath, "entities", plan.type, encodeKeyPath(key), "history"),
1223
1241
  context.historyIndex.byEntity[getHistoryEntityKey(plan.type, key, set)] || [],
1224
1242
  );
1225
1243
 
@@ -35,6 +35,7 @@ import {
35
35
  type SchemaLike,
36
36
  } from "../components/variables";
37
37
  import { FeatureVariationsList } from "../components/variations";
38
+ import { EntityTests } from "../components/tests";
38
39
 
39
40
  function isEntityPath(value: string | undefined): value is EntityPath {
40
41
  return (
@@ -78,6 +79,129 @@ function RulePermalink(props: { targetId: string }) {
78
79
  );
79
80
  }
80
81
 
82
+ async function copyText(value: string) {
83
+ if (navigator.clipboard?.writeText) {
84
+ try {
85
+ await navigator.clipboard.writeText(value);
86
+ return;
87
+ } catch {
88
+ // Fall through for browsers that expose the API but deny access in the current context.
89
+ }
90
+ }
91
+
92
+ const textarea = document.createElement("textarea");
93
+ textarea.value = value;
94
+ textarea.setAttribute("readonly", "");
95
+ textarea.style.position = "fixed";
96
+ textarea.style.opacity = "0";
97
+ document.body.appendChild(textarea);
98
+ textarea.select();
99
+
100
+ let copied = false;
101
+
102
+ try {
103
+ copied = document.execCommand("copy");
104
+ } finally {
105
+ document.body.removeChild(textarea);
106
+ }
107
+
108
+ if (!copied) {
109
+ throw new Error("Could not copy entity key");
110
+ }
111
+ }
112
+
113
+ function CopyEntityKeyButton(props: { entityKey: string }) {
114
+ const [status, setStatus] = React.useState<"idle" | "copied" | "error">("idle");
115
+ const resetTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
116
+
117
+ React.useEffect(() => {
118
+ return () => {
119
+ if (resetTimer.current) {
120
+ clearTimeout(resetTimer.current);
121
+ }
122
+ };
123
+ }, []);
124
+
125
+ async function handleCopy() {
126
+ if (resetTimer.current) {
127
+ clearTimeout(resetTimer.current);
128
+ }
129
+
130
+ try {
131
+ await copyText(props.entityKey);
132
+ setStatus("copied");
133
+ } catch {
134
+ setStatus("error");
135
+ }
136
+
137
+ resetTimer.current = setTimeout(() => {
138
+ setStatus("idle");
139
+ resetTimer.current = null;
140
+ }, 2000);
141
+ }
142
+
143
+ const label =
144
+ status === "copied"
145
+ ? `Copied ${props.entityKey}`
146
+ : status === "error"
147
+ ? `Could not copy ${props.entityKey}`
148
+ : `Copy ${props.entityKey}`;
149
+
150
+ return (
151
+ <>
152
+ <button
153
+ type="button"
154
+ aria-label={label}
155
+ title={label}
156
+ onClick={handleCopy}
157
+ className={[
158
+ "inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border text-faint outline-none transition-all",
159
+ "opacity-100 md:opacity-0 md:group-hover:opacity-100",
160
+ "hover:border-border hover:bg-elevated hover:text-text",
161
+ "focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-primary",
162
+ status === "copied"
163
+ ? "border-green-200 bg-green-50 text-green-600 !opacity-100"
164
+ : status === "error"
165
+ ? "border-red-200 bg-red-50 text-red-600 !opacity-100"
166
+ : "border-border/70 bg-surface",
167
+ ].join(" ")}
168
+ >
169
+ {status === "copied" ? (
170
+ <svg
171
+ aria-hidden="true"
172
+ viewBox="0 0 20 20"
173
+ fill="none"
174
+ stroke="currentColor"
175
+ strokeLinecap="round"
176
+ strokeLinejoin="round"
177
+ strokeWidth="1.8"
178
+ className="h-4 w-4"
179
+ >
180
+ <path d="m4 10 3.5 3.5L16 5" />
181
+ </svg>
182
+ ) : (
183
+ <svg
184
+ aria-hidden="true"
185
+ viewBox="0 0 20 20"
186
+ fill="none"
187
+ stroke="currentColor"
188
+ strokeLinecap="round"
189
+ strokeLinejoin="round"
190
+ strokeWidth="1.6"
191
+ className="h-4 w-4"
192
+ >
193
+ <rect x="6.5" y="6.5" width="9" height="9" rx="1.5" />
194
+ <path d="M13.5 6.5V5A1.5 1.5 0 0 0 12 3.5H5A1.5 1.5 0 0 0 3.5 5v7A1.5 1.5 0 0 0 5 13.5h1.5" />
195
+ </svg>
196
+ )}
197
+ </button>
198
+ <span className="sr-only" aria-live="polite">
199
+ {status === "copied" ? "Entity key copied" : status === "error" ? "Copy failed" : ""}
200
+ </span>
201
+ </>
202
+ );
203
+ }
204
+
81
205
  function useScrollToHash(dependencies: React.DependencyList) {
82
206
  React.useEffect(() => {
83
207
  if (typeof window === "undefined" || !window.location.hash) {
@@ -362,6 +486,7 @@ export function EntityDetailPage() {
362
486
  { to: "force", label: "Force" },
363
487
  ]
364
488
  : []),
489
+ ...(type === "feature" || type === "segment" ? [{ to: "tests", label: "Tests" }] : []),
365
490
  ...(type !== "test" ? [{ to: "usage", label: "Usage" }] : []),
366
491
  { to: "history", label: "History" },
367
492
  ];
@@ -370,6 +495,7 @@ export function EntityDetailPage() {
370
495
  <div>
371
496
  <PageHeader
372
497
  title={detail.key}
498
+ titleAction={<CopyEntityKeyButton entityKey={detail.key} />}
373
499
  description={entityLabels[type].singular}
374
500
  actions={<EditLink sourcePath={detail.sourcePath} editLinks={detail.editLinks} />}
375
501
  />
@@ -948,6 +1074,20 @@ export function UsageTab() {
948
1074
  );
949
1075
  }
950
1076
 
1077
+ export function TestsTab() {
1078
+ const { detail } = useEntityDetail();
1079
+
1080
+ if (detail.type !== "feature" && detail.type !== "segment") {
1081
+ return <Navigate to=".." replace />;
1082
+ }
1083
+
1084
+ if (!detail.tests?.length) {
1085
+ return <EmptyState title="No test specs found" />;
1086
+ }
1087
+
1088
+ return <EntityTests tests={detail.tests} />;
1089
+ }
1090
+
951
1091
  export function HistoryTab() {
952
1092
  const { detail } = useEntityDetail();
953
1093
  const { manifest } = useCatalog();
@@ -450,7 +450,7 @@ function VariationsIcon() {
450
450
  return (
451
451
  <span
452
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"
453
+ className="inline-flex items-center justify-center font-mono text-[9px] font-semibold leading-none tracking-tight"
454
454
  >
455
455
  a/b
456
456
  </span>
@@ -461,31 +461,19 @@ function VariablesIcon() {
461
461
  return (
462
462
  <span
463
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"
464
+ className="inline-flex items-center justify-center font-mono text-[9px] font-semibold leading-none"
465
465
  >
466
466
  {"{ }"}
467
467
  </span>
468
468
  );
469
469
  }
470
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
471
  function sortValues(values?: string[]) {
484
472
  return Array.from(new Set(values || [])).sort((left, right) => left.localeCompare(right));
485
473
  }
486
474
 
487
- const rowMetadataIconClassName =
488
- "rounded-full bg-slate-100 p-1 text-slate-400 hover:bg-slate-200 hover:text-slate-600";
475
+ const rowMetadataItemClassName =
476
+ "h-full min-w-5 items-center justify-center px-1 text-faint transition-colors hover:bg-elevated hover:text-muted";
489
477
 
490
478
  function RowMetadataIcons(props: { entity: EntitySummary; type: CatalogEntityType }) {
491
479
  const targets = sortValues(props.entity.targets);
@@ -498,20 +486,26 @@ function RowMetadataIcons(props: { entity: EntitySummary; type: CatalogEntityTyp
498
486
  }
499
487
 
500
488
  return (
501
- <div className="flex shrink-0 items-center gap-1">
489
+ <div className="flex h-5 shrink-0 divide-x divide-border/70 overflow-hidden rounded-md border border-border/70 bg-surface">
502
490
  {showVariations && (
503
- <HoverTooltip label="Has variations" className={rowMetadataIconClassName}>
491
+ <HoverTooltip label="Has variations" className={rowMetadataItemClassName}>
504
492
  <VariationsIcon />
505
493
  </HoverTooltip>
506
494
  )}
507
495
  {showVariables && (
508
- <HoverTooltip label="Has variables" className={rowMetadataIconClassName}>
496
+ <HoverTooltip label="Has variables" className={rowMetadataItemClassName}>
509
497
  <VariablesIcon />
510
498
  </HoverTooltip>
511
499
  )}
512
500
  {targets.length > 0 && (
513
- <HoverTooltip label={`Targets: ${targets.join(", ")}`} className={rowMetadataIconClassName}>
514
- <TargetIcon />
501
+ <HoverTooltip
502
+ label={`Targets: ${targets.join(", ")}`}
503
+ className={`${rowMetadataItemClassName} px-1.5`}
504
+ >
505
+ <span className="inline-flex items-baseline gap-1 text-[10px] leading-none">
506
+ <span>Targets</span>
507
+ <span className="font-semibold tabular-nums text-muted">{targets.length}</span>
508
+ </span>
515
509
  </HoverTooltip>
516
510
  )}
517
511
  </div>
@@ -0,0 +1,123 @@
1
+ import type {
2
+ AssertionMatrix,
3
+ FeatureAssertion,
4
+ SegmentAssertion,
5
+ Test,
6
+ } from "@featurevisor/types";
7
+
8
+ export interface ExpandedTestAssertion {
9
+ assertion: FeatureAssertion | SegmentAssertion;
10
+ assertionIndex: number;
11
+ caseIndex?: number;
12
+ caseCount?: number;
13
+ label: string;
14
+ matrixValues?: Record<string, unknown>;
15
+ }
16
+
17
+ function getMatrixCombinations(matrix: AssertionMatrix) {
18
+ const keys = Object.keys(matrix);
19
+
20
+ if (keys.length === 0) {
21
+ return [];
22
+ }
23
+
24
+ return keys.reduce<Array<Record<string, unknown>>>(
25
+ (combinations, key) =>
26
+ combinations.flatMap((combination) =>
27
+ matrix[key].map((value) => ({ ...combination, [key]: value })),
28
+ ),
29
+ [{}],
30
+ );
31
+ }
32
+
33
+ function applyCombinationToValue(value: unknown, combination: Record<string, unknown>) {
34
+ if (typeof value !== "string") {
35
+ return value;
36
+ }
37
+
38
+ const placeholders = value.match(/\${{(.+?)}}/g);
39
+ if (!placeholders) {
40
+ return value;
41
+ }
42
+
43
+ if (placeholders.length === 1 && value.startsWith("${{") && value.endsWith("}}")) {
44
+ const key = value.replace("${{", "").replace("}}", "").trim();
45
+ return combination[key];
46
+ }
47
+
48
+ return value.replace(/\${{(.+?)}}/g, (_, key) => String(combination[key.trim()]));
49
+ }
50
+
51
+ function applyCombinationToContext(
52
+ context: Record<string, unknown> | undefined,
53
+ combination: Record<string, unknown>,
54
+ ) {
55
+ return Object.fromEntries(
56
+ Object.entries(context || {}).map(([key, value]) => [
57
+ key,
58
+ applyCombinationToValue(value, combination),
59
+ ]),
60
+ );
61
+ }
62
+
63
+ function applyCombinationToAssertion(
64
+ test: Test,
65
+ assertion: FeatureAssertion | SegmentAssertion,
66
+ combination: Record<string, unknown>,
67
+ ) {
68
+ const result = {
69
+ ...assertion,
70
+ context: applyCombinationToContext(assertion.context, combination),
71
+ };
72
+ delete result.matrix;
73
+
74
+ if (result.description) {
75
+ result.description = String(applyCombinationToValue(result.description, combination));
76
+ }
77
+
78
+ if ("feature" in test) {
79
+ const featureResult = result as FeatureAssertion;
80
+ featureResult.environment = applyCombinationToValue(
81
+ featureResult.environment,
82
+ combination,
83
+ ) as FeatureAssertion["environment"];
84
+ featureResult.target = applyCombinationToValue(
85
+ featureResult.target,
86
+ combination,
87
+ ) as FeatureAssertion["target"];
88
+ const at = applyCombinationToValue(featureResult.at, combination);
89
+ featureResult.at = (
90
+ typeof at === "string" ? (at.includes(".") ? parseFloat(at) : parseInt(at, 10)) : at
91
+ ) as FeatureAssertion["at"];
92
+ }
93
+
94
+ return result;
95
+ }
96
+
97
+ export function expandTestAssertions(test: Test): ExpandedTestAssertion[] {
98
+ return test.assertions.flatMap((assertion, assertionIndex) => {
99
+ if (!assertion.matrix) {
100
+ return [
101
+ {
102
+ assertion: { ...assertion },
103
+ assertionIndex,
104
+ label: String(assertionIndex + 1),
105
+ },
106
+ ];
107
+ }
108
+
109
+ const combinations = getMatrixCombinations(assertion.matrix);
110
+ return combinations.map((combination, caseIndex) => ({
111
+ assertion: applyCombinationToAssertion(test, assertion, combination),
112
+ assertionIndex,
113
+ caseIndex,
114
+ caseCount: combinations.length,
115
+ label: `${assertionIndex + 1}.${caseIndex + 1}`,
116
+ matrixValues: combination,
117
+ }));
118
+ });
119
+ }
120
+
121
+ export function getTestAssertionPermalink(testKey: string, assertionLabel: string) {
122
+ return `${testKey}:${assertionLabel}`;
123
+ }
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { EntityType } from "@featurevisor/types";
1
+ import type { EntityType, Test } from "@featurevisor/types";
2
2
 
3
3
  export type CatalogEntityType = EntityType;
4
4
  export type EntityPath =
@@ -106,6 +106,7 @@ export interface EntityDetail<T = Record<string, unknown>> {
106
106
  editLinks?: Partial<Record<DevEditorId, string>>;
107
107
  lastModified?: LastModified;
108
108
  relationships?: Record<string, string[]>;
109
+ tests?: Test[];
109
110
  environments?: string[];
110
111
  historyPath?: string;
111
112
  }