@messagevisor/catalog 0.0.1 → 0.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/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/assets/index-CfGbXx4X.css +1 -0
- package/dist/assets/index-r8ugP5JL.js +73 -0
- package/dist/favicon.png +0 -0
- package/dist/index.html +14 -0
- package/dist/logo-text.png +0 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +18 -0
- package/lib/index.js.map +1 -0
- package/lib/node/formatExamplePreview.d.ts +10 -0
- package/lib/node/formatExamplePreview.js +79 -0
- package/lib/node/formatExamplePreview.js.map +1 -0
- package/lib/node/index.d.ts +191 -0
- package/lib/node/index.js +1645 -0
- package/lib/node/index.js.map +1 -0
- package/package.json +59 -13
- package/src/App.tsx +73 -0
- package/src/api.spec.ts +42 -0
- package/src/api.ts +87 -0
- package/src/catalogBrandAssets.ts +8 -0
- package/src/components/details/ConditionTree.tsx +146 -0
- package/src/components/details/FieldGrid.tsx +16 -0
- package/src/components/details/GroupSegmentTree.tsx +73 -0
- package/src/components/details/MarkdownContent.tsx +23 -0
- package/src/components/details/TranslationsTable.tsx +263 -0
- package/src/components/details/UsageLinks.tsx +29 -0
- package/src/components/history/HistoryTimeline.tsx +122 -0
- package/src/components/layout/AppShell.tsx +338 -0
- package/src/components/layout/PageHeader.tsx +13 -0
- package/src/components/layout/Tabs.tsx +35 -0
- package/src/components/lists/EntityList.tsx +451 -0
- package/src/components/ui/Badge.tsx +21 -0
- package/src/components/ui/Button.tsx +12 -0
- package/src/components/ui/Card.tsx +9 -0
- package/src/components/ui/CodeBlock.tsx +7 -0
- package/src/components/ui/EmptyState.tsx +8 -0
- package/src/components/ui/Input.tsx +12 -0
- package/src/components/ui/LabelValueBadge.tsx +55 -0
- package/src/config.ts +2 -0
- package/src/context/CatalogContext.tsx +50 -0
- package/src/entityTypes.ts +49 -0
- package/src/index.ts +1 -0
- package/src/main.tsx +28 -0
- package/src/node/formatExamplePreview.ts +85 -0
- package/src/node/index.spec.ts +713 -0
- package/src/node/index.ts +2007 -0
- package/src/pages/EntityDetailPage.tsx +3345 -0
- package/src/pages/HistoryPage.tsx +26 -0
- package/src/pages/HomePage.tsx +21 -0
- package/src/pages/ListPage.tsx +59 -0
- package/src/styles.css +95 -0
- package/src/theme.ts +36 -0
- package/src/types.ts +127 -0
- package/src/utils/formatCatalogTimestamp.ts +77 -0
- package/src/utils/hashTranslationValue.spec.ts +20 -0
- package/src/utils/hashTranslationValue.ts +22 -0
- package/src/utils/searchQuery.ts +46 -0
|
@@ -0,0 +1,3345 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {
|
|
3
|
+
Link,
|
|
4
|
+
Navigate,
|
|
5
|
+
Outlet,
|
|
6
|
+
useOutletContext,
|
|
7
|
+
useParams,
|
|
8
|
+
useSearchParams,
|
|
9
|
+
} from "react-router-dom";
|
|
10
|
+
|
|
11
|
+
import { fetchEntityDetail, fetchLocaleDuplicates } from "../api";
|
|
12
|
+
import {
|
|
13
|
+
encodeRouteSegment,
|
|
14
|
+
entityLabels,
|
|
15
|
+
entityPathToType,
|
|
16
|
+
entityTypeToPath,
|
|
17
|
+
getBasePath,
|
|
18
|
+
getEntityRoute,
|
|
19
|
+
} from "../entityTypes";
|
|
20
|
+
import type {
|
|
21
|
+
DevEditor,
|
|
22
|
+
DuplicateTranslationValue,
|
|
23
|
+
EntityDetail,
|
|
24
|
+
EntityPath,
|
|
25
|
+
FormatRow,
|
|
26
|
+
LocaleDuplicates,
|
|
27
|
+
TranslationRow,
|
|
28
|
+
} from "../types";
|
|
29
|
+
import { PageHeader } from "../components/layout/PageHeader";
|
|
30
|
+
import { Tabs } from "../components/layout/Tabs";
|
|
31
|
+
import { Badge } from "../components/ui/Badge";
|
|
32
|
+
import { CodeBlock } from "../components/ui/CodeBlock";
|
|
33
|
+
import { LabelValueBadge } from "../components/ui/LabelValueBadge";
|
|
34
|
+
import { EmptyState } from "../components/ui/EmptyState";
|
|
35
|
+
import { Input } from "../components/ui/Input";
|
|
36
|
+
import { FieldGrid } from "../components/details/FieldGrid";
|
|
37
|
+
import { ConditionTree } from "../components/details/ConditionTree";
|
|
38
|
+
import { GroupSegmentTree } from "../components/details/GroupSegmentTree";
|
|
39
|
+
import { MarkdownContent } from "../components/details/MarkdownContent";
|
|
40
|
+
import { TranslationsTable } from "../components/details/TranslationsTable";
|
|
41
|
+
import { UsageLinks } from "../components/details/UsageLinks";
|
|
42
|
+
import { HistoryTimeline } from "../components/history/HistoryTimeline";
|
|
43
|
+
import { useCatalog } from "../context/CatalogContext";
|
|
44
|
+
import { hashTranslationValue } from "../utils/hashTranslationValue";
|
|
45
|
+
import type { ParsedQuery } from "../utils/searchQuery";
|
|
46
|
+
import { parseQuery } from "../utils/searchQuery";
|
|
47
|
+
|
|
48
|
+
interface EvaluatedMessageExample {
|
|
49
|
+
locale: string;
|
|
50
|
+
exampleIndex: number;
|
|
51
|
+
matrixIndex?: number;
|
|
52
|
+
description?: string;
|
|
53
|
+
values?: Record<string, unknown>;
|
|
54
|
+
context?: Record<string, unknown>;
|
|
55
|
+
formats?: Record<string, unknown>;
|
|
56
|
+
currency?: string;
|
|
57
|
+
timeZone?: string;
|
|
58
|
+
evaluatedTranslation: unknown;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface EvaluatedLocaleExample {
|
|
62
|
+
locale: string;
|
|
63
|
+
sourceLocale: string;
|
|
64
|
+
exampleIndex: number;
|
|
65
|
+
matrixIndex?: number;
|
|
66
|
+
description?: string;
|
|
67
|
+
rawMessage?: string;
|
|
68
|
+
message?: string;
|
|
69
|
+
originalTranslation?: string;
|
|
70
|
+
values?: Record<string, unknown>;
|
|
71
|
+
context?: Record<string, unknown>;
|
|
72
|
+
formats?: Record<string, unknown>;
|
|
73
|
+
currency?: string;
|
|
74
|
+
timeZone?: string;
|
|
75
|
+
evaluatedTranslation: unknown;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getLocaleDirection(
|
|
79
|
+
localeKey: string | undefined,
|
|
80
|
+
localeDirections?: Record<string, string | undefined>,
|
|
81
|
+
) {
|
|
82
|
+
if (!localeKey) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return localeDirections?.[localeKey];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function slugifyFragment(value: string) {
|
|
90
|
+
return value
|
|
91
|
+
.toLowerCase()
|
|
92
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
93
|
+
.replace(/^-+|-+$/g, "");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function escapeRegExp(value: string) {
|
|
97
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function ExamplesSearchHighlight(props: { text: string; query: string }) {
|
|
101
|
+
const q = props.query.trim();
|
|
102
|
+
if (!q) {
|
|
103
|
+
return <>{props.text}</>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const escaped = escapeRegExp(q);
|
|
107
|
+
const regex = new RegExp(escaped, "gi");
|
|
108
|
+
const parts: React.ReactNode[] = [];
|
|
109
|
+
let lastIndex = 0;
|
|
110
|
+
let key = 0;
|
|
111
|
+
|
|
112
|
+
for (const match of props.text.matchAll(regex)) {
|
|
113
|
+
if (match.index !== undefined && match.index > lastIndex) {
|
|
114
|
+
parts.push(props.text.slice(lastIndex, match.index));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (match.index !== undefined) {
|
|
118
|
+
parts.push(
|
|
119
|
+
<mark
|
|
120
|
+
key={`hm-${match.index}-${key++}`}
|
|
121
|
+
className={[
|
|
122
|
+
"rounded-[3px] bg-amber-100 px-0.5 py-px text-inherit",
|
|
123
|
+
"shadow-[inset_0_-2px_0_0_rgba(251,191,36,0.35)] ring-1 ring-amber-400/25 ring-inset",
|
|
124
|
+
"transition-[background-color,box-shadow] duration-150",
|
|
125
|
+
].join(" ")}
|
|
126
|
+
>
|
|
127
|
+
{match[0]}
|
|
128
|
+
</mark>,
|
|
129
|
+
);
|
|
130
|
+
lastIndex = match.index + match[0].length;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (lastIndex < props.text.length) {
|
|
135
|
+
parts.push(props.text.slice(lastIndex));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return <>{parts}</>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isEntityPath(value: string | undefined): value is EntityPath {
|
|
142
|
+
return (
|
|
143
|
+
value === "locales" ||
|
|
144
|
+
value === "messages" ||
|
|
145
|
+
value === "attributes" ||
|
|
146
|
+
value === "segments" ||
|
|
147
|
+
value === "targets"
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function useEntityDetail() {
|
|
152
|
+
return useOutletContext<{ detail: EntityDetail; setKey?: string }>();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function formatValue(value: unknown) {
|
|
156
|
+
if (typeof value === "undefined" || value === null || value === "") {
|
|
157
|
+
return "n/a";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (Array.isArray(value)) {
|
|
161
|
+
return value.length > 0 ? value.join(", ") : "none";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (typeof value === "object") {
|
|
165
|
+
const keys = Object.keys(value as Record<string, unknown>);
|
|
166
|
+
return keys.length > 0 ? keys.join(", ") : "none";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return String(value);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function rowMatchesFormatSearch(row: FormatRow, parsed: ParsedQuery): boolean {
|
|
173
|
+
const parts = splitFormatPath(row.path);
|
|
174
|
+
const valueStr = formatValue(row.value);
|
|
175
|
+
const preview = row.examplePreview ?? "";
|
|
176
|
+
const haystack =
|
|
177
|
+
`${row.path} ${valueStr} ${preview} ${row.source} ${row.from ?? ""}`.toLowerCase();
|
|
178
|
+
|
|
179
|
+
for (const term of parsed.freeText) {
|
|
180
|
+
if (!haystack.includes(term)) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const q of parsed.qualifiers) {
|
|
186
|
+
const v = q.value.toLowerCase();
|
|
187
|
+
switch (q.key) {
|
|
188
|
+
case "type":
|
|
189
|
+
if (!parts.type.toLowerCase().includes(v)) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
break;
|
|
193
|
+
case "style":
|
|
194
|
+
if (!parts.style.toLowerCase().includes(v)) {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
break;
|
|
198
|
+
case "param":
|
|
199
|
+
if (!parts.param.toLowerCase().includes(v)) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
break;
|
|
203
|
+
case "value":
|
|
204
|
+
if (!valueStr.toLowerCase().includes(v)) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
break;
|
|
208
|
+
case "from":
|
|
209
|
+
if (!(row.from || "").toLowerCase().includes(v)) {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
break;
|
|
213
|
+
case "source":
|
|
214
|
+
if (!row.source.toLowerCase().includes(v)) {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
break;
|
|
218
|
+
default:
|
|
219
|
+
if (!haystack.includes(v)) {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function filterFormatRowsBySearch(rows: FormatRow[], query: string): FormatRow[] {
|
|
229
|
+
const trimmed = query.trim();
|
|
230
|
+
if (!trimmed) {
|
|
231
|
+
return rows;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const parsed = parseQuery(trimmed);
|
|
235
|
+
if (parsed.freeText.length === 0 && parsed.qualifiers.length === 0) {
|
|
236
|
+
return rows;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return rows.filter((row) => rowMatchesFormatSearch(row, parsed));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Single substring for amber highlight (matches longest searchable term). */
|
|
243
|
+
function formatSearchHighlightNeedle(raw: string): string {
|
|
244
|
+
const trimmed = raw.trim();
|
|
245
|
+
if (!trimmed) {
|
|
246
|
+
return "";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const parsed = parseQuery(trimmed);
|
|
250
|
+
const parts: string[] = [...parsed.freeText];
|
|
251
|
+
const scopedKeys = new Set(["type", "style", "param", "value", "from", "source"]);
|
|
252
|
+
for (const q of parsed.qualifiers) {
|
|
253
|
+
if (scopedKeys.has(q.key)) {
|
|
254
|
+
parts.push(q.value.toLowerCase());
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (parts.length === 0) {
|
|
259
|
+
return trimmed;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return parts.reduce((longest, next) => (next.length > longest.length ? next : longest));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Splits a flattened format path into catalog columns: type → style → remainder (param). */
|
|
266
|
+
function splitFormatPath(path: string): { type: string; style: string; param: string } {
|
|
267
|
+
const segments = path.split(".").filter(Boolean);
|
|
268
|
+
if (segments.length === 0) {
|
|
269
|
+
return { type: "", style: "", param: "" };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (segments.length === 1) {
|
|
273
|
+
return { type: segments[0], style: "", param: "" };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (segments.length === 2) {
|
|
277
|
+
return { type: segments[0], style: segments[1], param: "" };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
type: segments[0],
|
|
282
|
+
style: segments[1],
|
|
283
|
+
param: segments.slice(2).join("."),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
interface FormatSplitRowPlan {
|
|
288
|
+
row: FormatRow;
|
|
289
|
+
parts: { type: string; style: string; param: string };
|
|
290
|
+
showTypeCell: boolean;
|
|
291
|
+
typeRowSpan: number;
|
|
292
|
+
showStyleCell: boolean;
|
|
293
|
+
styleRowSpan: number;
|
|
294
|
+
/** Alternates by format *type* group for subtle banding. */
|
|
295
|
+
typeBand: number;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Sorts by path, then yields rowspan plans so repeated type/style cells are not duplicated. */
|
|
299
|
+
function buildFormatSplitRowPlans(rows: FormatRow[]): FormatSplitRowPlan[] {
|
|
300
|
+
const sorted = [...rows].sort((a, b) => a.path.localeCompare(b.path));
|
|
301
|
+
const withParts = sorted.map((row) => ({
|
|
302
|
+
row,
|
|
303
|
+
parts: splitFormatPath(row.path),
|
|
304
|
+
}));
|
|
305
|
+
|
|
306
|
+
const plans: FormatSplitRowPlan[] = [];
|
|
307
|
+
let typeBand = 0;
|
|
308
|
+
let i = 0;
|
|
309
|
+
|
|
310
|
+
while (i < withParts.length) {
|
|
311
|
+
const type = withParts[i].parts.type;
|
|
312
|
+
let j = i;
|
|
313
|
+
while (j < withParts.length && withParts[j].parts.type === type) {
|
|
314
|
+
j++;
|
|
315
|
+
}
|
|
316
|
+
const typeRowSpan = j - i;
|
|
317
|
+
|
|
318
|
+
let k = i;
|
|
319
|
+
while (k < j) {
|
|
320
|
+
const style = withParts[k].parts.style;
|
|
321
|
+
let m = k;
|
|
322
|
+
while (m < j && withParts[m].parts.style === style) {
|
|
323
|
+
m++;
|
|
324
|
+
}
|
|
325
|
+
const styleRowSpan = m - k;
|
|
326
|
+
|
|
327
|
+
for (let n = k; n < m; n++) {
|
|
328
|
+
plans.push({
|
|
329
|
+
row: withParts[n].row,
|
|
330
|
+
parts: withParts[n].parts,
|
|
331
|
+
showTypeCell: n === i,
|
|
332
|
+
typeRowSpan,
|
|
333
|
+
showStyleCell: n === k,
|
|
334
|
+
styleRowSpan,
|
|
335
|
+
typeBand,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
k = m;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
typeBand += 1;
|
|
342
|
+
i = j;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return plans;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function collectSortedFormatTypes(rows: FormatRow[]): string[] {
|
|
349
|
+
const seen = new Set<string>();
|
|
350
|
+
for (const row of rows) {
|
|
351
|
+
const t = splitFormatPath(row.path).type;
|
|
352
|
+
if (t) {
|
|
353
|
+
seen.add(t);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return [...seen].sort((a, b) => a.localeCompare(b));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Pills after "All types": always `number`, `date`, `time`, then other types from data (sorted). */
|
|
360
|
+
const FORMAT_TYPE_PRIMARY_PILLS: readonly string[] = ["number", "date", "time"];
|
|
361
|
+
|
|
362
|
+
/** Set true to show the Example column; `examplePreview` is still generated at catalog build. */
|
|
363
|
+
const SHOW_FORMAT_EXAMPLE_COLUMN_IN_UI = false;
|
|
364
|
+
|
|
365
|
+
function showFormatExampleColumn(selectedFormatType: string | null | undefined): boolean {
|
|
366
|
+
if (!SHOW_FORMAT_EXAMPLE_COLUMN_IN_UI) {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
return (
|
|
370
|
+
selectedFormatType === "number" ||
|
|
371
|
+
selectedFormatType === "date" ||
|
|
372
|
+
selectedFormatType === "time"
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function orderedFormatTypePillKeys(typesFromData: string[]): string[] {
|
|
377
|
+
const primarySet = new Set(FORMAT_TYPE_PRIMARY_PILLS);
|
|
378
|
+
const rest = typesFromData.filter((t) => !primarySet.has(t)).sort((a, b) => a.localeCompare(b));
|
|
379
|
+
return [...FORMAT_TYPE_PRIMARY_PILLS, ...rest];
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function setSearchParam(searchParams: URLSearchParams, key: string, value?: string) {
|
|
383
|
+
const next = new URLSearchParams(searchParams);
|
|
384
|
+
|
|
385
|
+
if (!value) {
|
|
386
|
+
next.delete(key);
|
|
387
|
+
} else {
|
|
388
|
+
next.set(key, value);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return next;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function ExamplesCompactViewIcon() {
|
|
395
|
+
return (
|
|
396
|
+
<svg
|
|
397
|
+
className="h-3.5 w-3.5 shrink-0"
|
|
398
|
+
viewBox="0 0 24 24"
|
|
399
|
+
fill="none"
|
|
400
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
401
|
+
aria-hidden={true}
|
|
402
|
+
>
|
|
403
|
+
<path
|
|
404
|
+
d="M4 6h16M4 12h16M4 18h16"
|
|
405
|
+
stroke="currentColor"
|
|
406
|
+
strokeWidth="2"
|
|
407
|
+
strokeLinecap="round"
|
|
408
|
+
strokeLinejoin="round"
|
|
409
|
+
/>
|
|
410
|
+
</svg>
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function ExamplesExpandedViewIcon() {
|
|
415
|
+
return (
|
|
416
|
+
<svg
|
|
417
|
+
className="h-3.5 w-3.5 shrink-0"
|
|
418
|
+
viewBox="0 0 24 24"
|
|
419
|
+
fill="none"
|
|
420
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
421
|
+
aria-hidden={true}
|
|
422
|
+
>
|
|
423
|
+
<rect x="3" y="3" width="18" height="6" rx="1.5" stroke="currentColor" strokeWidth="2" />
|
|
424
|
+
<rect x="3" y="15" width="18" height="6" rx="1.5" stroke="currentColor" strokeWidth="2" />
|
|
425
|
+
</svg>
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const EXAMPLES_TOOLBAR_CONTROL_HEIGHT_CLASS = "h-7";
|
|
430
|
+
|
|
431
|
+
function ExamplesViewModeSwitch(props: {
|
|
432
|
+
activeView: "compact" | "expanded";
|
|
433
|
+
onViewChange: (view: "compact" | "expanded") => void;
|
|
434
|
+
}) {
|
|
435
|
+
return (
|
|
436
|
+
<div
|
|
437
|
+
className={[
|
|
438
|
+
"inline-flex shrink-0 items-stretch rounded-lg border border-border bg-elevated p-px",
|
|
439
|
+
EXAMPLES_TOOLBAR_CONTROL_HEIGHT_CLASS,
|
|
440
|
+
].join(" ")}
|
|
441
|
+
role="tablist"
|
|
442
|
+
aria-label="Example layout"
|
|
443
|
+
>
|
|
444
|
+
<button
|
|
445
|
+
type="button"
|
|
446
|
+
role="tab"
|
|
447
|
+
aria-selected={props.activeView === "compact"}
|
|
448
|
+
aria-label="Compact"
|
|
449
|
+
title="Compact"
|
|
450
|
+
className={[
|
|
451
|
+
"inline-flex h-full min-h-0 items-center justify-center rounded-md px-2 py-0 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
452
|
+
props.activeView === "compact"
|
|
453
|
+
? "bg-header-active text-header-text shadow-sm"
|
|
454
|
+
: "text-muted hover:text-text",
|
|
455
|
+
].join(" ")}
|
|
456
|
+
onClick={() => props.onViewChange("compact")}
|
|
457
|
+
>
|
|
458
|
+
<ExamplesCompactViewIcon />
|
|
459
|
+
</button>
|
|
460
|
+
<button
|
|
461
|
+
type="button"
|
|
462
|
+
role="tab"
|
|
463
|
+
aria-selected={props.activeView === "expanded"}
|
|
464
|
+
aria-label="Expanded"
|
|
465
|
+
title="Expanded"
|
|
466
|
+
className={[
|
|
467
|
+
"inline-flex h-full min-h-0 items-center justify-center rounded-md px-2 py-0 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
468
|
+
props.activeView === "expanded"
|
|
469
|
+
? "bg-header-active text-header-text shadow-sm"
|
|
470
|
+
: "text-muted hover:text-text",
|
|
471
|
+
].join(" ")}
|
|
472
|
+
onClick={() => props.onViewChange("expanded")}
|
|
473
|
+
>
|
|
474
|
+
<ExamplesExpandedViewIcon />
|
|
475
|
+
</button>
|
|
476
|
+
</div>
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function setWindowHash(targetId?: string) {
|
|
481
|
+
if (typeof window === "undefined") {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const url = new URL(window.location.href);
|
|
486
|
+
|
|
487
|
+
if (!targetId) {
|
|
488
|
+
url.hash = "";
|
|
489
|
+
} else {
|
|
490
|
+
url.hash = targetId;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
window.history.replaceState(null, "", url.toString());
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function JsonValueBlock(props: { value: unknown }) {
|
|
497
|
+
if (
|
|
498
|
+
props.value &&
|
|
499
|
+
typeof props.value === "object" &&
|
|
500
|
+
!Array.isArray(props.value) &&
|
|
501
|
+
Object.keys(props.value as Record<string, unknown>).length === 1
|
|
502
|
+
) {
|
|
503
|
+
const [key, value] = Object.entries(props.value as Record<string, unknown>)[0];
|
|
504
|
+
const isPrimitive =
|
|
505
|
+
value === null ||
|
|
506
|
+
typeof value === "string" ||
|
|
507
|
+
typeof value === "number" ||
|
|
508
|
+
typeof value === "boolean";
|
|
509
|
+
|
|
510
|
+
if (isPrimitive) {
|
|
511
|
+
return (
|
|
512
|
+
<pre className="max-w-full whitespace-pre-wrap rounded border border-border bg-elevated p-4 text-xs text-text [overflow-wrap:anywhere]">
|
|
513
|
+
{`{${JSON.stringify(key)}: ${JSON.stringify(value)}}`}
|
|
514
|
+
</pre>
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return <CodeBlock value={props.value} />;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function TranslationValueBlock(props: { value: unknown; direction?: string }) {
|
|
523
|
+
return (
|
|
524
|
+
<div
|
|
525
|
+
dir={props.direction}
|
|
526
|
+
className={["min-w-0 max-w-full", props.direction === "rtl" ? "text-right" : ""]
|
|
527
|
+
.filter(Boolean)
|
|
528
|
+
.join(" ")}
|
|
529
|
+
style={props.direction ? { unicodeBidi: "plaintext" } : undefined}
|
|
530
|
+
>
|
|
531
|
+
<CodeBlock value={props.value} />
|
|
532
|
+
</div>
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function ExamplePermalink(props: { targetId: string }) {
|
|
537
|
+
return (
|
|
538
|
+
<a
|
|
539
|
+
href={`#${props.targetId}`}
|
|
540
|
+
aria-label="Link to this example"
|
|
541
|
+
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-ring group-hover:opacity-100"
|
|
542
|
+
>
|
|
543
|
+
<svg
|
|
544
|
+
aria-hidden="true"
|
|
545
|
+
viewBox="0 0 24 24"
|
|
546
|
+
className="h-4 w-4"
|
|
547
|
+
fill="none"
|
|
548
|
+
stroke="currentColor"
|
|
549
|
+
strokeWidth="2"
|
|
550
|
+
strokeLinecap="round"
|
|
551
|
+
strokeLinejoin="round"
|
|
552
|
+
>
|
|
553
|
+
<path d="M10 13a5 5 0 0 0 7.07 0l2.83-2.83a5 5 0 0 0-7.07-7.07L11 4" />
|
|
554
|
+
<path d="M14 11a5 5 0 0 0-7.07 0L4.1 13.83a5 5 0 1 0 7.07 7.07L13 20" />
|
|
555
|
+
</svg>
|
|
556
|
+
</a>
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function useScrollToHash(dependencies: React.DependencyList) {
|
|
561
|
+
React.useEffect(() => {
|
|
562
|
+
if (typeof window === "undefined" || !window.location.hash) {
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const targetId = decodeURIComponent(window.location.hash.slice(1));
|
|
567
|
+
|
|
568
|
+
if (!targetId) {
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const frame = window.requestAnimationFrame(() => {
|
|
573
|
+
const targetElement = document.getElementById(targetId);
|
|
574
|
+
|
|
575
|
+
if (!targetElement) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
targetElement.scrollIntoView({ block: "start" });
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
return () => {
|
|
583
|
+
window.cancelAnimationFrame(frame);
|
|
584
|
+
};
|
|
585
|
+
}, dependencies);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function CaretIcon() {
|
|
589
|
+
return (
|
|
590
|
+
<svg aria-hidden="true" viewBox="0 0 12 12" fill="none" className="h-3 w-3">
|
|
591
|
+
<path
|
|
592
|
+
d="M3.25 4.5 6 7.25 8.75 4.5"
|
|
593
|
+
stroke="currentColor"
|
|
594
|
+
strokeWidth="1.5"
|
|
595
|
+
strokeLinecap="round"
|
|
596
|
+
strokeLinejoin="round"
|
|
597
|
+
/>
|
|
598
|
+
</svg>
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function EditorIcon(props: { icon: DevEditor["icon"] }) {
|
|
603
|
+
if (props.icon === "cursor") {
|
|
604
|
+
return (
|
|
605
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" className="h-4 w-4">
|
|
606
|
+
<path d="M4 3l16 9-7 2-3 7L4 3Z" fill="#111827" />
|
|
607
|
+
<path d="M8.2 8.6 15 12.6" stroke="#ffffff" strokeWidth="1.4" strokeLinecap="round" />
|
|
608
|
+
<path d="M10.2 12.9 12.6 14.3 10.5 18.8Z" fill="#ffffff" opacity=".92" />
|
|
609
|
+
</svg>
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return (
|
|
614
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" className="h-4 w-4">
|
|
615
|
+
<path
|
|
616
|
+
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"
|
|
617
|
+
fill="#007ACC"
|
|
618
|
+
/>
|
|
619
|
+
<path d="M17.2 8.2v7.6L12.5 12l4.7-3.8Z" fill="#ffffff" opacity=".35" />
|
|
620
|
+
</svg>
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function EditLink(props: { sourcePath?: string; editLinks?: EntityDetail["editLinks"] }) {
|
|
625
|
+
const { manifest } = useCatalog();
|
|
626
|
+
const [open, setOpen] = React.useState(false);
|
|
627
|
+
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
|
628
|
+
|
|
629
|
+
React.useEffect(() => {
|
|
630
|
+
if (!open) {
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function handlePointerDown(event: PointerEvent) {
|
|
635
|
+
if (!containerRef.current?.contains(event.target as Node)) {
|
|
636
|
+
setOpen(false);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
641
|
+
if (event.key === "Escape") {
|
|
642
|
+
setOpen(false);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
document.addEventListener("pointerdown", handlePointerDown);
|
|
647
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
648
|
+
|
|
649
|
+
return () => {
|
|
650
|
+
document.removeEventListener("pointerdown", handlePointerDown);
|
|
651
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
652
|
+
};
|
|
653
|
+
}, [open]);
|
|
654
|
+
|
|
655
|
+
const sourceHref =
|
|
656
|
+
props.sourcePath && manifest.links?.source
|
|
657
|
+
? manifest.links.source.replace("{{path}}", props.sourcePath)
|
|
658
|
+
: undefined;
|
|
659
|
+
const editors = (manifest.dev?.editors || []).filter((editor) => props.editLinks?.[editor.id]);
|
|
660
|
+
const hasEditorLinks = editors.length > 0;
|
|
661
|
+
|
|
662
|
+
if (!sourceHref && !hasEditorLinks) {
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const buttonClass =
|
|
667
|
+
"rounded border border-border bg-elevated px-4 py-2 text-sm font-bold text-muted shadow-sm hover:bg-background";
|
|
668
|
+
const splitButtonClass =
|
|
669
|
+
"border border-border bg-elevated px-4 py-2 text-sm font-bold text-muted shadow-sm hover:bg-background";
|
|
670
|
+
const menuButtonClass =
|
|
671
|
+
"border border-border bg-elevated py-2 text-sm font-bold text-muted shadow-sm hover:bg-background";
|
|
672
|
+
|
|
673
|
+
const dropdown = hasEditorLinks ? (
|
|
674
|
+
<div
|
|
675
|
+
role="menu"
|
|
676
|
+
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"
|
|
677
|
+
>
|
|
678
|
+
{editors.map((editor) => (
|
|
679
|
+
<a
|
|
680
|
+
key={editor.id}
|
|
681
|
+
role="menuitem"
|
|
682
|
+
href={props.editLinks?.[editor.id]}
|
|
683
|
+
className="flex items-center gap-2 px-3 py-2 text-sm font-semibold text-muted hover:bg-elevated hover:text-text"
|
|
684
|
+
onClick={() => setOpen(false)}
|
|
685
|
+
>
|
|
686
|
+
<EditorIcon icon={editor.icon} />
|
|
687
|
+
<span>Open in {editor.label}</span>
|
|
688
|
+
</a>
|
|
689
|
+
))}
|
|
690
|
+
</div>
|
|
691
|
+
) : null;
|
|
692
|
+
|
|
693
|
+
if (!hasEditorLinks) {
|
|
694
|
+
return sourceHref ? (
|
|
695
|
+
<a href={sourceHref} target="_blank" rel="noreferrer" className={buttonClass}>
|
|
696
|
+
Edit
|
|
697
|
+
</a>
|
|
698
|
+
) : null;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
return (
|
|
702
|
+
<div ref={containerRef} className="relative inline-flex">
|
|
703
|
+
{sourceHref ? (
|
|
704
|
+
<a
|
|
705
|
+
href={sourceHref}
|
|
706
|
+
target="_blank"
|
|
707
|
+
rel="noreferrer"
|
|
708
|
+
className={`${splitButtonClass} rounded-l`}
|
|
709
|
+
>
|
|
710
|
+
Edit
|
|
711
|
+
</a>
|
|
712
|
+
) : (
|
|
713
|
+
<button
|
|
714
|
+
type="button"
|
|
715
|
+
className={`${splitButtonClass} inline-flex items-center gap-2 rounded`}
|
|
716
|
+
aria-haspopup="menu"
|
|
717
|
+
aria-expanded={open}
|
|
718
|
+
onClick={() => setOpen((value) => !value)}
|
|
719
|
+
>
|
|
720
|
+
Edit
|
|
721
|
+
<CaretIcon />
|
|
722
|
+
</button>
|
|
723
|
+
)}
|
|
724
|
+
{sourceHref && (
|
|
725
|
+
<button
|
|
726
|
+
type="button"
|
|
727
|
+
className={`${menuButtonClass} -ml-px rounded-r px-1.5`}
|
|
728
|
+
aria-label="Open edit menu"
|
|
729
|
+
aria-haspopup="menu"
|
|
730
|
+
aria-expanded={open}
|
|
731
|
+
onClick={() => setOpen((value) => !value)}
|
|
732
|
+
>
|
|
733
|
+
<CaretIcon />
|
|
734
|
+
</button>
|
|
735
|
+
)}
|
|
736
|
+
{open && dropdown}
|
|
737
|
+
</div>
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function FormatRowsTable(props: {
|
|
742
|
+
rows?: FormatRow[];
|
|
743
|
+
searchQuery?: string;
|
|
744
|
+
/** Locale Formats tab: split path into Type / Style / Param; targets keep a single Format column. */
|
|
745
|
+
formatPathLayout?: "flat" | "split";
|
|
746
|
+
/** When set with split layout, only rows whose path starts with this type (first segment). */
|
|
747
|
+
selectedFormatType?: string | null;
|
|
748
|
+
/** Omit the Type column (types are shown as pills elsewhere). */
|
|
749
|
+
hideTypeColumn?: boolean;
|
|
750
|
+
/** Last column: sample Intl output; only for number / date / time pill selection. */
|
|
751
|
+
showExampleColumn?: boolean;
|
|
752
|
+
}) {
|
|
753
|
+
const { setKey } = useEntityDetail();
|
|
754
|
+
const rows = props.rows || [];
|
|
755
|
+
const q = props.searchQuery ?? "";
|
|
756
|
+
const highlightNeedle = formatSearchHighlightNeedle(q);
|
|
757
|
+
const highlight = Boolean(highlightNeedle.trim());
|
|
758
|
+
const splitPath = props.formatPathLayout === "split";
|
|
759
|
+
const hideTypeColumn = Boolean(props.hideTypeColumn && splitPath);
|
|
760
|
+
const showExampleColumn = Boolean(props.showExampleColumn);
|
|
761
|
+
|
|
762
|
+
let visibleRows = q.trim() ? filterFormatRowsBySearch(rows, q) : rows;
|
|
763
|
+
if (splitPath && props.selectedFormatType) {
|
|
764
|
+
visibleRows = visibleRows.filter(
|
|
765
|
+
(row) => splitFormatPath(row.path).type === props.selectedFormatType,
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (rows.length === 0) {
|
|
770
|
+
return <p className="text-sm text-muted">No formats found.</p>;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (visibleRows.length === 0) {
|
|
774
|
+
const emptyMsg = q.trim()
|
|
775
|
+
? "No formats match your search."
|
|
776
|
+
: props.selectedFormatType
|
|
777
|
+
? "No formats for this type."
|
|
778
|
+
: "No formats match your search.";
|
|
779
|
+
return <p className="text-sm text-muted">{emptyMsg}</p>;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const splitPlans = splitPath ? buildFormatSplitRowPlans(visibleRows) : null;
|
|
783
|
+
|
|
784
|
+
function segmentBody(segment: string) {
|
|
785
|
+
return segment ? (
|
|
786
|
+
highlight ? (
|
|
787
|
+
<ExamplesSearchHighlight text={segment} query={highlightNeedle} />
|
|
788
|
+
) : (
|
|
789
|
+
segment
|
|
790
|
+
)
|
|
791
|
+
) : (
|
|
792
|
+
<span className="font-normal text-muted">—</span>
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** Plain text in the cell. Param stays monospace for paths. */
|
|
797
|
+
function renderSplitSegment(segment: string, role: "type" | "style" | "param") {
|
|
798
|
+
const body = segmentBody(segment);
|
|
799
|
+
if (role === "param") {
|
|
800
|
+
return (
|
|
801
|
+
<div className="overflow-x-auto whitespace-nowrap font-mono text-[11px] leading-snug text-muted">
|
|
802
|
+
{body}
|
|
803
|
+
</div>
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
if (role === "style") {
|
|
807
|
+
return <div className="overflow-x-auto whitespace-nowrap leading-snug text-text">{body}</div>;
|
|
808
|
+
}
|
|
809
|
+
return (
|
|
810
|
+
<div className="whitespace-pre-wrap leading-snug text-text [overflow-wrap:anywhere]">
|
|
811
|
+
{body}
|
|
812
|
+
</div>
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function bandSurfaceClass(band: number) {
|
|
817
|
+
return band % 2 === 0 ? "bg-surface" : "bg-elevated/[0.2]";
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function formatSplitCellBorderClass(
|
|
821
|
+
column: "type" | "example" | "style" | "param" | "value",
|
|
822
|
+
): string {
|
|
823
|
+
switch (column) {
|
|
824
|
+
case "type":
|
|
825
|
+
return "border-b border-border border-r border-border/50";
|
|
826
|
+
case "example":
|
|
827
|
+
return "border-b border-border border-r border-border/50";
|
|
828
|
+
case "style":
|
|
829
|
+
return "border-b border-border border-r border-border/50";
|
|
830
|
+
case "param":
|
|
831
|
+
return "border-b border-border border-r border-border/40";
|
|
832
|
+
case "value":
|
|
833
|
+
return "border-b border-border";
|
|
834
|
+
default:
|
|
835
|
+
return "";
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function renderValueColumn(row: FormatRow, bandAndPaddingClass: string) {
|
|
840
|
+
const valueText = formatValue(row.value);
|
|
841
|
+
const showInheritedBadge = row.source === "inherited" && Boolean(row.from);
|
|
842
|
+
const showTargetBadge = row.source === "target";
|
|
843
|
+
|
|
844
|
+
return (
|
|
845
|
+
<td className={[bandAndPaddingClass, formatSplitCellBorderClass("value")].join(" ")}>
|
|
846
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
847
|
+
<div
|
|
848
|
+
className={[
|
|
849
|
+
"min-w-0 flex-1 whitespace-pre-wrap [overflow-wrap:anywhere]",
|
|
850
|
+
row.source === "inherited" ? "text-muted" : "",
|
|
851
|
+
].join(" ")}
|
|
852
|
+
>
|
|
853
|
+
{highlight ? (
|
|
854
|
+
<ExamplesSearchHighlight text={valueText} query={highlightNeedle} />
|
|
855
|
+
) : (
|
|
856
|
+
valueText
|
|
857
|
+
)}
|
|
858
|
+
</div>
|
|
859
|
+
{(showInheritedBadge || showTargetBadge) && (
|
|
860
|
+
<div className="flex shrink-0 flex-col items-end justify-center gap-1">
|
|
861
|
+
{showInheritedBadge && row.from ? (
|
|
862
|
+
<LabelValueBadge
|
|
863
|
+
label="inherited from"
|
|
864
|
+
value={row.from}
|
|
865
|
+
to={getEntityRoute("locale", row.from, setKey)}
|
|
866
|
+
tone="inheritance"
|
|
867
|
+
compact
|
|
868
|
+
/>
|
|
869
|
+
) : null}
|
|
870
|
+
{showTargetBadge ? (
|
|
871
|
+
<LabelValueBadge label="from" value="target" tone="neutral" compact />
|
|
872
|
+
) : null}
|
|
873
|
+
</div>
|
|
874
|
+
)}
|
|
875
|
+
</div>
|
|
876
|
+
</td>
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function renderExampleCellContent(preview: string | undefined) {
|
|
881
|
+
return preview ? (
|
|
882
|
+
highlight ? (
|
|
883
|
+
<ExamplesSearchHighlight text={preview} query={highlightNeedle} />
|
|
884
|
+
) : (
|
|
885
|
+
preview
|
|
886
|
+
)
|
|
887
|
+
) : (
|
|
888
|
+
<span className="font-normal text-muted">—</span>
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/** Split layout: merged per style group (same rowSpan as Style column). */
|
|
893
|
+
function renderSplitExampleColumn(plan: FormatSplitRowPlan, bandClass: string) {
|
|
894
|
+
if (!showExampleColumn || !plan.showStyleCell) {
|
|
895
|
+
return null;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
return (
|
|
899
|
+
<td
|
|
900
|
+
rowSpan={plan.styleRowSpan}
|
|
901
|
+
className={[
|
|
902
|
+
"align-middle min-w-0 px-3 py-2 whitespace-pre-wrap [overflow-wrap:anywhere]",
|
|
903
|
+
bandClass,
|
|
904
|
+
formatSplitCellBorderClass("example"),
|
|
905
|
+
].join(" ")}
|
|
906
|
+
>
|
|
907
|
+
{renderExampleCellContent(plan.row.examplePreview)}
|
|
908
|
+
</td>
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function renderFlatExampleColumn(row: FormatRow) {
|
|
913
|
+
return (
|
|
914
|
+
<td
|
|
915
|
+
className={[
|
|
916
|
+
"align-middle min-w-0 px-3 py-2 whitespace-pre-wrap [overflow-wrap:anywhere]",
|
|
917
|
+
"border-b border-border border-r border-border/40",
|
|
918
|
+
].join(" ")}
|
|
919
|
+
>
|
|
920
|
+
{renderExampleCellContent(row.examplePreview)}
|
|
921
|
+
</td>
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
return (
|
|
926
|
+
<div className="min-w-0 overflow-x-auto rounded-xl border border-border">
|
|
927
|
+
<table className="w-full min-w-[40rem] table-fixed border-collapse bg-surface text-xs">
|
|
928
|
+
{splitPath ? (
|
|
929
|
+
<colgroup>
|
|
930
|
+
{hideTypeColumn ? (
|
|
931
|
+
showExampleColumn ? (
|
|
932
|
+
<>
|
|
933
|
+
<col className="min-w-0 w-[18%]" />
|
|
934
|
+
<col className="min-w-0 w-[14%]" />
|
|
935
|
+
<col className="min-w-0 w-[36%]" />
|
|
936
|
+
<col className="min-w-0 w-[32%]" />
|
|
937
|
+
</>
|
|
938
|
+
) : (
|
|
939
|
+
<>
|
|
940
|
+
<col className="min-w-0 w-[22%]" />
|
|
941
|
+
<col className="min-w-0 w-[36%]" />
|
|
942
|
+
<col className="min-w-0 w-[42%]" />
|
|
943
|
+
</>
|
|
944
|
+
)
|
|
945
|
+
) : showExampleColumn ? (
|
|
946
|
+
<>
|
|
947
|
+
<col className="min-w-0 w-[10%]" />
|
|
948
|
+
<col className="min-w-0 w-[16%]" />
|
|
949
|
+
<col className="min-w-0 w-[12%]" />
|
|
950
|
+
<col className="min-w-0 w-[34%]" />
|
|
951
|
+
<col className="min-w-0 w-[28%]" />
|
|
952
|
+
</>
|
|
953
|
+
) : (
|
|
954
|
+
<>
|
|
955
|
+
<col className="min-w-0 w-[12%]" />
|
|
956
|
+
<col className="min-w-0 w-[18%]" />
|
|
957
|
+
<col className="min-w-0 w-[32%]" />
|
|
958
|
+
<col className="min-w-0 w-[38%]" />
|
|
959
|
+
</>
|
|
960
|
+
)}
|
|
961
|
+
</colgroup>
|
|
962
|
+
) : (
|
|
963
|
+
<colgroup>
|
|
964
|
+
{showExampleColumn ? (
|
|
965
|
+
<>
|
|
966
|
+
<col className="min-w-0 w-[40%]" />
|
|
967
|
+
<col className="min-w-0 w-[20%]" />
|
|
968
|
+
<col className="min-w-0 w-[40%]" />
|
|
969
|
+
</>
|
|
970
|
+
) : (
|
|
971
|
+
<>
|
|
972
|
+
<col className="min-w-0 w-1/2" />
|
|
973
|
+
<col className="min-w-0 w-1/2" />
|
|
974
|
+
</>
|
|
975
|
+
)}
|
|
976
|
+
</colgroup>
|
|
977
|
+
)}
|
|
978
|
+
<thead className="bg-elevated text-left text-[11px] uppercase tracking-wide text-muted">
|
|
979
|
+
<tr>
|
|
980
|
+
{splitPath ? (
|
|
981
|
+
<>
|
|
982
|
+
{hideTypeColumn ? null : (
|
|
983
|
+
<th className="align-middle border-b border-r border-border/50 px-3 py-2 font-semibold">
|
|
984
|
+
Type
|
|
985
|
+
</th>
|
|
986
|
+
)}
|
|
987
|
+
<th className="align-middle border-b border-r border-border/50 px-3 py-2 font-semibold">
|
|
988
|
+
Style
|
|
989
|
+
</th>
|
|
990
|
+
{showExampleColumn ? (
|
|
991
|
+
<th className="align-middle border-b border-r border-border/50 px-3 py-2 font-semibold">
|
|
992
|
+
Example
|
|
993
|
+
</th>
|
|
994
|
+
) : null}
|
|
995
|
+
<th className="align-middle border-b border-r border-border/40 px-3 py-2 font-semibold">
|
|
996
|
+
Param
|
|
997
|
+
</th>
|
|
998
|
+
<th className="align-middle border-b border-border px-3 py-2 font-semibold">
|
|
999
|
+
Value
|
|
1000
|
+
</th>
|
|
1001
|
+
</>
|
|
1002
|
+
) : (
|
|
1003
|
+
<>
|
|
1004
|
+
<th className="align-middle border-b border-border px-3 py-2 font-semibold">
|
|
1005
|
+
Format
|
|
1006
|
+
</th>
|
|
1007
|
+
{showExampleColumn ? (
|
|
1008
|
+
<th className="align-middle border-b border-border px-3 py-2 font-semibold">
|
|
1009
|
+
Example
|
|
1010
|
+
</th>
|
|
1011
|
+
) : null}
|
|
1012
|
+
<th className="align-middle border-b border-border px-3 py-2 font-semibold">
|
|
1013
|
+
Value
|
|
1014
|
+
</th>
|
|
1015
|
+
</>
|
|
1016
|
+
)}
|
|
1017
|
+
</tr>
|
|
1018
|
+
</thead>
|
|
1019
|
+
<tbody>
|
|
1020
|
+
{splitPath && splitPlans
|
|
1021
|
+
? splitPlans.map((plan) => {
|
|
1022
|
+
const bandClass = bandSurfaceClass(plan.typeBand);
|
|
1023
|
+
|
|
1024
|
+
return (
|
|
1025
|
+
<tr key={plan.row.path}>
|
|
1026
|
+
{!hideTypeColumn && plan.showTypeCell ? (
|
|
1027
|
+
<td
|
|
1028
|
+
rowSpan={plan.typeRowSpan}
|
|
1029
|
+
className={[
|
|
1030
|
+
"align-middle min-w-0 px-3 py-2 font-medium",
|
|
1031
|
+
bandClass,
|
|
1032
|
+
formatSplitCellBorderClass("type"),
|
|
1033
|
+
].join(" ")}
|
|
1034
|
+
>
|
|
1035
|
+
{renderSplitSegment(plan.parts.type, "type")}
|
|
1036
|
+
</td>
|
|
1037
|
+
) : null}
|
|
1038
|
+
{plan.showStyleCell ? (
|
|
1039
|
+
<td
|
|
1040
|
+
rowSpan={plan.styleRowSpan}
|
|
1041
|
+
className={[
|
|
1042
|
+
"align-middle min-w-0 px-3 py-2 font-medium",
|
|
1043
|
+
bandClass,
|
|
1044
|
+
formatSplitCellBorderClass("style"),
|
|
1045
|
+
].join(" ")}
|
|
1046
|
+
>
|
|
1047
|
+
{renderSplitSegment(plan.parts.style, "style")}
|
|
1048
|
+
</td>
|
|
1049
|
+
) : null}
|
|
1050
|
+
{renderSplitExampleColumn(plan, bandClass)}
|
|
1051
|
+
<td
|
|
1052
|
+
className={[
|
|
1053
|
+
"align-middle min-w-0 px-3 py-2 font-medium text-muted",
|
|
1054
|
+
bandClass,
|
|
1055
|
+
formatSplitCellBorderClass("param"),
|
|
1056
|
+
].join(" ")}
|
|
1057
|
+
>
|
|
1058
|
+
{renderSplitSegment(plan.parts.param, "param")}
|
|
1059
|
+
</td>
|
|
1060
|
+
{renderValueColumn(
|
|
1061
|
+
plan.row,
|
|
1062
|
+
["align-middle min-w-0 px-3 py-2", bandClass].join(" "),
|
|
1063
|
+
)}
|
|
1064
|
+
</tr>
|
|
1065
|
+
);
|
|
1066
|
+
})
|
|
1067
|
+
: visibleRows.map((row) => {
|
|
1068
|
+
const flatFormatClass = [
|
|
1069
|
+
"align-middle min-w-0 px-3 py-2 font-medium text-muted",
|
|
1070
|
+
showExampleColumn
|
|
1071
|
+
? "border-b border-border border-r border-border/40"
|
|
1072
|
+
: "border-b border-border",
|
|
1073
|
+
].join(" ");
|
|
1074
|
+
|
|
1075
|
+
return (
|
|
1076
|
+
<tr key={row.path}>
|
|
1077
|
+
<td className={flatFormatClass}>
|
|
1078
|
+
<div className="whitespace-pre-wrap [overflow-wrap:anywhere]">
|
|
1079
|
+
{highlight ? (
|
|
1080
|
+
<ExamplesSearchHighlight text={row.path} query={highlightNeedle} />
|
|
1081
|
+
) : (
|
|
1082
|
+
row.path
|
|
1083
|
+
)}
|
|
1084
|
+
</div>
|
|
1085
|
+
</td>
|
|
1086
|
+
{showExampleColumn ? renderFlatExampleColumn(row) : null}
|
|
1087
|
+
{renderValueColumn(
|
|
1088
|
+
row,
|
|
1089
|
+
"align-middle min-w-0 px-3 py-2 font-medium text-muted",
|
|
1090
|
+
)}
|
|
1091
|
+
</tr>
|
|
1092
|
+
);
|
|
1093
|
+
})}
|
|
1094
|
+
</tbody>
|
|
1095
|
+
</table>
|
|
1096
|
+
</div>
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function SourceLocaleLink(props: { localeKey: string }) {
|
|
1101
|
+
const { setKey } = useEntityDetail();
|
|
1102
|
+
|
|
1103
|
+
return (
|
|
1104
|
+
<Link
|
|
1105
|
+
to={getEntityRoute("locale", props.localeKey, setKey)}
|
|
1106
|
+
className="font-medium text-primary hover:underline"
|
|
1107
|
+
>
|
|
1108
|
+
{props.localeKey}
|
|
1109
|
+
</Link>
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function LinkedLocaleList(props: { localeKeys?: string[]; setKey?: string }) {
|
|
1114
|
+
const localeKeys = props.localeKeys || [];
|
|
1115
|
+
|
|
1116
|
+
if (localeKeys.length === 0) {
|
|
1117
|
+
return <>n/a</>;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
return (
|
|
1121
|
+
<>
|
|
1122
|
+
{localeKeys.map((localeKey, index) => (
|
|
1123
|
+
<React.Fragment key={localeKey}>
|
|
1124
|
+
{index > 0 ? ", " : null}
|
|
1125
|
+
<Link
|
|
1126
|
+
className="font-medium text-primary hover:underline"
|
|
1127
|
+
to={getEntityRoute("locale", localeKey, props.setKey)}
|
|
1128
|
+
>
|
|
1129
|
+
{localeKey}
|
|
1130
|
+
</Link>
|
|
1131
|
+
</React.Fragment>
|
|
1132
|
+
))}
|
|
1133
|
+
</>
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function LinkedTargetBadges(props: { targetKeys?: string[]; setKey?: string }) {
|
|
1138
|
+
const targetKeys = props.targetKeys || [];
|
|
1139
|
+
|
|
1140
|
+
if (targetKeys.length === 0) {
|
|
1141
|
+
return <>none</>;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
return (
|
|
1145
|
+
<div className="flex flex-wrap gap-2">
|
|
1146
|
+
{targetKeys.map((targetKey) => (
|
|
1147
|
+
<Link
|
|
1148
|
+
key={targetKey}
|
|
1149
|
+
className="inline-flex"
|
|
1150
|
+
to={getEntityRoute("target", targetKey, props.setKey)}
|
|
1151
|
+
>
|
|
1152
|
+
<Badge>{targetKey}</Badge>
|
|
1153
|
+
</Link>
|
|
1154
|
+
))}
|
|
1155
|
+
</div>
|
|
1156
|
+
);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
export function EntityDetailPage() {
|
|
1160
|
+
const { entityPath, entityKey, setKey } = useParams();
|
|
1161
|
+
const [detail, setDetail] = React.useState<EntityDetail | null>(null);
|
|
1162
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
1163
|
+
|
|
1164
|
+
React.useEffect(() => {
|
|
1165
|
+
if (!isEntityPath(entityPath) || !entityKey) {
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
const type = entityPathToType[entityPath];
|
|
1170
|
+
setDetail(null);
|
|
1171
|
+
setError(null);
|
|
1172
|
+
fetchEntityDetail(type, entityKey, setKey)
|
|
1173
|
+
.then(setDetail)
|
|
1174
|
+
.catch((err: Error) => setError(err.message));
|
|
1175
|
+
}, [entityPath, entityKey, setKey]);
|
|
1176
|
+
|
|
1177
|
+
if (!isEntityPath(entityPath) || !entityKey) {
|
|
1178
|
+
return <Navigate to={getBasePath(setKey) || "/"} replace />;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
const type = entityPathToType[entityPath];
|
|
1182
|
+
const baseRoute = `${getBasePath(setKey)}/${entityTypeToPath[type]}/${encodeRouteSegment(entityKey)}`;
|
|
1183
|
+
|
|
1184
|
+
if (error) {
|
|
1185
|
+
return <EmptyState title="Unable to load entity" description={error} />;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
if (!detail) {
|
|
1189
|
+
return <div className="text-muted">Loading {entityLabels[type].singular.toLowerCase()}...</div>;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
const entity = detail.entity as Record<string, any>;
|
|
1193
|
+
const tabs = getTabs(type, baseRoute);
|
|
1194
|
+
|
|
1195
|
+
return (
|
|
1196
|
+
<div>
|
|
1197
|
+
<PageHeader
|
|
1198
|
+
title={`${entityLabels[type].singular}: ${detail.key}`}
|
|
1199
|
+
description={
|
|
1200
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
1201
|
+
{entity.archived && <Badge tone="danger">archived</Badge>}
|
|
1202
|
+
{entity.deprecated && <Badge tone="warning">deprecated</Badge>}
|
|
1203
|
+
</div>
|
|
1204
|
+
}
|
|
1205
|
+
actions={<EditLink sourcePath={detail.sourcePath} editLinks={detail.editLinks} />}
|
|
1206
|
+
/>
|
|
1207
|
+
<Tabs tabs={tabs}>
|
|
1208
|
+
<Outlet context={{ detail, setKey }} />
|
|
1209
|
+
</Tabs>
|
|
1210
|
+
</div>
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
function getTabs(type: string, baseRoute: string) {
|
|
1215
|
+
const shared = [
|
|
1216
|
+
{ label: "Overview", to: baseRoute, end: true },
|
|
1217
|
+
{ label: "History", to: `${baseRoute}/history` },
|
|
1218
|
+
];
|
|
1219
|
+
|
|
1220
|
+
if (type === "locale") {
|
|
1221
|
+
return [
|
|
1222
|
+
shared[0],
|
|
1223
|
+
{ label: "Formats", to: `${baseRoute}/formats` },
|
|
1224
|
+
{ label: "Examples", to: `${baseRoute}/examples` },
|
|
1225
|
+
{ label: "Duplicates", to: `${baseRoute}/duplicates` },
|
|
1226
|
+
shared[1],
|
|
1227
|
+
];
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
if (type === "message") {
|
|
1231
|
+
return [
|
|
1232
|
+
shared[0],
|
|
1233
|
+
{ label: "Translations", to: `${baseRoute}/translations` },
|
|
1234
|
+
{ label: "Overrides", to: `${baseRoute}/overrides` },
|
|
1235
|
+
{ label: "Examples", to: `${baseRoute}/examples` },
|
|
1236
|
+
shared[1],
|
|
1237
|
+
];
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
if (type === "target") {
|
|
1241
|
+
return [
|
|
1242
|
+
shared[0],
|
|
1243
|
+
{ label: "Formats", to: `${baseRoute}/formats`, end: false },
|
|
1244
|
+
{ label: "Messages", to: `${baseRoute}/messages` },
|
|
1245
|
+
shared[1],
|
|
1246
|
+
];
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
if (type === "segment") {
|
|
1250
|
+
return [
|
|
1251
|
+
shared[0],
|
|
1252
|
+
{ label: "Conditions", to: `${baseRoute}/conditions` },
|
|
1253
|
+
{ label: "Usage", to: `${baseRoute}/usage` },
|
|
1254
|
+
shared[1],
|
|
1255
|
+
];
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
return [shared[0], { label: "Usage", to: `${baseRoute}/usage` }, shared[1]];
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
export function EntityOverviewTab() {
|
|
1262
|
+
const { detail, setKey } = useEntityDetail();
|
|
1263
|
+
const { manifest } = useCatalog();
|
|
1264
|
+
const entity = detail.entity as Record<string, any>;
|
|
1265
|
+
const fields = getOverviewFields(detail, entity, setKey, manifest.sets);
|
|
1266
|
+
|
|
1267
|
+
return (
|
|
1268
|
+
<div className="space-y-5">
|
|
1269
|
+
<FieldGrid fields={fields} />
|
|
1270
|
+
</div>
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function getOverviewFields(
|
|
1275
|
+
detail: EntityDetail,
|
|
1276
|
+
entity: Record<string, any>,
|
|
1277
|
+
setKey?: string,
|
|
1278
|
+
showPromotable?: boolean,
|
|
1279
|
+
) {
|
|
1280
|
+
const promotableField = showPromotable
|
|
1281
|
+
? { label: "Promotable", value: entity.promotable === false ? "No" : "Yes" }
|
|
1282
|
+
: undefined;
|
|
1283
|
+
const compact = (
|
|
1284
|
+
fields: Array<{ label: string; value: React.ReactNode; fullWidth?: boolean } | undefined>,
|
|
1285
|
+
) =>
|
|
1286
|
+
fields.filter(
|
|
1287
|
+
(
|
|
1288
|
+
field,
|
|
1289
|
+
): field is {
|
|
1290
|
+
label: string;
|
|
1291
|
+
value: React.ReactNode;
|
|
1292
|
+
fullWidth?: boolean;
|
|
1293
|
+
} => Boolean(field?.value),
|
|
1294
|
+
);
|
|
1295
|
+
|
|
1296
|
+
if (detail.type === "locale") {
|
|
1297
|
+
const fields = [
|
|
1298
|
+
{ label: "Direction", value: entity.direction },
|
|
1299
|
+
{ label: "Inherits formats from", value: entity.inheritFormatsFrom },
|
|
1300
|
+
{ label: "Inherits translations from", value: entity.inheritTranslationsFrom },
|
|
1301
|
+
promotableField,
|
|
1302
|
+
{
|
|
1303
|
+
label: "Description",
|
|
1304
|
+
value: <MarkdownContent value={entity.description} />,
|
|
1305
|
+
fullWidth: true,
|
|
1306
|
+
},
|
|
1307
|
+
];
|
|
1308
|
+
|
|
1309
|
+
return compact(fields);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
if (detail.type === "message") {
|
|
1313
|
+
const fields = [
|
|
1314
|
+
promotableField,
|
|
1315
|
+
{ label: "Deprecated", value: entity.deprecated ? "Yes" : "No" },
|
|
1316
|
+
{ label: "Deprecation warning", value: entity.deprecationWarning },
|
|
1317
|
+
{
|
|
1318
|
+
label: "Targets",
|
|
1319
|
+
value: (
|
|
1320
|
+
<LinkedTargetBadges targetKeys={detail.targets as string[] | undefined} setKey={setKey} />
|
|
1321
|
+
),
|
|
1322
|
+
},
|
|
1323
|
+
{
|
|
1324
|
+
label: "Summary",
|
|
1325
|
+
value: entity.summary,
|
|
1326
|
+
fullWidth: true,
|
|
1327
|
+
},
|
|
1328
|
+
{
|
|
1329
|
+
label: "Description",
|
|
1330
|
+
value: <MarkdownContent value={entity.description} />,
|
|
1331
|
+
fullWidth: true,
|
|
1332
|
+
},
|
|
1333
|
+
{
|
|
1334
|
+
label: "Meta",
|
|
1335
|
+
value: entity.meta ? <CodeBlock value={entity.meta} /> : undefined,
|
|
1336
|
+
fullWidth: true,
|
|
1337
|
+
},
|
|
1338
|
+
];
|
|
1339
|
+
|
|
1340
|
+
return compact(fields);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
if (detail.type === "attribute") {
|
|
1344
|
+
const hasAllowedValues = Array.isArray(entity.enum)
|
|
1345
|
+
? entity.enum.length > 0
|
|
1346
|
+
: Boolean(entity.enum);
|
|
1347
|
+
const hasRequiredFields = Array.isArray(entity.required)
|
|
1348
|
+
? entity.required.length > 0
|
|
1349
|
+
: Boolean(entity.required);
|
|
1350
|
+
const hasRange = typeof entity.minimum !== "undefined" || typeof entity.maximum !== "undefined";
|
|
1351
|
+
const fields = [
|
|
1352
|
+
{ label: "Type", value: entity.type },
|
|
1353
|
+
promotableField,
|
|
1354
|
+
{ label: "Allowed values", value: hasAllowedValues ? formatValue(entity.enum) : undefined },
|
|
1355
|
+
{
|
|
1356
|
+
label: "Required fields",
|
|
1357
|
+
value: hasRequiredFields ? formatValue(entity.required) : undefined,
|
|
1358
|
+
},
|
|
1359
|
+
{ label: "Pattern", value: entity.pattern },
|
|
1360
|
+
{
|
|
1361
|
+
label: "Range",
|
|
1362
|
+
value: hasRange
|
|
1363
|
+
? `${typeof entity.minimum !== "undefined" ? entity.minimum : "-∞"} to ${typeof entity.maximum !== "undefined" ? entity.maximum : "∞"}`
|
|
1364
|
+
: undefined,
|
|
1365
|
+
},
|
|
1366
|
+
{
|
|
1367
|
+
label: "Description",
|
|
1368
|
+
value: <MarkdownContent value={entity.description} />,
|
|
1369
|
+
fullWidth: true,
|
|
1370
|
+
},
|
|
1371
|
+
];
|
|
1372
|
+
|
|
1373
|
+
return compact(fields);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
if (detail.type === "segment") {
|
|
1377
|
+
return compact([
|
|
1378
|
+
{ label: "Archived", value: entity.archived ? "Yes" : "No" },
|
|
1379
|
+
promotableField,
|
|
1380
|
+
{
|
|
1381
|
+
label: "Description",
|
|
1382
|
+
value: <MarkdownContent value={entity.description} />,
|
|
1383
|
+
fullWidth: true,
|
|
1384
|
+
},
|
|
1385
|
+
]);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
const fields = [
|
|
1389
|
+
promotableField,
|
|
1390
|
+
{ label: "Included message patterns", value: formatValue(entity.includeMessages) },
|
|
1391
|
+
{ label: "Excluded message patterns", value: formatValue(entity.excludeMessages) },
|
|
1392
|
+
{
|
|
1393
|
+
label: "Locales",
|
|
1394
|
+
value: (
|
|
1395
|
+
<LinkedLocaleList localeKeys={detail.locales as string[] | undefined} setKey={setKey} />
|
|
1396
|
+
),
|
|
1397
|
+
},
|
|
1398
|
+
{
|
|
1399
|
+
label: "Description",
|
|
1400
|
+
value: <MarkdownContent value={entity.description} />,
|
|
1401
|
+
fullWidth: true,
|
|
1402
|
+
},
|
|
1403
|
+
];
|
|
1404
|
+
|
|
1405
|
+
return compact(fields);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
const FORMAT_SEARCH_HINTS = [
|
|
1409
|
+
"type:number",
|
|
1410
|
+
"style:decimal",
|
|
1411
|
+
"type:date",
|
|
1412
|
+
"param:maximumFractionDigits",
|
|
1413
|
+
"value:2",
|
|
1414
|
+
"from:en",
|
|
1415
|
+
"source:direct",
|
|
1416
|
+
];
|
|
1417
|
+
|
|
1418
|
+
function FormatSearchHints(props: { query: string; onHintClick: (hint: string) => void }) {
|
|
1419
|
+
return (
|
|
1420
|
+
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5 pt-2 text-xs text-muted">
|
|
1421
|
+
<span className="shrink-0">Try:</span>
|
|
1422
|
+
{FORMAT_SEARCH_HINTS.map((hint) => {
|
|
1423
|
+
const isActive = props.query
|
|
1424
|
+
.trim()
|
|
1425
|
+
.split(/\s+/)
|
|
1426
|
+
.some((t) => t.toLowerCase() === hint.toLowerCase());
|
|
1427
|
+
return (
|
|
1428
|
+
<button
|
|
1429
|
+
key={hint}
|
|
1430
|
+
type="button"
|
|
1431
|
+
onClick={() => props.onHintClick(hint)}
|
|
1432
|
+
className={[
|
|
1433
|
+
"cursor-pointer rounded px-1.5 py-0.5 font-mono transition-colors",
|
|
1434
|
+
isActive ? "bg-primary/10 text-primary" : "bg-elevated text-muted hover:text-text",
|
|
1435
|
+
].join(" ")}
|
|
1436
|
+
>
|
|
1437
|
+
{hint}
|
|
1438
|
+
</button>
|
|
1439
|
+
);
|
|
1440
|
+
})}
|
|
1441
|
+
</div>
|
|
1442
|
+
);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
/** Formats tab: filter by path type (first segment); syncs `formatType` search param. */
|
|
1446
|
+
function FormatsTypePills(props: { typeKeys: string[] }) {
|
|
1447
|
+
const [searchParams] = useSearchParams();
|
|
1448
|
+
const formatTypeParam = searchParams.get("formatType") ?? "";
|
|
1449
|
+
const selectedType =
|
|
1450
|
+
formatTypeParam && props.typeKeys.includes(formatTypeParam) ? formatTypeParam : null;
|
|
1451
|
+
|
|
1452
|
+
function typeLinkSearch(nextType: string | null): string {
|
|
1453
|
+
const next = setSearchParam(
|
|
1454
|
+
searchParams,
|
|
1455
|
+
"formatType",
|
|
1456
|
+
nextType === null ? undefined : nextType,
|
|
1457
|
+
);
|
|
1458
|
+
const qs = next.toString();
|
|
1459
|
+
return qs ? `?${qs}` : "";
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
const pillClass = (isActive: boolean) =>
|
|
1463
|
+
[
|
|
1464
|
+
"inline-flex rounded-full border px-3 py-1 text-xs font-semibold transition-colors",
|
|
1465
|
+
isActive
|
|
1466
|
+
? "border-primary bg-header-active text-header-text"
|
|
1467
|
+
: "border-pill bg-transparent text-text hover:bg-elevated",
|
|
1468
|
+
].join(" ");
|
|
1469
|
+
|
|
1470
|
+
return (
|
|
1471
|
+
<nav className="flex min-w-0 flex-wrap gap-2" aria-label="Format types">
|
|
1472
|
+
<Link
|
|
1473
|
+
replace
|
|
1474
|
+
to={{ search: typeLinkSearch(null) }}
|
|
1475
|
+
className={pillClass(selectedType === null)}
|
|
1476
|
+
>
|
|
1477
|
+
All types
|
|
1478
|
+
</Link>
|
|
1479
|
+
{props.typeKeys.map((typeKey) => {
|
|
1480
|
+
const isActive = typeKey === selectedType;
|
|
1481
|
+
return (
|
|
1482
|
+
<Link
|
|
1483
|
+
key={typeKey}
|
|
1484
|
+
replace
|
|
1485
|
+
to={{ search: typeLinkSearch(typeKey) }}
|
|
1486
|
+
className={pillClass(isActive)}
|
|
1487
|
+
>
|
|
1488
|
+
{typeKey}
|
|
1489
|
+
</Link>
|
|
1490
|
+
);
|
|
1491
|
+
})}
|
|
1492
|
+
</nav>
|
|
1493
|
+
);
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
function useFormatsTypePillSelection(formatRows: FormatRow[]) {
|
|
1497
|
+
const [searchParams] = useSearchParams();
|
|
1498
|
+
const formatTypeParam = searchParams.get("formatType") ?? "";
|
|
1499
|
+
const formatTypes = React.useMemo(() => collectSortedFormatTypes(formatRows), [formatRows]);
|
|
1500
|
+
const formatTypePillKeys = React.useMemo(
|
|
1501
|
+
() => orderedFormatTypePillKeys(formatTypes),
|
|
1502
|
+
[formatTypes],
|
|
1503
|
+
);
|
|
1504
|
+
const selectedFormatType =
|
|
1505
|
+
formatTypeParam && formatTypePillKeys.includes(formatTypeParam) ? formatTypeParam : null;
|
|
1506
|
+
return { formatTypes, formatTypePillKeys, selectedFormatType };
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/** Shared by Locale and Target entity Format tabs (below type pills; on Target, below locale pills). */
|
|
1510
|
+
function FormatsSearchToolbar() {
|
|
1511
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
1512
|
+
const [showHints, setShowHints] = React.useState(false);
|
|
1513
|
+
const searchQuery = searchParams.get("q") ?? "";
|
|
1514
|
+
|
|
1515
|
+
function handleFormatSearchHintClick(hint: string) {
|
|
1516
|
+
const current = searchQuery.trim();
|
|
1517
|
+
const tokens = current.split(/\s+/).filter(Boolean);
|
|
1518
|
+
const idx = tokens.findIndex((t) => t.toLowerCase() === hint.toLowerCase());
|
|
1519
|
+
const next =
|
|
1520
|
+
idx !== -1
|
|
1521
|
+
? tokens.filter((_, i) => i !== idx).join(" ")
|
|
1522
|
+
: current
|
|
1523
|
+
? `${current} ${hint}`
|
|
1524
|
+
: hint;
|
|
1525
|
+
setSearchParams(setSearchParam(searchParams, "q", next.trim() ? next.trim() : undefined), {
|
|
1526
|
+
replace: true,
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
return (
|
|
1531
|
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
|
|
1532
|
+
<div className="flex min-h-0 min-w-0 flex-1 basis-[min(100%,22rem)] flex-col gap-0">
|
|
1533
|
+
<div className="relative">
|
|
1534
|
+
<Input
|
|
1535
|
+
type="search"
|
|
1536
|
+
value={searchQuery}
|
|
1537
|
+
onChange={(event) => {
|
|
1538
|
+
const val = event.target.value;
|
|
1539
|
+
setSearchParams(setSearchParam(searchParams, "q", val.trim() ? val : undefined), {
|
|
1540
|
+
replace: true,
|
|
1541
|
+
});
|
|
1542
|
+
}}
|
|
1543
|
+
placeholder="Search formats…"
|
|
1544
|
+
aria-label="Search formats"
|
|
1545
|
+
className={[
|
|
1546
|
+
EXAMPLES_TOOLBAR_CONTROL_HEIGHT_CLASS,
|
|
1547
|
+
"box-border rounded-lg border border-border bg-elevated py-0 pl-2 pr-10",
|
|
1548
|
+
"text-xs leading-snug text-text",
|
|
1549
|
+
"placeholder:text-xs placeholder:text-muted placeholder:leading-snug",
|
|
1550
|
+
].join(" ")}
|
|
1551
|
+
/>
|
|
1552
|
+
<button
|
|
1553
|
+
type="button"
|
|
1554
|
+
onClick={() => setShowHints((v) => !v)}
|
|
1555
|
+
aria-label={showHints ? "Hide advanced search hints" : "Show advanced search hints"}
|
|
1556
|
+
className={[
|
|
1557
|
+
"absolute right-2 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-full border text-xs font-bold transition-colors",
|
|
1558
|
+
showHints
|
|
1559
|
+
? "border-primary bg-primary/10 text-primary"
|
|
1560
|
+
: "border-border bg-surface text-muted hover:border-primary hover:text-primary",
|
|
1561
|
+
].join(" ")}
|
|
1562
|
+
>
|
|
1563
|
+
?
|
|
1564
|
+
</button>
|
|
1565
|
+
</div>
|
|
1566
|
+
|
|
1567
|
+
<div
|
|
1568
|
+
className={[
|
|
1569
|
+
"grid transition-all duration-200 ease-in-out",
|
|
1570
|
+
showHints ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
|
|
1571
|
+
].join(" ")}
|
|
1572
|
+
>
|
|
1573
|
+
<div className="overflow-hidden pl-1">
|
|
1574
|
+
<FormatSearchHints query={searchQuery} onHintClick={handleFormatSearchHintClick} />
|
|
1575
|
+
</div>
|
|
1576
|
+
</div>
|
|
1577
|
+
</div>
|
|
1578
|
+
</div>
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
export function LocaleFormatsTab() {
|
|
1583
|
+
const { detail } = useEntityDetail();
|
|
1584
|
+
const [searchParams] = useSearchParams();
|
|
1585
|
+
const searchQuery = searchParams.get("q") ?? "";
|
|
1586
|
+
const rows = detail.formatRows as FormatRow[] | undefined;
|
|
1587
|
+
const allRows = rows ?? [];
|
|
1588
|
+
const { formatTypes, formatTypePillKeys, selectedFormatType } =
|
|
1589
|
+
useFormatsTypePillSelection(allRows);
|
|
1590
|
+
const showExampleColumn = showFormatExampleColumn(selectedFormatType);
|
|
1591
|
+
|
|
1592
|
+
return (
|
|
1593
|
+
<div className="space-y-6">
|
|
1594
|
+
{formatTypes.length > 0 ? <FormatsTypePills typeKeys={formatTypePillKeys} /> : null}
|
|
1595
|
+
|
|
1596
|
+
<FormatsSearchToolbar />
|
|
1597
|
+
|
|
1598
|
+
<FormatRowsTable
|
|
1599
|
+
rows={rows}
|
|
1600
|
+
searchQuery={searchQuery}
|
|
1601
|
+
formatPathLayout="split"
|
|
1602
|
+
hideTypeColumn={selectedFormatType != null}
|
|
1603
|
+
selectedFormatType={selectedFormatType}
|
|
1604
|
+
showExampleColumn={showExampleColumn}
|
|
1605
|
+
/>
|
|
1606
|
+
</div>
|
|
1607
|
+
);
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
export function FormatsTab() {
|
|
1611
|
+
const { detail } = useEntityDetail();
|
|
1612
|
+
|
|
1613
|
+
if (detail.type === "target") {
|
|
1614
|
+
return <TargetFormatsTab />;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
return <LocaleFormatsTab />;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
function MessageTranslationOverridesDetails(props: {
|
|
1621
|
+
messageKey: string;
|
|
1622
|
+
overrides: Array<{ key: string; row: TranslationRow; override: Record<string, any> }>;
|
|
1623
|
+
localeDirections?: Record<string, string | undefined>;
|
|
1624
|
+
setKey?: string;
|
|
1625
|
+
}) {
|
|
1626
|
+
if (props.overrides.length === 0) {
|
|
1627
|
+
return <p className="text-sm text-muted">No overrides.</p>;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
return (
|
|
1631
|
+
<div className="space-y-3">
|
|
1632
|
+
<p className="text-sm font-medium text-muted">Overrides</p>
|
|
1633
|
+
<div className="space-y-3">
|
|
1634
|
+
{props.overrides.map(({ key, row, override }) => (
|
|
1635
|
+
<div key={key} className="rounded border border-border bg-surface p-4">
|
|
1636
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
1637
|
+
<Link
|
|
1638
|
+
to={`${getEntityRoute("message", props.messageKey, props.setKey)}/overrides#${key}`}
|
|
1639
|
+
className="text-sm font-semibold text-primary hover:underline"
|
|
1640
|
+
>
|
|
1641
|
+
{key}
|
|
1642
|
+
</Link>
|
|
1643
|
+
{row.source === "inherited" && row.from && (
|
|
1644
|
+
<LabelValueBadge
|
|
1645
|
+
label="inherited from"
|
|
1646
|
+
value={row.from}
|
|
1647
|
+
to={getEntityRoute("locale", row.from, props.setKey)}
|
|
1648
|
+
tone="inheritance"
|
|
1649
|
+
compact
|
|
1650
|
+
/>
|
|
1651
|
+
)}
|
|
1652
|
+
</div>
|
|
1653
|
+
{(override.segments || override.conditions) && (
|
|
1654
|
+
<div className="mt-4 space-y-4">
|
|
1655
|
+
{override.segments && (
|
|
1656
|
+
<div className="space-y-2">
|
|
1657
|
+
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
|
|
1658
|
+
Segments
|
|
1659
|
+
</h4>
|
|
1660
|
+
<GroupSegmentTree segments={override.segments} setKey={props.setKey} />
|
|
1661
|
+
</div>
|
|
1662
|
+
)}
|
|
1663
|
+
{override.conditions && (
|
|
1664
|
+
<div className="space-y-2">
|
|
1665
|
+
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
|
|
1666
|
+
Conditions
|
|
1667
|
+
</h4>
|
|
1668
|
+
<ConditionTree conditions={override.conditions} setKey={props.setKey} />
|
|
1669
|
+
</div>
|
|
1670
|
+
)}
|
|
1671
|
+
</div>
|
|
1672
|
+
)}
|
|
1673
|
+
<div className="mt-4 space-y-2">
|
|
1674
|
+
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
|
|
1675
|
+
Overridden translation
|
|
1676
|
+
</h4>
|
|
1677
|
+
<TranslationValueBlock
|
|
1678
|
+
value={row.value || "—"}
|
|
1679
|
+
direction={getLocaleDirection(row.locale, props.localeDirections)}
|
|
1680
|
+
/>
|
|
1681
|
+
</div>
|
|
1682
|
+
</div>
|
|
1683
|
+
))}
|
|
1684
|
+
</div>
|
|
1685
|
+
</div>
|
|
1686
|
+
);
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
export function MessageTranslationsTab() {
|
|
1690
|
+
const { detail, setKey } = useEntityDetail();
|
|
1691
|
+
const entity = detail.entity as Record<string, any>;
|
|
1692
|
+
const localeDirections = (detail.localeDirections || {}) as Record<string, string | undefined>;
|
|
1693
|
+
const overrideTranslations = (detail.overrideTranslations || []) as Array<{
|
|
1694
|
+
key: string;
|
|
1695
|
+
rows: TranslationRow[];
|
|
1696
|
+
}>;
|
|
1697
|
+
const overrides = (entity.overrides || []) as Record<string, any>[];
|
|
1698
|
+
const overridesByLocale = Object.fromEntries(
|
|
1699
|
+
((detail.translations as TranslationRow[] | undefined) || []).map((translationRow) => [
|
|
1700
|
+
translationRow.locale,
|
|
1701
|
+
overrideTranslations
|
|
1702
|
+
.map((override) => ({
|
|
1703
|
+
key: override.key,
|
|
1704
|
+
row: (override.rows || []).find((row) => row.locale === translationRow.locale),
|
|
1705
|
+
override: overrides.find((item) => item.key === override.key),
|
|
1706
|
+
}))
|
|
1707
|
+
.filter(
|
|
1708
|
+
(
|
|
1709
|
+
value,
|
|
1710
|
+
): value is {
|
|
1711
|
+
key: string;
|
|
1712
|
+
row: TranslationRow;
|
|
1713
|
+
override: Record<string, any>;
|
|
1714
|
+
} => Boolean(value.row && value.row.source !== "missing" && value.override),
|
|
1715
|
+
),
|
|
1716
|
+
]),
|
|
1717
|
+
) as Record<string, Array<{ key: string; row: TranslationRow; override: Record<string, any> }>>;
|
|
1718
|
+
const overriddenLocales = new Set(
|
|
1719
|
+
Object.entries(overridesByLocale)
|
|
1720
|
+
.filter(([, rows]) => rows.length > 0)
|
|
1721
|
+
.map(([locale]) => locale),
|
|
1722
|
+
);
|
|
1723
|
+
|
|
1724
|
+
return (
|
|
1725
|
+
<TranslationsTable
|
|
1726
|
+
rows={detail.translations as TranslationRow[] | undefined}
|
|
1727
|
+
linkLocales
|
|
1728
|
+
showSource={false}
|
|
1729
|
+
localeDirections={localeDirections}
|
|
1730
|
+
getRowFragmentId={(entry) => slugifyFragment(`translation-${entry.locale}`)}
|
|
1731
|
+
renderExpandedRow={(entry) => (
|
|
1732
|
+
<MessageTranslationOverridesDetails
|
|
1733
|
+
messageKey={detail.key}
|
|
1734
|
+
overrides={overridesByLocale[entry.locale] || []}
|
|
1735
|
+
localeDirections={localeDirections}
|
|
1736
|
+
setKey={setKey}
|
|
1737
|
+
/>
|
|
1738
|
+
)}
|
|
1739
|
+
renderMetaCell={(entry) => {
|
|
1740
|
+
const badges: React.ReactNode[] = [];
|
|
1741
|
+
|
|
1742
|
+
if (entry.source === "inherited" && entry.from) {
|
|
1743
|
+
badges.push(
|
|
1744
|
+
<LabelValueBadge
|
|
1745
|
+
key={`inherited-${entry.locale}`}
|
|
1746
|
+
label="inherited from"
|
|
1747
|
+
value={entry.from}
|
|
1748
|
+
to={getEntityRoute("locale", entry.from, setKey)}
|
|
1749
|
+
tone="inheritance"
|
|
1750
|
+
compact
|
|
1751
|
+
/>,
|
|
1752
|
+
);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
if (overriddenLocales.has(entry.locale)) {
|
|
1756
|
+
badges.push(
|
|
1757
|
+
<LabelValueBadge
|
|
1758
|
+
key={`override-${entry.locale}`}
|
|
1759
|
+
label="overrides"
|
|
1760
|
+
value="yes"
|
|
1761
|
+
tone="override"
|
|
1762
|
+
compact
|
|
1763
|
+
/>,
|
|
1764
|
+
);
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
if (badges.length === 0) {
|
|
1768
|
+
return null;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
return <div className="flex flex-wrap justify-end gap-1.5">{badges}</div>;
|
|
1772
|
+
}}
|
|
1773
|
+
/>
|
|
1774
|
+
);
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
export function MessageOverridesTab() {
|
|
1778
|
+
const { detail, setKey } = useEntityDetail();
|
|
1779
|
+
const entity = detail.entity as Record<string, any>;
|
|
1780
|
+
const localeDirections = (detail.localeDirections || {}) as Record<string, string | undefined>;
|
|
1781
|
+
const baseTranslationsByLocale = Object.fromEntries(
|
|
1782
|
+
((detail.translations as TranslationRow[] | undefined) || []).map((row) => [
|
|
1783
|
+
row.locale,
|
|
1784
|
+
row.value,
|
|
1785
|
+
]),
|
|
1786
|
+
);
|
|
1787
|
+
|
|
1788
|
+
const overrides = entity.overrides || [];
|
|
1789
|
+
|
|
1790
|
+
useScrollToHash([overrides.length]);
|
|
1791
|
+
|
|
1792
|
+
if (overrides.length === 0) {
|
|
1793
|
+
return <p className="text-sm text-muted">No overrides found.</p>;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
return (
|
|
1797
|
+
<div className="space-y-6">
|
|
1798
|
+
{overrides.map((override: Record<string, any>) => {
|
|
1799
|
+
return (
|
|
1800
|
+
<section key={override.key} className="space-y-4">
|
|
1801
|
+
<div className="space-y-3">
|
|
1802
|
+
<div className="group flex items-center gap-2">
|
|
1803
|
+
<h2 id={override.key} className="font-semibold">
|
|
1804
|
+
{override.key}
|
|
1805
|
+
</h2>
|
|
1806
|
+
<ExamplePermalink targetId={override.key} />
|
|
1807
|
+
</div>
|
|
1808
|
+
{override.summary && <p className="mt-1 text-sm text-muted">{override.summary}</p>}
|
|
1809
|
+
{override.description && (
|
|
1810
|
+
<div className="mt-3">
|
|
1811
|
+
<MarkdownContent value={override.description} />
|
|
1812
|
+
</div>
|
|
1813
|
+
)}
|
|
1814
|
+
</div>
|
|
1815
|
+
|
|
1816
|
+
<div className="space-y-4">
|
|
1817
|
+
{override.segments && (
|
|
1818
|
+
<div className="space-y-2 rounded-xl border border-border bg-elevated p-4">
|
|
1819
|
+
<h3 className="text-sm font-semibold text-muted">Segments</h3>
|
|
1820
|
+
<GroupSegmentTree segments={override.segments} setKey={setKey} />
|
|
1821
|
+
</div>
|
|
1822
|
+
)}
|
|
1823
|
+
|
|
1824
|
+
{override.conditions && (
|
|
1825
|
+
<div className="space-y-2 rounded-xl border border-border bg-elevated p-4">
|
|
1826
|
+
<h3 className="text-sm font-semibold text-muted">Conditions</h3>
|
|
1827
|
+
<ConditionTree conditions={override.conditions} setKey={setKey} />
|
|
1828
|
+
</div>
|
|
1829
|
+
)}
|
|
1830
|
+
|
|
1831
|
+
<TranslationsTable
|
|
1832
|
+
translations={override.translations}
|
|
1833
|
+
linkLocales
|
|
1834
|
+
showSource={false}
|
|
1835
|
+
translationLabel="Overridden translation"
|
|
1836
|
+
comparisonLabel="Original translation"
|
|
1837
|
+
comparisonValues={baseTranslationsByLocale}
|
|
1838
|
+
localeDirections={localeDirections}
|
|
1839
|
+
/>
|
|
1840
|
+
</div>
|
|
1841
|
+
</section>
|
|
1842
|
+
);
|
|
1843
|
+
})}
|
|
1844
|
+
</div>
|
|
1845
|
+
);
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
export function MessageExamplesTab() {
|
|
1849
|
+
const { detail } = useEntityDetail();
|
|
1850
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
1851
|
+
const examples = (detail.evaluatedExamples || []) as EvaluatedMessageExample[];
|
|
1852
|
+
const localeDirections = (detail.localeDirections || {}) as Record<string, string | undefined>;
|
|
1853
|
+
const originalTranslationsByLocale = Object.fromEntries(
|
|
1854
|
+
((detail.translations as TranslationRow[] | undefined) || []).map((row) => [
|
|
1855
|
+
row.locale,
|
|
1856
|
+
row.value,
|
|
1857
|
+
]),
|
|
1858
|
+
);
|
|
1859
|
+
const view = searchParams.get("view");
|
|
1860
|
+
const activeView = view === "expanded" ? "expanded" : "compact";
|
|
1861
|
+
const localeKeys = Array.from(new Set(examples.map((example) => example.locale))).sort();
|
|
1862
|
+
const localeFilter = searchParams.get("locale");
|
|
1863
|
+
const activeLocaleFilter = localeFilter && localeKeys.includes(localeFilter) ? localeFilter : "";
|
|
1864
|
+
const visibleExamples = activeLocaleFilter
|
|
1865
|
+
? examples.filter((example) => example.locale === activeLocaleFilter)
|
|
1866
|
+
: examples;
|
|
1867
|
+
|
|
1868
|
+
useScrollToHash([visibleExamples.length, activeLocaleFilter, activeView]);
|
|
1869
|
+
|
|
1870
|
+
if (examples.length === 0) {
|
|
1871
|
+
return <p className="text-sm text-muted">No examples found.</p>;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
return (
|
|
1875
|
+
<div className="space-y-6">
|
|
1876
|
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
|
|
1877
|
+
<nav className="flex min-w-0 flex-wrap gap-2">
|
|
1878
|
+
<button
|
|
1879
|
+
type="button"
|
|
1880
|
+
className={[
|
|
1881
|
+
"inline-flex rounded-full border px-3 py-1 text-xs font-semibold transition-colors",
|
|
1882
|
+
activeLocaleFilter
|
|
1883
|
+
? "border-pill bg-transparent text-text hover:bg-elevated"
|
|
1884
|
+
: "border-primary bg-header-active text-header-text",
|
|
1885
|
+
].join(" ")}
|
|
1886
|
+
onClick={() => setSearchParams(setSearchParam(searchParams, "locale", undefined))}
|
|
1887
|
+
>
|
|
1888
|
+
All locales
|
|
1889
|
+
</button>
|
|
1890
|
+
|
|
1891
|
+
{localeKeys.map((localeKey) => {
|
|
1892
|
+
const isActive = activeLocaleFilter === localeKey;
|
|
1893
|
+
|
|
1894
|
+
return (
|
|
1895
|
+
<button
|
|
1896
|
+
key={localeKey}
|
|
1897
|
+
type="button"
|
|
1898
|
+
className={[
|
|
1899
|
+
"inline-flex rounded-full border px-3 py-1 text-xs font-semibold transition-colors",
|
|
1900
|
+
isActive
|
|
1901
|
+
? "border-primary bg-header-active text-header-text"
|
|
1902
|
+
: "border-pill bg-transparent text-text hover:bg-elevated",
|
|
1903
|
+
].join(" ")}
|
|
1904
|
+
onClick={() =>
|
|
1905
|
+
setSearchParams(
|
|
1906
|
+
setSearchParam(searchParams, "locale", isActive ? undefined : localeKey),
|
|
1907
|
+
)
|
|
1908
|
+
}
|
|
1909
|
+
>
|
|
1910
|
+
{localeKey}
|
|
1911
|
+
</button>
|
|
1912
|
+
);
|
|
1913
|
+
})}
|
|
1914
|
+
</nav>
|
|
1915
|
+
|
|
1916
|
+
<div className="ml-auto shrink-0">
|
|
1917
|
+
<ExamplesViewModeSwitch
|
|
1918
|
+
activeView={activeView}
|
|
1919
|
+
onViewChange={(view) =>
|
|
1920
|
+
setSearchParams(
|
|
1921
|
+
setSearchParam(searchParams, "view", view === "compact" ? undefined : view),
|
|
1922
|
+
)
|
|
1923
|
+
}
|
|
1924
|
+
/>
|
|
1925
|
+
</div>
|
|
1926
|
+
</div>
|
|
1927
|
+
|
|
1928
|
+
{activeView === "compact" ? (
|
|
1929
|
+
<MessageExamplesCompactView
|
|
1930
|
+
examples={visibleExamples}
|
|
1931
|
+
localeDirections={localeDirections}
|
|
1932
|
+
originalTranslationsByLocale={originalTranslationsByLocale}
|
|
1933
|
+
/>
|
|
1934
|
+
) : (
|
|
1935
|
+
<MessageExamplesExpandedView
|
|
1936
|
+
examples={visibleExamples}
|
|
1937
|
+
localeDirections={localeDirections}
|
|
1938
|
+
originalTranslationsByLocale={originalTranslationsByLocale}
|
|
1939
|
+
/>
|
|
1940
|
+
)}
|
|
1941
|
+
</div>
|
|
1942
|
+
);
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
function ExampleTitle(props: {
|
|
1946
|
+
title: string;
|
|
1947
|
+
targetId: string;
|
|
1948
|
+
description?: string;
|
|
1949
|
+
highlightQuery?: string;
|
|
1950
|
+
}) {
|
|
1951
|
+
const titleContent =
|
|
1952
|
+
props.highlightQuery?.trim() && props.title ? (
|
|
1953
|
+
<ExamplesSearchHighlight text={props.title} query={props.highlightQuery} />
|
|
1954
|
+
) : (
|
|
1955
|
+
props.title
|
|
1956
|
+
);
|
|
1957
|
+
|
|
1958
|
+
return (
|
|
1959
|
+
<div className="space-y-2">
|
|
1960
|
+
<div className="group flex items-center gap-2">
|
|
1961
|
+
<h2 id={props.targetId} className="font-semibold">
|
|
1962
|
+
{titleContent}
|
|
1963
|
+
</h2>
|
|
1964
|
+
<ExamplePermalink targetId={props.targetId} />
|
|
1965
|
+
</div>
|
|
1966
|
+
{props.description?.trim() ? (
|
|
1967
|
+
props.highlightQuery?.trim() ? (
|
|
1968
|
+
<div className="text-sm text-muted whitespace-pre-wrap [overflow-wrap:anywhere]">
|
|
1969
|
+
<ExamplesSearchHighlight text={props.description.trim()} query={props.highlightQuery} />
|
|
1970
|
+
</div>
|
|
1971
|
+
) : (
|
|
1972
|
+
<div className="text-sm text-muted">
|
|
1973
|
+
<MarkdownContent value={props.description} />
|
|
1974
|
+
</div>
|
|
1975
|
+
)
|
|
1976
|
+
) : null}
|
|
1977
|
+
</div>
|
|
1978
|
+
);
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
function ExampleTable(props: {
|
|
1982
|
+
inputs: React.ReactNode;
|
|
1983
|
+
evaluatedTranslation: unknown;
|
|
1984
|
+
direction?: string;
|
|
1985
|
+
highlightQuery?: string;
|
|
1986
|
+
}) {
|
|
1987
|
+
const highlightTranslation =
|
|
1988
|
+
props.highlightQuery?.trim() &&
|
|
1989
|
+
props.evaluatedTranslation !== undefined &&
|
|
1990
|
+
props.evaluatedTranslation !== null;
|
|
1991
|
+
|
|
1992
|
+
const translationBody = highlightTranslation ? (
|
|
1993
|
+
<div
|
|
1994
|
+
dir={props.direction}
|
|
1995
|
+
className={[
|
|
1996
|
+
"min-w-0 max-w-full whitespace-pre-wrap [overflow-wrap:anywhere]",
|
|
1997
|
+
props.direction === "rtl" ? "text-right" : "",
|
|
1998
|
+
]
|
|
1999
|
+
.filter(Boolean)
|
|
2000
|
+
.join(" ")}
|
|
2001
|
+
style={props.direction ? { unicodeBidi: "plaintext" } : undefined}
|
|
2002
|
+
>
|
|
2003
|
+
<ExamplesSearchHighlight
|
|
2004
|
+
text={
|
|
2005
|
+
typeof props.evaluatedTranslation === "string"
|
|
2006
|
+
? props.evaluatedTranslation
|
|
2007
|
+
: JSON.stringify(props.evaluatedTranslation)
|
|
2008
|
+
}
|
|
2009
|
+
query={props.highlightQuery ?? ""}
|
|
2010
|
+
/>
|
|
2011
|
+
</div>
|
|
2012
|
+
) : (
|
|
2013
|
+
<TranslationValueBlock value={props.evaluatedTranslation} direction={props.direction} />
|
|
2014
|
+
);
|
|
2015
|
+
|
|
2016
|
+
return (
|
|
2017
|
+
<div className="min-w-0 overflow-hidden rounded-xl border border-border">
|
|
2018
|
+
<table className="w-full table-fixed border-collapse text-sm">
|
|
2019
|
+
<colgroup>
|
|
2020
|
+
<col className="w-1/2" />
|
|
2021
|
+
<col className="w-1/2" />
|
|
2022
|
+
</colgroup>
|
|
2023
|
+
<thead className="bg-elevated text-left text-muted">
|
|
2024
|
+
<tr>
|
|
2025
|
+
<th className="border-b border-border px-4 py-3 font-semibold">Input</th>
|
|
2026
|
+
<th className="border-b border-border px-4 py-3 font-semibold">Output</th>
|
|
2027
|
+
</tr>
|
|
2028
|
+
</thead>
|
|
2029
|
+
<tbody>
|
|
2030
|
+
<tr className="align-top">
|
|
2031
|
+
<td className="min-w-0 border-b border-border px-4 py-4">{props.inputs}</td>
|
|
2032
|
+
<td className="min-w-0 border-b border-border px-4 py-4">
|
|
2033
|
+
<div className="space-y-1">
|
|
2034
|
+
<div className="text-xs font-semibold uppercase tracking-wide text-muted">
|
|
2035
|
+
Evaluated translation
|
|
2036
|
+
</div>
|
|
2037
|
+
<div className="min-w-0">{translationBody}</div>
|
|
2038
|
+
</div>
|
|
2039
|
+
</td>
|
|
2040
|
+
</tr>
|
|
2041
|
+
</tbody>
|
|
2042
|
+
</table>
|
|
2043
|
+
</div>
|
|
2044
|
+
);
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
function InputField(props: { label: string; children: React.ReactNode }) {
|
|
2048
|
+
return (
|
|
2049
|
+
<div className="space-y-1">
|
|
2050
|
+
<div className="text-xs font-semibold uppercase tracking-wide text-muted">{props.label}</div>
|
|
2051
|
+
<div className="min-w-0">{props.children}</div>
|
|
2052
|
+
</div>
|
|
2053
|
+
);
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
function getMessageExampleId(example: EvaluatedMessageExample) {
|
|
2057
|
+
return slugifyFragment(
|
|
2058
|
+
[
|
|
2059
|
+
`example-${example.exampleIndex + 1}`,
|
|
2060
|
+
example.locale,
|
|
2061
|
+
typeof example.matrixIndex === "number" ? `matrix-${example.matrixIndex + 1}` : "",
|
|
2062
|
+
]
|
|
2063
|
+
.filter(Boolean)
|
|
2064
|
+
.join("-"),
|
|
2065
|
+
);
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
function getMessageExampleTitle(example: EvaluatedMessageExample) {
|
|
2069
|
+
const titleParts = [`Example #${example.exampleIndex + 1}`];
|
|
2070
|
+
|
|
2071
|
+
if (typeof example.matrixIndex === "number") {
|
|
2072
|
+
titleParts.push(`matrix #${example.matrixIndex + 1}`);
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
return titleParts.join(" · ");
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
function getMessageExampleCompactLabel(example: EvaluatedMessageExample) {
|
|
2079
|
+
if (typeof example.matrixIndex === "number") {
|
|
2080
|
+
return `#${example.exampleIndex + 1}.${example.matrixIndex + 1}`;
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
return `#${example.exampleIndex + 1}`;
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
function MessageExampleDetails(props: {
|
|
2087
|
+
example: EvaluatedMessageExample;
|
|
2088
|
+
showLocale?: boolean;
|
|
2089
|
+
originalTranslationsByLocale?: Record<string, string | undefined>;
|
|
2090
|
+
}) {
|
|
2091
|
+
const { example } = props;
|
|
2092
|
+
const { detail, setKey } = useEntityDetail();
|
|
2093
|
+
const originalTranslation = props.originalTranslationsByLocale?.[example.locale];
|
|
2094
|
+
|
|
2095
|
+
return (
|
|
2096
|
+
<div className="min-w-0 space-y-4">
|
|
2097
|
+
{props.showLocale !== false && (
|
|
2098
|
+
<InputField label="Locale">
|
|
2099
|
+
<SourceLocaleLink localeKey={example.locale} />
|
|
2100
|
+
</InputField>
|
|
2101
|
+
)}
|
|
2102
|
+
|
|
2103
|
+
{typeof example.values !== "undefined" && (
|
|
2104
|
+
<InputField label="Values">
|
|
2105
|
+
<JsonValueBlock value={example.values} />
|
|
2106
|
+
</InputField>
|
|
2107
|
+
)}
|
|
2108
|
+
|
|
2109
|
+
{typeof example.context !== "undefined" && (
|
|
2110
|
+
<InputField label="Context">
|
|
2111
|
+
<JsonValueBlock value={example.context} />
|
|
2112
|
+
</InputField>
|
|
2113
|
+
)}
|
|
2114
|
+
|
|
2115
|
+
{typeof example.timeZone !== "undefined" && (
|
|
2116
|
+
<InputField label="Time zone">
|
|
2117
|
+
<span className="font-mono text-sm text-text">{example.timeZone}</span>
|
|
2118
|
+
</InputField>
|
|
2119
|
+
)}
|
|
2120
|
+
|
|
2121
|
+
{typeof example.currency !== "undefined" && (
|
|
2122
|
+
<InputField label="Currency">
|
|
2123
|
+
<span className="font-mono text-sm text-text">{example.currency}</span>
|
|
2124
|
+
</InputField>
|
|
2125
|
+
)}
|
|
2126
|
+
|
|
2127
|
+
{typeof example.formats !== "undefined" && (
|
|
2128
|
+
<InputField label="Formats">
|
|
2129
|
+
<JsonValueBlock value={example.formats} />
|
|
2130
|
+
</InputField>
|
|
2131
|
+
)}
|
|
2132
|
+
|
|
2133
|
+
{originalTranslation && (
|
|
2134
|
+
<InputField label="Original translation">
|
|
2135
|
+
<div className="space-y-2">
|
|
2136
|
+
<TranslationValueBlock value={originalTranslation} />
|
|
2137
|
+
<p className="text-xs text-muted">
|
|
2138
|
+
See more{" "}
|
|
2139
|
+
<Link
|
|
2140
|
+
to={`${getEntityRoute("message", detail.key, setKey)}/translations`}
|
|
2141
|
+
className="font-medium text-primary hover:underline"
|
|
2142
|
+
>
|
|
2143
|
+
translations
|
|
2144
|
+
</Link>{" "}
|
|
2145
|
+
and{" "}
|
|
2146
|
+
<Link
|
|
2147
|
+
to={`${getEntityRoute("message", detail.key, setKey)}/overrides`}
|
|
2148
|
+
className="font-medium text-primary hover:underline"
|
|
2149
|
+
>
|
|
2150
|
+
overrides
|
|
2151
|
+
</Link>
|
|
2152
|
+
.
|
|
2153
|
+
</p>
|
|
2154
|
+
</div>
|
|
2155
|
+
</InputField>
|
|
2156
|
+
)}
|
|
2157
|
+
</div>
|
|
2158
|
+
);
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
function MessageExamplesExpandedView(props: {
|
|
2162
|
+
examples: EvaluatedMessageExample[];
|
|
2163
|
+
localeDirections: Record<string, string | undefined>;
|
|
2164
|
+
originalTranslationsByLocale: Record<string, string | undefined>;
|
|
2165
|
+
}) {
|
|
2166
|
+
return (
|
|
2167
|
+
<div className="space-y-6">
|
|
2168
|
+
{props.examples.map((example) => {
|
|
2169
|
+
const titleId = getMessageExampleId(example);
|
|
2170
|
+
|
|
2171
|
+
return (
|
|
2172
|
+
<section
|
|
2173
|
+
key={`${example.exampleIndex}-${example.matrixIndex ?? "base"}-${example.locale}`}
|
|
2174
|
+
className="min-w-0 space-y-4"
|
|
2175
|
+
>
|
|
2176
|
+
<ExampleTitle
|
|
2177
|
+
title={getMessageExampleTitle(example)}
|
|
2178
|
+
targetId={titleId}
|
|
2179
|
+
description={example.description}
|
|
2180
|
+
/>
|
|
2181
|
+
|
|
2182
|
+
<ExampleTable
|
|
2183
|
+
inputs={
|
|
2184
|
+
<MessageExampleDetails
|
|
2185
|
+
example={example}
|
|
2186
|
+
showLocale
|
|
2187
|
+
originalTranslationsByLocale={props.originalTranslationsByLocale}
|
|
2188
|
+
/>
|
|
2189
|
+
}
|
|
2190
|
+
evaluatedTranslation={example.evaluatedTranslation}
|
|
2191
|
+
direction={getLocaleDirection(example.locale, props.localeDirections)}
|
|
2192
|
+
/>
|
|
2193
|
+
</section>
|
|
2194
|
+
);
|
|
2195
|
+
})}
|
|
2196
|
+
</div>
|
|
2197
|
+
);
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
function MessageExamplesCompactView(props: {
|
|
2201
|
+
examples: EvaluatedMessageExample[];
|
|
2202
|
+
localeDirections: Record<string, string | undefined>;
|
|
2203
|
+
originalTranslationsByLocale: Record<string, string | undefined>;
|
|
2204
|
+
}) {
|
|
2205
|
+
const [expandedExampleIds, setExpandedExampleIds] = React.useState<string[]>([]);
|
|
2206
|
+
const [lastOpenedExampleId, setLastOpenedExampleId] = React.useState<string | null>(null);
|
|
2207
|
+
|
|
2208
|
+
React.useEffect(() => {
|
|
2209
|
+
if (typeof window === "undefined") {
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
const hashTargetId = decodeURIComponent(window.location.hash.slice(1));
|
|
2214
|
+
|
|
2215
|
+
if (!hashTargetId) {
|
|
2216
|
+
return;
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
const matchingExample = props.examples.find(
|
|
2220
|
+
(example) => getMessageExampleId(example) === hashTargetId,
|
|
2221
|
+
);
|
|
2222
|
+
|
|
2223
|
+
if (!matchingExample) {
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
setExpandedExampleIds((current) =>
|
|
2228
|
+
current.includes(hashTargetId) ? current : [...current, hashTargetId],
|
|
2229
|
+
);
|
|
2230
|
+
setLastOpenedExampleId(hashTargetId);
|
|
2231
|
+
}, [props.examples]);
|
|
2232
|
+
|
|
2233
|
+
function toggleExample(exampleId: string) {
|
|
2234
|
+
if (expandedExampleIds.includes(exampleId)) {
|
|
2235
|
+
const nextExpandedExampleIds = expandedExampleIds.filter(
|
|
2236
|
+
(currentId) => currentId !== exampleId,
|
|
2237
|
+
);
|
|
2238
|
+
const nextLastOpenedExampleId =
|
|
2239
|
+
lastOpenedExampleId === exampleId
|
|
2240
|
+
? nextExpandedExampleIds[nextExpandedExampleIds.length - 1] || null
|
|
2241
|
+
: lastOpenedExampleId;
|
|
2242
|
+
|
|
2243
|
+
setExpandedExampleIds(nextExpandedExampleIds);
|
|
2244
|
+
setLastOpenedExampleId(nextLastOpenedExampleId);
|
|
2245
|
+
setWindowHash(nextLastOpenedExampleId || undefined);
|
|
2246
|
+
return;
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
setExpandedExampleIds((current) => [...current, exampleId]);
|
|
2250
|
+
setLastOpenedExampleId(exampleId);
|
|
2251
|
+
setWindowHash(exampleId);
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
return (
|
|
2255
|
+
<div className="min-w-0 overflow-hidden rounded-xl border border-border">
|
|
2256
|
+
<table className="w-full table-fixed border-collapse bg-surface text-xs">
|
|
2257
|
+
<colgroup>
|
|
2258
|
+
<col className="w-16" />
|
|
2259
|
+
<col className="w-28" />
|
|
2260
|
+
<col className="w-1/3" />
|
|
2261
|
+
<col className="w-auto" />
|
|
2262
|
+
</colgroup>
|
|
2263
|
+
<thead className="bg-elevated text-left text-[11px] uppercase tracking-wide text-muted">
|
|
2264
|
+
<tr>
|
|
2265
|
+
<th className="border-b border-border px-3 py-2 font-semibold">#</th>
|
|
2266
|
+
<th className="border-b border-border px-3 py-2 font-semibold">Locale</th>
|
|
2267
|
+
<th className="border-b border-border px-3 py-2 font-semibold">Description</th>
|
|
2268
|
+
<th className="border-b border-border px-3 py-2 font-semibold">
|
|
2269
|
+
Evaluated translation
|
|
2270
|
+
</th>
|
|
2271
|
+
</tr>
|
|
2272
|
+
</thead>
|
|
2273
|
+
<tbody>
|
|
2274
|
+
{props.examples.map((example) => {
|
|
2275
|
+
const exampleId = getMessageExampleId(example);
|
|
2276
|
+
const isExpanded = expandedExampleIds.includes(exampleId);
|
|
2277
|
+
const direction = getLocaleDirection(example.locale, props.localeDirections);
|
|
2278
|
+
|
|
2279
|
+
return (
|
|
2280
|
+
<React.Fragment key={exampleId}>
|
|
2281
|
+
<tr
|
|
2282
|
+
id={exampleId}
|
|
2283
|
+
className={[
|
|
2284
|
+
"cursor-pointer align-top transition-colors",
|
|
2285
|
+
isExpanded ? "bg-elevated" : "hover:bg-elevated/60",
|
|
2286
|
+
].join(" ")}
|
|
2287
|
+
onClick={() => toggleExample(exampleId)}
|
|
2288
|
+
>
|
|
2289
|
+
<td className="border-b border-border px-3 py-2 font-medium text-muted">
|
|
2290
|
+
{getMessageExampleCompactLabel(example)}
|
|
2291
|
+
</td>
|
|
2292
|
+
<td className="border-b border-border px-3 py-2">{example.locale}</td>
|
|
2293
|
+
<td className="min-w-0 border-b border-border px-3 py-2 text-muted">
|
|
2294
|
+
<div className="whitespace-pre-wrap [overflow-wrap:anywhere]">
|
|
2295
|
+
{example.description || "—"}
|
|
2296
|
+
</div>
|
|
2297
|
+
</td>
|
|
2298
|
+
<td
|
|
2299
|
+
className={[
|
|
2300
|
+
"min-w-0 border-b border-border px-3 py-2",
|
|
2301
|
+
direction === "rtl" ? "text-right" : "",
|
|
2302
|
+
].join(" ")}
|
|
2303
|
+
dir={direction}
|
|
2304
|
+
style={direction ? { unicodeBidi: "plaintext" } : undefined}
|
|
2305
|
+
>
|
|
2306
|
+
<div className="whitespace-pre-wrap [overflow-wrap:anywhere]">
|
|
2307
|
+
{typeof example.evaluatedTranslation === "string"
|
|
2308
|
+
? example.evaluatedTranslation
|
|
2309
|
+
: JSON.stringify(example.evaluatedTranslation)}
|
|
2310
|
+
</div>
|
|
2311
|
+
</td>
|
|
2312
|
+
</tr>
|
|
2313
|
+
{isExpanded && (
|
|
2314
|
+
<tr className="bg-background/60">
|
|
2315
|
+
<td colSpan={4} className="min-w-0 border-b border-border px-4 py-4">
|
|
2316
|
+
<div className="min-w-0 space-y-4">
|
|
2317
|
+
<div className="group flex items-center gap-2">
|
|
2318
|
+
<h3 className="text-sm font-semibold">
|
|
2319
|
+
{getMessageExampleTitle(example)}
|
|
2320
|
+
</h3>
|
|
2321
|
+
<ExamplePermalink targetId={exampleId} />
|
|
2322
|
+
</div>
|
|
2323
|
+
|
|
2324
|
+
<MessageExampleDetails
|
|
2325
|
+
example={example}
|
|
2326
|
+
showLocale={false}
|
|
2327
|
+
originalTranslationsByLocale={props.originalTranslationsByLocale}
|
|
2328
|
+
/>
|
|
2329
|
+
</div>
|
|
2330
|
+
</td>
|
|
2331
|
+
</tr>
|
|
2332
|
+
)}
|
|
2333
|
+
</React.Fragment>
|
|
2334
|
+
);
|
|
2335
|
+
})}
|
|
2336
|
+
</tbody>
|
|
2337
|
+
</table>
|
|
2338
|
+
</div>
|
|
2339
|
+
);
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
function getLocaleExampleId(example: EvaluatedLocaleExample) {
|
|
2343
|
+
return slugifyFragment(
|
|
2344
|
+
[
|
|
2345
|
+
`example-${example.exampleIndex + 1}`,
|
|
2346
|
+
example.locale,
|
|
2347
|
+
typeof example.matrixIndex === "number" ? `matrix-${example.matrixIndex + 1}` : "",
|
|
2348
|
+
]
|
|
2349
|
+
.filter(Boolean)
|
|
2350
|
+
.join("-"),
|
|
2351
|
+
);
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
function getLocaleExampleTitle(example: EvaluatedLocaleExample) {
|
|
2355
|
+
const titleParts = [`Example #${example.exampleIndex + 1}`];
|
|
2356
|
+
|
|
2357
|
+
if (typeof example.matrixIndex === "number") {
|
|
2358
|
+
titleParts.push(`matrix #${example.matrixIndex + 1}`);
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
return titleParts.join(" · ");
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
function getLocaleExampleCompactLabel(example: EvaluatedLocaleExample) {
|
|
2365
|
+
if (typeof example.matrixIndex === "number") {
|
|
2366
|
+
return `#${example.exampleIndex + 1}.${example.matrixIndex + 1}`;
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
return `#${example.exampleIndex + 1}`;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
function getLocaleExampleEvaluatedTranslationText(example: EvaluatedLocaleExample) {
|
|
2373
|
+
return typeof example.evaluatedTranslation === "string"
|
|
2374
|
+
? example.evaluatedTranslation
|
|
2375
|
+
: JSON.stringify(example.evaluatedTranslation);
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
function filterLocaleExamplesBySearch(
|
|
2379
|
+
examples: EvaluatedLocaleExample[],
|
|
2380
|
+
query: string,
|
|
2381
|
+
): EvaluatedLocaleExample[] {
|
|
2382
|
+
const needle = query.trim().toLowerCase();
|
|
2383
|
+
if (!needle) {
|
|
2384
|
+
return examples;
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
return examples.filter((example) => {
|
|
2388
|
+
const id = getLocaleExampleId(example);
|
|
2389
|
+
const compactId = getLocaleExampleCompactLabel(example);
|
|
2390
|
+
const description = example.description ?? "";
|
|
2391
|
+
const translation = getLocaleExampleEvaluatedTranslationText(example);
|
|
2392
|
+
const haystack = `${id} ${compactId} ${description} ${translation}`.toLowerCase();
|
|
2393
|
+
return haystack.includes(needle);
|
|
2394
|
+
});
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
function LocaleExampleDetails(props: {
|
|
2398
|
+
example: EvaluatedLocaleExample;
|
|
2399
|
+
setKey?: string;
|
|
2400
|
+
localeDirection?: string;
|
|
2401
|
+
showLocale?: boolean;
|
|
2402
|
+
highlightQuery?: string;
|
|
2403
|
+
}) {
|
|
2404
|
+
const { example, setKey, localeDirection } = props;
|
|
2405
|
+
const q = props.highlightQuery?.trim() ?? "";
|
|
2406
|
+
const highlight = Boolean(q);
|
|
2407
|
+
|
|
2408
|
+
function localeLink(localeKey: string) {
|
|
2409
|
+
return highlight ? (
|
|
2410
|
+
<Link
|
|
2411
|
+
to={getEntityRoute("locale", localeKey, setKey)}
|
|
2412
|
+
className="font-medium text-primary hover:underline"
|
|
2413
|
+
>
|
|
2414
|
+
<ExamplesSearchHighlight text={localeKey} query={q} />
|
|
2415
|
+
</Link>
|
|
2416
|
+
) : (
|
|
2417
|
+
<SourceLocaleLink localeKey={localeKey} />
|
|
2418
|
+
);
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
return (
|
|
2422
|
+
<div className="min-w-0 space-y-4">
|
|
2423
|
+
{props.showLocale !== false && (
|
|
2424
|
+
<InputField label="Locale">{localeLink(example.locale)}</InputField>
|
|
2425
|
+
)}
|
|
2426
|
+
|
|
2427
|
+
{example.sourceLocale !== example.locale && (
|
|
2428
|
+
<InputField label="Merged from">{localeLink(example.sourceLocale)}</InputField>
|
|
2429
|
+
)}
|
|
2430
|
+
|
|
2431
|
+
{example.message && (
|
|
2432
|
+
<>
|
|
2433
|
+
<InputField label="Message">
|
|
2434
|
+
<Link
|
|
2435
|
+
to={getEntityRoute("message", example.message, setKey)}
|
|
2436
|
+
className="font-medium text-primary hover:underline"
|
|
2437
|
+
>
|
|
2438
|
+
{highlight ? (
|
|
2439
|
+
<ExamplesSearchHighlight text={example.message} query={q} />
|
|
2440
|
+
) : (
|
|
2441
|
+
example.message
|
|
2442
|
+
)}
|
|
2443
|
+
</Link>
|
|
2444
|
+
</InputField>
|
|
2445
|
+
|
|
2446
|
+
{example.originalTranslation && (
|
|
2447
|
+
<InputField label="Original translation">
|
|
2448
|
+
<div className="space-y-2">
|
|
2449
|
+
{highlight ? (
|
|
2450
|
+
<div
|
|
2451
|
+
dir={localeDirection}
|
|
2452
|
+
className={[
|
|
2453
|
+
"min-w-0 max-w-full whitespace-pre-wrap [overflow-wrap:anywhere]",
|
|
2454
|
+
localeDirection === "rtl" ? "text-right" : "",
|
|
2455
|
+
]
|
|
2456
|
+
.filter(Boolean)
|
|
2457
|
+
.join(" ")}
|
|
2458
|
+
style={localeDirection ? { unicodeBidi: "plaintext" } : undefined}
|
|
2459
|
+
>
|
|
2460
|
+
<ExamplesSearchHighlight text={example.originalTranslation} query={q} />
|
|
2461
|
+
</div>
|
|
2462
|
+
) : (
|
|
2463
|
+
<TranslationValueBlock
|
|
2464
|
+
value={example.originalTranslation}
|
|
2465
|
+
direction={localeDirection}
|
|
2466
|
+
/>
|
|
2467
|
+
)}
|
|
2468
|
+
<p className="text-xs text-muted">
|
|
2469
|
+
See more{" "}
|
|
2470
|
+
<Link
|
|
2471
|
+
to={`${getEntityRoute("message", example.message, setKey)}/translations`}
|
|
2472
|
+
className="font-medium text-primary hover:underline"
|
|
2473
|
+
>
|
|
2474
|
+
translations
|
|
2475
|
+
</Link>{" "}
|
|
2476
|
+
and{" "}
|
|
2477
|
+
<Link
|
|
2478
|
+
to={`${getEntityRoute("message", example.message, setKey)}/overrides`}
|
|
2479
|
+
className="font-medium text-primary hover:underline"
|
|
2480
|
+
>
|
|
2481
|
+
overrides
|
|
2482
|
+
</Link>
|
|
2483
|
+
.
|
|
2484
|
+
</p>
|
|
2485
|
+
</div>
|
|
2486
|
+
</InputField>
|
|
2487
|
+
)}
|
|
2488
|
+
</>
|
|
2489
|
+
)}
|
|
2490
|
+
|
|
2491
|
+
{example.rawMessage && (
|
|
2492
|
+
<InputField label="Raw message">
|
|
2493
|
+
<div
|
|
2494
|
+
dir={localeDirection}
|
|
2495
|
+
className={["min-w-0 max-w-full", localeDirection === "rtl" ? "text-right" : ""]
|
|
2496
|
+
.filter(Boolean)
|
|
2497
|
+
.join(" ")}
|
|
2498
|
+
style={localeDirection ? { unicodeBidi: "plaintext" } : undefined}
|
|
2499
|
+
>
|
|
2500
|
+
{highlight ? (
|
|
2501
|
+
<pre className="max-w-full whitespace-pre-wrap rounded border border-border bg-elevated p-4 text-xs text-text [overflow-wrap:anywhere]">
|
|
2502
|
+
<ExamplesSearchHighlight text={example.rawMessage} query={q} />
|
|
2503
|
+
</pre>
|
|
2504
|
+
) : (
|
|
2505
|
+
<CodeBlock value={example.rawMessage} />
|
|
2506
|
+
)}
|
|
2507
|
+
</div>
|
|
2508
|
+
</InputField>
|
|
2509
|
+
)}
|
|
2510
|
+
|
|
2511
|
+
{typeof example.values !== "undefined" && (
|
|
2512
|
+
<InputField label="Values">
|
|
2513
|
+
{highlight ? (
|
|
2514
|
+
<pre className="max-w-full whitespace-pre-wrap rounded border border-border bg-elevated p-4 text-xs text-text [overflow-wrap:anywhere]">
|
|
2515
|
+
<ExamplesSearchHighlight text={JSON.stringify(example.values, null, 2)} query={q} />
|
|
2516
|
+
</pre>
|
|
2517
|
+
) : (
|
|
2518
|
+
<JsonValueBlock value={example.values} />
|
|
2519
|
+
)}
|
|
2520
|
+
</InputField>
|
|
2521
|
+
)}
|
|
2522
|
+
|
|
2523
|
+
{typeof example.context !== "undefined" && (
|
|
2524
|
+
<InputField label="Context">
|
|
2525
|
+
{highlight ? (
|
|
2526
|
+
<pre className="max-w-full whitespace-pre-wrap rounded border border-border bg-elevated p-4 text-xs text-text [overflow-wrap:anywhere]">
|
|
2527
|
+
<ExamplesSearchHighlight text={JSON.stringify(example.context, null, 2)} query={q} />
|
|
2528
|
+
</pre>
|
|
2529
|
+
) : (
|
|
2530
|
+
<JsonValueBlock value={example.context} />
|
|
2531
|
+
)}
|
|
2532
|
+
</InputField>
|
|
2533
|
+
)}
|
|
2534
|
+
|
|
2535
|
+
{typeof example.timeZone !== "undefined" && (
|
|
2536
|
+
<InputField label="Time zone">
|
|
2537
|
+
<span className="font-mono text-sm text-text">
|
|
2538
|
+
{highlight ? (
|
|
2539
|
+
<ExamplesSearchHighlight text={example.timeZone} query={q} />
|
|
2540
|
+
) : (
|
|
2541
|
+
example.timeZone
|
|
2542
|
+
)}
|
|
2543
|
+
</span>
|
|
2544
|
+
</InputField>
|
|
2545
|
+
)}
|
|
2546
|
+
|
|
2547
|
+
{typeof example.currency !== "undefined" && (
|
|
2548
|
+
<InputField label="Currency">
|
|
2549
|
+
<span className="font-mono text-sm text-text">
|
|
2550
|
+
{highlight ? (
|
|
2551
|
+
<ExamplesSearchHighlight text={example.currency} query={q} />
|
|
2552
|
+
) : (
|
|
2553
|
+
example.currency
|
|
2554
|
+
)}
|
|
2555
|
+
</span>
|
|
2556
|
+
</InputField>
|
|
2557
|
+
)}
|
|
2558
|
+
|
|
2559
|
+
{typeof example.formats !== "undefined" && (
|
|
2560
|
+
<InputField label="Formats">
|
|
2561
|
+
{highlight ? (
|
|
2562
|
+
<pre className="max-w-full whitespace-pre-wrap rounded border border-border bg-elevated p-4 text-xs text-text [overflow-wrap:anywhere]">
|
|
2563
|
+
<ExamplesSearchHighlight text={JSON.stringify(example.formats, null, 2)} query={q} />
|
|
2564
|
+
</pre>
|
|
2565
|
+
) : (
|
|
2566
|
+
<JsonValueBlock value={example.formats} />
|
|
2567
|
+
)}
|
|
2568
|
+
</InputField>
|
|
2569
|
+
)}
|
|
2570
|
+
</div>
|
|
2571
|
+
);
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
function LocaleExamplesExpandedView(props: {
|
|
2575
|
+
examples: EvaluatedLocaleExample[];
|
|
2576
|
+
setKey?: string;
|
|
2577
|
+
localeDirection?: string;
|
|
2578
|
+
searchQuery: string;
|
|
2579
|
+
}) {
|
|
2580
|
+
return (
|
|
2581
|
+
<div className="space-y-6">
|
|
2582
|
+
{props.examples.map((example) => {
|
|
2583
|
+
const titleId = getLocaleExampleId(example);
|
|
2584
|
+
|
|
2585
|
+
return (
|
|
2586
|
+
<section
|
|
2587
|
+
key={`${example.exampleIndex}-${example.matrixIndex ?? "base"}-${example.sourceLocale}-${example.locale}`}
|
|
2588
|
+
className="min-w-0 space-y-4"
|
|
2589
|
+
>
|
|
2590
|
+
<ExampleTitle
|
|
2591
|
+
title={getLocaleExampleTitle(example)}
|
|
2592
|
+
targetId={titleId}
|
|
2593
|
+
description={example.description}
|
|
2594
|
+
highlightQuery={props.searchQuery}
|
|
2595
|
+
/>
|
|
2596
|
+
|
|
2597
|
+
<ExampleTable
|
|
2598
|
+
inputs={
|
|
2599
|
+
<LocaleExampleDetails
|
|
2600
|
+
example={example}
|
|
2601
|
+
setKey={props.setKey}
|
|
2602
|
+
localeDirection={props.localeDirection}
|
|
2603
|
+
showLocale={false}
|
|
2604
|
+
highlightQuery={props.searchQuery}
|
|
2605
|
+
/>
|
|
2606
|
+
}
|
|
2607
|
+
evaluatedTranslation={example.evaluatedTranslation}
|
|
2608
|
+
direction={props.localeDirection}
|
|
2609
|
+
highlightQuery={props.searchQuery}
|
|
2610
|
+
/>
|
|
2611
|
+
</section>
|
|
2612
|
+
);
|
|
2613
|
+
})}
|
|
2614
|
+
</div>
|
|
2615
|
+
);
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
function LocaleExamplesCompactView(props: {
|
|
2619
|
+
examples: EvaluatedLocaleExample[];
|
|
2620
|
+
setKey?: string;
|
|
2621
|
+
localeDirection?: string;
|
|
2622
|
+
searchQuery: string;
|
|
2623
|
+
}) {
|
|
2624
|
+
const [expandedExampleIds, setExpandedExampleIds] = React.useState<string[]>([]);
|
|
2625
|
+
const [lastOpenedExampleId, setLastOpenedExampleId] = React.useState<string | null>(null);
|
|
2626
|
+
|
|
2627
|
+
React.useEffect(() => {
|
|
2628
|
+
if (typeof window === "undefined") {
|
|
2629
|
+
return;
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
const hashTargetId = decodeURIComponent(window.location.hash.slice(1));
|
|
2633
|
+
|
|
2634
|
+
if (!hashTargetId) {
|
|
2635
|
+
return;
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
const matchingExample = props.examples.find(
|
|
2639
|
+
(example) => getLocaleExampleId(example) === hashTargetId,
|
|
2640
|
+
);
|
|
2641
|
+
|
|
2642
|
+
if (!matchingExample) {
|
|
2643
|
+
return;
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
setExpandedExampleIds((current) =>
|
|
2647
|
+
current.includes(hashTargetId) ? current : [...current, hashTargetId],
|
|
2648
|
+
);
|
|
2649
|
+
setLastOpenedExampleId(hashTargetId);
|
|
2650
|
+
}, [props.examples]);
|
|
2651
|
+
|
|
2652
|
+
function toggleExample(exampleId: string) {
|
|
2653
|
+
if (expandedExampleIds.includes(exampleId)) {
|
|
2654
|
+
const nextExpandedExampleIds = expandedExampleIds.filter(
|
|
2655
|
+
(currentId) => currentId !== exampleId,
|
|
2656
|
+
);
|
|
2657
|
+
const nextLastOpenedExampleId =
|
|
2658
|
+
lastOpenedExampleId === exampleId
|
|
2659
|
+
? nextExpandedExampleIds[nextExpandedExampleIds.length - 1] || null
|
|
2660
|
+
: lastOpenedExampleId;
|
|
2661
|
+
|
|
2662
|
+
setExpandedExampleIds(nextExpandedExampleIds);
|
|
2663
|
+
setLastOpenedExampleId(nextLastOpenedExampleId);
|
|
2664
|
+
setWindowHash(nextLastOpenedExampleId || undefined);
|
|
2665
|
+
return;
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
setExpandedExampleIds((current) => [...current, exampleId]);
|
|
2669
|
+
setLastOpenedExampleId(exampleId);
|
|
2670
|
+
setWindowHash(exampleId);
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
return (
|
|
2674
|
+
<div className="min-w-0 overflow-hidden rounded-xl border border-border">
|
|
2675
|
+
<table className="w-full table-fixed border-collapse bg-surface text-xs">
|
|
2676
|
+
<colgroup>
|
|
2677
|
+
<col className="w-16" />
|
|
2678
|
+
<col className="w-2/5" />
|
|
2679
|
+
<col className="w-auto" />
|
|
2680
|
+
</colgroup>
|
|
2681
|
+
<thead className="bg-elevated text-left text-[11px] uppercase tracking-wide text-muted">
|
|
2682
|
+
<tr>
|
|
2683
|
+
<th className="border-b border-border px-3 py-2 font-semibold">#</th>
|
|
2684
|
+
<th className="border-b border-border px-3 py-2 font-semibold">Description</th>
|
|
2685
|
+
<th className="border-b border-border px-3 py-2 font-semibold">
|
|
2686
|
+
Evaluated translation
|
|
2687
|
+
</th>
|
|
2688
|
+
</tr>
|
|
2689
|
+
</thead>
|
|
2690
|
+
<tbody>
|
|
2691
|
+
{props.examples.map((example) => {
|
|
2692
|
+
const exampleId = getLocaleExampleId(example);
|
|
2693
|
+
const isExpanded = expandedExampleIds.includes(exampleId);
|
|
2694
|
+
|
|
2695
|
+
return (
|
|
2696
|
+
<React.Fragment key={exampleId}>
|
|
2697
|
+
<tr
|
|
2698
|
+
id={exampleId}
|
|
2699
|
+
className={[
|
|
2700
|
+
"cursor-pointer align-top transition-colors",
|
|
2701
|
+
isExpanded ? "bg-elevated" : "hover:bg-elevated/60",
|
|
2702
|
+
].join(" ")}
|
|
2703
|
+
onClick={() => toggleExample(exampleId)}
|
|
2704
|
+
>
|
|
2705
|
+
<td className="border-b border-border px-3 py-2 font-medium text-muted">
|
|
2706
|
+
<ExamplesSearchHighlight
|
|
2707
|
+
text={getLocaleExampleCompactLabel(example)}
|
|
2708
|
+
query={props.searchQuery}
|
|
2709
|
+
/>
|
|
2710
|
+
</td>
|
|
2711
|
+
<td className="min-w-0 border-b border-border px-3 py-2 text-muted">
|
|
2712
|
+
<div className="whitespace-pre-wrap [overflow-wrap:anywhere]">
|
|
2713
|
+
<ExamplesSearchHighlight
|
|
2714
|
+
text={example.description || "—"}
|
|
2715
|
+
query={props.searchQuery}
|
|
2716
|
+
/>
|
|
2717
|
+
</div>
|
|
2718
|
+
</td>
|
|
2719
|
+
<td
|
|
2720
|
+
className={[
|
|
2721
|
+
"min-w-0 border-b border-border px-3 py-2",
|
|
2722
|
+
props.localeDirection === "rtl" ? "text-right" : "",
|
|
2723
|
+
].join(" ")}
|
|
2724
|
+
dir={props.localeDirection}
|
|
2725
|
+
style={props.localeDirection ? { unicodeBidi: "plaintext" } : undefined}
|
|
2726
|
+
>
|
|
2727
|
+
<div className="whitespace-pre-wrap [overflow-wrap:anywhere]">
|
|
2728
|
+
<ExamplesSearchHighlight
|
|
2729
|
+
text={
|
|
2730
|
+
typeof example.evaluatedTranslation === "string"
|
|
2731
|
+
? example.evaluatedTranslation
|
|
2732
|
+
: JSON.stringify(example.evaluatedTranslation)
|
|
2733
|
+
}
|
|
2734
|
+
query={props.searchQuery}
|
|
2735
|
+
/>
|
|
2736
|
+
</div>
|
|
2737
|
+
</td>
|
|
2738
|
+
</tr>
|
|
2739
|
+
{isExpanded && (
|
|
2740
|
+
<tr className="bg-background/60">
|
|
2741
|
+
<td colSpan={3} className="min-w-0 border-b border-border px-4 py-4">
|
|
2742
|
+
<div className="min-w-0 space-y-4">
|
|
2743
|
+
<div className="group flex items-center gap-2">
|
|
2744
|
+
<h3 className="text-sm font-semibold">
|
|
2745
|
+
{props.searchQuery.trim() ? (
|
|
2746
|
+
<ExamplesSearchHighlight
|
|
2747
|
+
text={getLocaleExampleTitle(example)}
|
|
2748
|
+
query={props.searchQuery}
|
|
2749
|
+
/>
|
|
2750
|
+
) : (
|
|
2751
|
+
getLocaleExampleTitle(example)
|
|
2752
|
+
)}
|
|
2753
|
+
</h3>
|
|
2754
|
+
<ExamplePermalink targetId={exampleId} />
|
|
2755
|
+
</div>
|
|
2756
|
+
|
|
2757
|
+
<LocaleExampleDetails
|
|
2758
|
+
example={example}
|
|
2759
|
+
setKey={props.setKey}
|
|
2760
|
+
localeDirection={props.localeDirection}
|
|
2761
|
+
showLocale={false}
|
|
2762
|
+
highlightQuery={props.searchQuery}
|
|
2763
|
+
/>
|
|
2764
|
+
</div>
|
|
2765
|
+
</td>
|
|
2766
|
+
</tr>
|
|
2767
|
+
)}
|
|
2768
|
+
</React.Fragment>
|
|
2769
|
+
);
|
|
2770
|
+
})}
|
|
2771
|
+
</tbody>
|
|
2772
|
+
</table>
|
|
2773
|
+
</div>
|
|
2774
|
+
);
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
export function LocaleExamplesTab() {
|
|
2778
|
+
const { detail, setKey } = useEntityDetail();
|
|
2779
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
2780
|
+
const examples = (detail.evaluatedExamples || []) as EvaluatedLocaleExample[];
|
|
2781
|
+
const localeDirection = (detail.entity as Record<string, any>).direction as string | undefined;
|
|
2782
|
+
const view = searchParams.get("view");
|
|
2783
|
+
const activeView = view === "expanded" ? "expanded" : "compact";
|
|
2784
|
+
const sourceLocaleKeys = Array.from(
|
|
2785
|
+
new Set(examples.map((example) => example.sourceLocale)),
|
|
2786
|
+
).sort();
|
|
2787
|
+
const sourceLocaleFilter = searchParams.get("sourceLocale");
|
|
2788
|
+
const activeSourceLocaleFilter =
|
|
2789
|
+
sourceLocaleFilter && sourceLocaleKeys.includes(sourceLocaleFilter) ? sourceLocaleFilter : "";
|
|
2790
|
+
const visibleExamples = activeSourceLocaleFilter
|
|
2791
|
+
? examples.filter((example) => example.sourceLocale === activeSourceLocaleFilter)
|
|
2792
|
+
: examples;
|
|
2793
|
+
|
|
2794
|
+
const searchQuery = searchParams.get("q") ?? "";
|
|
2795
|
+
const filteredExamples = filterLocaleExamplesBySearch(visibleExamples, searchQuery);
|
|
2796
|
+
|
|
2797
|
+
useScrollToHash([filteredExamples.length, activeSourceLocaleFilter, activeView, searchQuery]);
|
|
2798
|
+
|
|
2799
|
+
if (examples.length === 0) {
|
|
2800
|
+
return <p className="text-sm text-muted">No examples found.</p>;
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
return (
|
|
2804
|
+
<div className="space-y-6">
|
|
2805
|
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
|
|
2806
|
+
<div className="flex min-h-0 min-w-0 flex-1 basis-[min(100%,22rem)] items-center">
|
|
2807
|
+
<Input
|
|
2808
|
+
type="search"
|
|
2809
|
+
value={searchQuery}
|
|
2810
|
+
onChange={(event) => {
|
|
2811
|
+
const val = event.target.value;
|
|
2812
|
+
setSearchParams(setSearchParam(searchParams, "q", val.trim() ? val : undefined), {
|
|
2813
|
+
replace: true,
|
|
2814
|
+
});
|
|
2815
|
+
}}
|
|
2816
|
+
placeholder="Search examples…"
|
|
2817
|
+
aria-label="Search examples"
|
|
2818
|
+
className={[
|
|
2819
|
+
EXAMPLES_TOOLBAR_CONTROL_HEIGHT_CLASS,
|
|
2820
|
+
"box-border rounded-lg border border-border bg-elevated py-0 pl-2 pr-3",
|
|
2821
|
+
"text-xs leading-snug text-text",
|
|
2822
|
+
"placeholder:text-xs placeholder:text-muted placeholder:leading-snug",
|
|
2823
|
+
].join(" ")}
|
|
2824
|
+
/>
|
|
2825
|
+
</div>
|
|
2826
|
+
|
|
2827
|
+
{sourceLocaleKeys.length > 1 ? (
|
|
2828
|
+
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
2829
|
+
<span className="text-sm text-muted">Filter by source locale</span>
|
|
2830
|
+
<nav className="flex flex-wrap gap-2">
|
|
2831
|
+
<button
|
|
2832
|
+
type="button"
|
|
2833
|
+
className={[
|
|
2834
|
+
"inline-flex rounded-full border px-3 py-1 text-xs font-semibold transition-colors",
|
|
2835
|
+
activeSourceLocaleFilter
|
|
2836
|
+
? "border-pill bg-transparent text-text hover:bg-elevated"
|
|
2837
|
+
: "border-primary bg-header-active text-header-text",
|
|
2838
|
+
].join(" ")}
|
|
2839
|
+
onClick={() =>
|
|
2840
|
+
setSearchParams(setSearchParam(searchParams, "sourceLocale", undefined))
|
|
2841
|
+
}
|
|
2842
|
+
>
|
|
2843
|
+
All
|
|
2844
|
+
</button>
|
|
2845
|
+
|
|
2846
|
+
{sourceLocaleKeys.map((sourceLocaleKey) => {
|
|
2847
|
+
const isActive = activeSourceLocaleFilter === sourceLocaleKey;
|
|
2848
|
+
|
|
2849
|
+
return (
|
|
2850
|
+
<button
|
|
2851
|
+
key={sourceLocaleKey}
|
|
2852
|
+
type="button"
|
|
2853
|
+
className={[
|
|
2854
|
+
"inline-flex rounded-full border px-3 py-1 text-xs font-semibold transition-colors",
|
|
2855
|
+
isActive
|
|
2856
|
+
? "border-primary bg-header-active text-header-text"
|
|
2857
|
+
: "border-pill bg-transparent text-text hover:bg-elevated",
|
|
2858
|
+
].join(" ")}
|
|
2859
|
+
onClick={() =>
|
|
2860
|
+
setSearchParams(
|
|
2861
|
+
setSearchParam(
|
|
2862
|
+
searchParams,
|
|
2863
|
+
"sourceLocale",
|
|
2864
|
+
isActive ? undefined : sourceLocaleKey,
|
|
2865
|
+
),
|
|
2866
|
+
)
|
|
2867
|
+
}
|
|
2868
|
+
>
|
|
2869
|
+
{sourceLocaleKey}
|
|
2870
|
+
</button>
|
|
2871
|
+
);
|
|
2872
|
+
})}
|
|
2873
|
+
</nav>
|
|
2874
|
+
</div>
|
|
2875
|
+
) : null}
|
|
2876
|
+
|
|
2877
|
+
<div className="ml-auto shrink-0">
|
|
2878
|
+
<ExamplesViewModeSwitch
|
|
2879
|
+
activeView={activeView}
|
|
2880
|
+
onViewChange={(view) =>
|
|
2881
|
+
setSearchParams(
|
|
2882
|
+
setSearchParam(searchParams, "view", view === "compact" ? undefined : view),
|
|
2883
|
+
)
|
|
2884
|
+
}
|
|
2885
|
+
/>
|
|
2886
|
+
</div>
|
|
2887
|
+
</div>
|
|
2888
|
+
|
|
2889
|
+
{filteredExamples.length === 0 ? (
|
|
2890
|
+
<p className="text-sm text-muted">No examples match your search.</p>
|
|
2891
|
+
) : activeView === "compact" ? (
|
|
2892
|
+
<LocaleExamplesCompactView
|
|
2893
|
+
examples={filteredExamples}
|
|
2894
|
+
setKey={setKey}
|
|
2895
|
+
localeDirection={localeDirection}
|
|
2896
|
+
searchQuery={searchQuery}
|
|
2897
|
+
/>
|
|
2898
|
+
) : (
|
|
2899
|
+
<LocaleExamplesExpandedView
|
|
2900
|
+
examples={filteredExamples}
|
|
2901
|
+
setKey={setKey}
|
|
2902
|
+
localeDirection={localeDirection}
|
|
2903
|
+
searchQuery={searchQuery}
|
|
2904
|
+
/>
|
|
2905
|
+
)}
|
|
2906
|
+
</div>
|
|
2907
|
+
);
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
function filterDuplicateValuesBySearch(
|
|
2911
|
+
duplicateValues: DuplicateTranslationValue[],
|
|
2912
|
+
query: string,
|
|
2913
|
+
): DuplicateTranslationValue[] {
|
|
2914
|
+
const needle = query.trim().toLowerCase();
|
|
2915
|
+
|
|
2916
|
+
if (!needle) {
|
|
2917
|
+
return duplicateValues;
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
return duplicateValues.filter((duplicate) => {
|
|
2921
|
+
const haystack = [
|
|
2922
|
+
duplicate.value,
|
|
2923
|
+
...duplicate.messageKeys,
|
|
2924
|
+
...duplicate.sources.map((source) => source.locale),
|
|
2925
|
+
]
|
|
2926
|
+
.join(" ")
|
|
2927
|
+
.toLowerCase();
|
|
2928
|
+
|
|
2929
|
+
return haystack.includes(needle);
|
|
2930
|
+
});
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
export function LocaleDuplicatesTab() {
|
|
2934
|
+
const { detail, setKey } = useEntityDetail();
|
|
2935
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
2936
|
+
const [duplicates, setDuplicates] = React.useState<LocaleDuplicates | null>(null);
|
|
2937
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
2938
|
+
const [expandedDuplicateHashes, setExpandedDuplicateHashes] = React.useState<string[]>([]);
|
|
2939
|
+
const localeDirection = (detail.entity as Record<string, any>).direction as string | undefined;
|
|
2940
|
+
const searchQuery = searchParams.get("q") ?? "";
|
|
2941
|
+
|
|
2942
|
+
function toggleDuplicateValue(duplicate: DuplicateTranslationValue) {
|
|
2943
|
+
const duplicateHash = hashTranslationValue(duplicate.value);
|
|
2944
|
+
|
|
2945
|
+
setExpandedDuplicateHashes((current) => {
|
|
2946
|
+
if (current.includes(duplicateHash)) {
|
|
2947
|
+
const next = current.filter((item) => item !== duplicateHash);
|
|
2948
|
+
setWindowHash(undefined);
|
|
2949
|
+
return next;
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
setWindowHash(duplicateHash);
|
|
2953
|
+
return [...current, duplicateHash];
|
|
2954
|
+
});
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
React.useEffect(() => {
|
|
2958
|
+
let cancelled = false;
|
|
2959
|
+
|
|
2960
|
+
setDuplicates(null);
|
|
2961
|
+
setError(null);
|
|
2962
|
+
setExpandedDuplicateHashes([]);
|
|
2963
|
+
|
|
2964
|
+
fetchLocaleDuplicates(detail.key, setKey)
|
|
2965
|
+
.then((data) => {
|
|
2966
|
+
if (!cancelled) {
|
|
2967
|
+
setDuplicates(data);
|
|
2968
|
+
}
|
|
2969
|
+
})
|
|
2970
|
+
.catch((err: Error) => {
|
|
2971
|
+
if (!cancelled) {
|
|
2972
|
+
setError(err.message);
|
|
2973
|
+
}
|
|
2974
|
+
});
|
|
2975
|
+
|
|
2976
|
+
return () => {
|
|
2977
|
+
cancelled = true;
|
|
2978
|
+
};
|
|
2979
|
+
}, [detail.key, setKey]);
|
|
2980
|
+
|
|
2981
|
+
React.useEffect(() => {
|
|
2982
|
+
if (!duplicates || typeof window === "undefined") {
|
|
2983
|
+
return;
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
const hashTargetId = decodeURIComponent(window.location.hash.slice(1));
|
|
2987
|
+
|
|
2988
|
+
if (!hashTargetId) {
|
|
2989
|
+
return;
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
const matchingDuplicate = duplicates.duplicateValues.find(
|
|
2993
|
+
(duplicate) => hashTranslationValue(duplicate.value) === hashTargetId,
|
|
2994
|
+
);
|
|
2995
|
+
|
|
2996
|
+
if (!matchingDuplicate) {
|
|
2997
|
+
return;
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
const matchingHash = hashTranslationValue(matchingDuplicate.value);
|
|
3001
|
+
|
|
3002
|
+
setExpandedDuplicateHashes((current) =>
|
|
3003
|
+
current.includes(matchingHash) ? current : [...current, matchingHash],
|
|
3004
|
+
);
|
|
3005
|
+
}, [duplicates]);
|
|
3006
|
+
|
|
3007
|
+
useScrollToHash([duplicates?.duplicateValues.length, expandedDuplicateHashes.length]);
|
|
3008
|
+
|
|
3009
|
+
if (error) {
|
|
3010
|
+
return <EmptyState title="Unable to load duplicate translations" description={error} />;
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
if (!duplicates) {
|
|
3014
|
+
return <div className="text-sm text-muted">Loading duplicate translations...</div>;
|
|
3015
|
+
}
|
|
3016
|
+
|
|
3017
|
+
if (duplicates.duplicateValues.length === 0) {
|
|
3018
|
+
return <p className="text-sm text-muted">No duplicate translations found for this locale.</p>;
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
const visibleDuplicateValues = filterDuplicateValuesBySearch(
|
|
3022
|
+
duplicates.duplicateValues,
|
|
3023
|
+
searchQuery,
|
|
3024
|
+
);
|
|
3025
|
+
|
|
3026
|
+
return (
|
|
3027
|
+
<div className="space-y-6">
|
|
3028
|
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
|
|
3029
|
+
<div className="flex min-h-0 min-w-0 flex-1 basis-[min(100%,22rem)] items-center">
|
|
3030
|
+
<Input
|
|
3031
|
+
type="search"
|
|
3032
|
+
value={searchQuery}
|
|
3033
|
+
onChange={(event) => {
|
|
3034
|
+
const val = event.target.value;
|
|
3035
|
+
setSearchParams(setSearchParam(searchParams, "q", val.trim() ? val : undefined), {
|
|
3036
|
+
replace: true,
|
|
3037
|
+
});
|
|
3038
|
+
}}
|
|
3039
|
+
placeholder="Search duplicates..."
|
|
3040
|
+
aria-label="Search duplicates"
|
|
3041
|
+
className={[
|
|
3042
|
+
EXAMPLES_TOOLBAR_CONTROL_HEIGHT_CLASS,
|
|
3043
|
+
"box-border rounded-lg border border-border bg-elevated py-0 pl-2 pr-3",
|
|
3044
|
+
"text-xs leading-snug text-text",
|
|
3045
|
+
"placeholder:text-xs placeholder:text-muted placeholder:leading-snug",
|
|
3046
|
+
].join(" ")}
|
|
3047
|
+
/>
|
|
3048
|
+
</div>
|
|
3049
|
+
|
|
3050
|
+
<div className="shrink-0 text-xs text-muted">
|
|
3051
|
+
{duplicates.summary.duplicateValues} value
|
|
3052
|
+
{duplicates.summary.duplicateValues === 1 ? "" : "s"} ·{" "}
|
|
3053
|
+
{duplicates.summary.duplicateMessageKeys} message key
|
|
3054
|
+
{duplicates.summary.duplicateMessageKeys === 1 ? "" : "s"}
|
|
3055
|
+
</div>
|
|
3056
|
+
</div>
|
|
3057
|
+
|
|
3058
|
+
{visibleDuplicateValues.length === 0 ? (
|
|
3059
|
+
<p className="text-sm text-muted">No duplicates match your search.</p>
|
|
3060
|
+
) : (
|
|
3061
|
+
<div className="min-w-0 overflow-hidden rounded-xl border border-border">
|
|
3062
|
+
<table className="w-full table-fixed border-collapse bg-surface text-xs">
|
|
3063
|
+
<colgroup>
|
|
3064
|
+
<col className="w-[70%]" />
|
|
3065
|
+
<col className="w-[30%]" />
|
|
3066
|
+
</colgroup>
|
|
3067
|
+
<thead className="bg-elevated text-left text-[11px] uppercase tracking-wide text-muted">
|
|
3068
|
+
<tr>
|
|
3069
|
+
<th className="border-b border-border px-3 py-2 font-semibold">Duplicate value</th>
|
|
3070
|
+
<th className="border-b border-border px-3 py-2 font-semibold">Messages</th>
|
|
3071
|
+
</tr>
|
|
3072
|
+
</thead>
|
|
3073
|
+
<tbody>
|
|
3074
|
+
{visibleDuplicateValues.map((duplicate) => {
|
|
3075
|
+
const sourcesByMessageKey = Object.fromEntries(
|
|
3076
|
+
duplicate.sources.map((source) => [source.messageKey, source.locale]),
|
|
3077
|
+
);
|
|
3078
|
+
const duplicateHash = hashTranslationValue(duplicate.value);
|
|
3079
|
+
const isExpanded = expandedDuplicateHashes.includes(duplicateHash);
|
|
3080
|
+
|
|
3081
|
+
return (
|
|
3082
|
+
<React.Fragment key={duplicate.value}>
|
|
3083
|
+
<tr
|
|
3084
|
+
id={duplicateHash}
|
|
3085
|
+
className={[
|
|
3086
|
+
"cursor-pointer align-top transition-colors",
|
|
3087
|
+
isExpanded ? "bg-elevated" : "hover:bg-elevated/60",
|
|
3088
|
+
].join(" ")}
|
|
3089
|
+
onClick={() => toggleDuplicateValue(duplicate)}
|
|
3090
|
+
>
|
|
3091
|
+
<td
|
|
3092
|
+
className={[
|
|
3093
|
+
"min-w-0 border-b border-border px-3 py-2 font-medium text-text",
|
|
3094
|
+
localeDirection === "rtl" ? "text-right" : "",
|
|
3095
|
+
].join(" ")}
|
|
3096
|
+
dir={localeDirection}
|
|
3097
|
+
style={localeDirection ? { unicodeBidi: "plaintext" } : undefined}
|
|
3098
|
+
>
|
|
3099
|
+
<div className="truncate">
|
|
3100
|
+
<ExamplesSearchHighlight text={duplicate.value} query={searchQuery} />
|
|
3101
|
+
</div>
|
|
3102
|
+
</td>
|
|
3103
|
+
<td className="min-w-0 border-b border-border px-3 py-2 text-muted">
|
|
3104
|
+
{duplicate.messageKeys.length} message
|
|
3105
|
+
{duplicate.messageKeys.length === 1 ? "" : "s"}
|
|
3106
|
+
</td>
|
|
3107
|
+
</tr>
|
|
3108
|
+
{isExpanded && (
|
|
3109
|
+
<tr className="bg-background/60">
|
|
3110
|
+
<td colSpan={2} className="min-w-0 border-b border-border px-4 py-4">
|
|
3111
|
+
<div className="min-w-0 space-y-3">
|
|
3112
|
+
<div className="space-y-2">
|
|
3113
|
+
<h3 className="text-sm font-semibold">Translation value</h3>
|
|
3114
|
+
<CodeBlock value={duplicate.value} />
|
|
3115
|
+
</div>
|
|
3116
|
+
<h3 className="text-sm font-semibold">Messages</h3>
|
|
3117
|
+
<div className="min-w-0 overflow-hidden rounded-lg border border-border">
|
|
3118
|
+
<table className="w-full table-fixed border-collapse bg-surface text-xs">
|
|
3119
|
+
<colgroup>
|
|
3120
|
+
<col className="w-[70%]" />
|
|
3121
|
+
<col className="w-[30%]" />
|
|
3122
|
+
</colgroup>
|
|
3123
|
+
<thead className="bg-elevated text-left text-[11px] uppercase tracking-wide text-muted">
|
|
3124
|
+
<tr>
|
|
3125
|
+
<th className="border-b border-border px-3 py-2 font-semibold">
|
|
3126
|
+
Message
|
|
3127
|
+
</th>
|
|
3128
|
+
<th className="border-b border-border px-3 py-2 font-semibold">
|
|
3129
|
+
Source
|
|
3130
|
+
</th>
|
|
3131
|
+
</tr>
|
|
3132
|
+
</thead>
|
|
3133
|
+
<tbody>
|
|
3134
|
+
{duplicate.messageKeys.map((messageKey) => {
|
|
3135
|
+
const sourceLocale =
|
|
3136
|
+
sourcesByMessageKey[messageKey] || duplicates.locale;
|
|
3137
|
+
const isInherited = sourceLocale !== duplicates.locale;
|
|
3138
|
+
|
|
3139
|
+
return (
|
|
3140
|
+
<tr key={messageKey} className="align-top">
|
|
3141
|
+
<td className="min-w-0 border-b border-border px-3 py-2">
|
|
3142
|
+
<Link
|
|
3143
|
+
to={getEntityRoute("message", messageKey, setKey)}
|
|
3144
|
+
className="font-medium text-primary hover:underline"
|
|
3145
|
+
>
|
|
3146
|
+
<ExamplesSearchHighlight
|
|
3147
|
+
text={messageKey}
|
|
3148
|
+
query={searchQuery}
|
|
3149
|
+
/>
|
|
3150
|
+
</Link>
|
|
3151
|
+
</td>
|
|
3152
|
+
<td className="min-w-0 border-b border-border px-3 py-2 text-muted">
|
|
3153
|
+
{isInherited ? (
|
|
3154
|
+
<>
|
|
3155
|
+
<span>from </span>
|
|
3156
|
+
<Link
|
|
3157
|
+
to={getEntityRoute("locale", sourceLocale, setKey)}
|
|
3158
|
+
className="font-medium text-primary hover:underline"
|
|
3159
|
+
onClick={(event) => event.stopPropagation()}
|
|
3160
|
+
>
|
|
3161
|
+
<ExamplesSearchHighlight
|
|
3162
|
+
text={sourceLocale}
|
|
3163
|
+
query={searchQuery}
|
|
3164
|
+
/>
|
|
3165
|
+
</Link>
|
|
3166
|
+
</>
|
|
3167
|
+
) : (
|
|
3168
|
+
<span>direct</span>
|
|
3169
|
+
)}
|
|
3170
|
+
</td>
|
|
3171
|
+
</tr>
|
|
3172
|
+
);
|
|
3173
|
+
})}
|
|
3174
|
+
</tbody>
|
|
3175
|
+
</table>
|
|
3176
|
+
</div>
|
|
3177
|
+
</div>
|
|
3178
|
+
</td>
|
|
3179
|
+
</tr>
|
|
3180
|
+
)}
|
|
3181
|
+
</React.Fragment>
|
|
3182
|
+
);
|
|
3183
|
+
})}
|
|
3184
|
+
</tbody>
|
|
3185
|
+
</table>
|
|
3186
|
+
</div>
|
|
3187
|
+
)}
|
|
3188
|
+
</div>
|
|
3189
|
+
);
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
export function EntityExamplesTab() {
|
|
3193
|
+
const { detail } = useEntityDetail();
|
|
3194
|
+
|
|
3195
|
+
if (detail.type === "locale") {
|
|
3196
|
+
return <LocaleExamplesTab />;
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
return <MessageExamplesTab />;
|
|
3200
|
+
}
|
|
3201
|
+
|
|
3202
|
+
export function AttributeUsageTab() {
|
|
3203
|
+
const { detail, setKey } = useEntityDetail();
|
|
3204
|
+
const usage = (detail.usage || {}) as Record<string, string[]>;
|
|
3205
|
+
|
|
3206
|
+
return (
|
|
3207
|
+
<div className="grid gap-6 md:grid-cols-2">
|
|
3208
|
+
<section>
|
|
3209
|
+
<h2 className="mb-2 font-semibold">Segments</h2>
|
|
3210
|
+
<UsageLinks type="segment" keys={usage.segments} setKey={setKey} />
|
|
3211
|
+
</section>
|
|
3212
|
+
<section>
|
|
3213
|
+
<h2 className="mb-2 font-semibold">Messages</h2>
|
|
3214
|
+
<UsageLinks type="message" keys={usage.messages} setKey={setKey} />
|
|
3215
|
+
</section>
|
|
3216
|
+
</div>
|
|
3217
|
+
);
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
export function SegmentUsageTab() {
|
|
3221
|
+
const { detail, setKey } = useEntityDetail();
|
|
3222
|
+
const usage = (detail.usage || {}) as Record<string, string[]>;
|
|
3223
|
+
|
|
3224
|
+
return (
|
|
3225
|
+
<div className="grid gap-6 md:grid-cols-2">
|
|
3226
|
+
<section>
|
|
3227
|
+
<h2 className="mb-2 font-semibold">Attributes</h2>
|
|
3228
|
+
<UsageLinks type="attribute" keys={usage.attributes} setKey={setKey} />
|
|
3229
|
+
</section>
|
|
3230
|
+
<section>
|
|
3231
|
+
<h2 className="mb-2 font-semibold">Messages</h2>
|
|
3232
|
+
<UsageLinks type="message" keys={usage.messages} setKey={setKey} />
|
|
3233
|
+
</section>
|
|
3234
|
+
</div>
|
|
3235
|
+
);
|
|
3236
|
+
}
|
|
3237
|
+
|
|
3238
|
+
export function UsageTab() {
|
|
3239
|
+
const { detail } = useEntityDetail();
|
|
3240
|
+
|
|
3241
|
+
if (detail.type === "segment") {
|
|
3242
|
+
return <SegmentUsageTab />;
|
|
3243
|
+
}
|
|
3244
|
+
|
|
3245
|
+
return <AttributeUsageTab />;
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3248
|
+
export function SegmentConditionsTab() {
|
|
3249
|
+
const { detail, setKey } = useEntityDetail();
|
|
3250
|
+
const entity = detail.entity as Record<string, any>;
|
|
3251
|
+
|
|
3252
|
+
return <ConditionTree conditions={entity.conditions} setKey={setKey} />;
|
|
3253
|
+
}
|
|
3254
|
+
|
|
3255
|
+
export function TargetFormatsTab() {
|
|
3256
|
+
const { detail, setKey } = useEntityDetail();
|
|
3257
|
+
const { localeKey } = useParams();
|
|
3258
|
+
const [searchParams] = useSearchParams();
|
|
3259
|
+
const searchQuery = searchParams.get("q") ?? "";
|
|
3260
|
+
const rowsByLocale = (detail.formatRowsByLocale || {}) as Record<string, FormatRow[]>;
|
|
3261
|
+
const localeKeys = Object.keys(rowsByLocale).sort();
|
|
3262
|
+
|
|
3263
|
+
const activeLocaleKey =
|
|
3264
|
+
localeKeys.length === 0 ? "" : localeKey && rowsByLocale[localeKey] ? localeKey : localeKeys[0];
|
|
3265
|
+
|
|
3266
|
+
const activeFormatRows =
|
|
3267
|
+
activeLocaleKey && rowsByLocale[activeLocaleKey] ? rowsByLocale[activeLocaleKey] : [];
|
|
3268
|
+
|
|
3269
|
+
const { formatTypes, formatTypePillKeys, selectedFormatType } =
|
|
3270
|
+
useFormatsTypePillSelection(activeFormatRows);
|
|
3271
|
+
|
|
3272
|
+
if (localeKeys.length === 0) {
|
|
3273
|
+
return <p className="text-sm text-muted">No target formats found.</p>;
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
const basePath = `${getBasePath(setKey)}/targets/${encodeRouteSegment(detail.key)}/formats`;
|
|
3277
|
+
|
|
3278
|
+
if (!localeKey) {
|
|
3279
|
+
const qs = searchParams.toString();
|
|
3280
|
+
return (
|
|
3281
|
+
<Navigate
|
|
3282
|
+
to={`${basePath}/${encodeRouteSegment(activeLocaleKey)}${qs ? `?${qs}` : ""}`}
|
|
3283
|
+
replace
|
|
3284
|
+
/>
|
|
3285
|
+
);
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
return (
|
|
3289
|
+
<div className="space-y-6">
|
|
3290
|
+
<nav className="flex min-w-0 flex-wrap gap-2">
|
|
3291
|
+
{localeKeys.map((item) => {
|
|
3292
|
+
const qs = searchParams.toString();
|
|
3293
|
+
const suffix = qs ? `?${qs}` : "";
|
|
3294
|
+
const isActive = item === activeLocaleKey;
|
|
3295
|
+
return (
|
|
3296
|
+
<Link
|
|
3297
|
+
key={item}
|
|
3298
|
+
to={`${basePath}/${encodeRouteSegment(item)}${suffix}`}
|
|
3299
|
+
className={[
|
|
3300
|
+
"inline-flex rounded-full border px-3 py-1 text-xs font-semibold transition-colors",
|
|
3301
|
+
isActive
|
|
3302
|
+
? "border-primary bg-header-active text-header-text"
|
|
3303
|
+
: "border-pill bg-transparent text-text hover:bg-elevated",
|
|
3304
|
+
].join(" ")}
|
|
3305
|
+
>
|
|
3306
|
+
{item}
|
|
3307
|
+
</Link>
|
|
3308
|
+
);
|
|
3309
|
+
})}
|
|
3310
|
+
</nav>
|
|
3311
|
+
|
|
3312
|
+
{formatTypes.length > 0 ? <FormatsTypePills typeKeys={formatTypePillKeys} /> : null}
|
|
3313
|
+
|
|
3314
|
+
<FormatsSearchToolbar />
|
|
3315
|
+
|
|
3316
|
+
<FormatRowsTable
|
|
3317
|
+
rows={rowsByLocale[activeLocaleKey]}
|
|
3318
|
+
searchQuery={searchQuery}
|
|
3319
|
+
formatPathLayout="split"
|
|
3320
|
+
hideTypeColumn={selectedFormatType != null}
|
|
3321
|
+
selectedFormatType={selectedFormatType}
|
|
3322
|
+
showExampleColumn={showFormatExampleColumn(selectedFormatType)}
|
|
3323
|
+
/>
|
|
3324
|
+
</div>
|
|
3325
|
+
);
|
|
3326
|
+
}
|
|
3327
|
+
|
|
3328
|
+
export function TargetMessagesTab() {
|
|
3329
|
+
const { detail, setKey } = useEntityDetail();
|
|
3330
|
+
|
|
3331
|
+
return <UsageLinks type="message" keys={detail.messages as string[]} setKey={setKey} />;
|
|
3332
|
+
}
|
|
3333
|
+
|
|
3334
|
+
export function EntityHistoryTab() {
|
|
3335
|
+
const { manifest } = useCatalog();
|
|
3336
|
+
const { detail, setKey } = useEntityDetail();
|
|
3337
|
+
|
|
3338
|
+
return (
|
|
3339
|
+
<HistoryTimeline
|
|
3340
|
+
path={`${setKey ? `/data/sets/${encodeRouteSegment(setKey)}` : "/data/root"}/history/${detail.type}/${encodeRouteSegment(detail.key)}`}
|
|
3341
|
+
setKey={setKey}
|
|
3342
|
+
commitUrl={manifest.links?.commit}
|
|
3343
|
+
/>
|
|
3344
|
+
);
|
|
3345
|
+
}
|