@messagevisor/catalog 0.2.0 → 0.4.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/dist/assets/index-BJS9aW0t.js +73 -0
- package/dist/assets/index-DrsX4U8c.css +1 -0
- package/dist/index.html +2 -2
- package/lib/node/index.d.ts +7 -0
- package/lib/node/index.js +29 -14
- package/lib/node/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/details/FieldGrid.tsx +1 -1
- package/src/components/details/GroupSegmentTree.tsx +2 -1
- package/src/components/details/UsageLinks.tsx +3 -2
- package/src/components/history/HistoryTimeline.tsx +4 -3
- package/src/components/layout/PageHeader.tsx +13 -3
- package/src/components/lists/EntityList.spec.ts +34 -0
- package/src/components/lists/EntityList.tsx +70 -13
- package/src/components/ui/EntityKey.spec.ts +33 -0
- package/src/components/ui/EntityKey.tsx +99 -0
- package/src/components/ui/SearchHighlight.spec.ts +24 -0
- package/src/components/ui/SearchHighlight.tsx +58 -0
- package/src/node/index.spec.ts +112 -0
- package/src/node/index.ts +34 -15
- package/src/pages/EntityDetailPage.spec.ts +48 -0
- package/src/pages/EntityDetailPage.tsx +101 -94
- package/src/pages/ListPage.tsx +3 -0
- package/src/types.ts +3 -0
- package/src/utils/duplicateSorting.ts +46 -0
- package/dist/assets/index-BoO0zn_O.js +0 -73
- package/dist/assets/index-Cn0qFwkD.css +0 -1
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { getEntityListHighlightTerms } from "./EntityList";
|
|
2
|
+
|
|
3
|
+
describe("EntityList highlighting", function () {
|
|
4
|
+
it("uses free text for key and last-modified highlights only", function () {
|
|
5
|
+
expect(getEntityListHighlightTerms("welcome")).toEqual({
|
|
6
|
+
key: ["welcome"],
|
|
7
|
+
description: [],
|
|
8
|
+
relationship: [],
|
|
9
|
+
lastModified: ["welcome"],
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("uses scoped qualifiers for visible description and relationship highlights", function () {
|
|
14
|
+
expect(
|
|
15
|
+
getEntityListHighlightTerms('description:"Welcome back" target:web locale:en-US'),
|
|
16
|
+
).toEqual({
|
|
17
|
+
key: [],
|
|
18
|
+
description: ["Welcome back"],
|
|
19
|
+
relationship: ["web", "en-US"],
|
|
20
|
+
lastModified: [],
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("does not highlight status, overrides, or translation qualifiers in list rows", function () {
|
|
25
|
+
expect(
|
|
26
|
+
getEntityListHighlightTerms('has:overrides is:deprecated translation:"welcome"'),
|
|
27
|
+
).toEqual({
|
|
28
|
+
key: [],
|
|
29
|
+
description: [],
|
|
30
|
+
relationship: [],
|
|
31
|
+
lastModified: [],
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -9,14 +9,24 @@ import { Badge } from "../ui/Badge";
|
|
|
9
9
|
import { EmptyState } from "../ui/EmptyState";
|
|
10
10
|
import { Input } from "../ui/Input";
|
|
11
11
|
import { Button } from "../ui/Button";
|
|
12
|
+
import { EntityKey } from "../ui/EntityKey";
|
|
13
|
+
import { SearchHighlight } from "../ui/SearchHighlight";
|
|
12
14
|
import { CATALOG_LIST_INITIAL_LIMIT } from "../../config";
|
|
13
15
|
import type { ParsedQuery } from "../../utils/searchQuery";
|
|
14
16
|
import { parseQuery } from "../../utils/searchQuery";
|
|
15
17
|
|
|
18
|
+
interface EntityListHighlightTerms {
|
|
19
|
+
key: string[];
|
|
20
|
+
description: string[];
|
|
21
|
+
relationship: string[];
|
|
22
|
+
lastModified: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
function matchesQuery(
|
|
17
26
|
entity: EntitySummary,
|
|
18
27
|
parsed: ParsedQuery,
|
|
19
28
|
translationShard: TranslationShard | null,
|
|
29
|
+
translationSearchEnabled: boolean,
|
|
20
30
|
): boolean {
|
|
21
31
|
const { freeText, qualifiers } = parsed;
|
|
22
32
|
|
|
@@ -69,6 +79,7 @@ function matchesQuery(
|
|
|
69
79
|
break;
|
|
70
80
|
}
|
|
71
81
|
case "translation": {
|
|
82
|
+
if (!translationSearchEnabled) break;
|
|
72
83
|
if (q.value.length < 3) return true; // require 3+ chars; don't filter otherwise
|
|
73
84
|
if (!translationShard) return true; // optimistically include while loading
|
|
74
85
|
const values = translationShard[entity.key];
|
|
@@ -90,13 +101,14 @@ function getQueryHints(
|
|
|
90
101
|
type: EntityType,
|
|
91
102
|
firstTargetKey: string | undefined,
|
|
92
103
|
firstLocaleKey: string | undefined,
|
|
104
|
+
translationSearchEnabled: boolean,
|
|
93
105
|
): string[] | null {
|
|
94
106
|
const target = firstTargetKey;
|
|
95
107
|
const locale = firstLocaleKey;
|
|
96
108
|
|
|
97
109
|
if (type === "message") {
|
|
98
110
|
return [
|
|
99
|
-
'translation:"keyword"',
|
|
111
|
+
...(translationSearchEnabled ? ['translation:"keyword"'] : []),
|
|
100
112
|
...(target ? [`target:${target}`] : []),
|
|
101
113
|
...(locale ? [`locale:${locale}`] : []),
|
|
102
114
|
'description:"keyword"',
|
|
@@ -115,15 +127,17 @@ function QueryHints({
|
|
|
115
127
|
query,
|
|
116
128
|
firstTargetKey,
|
|
117
129
|
firstLocaleKey,
|
|
130
|
+
translationSearchEnabled,
|
|
118
131
|
onHintClick,
|
|
119
132
|
}: {
|
|
120
133
|
type: EntityType;
|
|
121
134
|
query: string;
|
|
122
135
|
firstTargetKey: string | undefined;
|
|
123
136
|
firstLocaleKey: string | undefined;
|
|
137
|
+
translationSearchEnabled: boolean;
|
|
124
138
|
onHintClick: (hint: string) => void;
|
|
125
139
|
}) {
|
|
126
|
-
const hints = getQueryHints(type, firstTargetKey, firstLocaleKey);
|
|
140
|
+
const hints = getQueryHints(type, firstTargetKey, firstLocaleKey, translationSearchEnabled);
|
|
127
141
|
if (!hints) return null;
|
|
128
142
|
|
|
129
143
|
return (
|
|
@@ -163,7 +177,7 @@ function getStatusBadges(entity: EntitySummary) {
|
|
|
163
177
|
);
|
|
164
178
|
}
|
|
165
179
|
|
|
166
|
-
function LastModified(props: { entity: EntitySummary }) {
|
|
180
|
+
function LastModified(props: { entity: EntitySummary; highlightQuery: string[] }) {
|
|
167
181
|
if (!props.entity.lastModified) {
|
|
168
182
|
return <span>Last modified n/a</span>;
|
|
169
183
|
}
|
|
@@ -179,8 +193,11 @@ function LastModified(props: { entity: EntitySummary }) {
|
|
|
179
193
|
|
|
180
194
|
return (
|
|
181
195
|
<span>
|
|
182
|
-
Last modified by
|
|
183
|
-
|
|
196
|
+
Last modified by{" "}
|
|
197
|
+
<span className="font-semibold">
|
|
198
|
+
<SearchHighlight text={props.entity.lastModified.author} query={props.highlightQuery} />
|
|
199
|
+
</span>{" "}
|
|
200
|
+
on <SearchHighlight text={formattedDate} query={props.highlightQuery} />
|
|
184
201
|
</span>
|
|
185
202
|
);
|
|
186
203
|
}
|
|
@@ -193,6 +210,32 @@ function getRelationshipBadges(type: EntityType, entity: EntitySummary) {
|
|
|
193
210
|
return entity.targets || [];
|
|
194
211
|
}
|
|
195
212
|
|
|
213
|
+
function uniqueTerms(terms: string[]) {
|
|
214
|
+
return Array.from(new Set(terms.map((term) => term.trim()).filter(Boolean)));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function getEntityListHighlightTerms(query: string): EntityListHighlightTerms {
|
|
218
|
+
const parsed = parseQuery(query);
|
|
219
|
+
const freeText = uniqueTerms(parsed.freeText);
|
|
220
|
+
const description = uniqueTerms(
|
|
221
|
+
parsed.qualifiers
|
|
222
|
+
.filter((qualifier) => qualifier.key === "description")
|
|
223
|
+
.map((qualifier) => qualifier.value),
|
|
224
|
+
);
|
|
225
|
+
const relationship = uniqueTerms(
|
|
226
|
+
parsed.qualifiers
|
|
227
|
+
.filter((qualifier) => qualifier.key === "target" || qualifier.key === "locale")
|
|
228
|
+
.map((qualifier) => qualifier.value),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
key: freeText,
|
|
233
|
+
description,
|
|
234
|
+
relationship,
|
|
235
|
+
lastModified: freeText,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
196
239
|
function getSortDirection(sortValue: string | null) {
|
|
197
240
|
if (!sortValue || sortValue === "name" || sortValue === "name:asc" || sortValue === "asc") {
|
|
198
241
|
return "asc";
|
|
@@ -224,6 +267,7 @@ export function EntityList(props: {
|
|
|
224
267
|
entities: EntitySummary[];
|
|
225
268
|
setKey?: string;
|
|
226
269
|
allEntities?: Record<EntityType, EntitySummary[]>;
|
|
270
|
+
translationSearchEnabled?: boolean;
|
|
227
271
|
}) {
|
|
228
272
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
229
273
|
const [showAll, setShowAll] = React.useState(false);
|
|
@@ -241,12 +285,14 @@ export function EntityList(props: {
|
|
|
241
285
|
|
|
242
286
|
const firstTargetKey = props.allEntities?.target?.find((e) => !e.archived)?.key;
|
|
243
287
|
const firstLocaleKey = props.allEntities?.locale?.find((e) => !e.archived)?.key;
|
|
244
|
-
const
|
|
288
|
+
const translationSearchEnabled = props.translationSearchEnabled === true;
|
|
289
|
+
const hasHintsDefined =
|
|
290
|
+
getQueryHints(props.type, firstTargetKey, firstLocaleKey, translationSearchEnabled) !== null;
|
|
245
291
|
|
|
246
292
|
// Compute the 3-char shard prefix needed for the current query
|
|
247
293
|
const _translationQual = parseQuery(query).qualifiers.find((q) => q.key === "translation");
|
|
248
294
|
const neededShardKey =
|
|
249
|
-
_translationQual && _translationQual.value.length >= 3
|
|
295
|
+
translationSearchEnabled && _translationQual && _translationQual.value.length >= 3
|
|
250
296
|
? _translationQual.value.slice(0, 3).toLowerCase()
|
|
251
297
|
: null;
|
|
252
298
|
|
|
@@ -278,6 +324,7 @@ export function EntityList(props: {
|
|
|
278
324
|
|
|
279
325
|
// Pass shard to matchesQuery only when the loaded shard matches what's needed
|
|
280
326
|
const activeShard = loadedShardKey === neededShardKey ? translationShard : null;
|
|
327
|
+
const highlightTerms = React.useMemo(() => getEntityListHighlightTerms(query), [query]);
|
|
281
328
|
|
|
282
329
|
const filtered = React.useMemo(() => {
|
|
283
330
|
const parsed = parseQuery(query);
|
|
@@ -285,14 +332,14 @@ export function EntityList(props: {
|
|
|
285
332
|
|
|
286
333
|
const matching = props.entities.filter((entity) => {
|
|
287
334
|
if (!hasQuery) return true;
|
|
288
|
-
return matchesQuery(entity, parsed, activeShard);
|
|
335
|
+
return matchesQuery(entity, parsed, activeShard, translationSearchEnabled);
|
|
289
336
|
});
|
|
290
337
|
|
|
291
338
|
return matching.slice().sort((left, right) => {
|
|
292
339
|
const result = left.key.localeCompare(left.key === right.key ? "" : right.key);
|
|
293
340
|
return sortDirection === "desc" ? result * -1 : result;
|
|
294
341
|
});
|
|
295
|
-
}, [query, props.entities, sortDirection, activeShard]);
|
|
342
|
+
}, [query, props.entities, sortDirection, activeShard, translationSearchEnabled]);
|
|
296
343
|
|
|
297
344
|
const visible = showAll ? filtered : filtered.slice(0, CATALOG_LIST_INITIAL_LIMIT);
|
|
298
345
|
const hasHiddenEntities = filtered.length > CATALOG_LIST_INITIAL_LIMIT && !showAll;
|
|
@@ -364,6 +411,7 @@ export function EntityList(props: {
|
|
|
364
411
|
query={query}
|
|
365
412
|
firstTargetKey={firstTargetKey}
|
|
366
413
|
firstLocaleKey={firstLocaleKey}
|
|
414
|
+
translationSearchEnabled={translationSearchEnabled}
|
|
367
415
|
onHintClick={handleHintClick}
|
|
368
416
|
/>
|
|
369
417
|
</div>
|
|
@@ -409,9 +457,16 @@ export function EntityList(props: {
|
|
|
409
457
|
<div className="min-w-0 flex-1">
|
|
410
458
|
<div className="flex flex-col justify-between gap-2 md:flex-row md:items-start">
|
|
411
459
|
<div className="min-w-0">
|
|
412
|
-
<
|
|
460
|
+
<EntityKey
|
|
461
|
+
value={entity.key}
|
|
462
|
+
className="text-sm font-semibold text-primary"
|
|
463
|
+
highlightQuery={highlightTerms.key}
|
|
464
|
+
/>
|
|
413
465
|
<div className="mt-1 truncate text-sm text-muted">
|
|
414
|
-
|
|
466
|
+
<SearchHighlight
|
|
467
|
+
text={entity.description || "No description"}
|
|
468
|
+
query={highlightTerms.description}
|
|
469
|
+
/>
|
|
415
470
|
</div>
|
|
416
471
|
</div>
|
|
417
472
|
<div className="shrink-0">{getStatusBadges(entity)}</div>
|
|
@@ -419,11 +474,13 @@ export function EntityList(props: {
|
|
|
419
474
|
<div className="mt-2 flex flex-col gap-2 text-xs text-muted md:flex-row md:items-center md:justify-between">
|
|
420
475
|
<div className="flex flex-wrap gap-2">
|
|
421
476
|
{getRelationshipBadges(props.type, entity).map((label) => (
|
|
422
|
-
<Badge key={label}>
|
|
477
|
+
<Badge key={label}>
|
|
478
|
+
<SearchHighlight text={label} query={highlightTerms.relationship} />
|
|
479
|
+
</Badge>
|
|
423
480
|
))}
|
|
424
481
|
</div>
|
|
425
482
|
<span className="shrink-0 md:text-right">
|
|
426
|
-
<LastModified entity={entity} />
|
|
483
|
+
<LastModified entity={entity} highlightQuery={highlightTerms.lastModified} />
|
|
427
484
|
</span>
|
|
428
485
|
</div>
|
|
429
486
|
</div>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
|
|
4
|
+
import { EntityKey } from "./EntityKey";
|
|
5
|
+
|
|
6
|
+
describe("EntityKey", function () {
|
|
7
|
+
it("highlights namespace queries that include dots", function () {
|
|
8
|
+
const html = renderToStaticMarkup(
|
|
9
|
+
React.createElement(EntityKey, {
|
|
10
|
+
value: "auth.xyz.login",
|
|
11
|
+
highlightQuery: ["auth.xyz"],
|
|
12
|
+
}),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
expect(html).toContain("<mark");
|
|
16
|
+
expect(html).toContain("auth");
|
|
17
|
+
expect(html).toContain("xyz");
|
|
18
|
+
expect(html).toContain("<wbr");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("highlights trailing namespace dots while preserving word-break hints", function () {
|
|
22
|
+
const html = renderToStaticMarkup(
|
|
23
|
+
React.createElement(EntityKey, {
|
|
24
|
+
value: "auth.signin.title",
|
|
25
|
+
highlightQuery: ["auth."],
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
expect(html).toContain("<mark");
|
|
30
|
+
expect(html).toContain("auth");
|
|
31
|
+
expect(html).toContain("<wbr");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { SearchHighlightMark } from "./SearchHighlight";
|
|
4
|
+
|
|
5
|
+
interface HighlightRange {
|
|
6
|
+
start: number;
|
|
7
|
+
end: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function getHighlightRanges(value: string, queries: string[]): HighlightRange[] {
|
|
11
|
+
const lowerValue = value.toLowerCase();
|
|
12
|
+
const ranges: HighlightRange[] = [];
|
|
13
|
+
|
|
14
|
+
for (const rawQuery of queries) {
|
|
15
|
+
const query = rawQuery.trim().toLowerCase();
|
|
16
|
+
if (!query) continue;
|
|
17
|
+
|
|
18
|
+
let start = lowerValue.indexOf(query);
|
|
19
|
+
while (start !== -1) {
|
|
20
|
+
ranges.push({ start, end: start + query.length });
|
|
21
|
+
start = lowerValue.indexOf(query, start + query.length);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return ranges
|
|
26
|
+
.sort((left, right) => left.start - right.start || right.end - left.end)
|
|
27
|
+
.reduce<HighlightRange[]>((merged, range) => {
|
|
28
|
+
const previous = merged[merged.length - 1];
|
|
29
|
+
if (!previous || range.start > previous.end) {
|
|
30
|
+
merged.push({ ...range });
|
|
31
|
+
} else {
|
|
32
|
+
previous.end = Math.max(previous.end, range.end);
|
|
33
|
+
}
|
|
34
|
+
return merged;
|
|
35
|
+
}, []);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function renderKeyTextWithBreaks(text: string) {
|
|
39
|
+
return text.split(".").flatMap((part, index, parts) => {
|
|
40
|
+
const nodes: React.ReactNode[] = [part];
|
|
41
|
+
if (index < parts.length - 1) {
|
|
42
|
+
nodes.push(
|
|
43
|
+
<React.Fragment key={`dot-${index}`}>
|
|
44
|
+
.<wbr />
|
|
45
|
+
</React.Fragment>,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return nodes;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function renderHighlightedKey(value: string, queries: string[]) {
|
|
53
|
+
const ranges = getHighlightRanges(value, queries);
|
|
54
|
+
if (ranges.length === 0) {
|
|
55
|
+
return renderKeyTextWithBreaks(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const nodes: React.ReactNode[] = [];
|
|
59
|
+
let cursor = 0;
|
|
60
|
+
|
|
61
|
+
ranges.forEach((range, index) => {
|
|
62
|
+
if (range.start > cursor) {
|
|
63
|
+
nodes.push(
|
|
64
|
+
<React.Fragment key={`text-${cursor}`}>
|
|
65
|
+
{renderKeyTextWithBreaks(value.slice(cursor, range.start))}
|
|
66
|
+
</React.Fragment>,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
nodes.push(
|
|
71
|
+
<SearchHighlightMark key={`highlight-${range.start}-${index}`}>
|
|
72
|
+
{renderKeyTextWithBreaks(value.slice(range.start, range.end))}
|
|
73
|
+
</SearchHighlightMark>,
|
|
74
|
+
);
|
|
75
|
+
cursor = range.end;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (cursor < value.length) {
|
|
79
|
+
nodes.push(
|
|
80
|
+
<React.Fragment key={`text-${cursor}`}>
|
|
81
|
+
{renderKeyTextWithBreaks(value.slice(cursor))}
|
|
82
|
+
</React.Fragment>,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return nodes;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function EntityKey(props: { value: string; className?: string; highlightQuery?: string[] }) {
|
|
90
|
+
const highlightQuery = props.highlightQuery || [];
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<span
|
|
94
|
+
className={["inline leading-snug [overflow-wrap:anywhere]", props.className || ""].join(" ")}
|
|
95
|
+
>
|
|
96
|
+
{renderHighlightedKey(props.value, highlightQuery)}
|
|
97
|
+
</span>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
|
|
4
|
+
import { SearchHighlight } from "./SearchHighlight";
|
|
5
|
+
|
|
6
|
+
describe("SearchHighlight", function () {
|
|
7
|
+
it("highlights case-insensitive text matches with the shared mark styling", function () {
|
|
8
|
+
const html = renderToStaticMarkup(
|
|
9
|
+
React.createElement(SearchHighlight, { text: "Welcome back", query: "welcome" }),
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
expect(html).toContain("<mark");
|
|
13
|
+
expect(html).toContain("bg-amber-100");
|
|
14
|
+
expect(html).toContain(">Welcome</mark>");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("escapes query text before matching", function () {
|
|
18
|
+
const html = renderToStaticMarkup(
|
|
19
|
+
React.createElement(SearchHighlight, { text: "Use plan.* literally", query: "plan.*" }),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
expect(html).toContain(">plan.*</mark>");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
function escapeRegExp(value: string) {
|
|
4
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function normalizeQueries(query: string | string[]) {
|
|
8
|
+
const queries = Array.isArray(query) ? query : [query];
|
|
9
|
+
|
|
10
|
+
return Array.from(
|
|
11
|
+
new Set(queries.map((item) => item.trim()).filter((item) => item.length > 0)),
|
|
12
|
+
).sort((left, right) => right.length - left.length);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function SearchHighlight(props: { text: string; query: string | string[] }) {
|
|
16
|
+
const queries = normalizeQueries(props.query);
|
|
17
|
+
if (queries.length === 0) {
|
|
18
|
+
return <>{props.text}</>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const regex = new RegExp(queries.map(escapeRegExp).join("|"), "gi");
|
|
22
|
+
const parts: React.ReactNode[] = [];
|
|
23
|
+
let lastIndex = 0;
|
|
24
|
+
let key = 0;
|
|
25
|
+
|
|
26
|
+
for (const match of props.text.matchAll(regex)) {
|
|
27
|
+
if (match.index !== undefined && match.index > lastIndex) {
|
|
28
|
+
parts.push(props.text.slice(lastIndex, match.index));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (match.index !== undefined) {
|
|
32
|
+
parts.push(
|
|
33
|
+
<SearchHighlightMark key={`hm-${match.index}-${key++}`}>{match[0]}</SearchHighlightMark>,
|
|
34
|
+
);
|
|
35
|
+
lastIndex = match.index + match[0].length;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (lastIndex < props.text.length) {
|
|
40
|
+
parts.push(props.text.slice(lastIndex));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return <>{parts}</>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function SearchHighlightMark(props: { children: React.ReactNode }) {
|
|
47
|
+
return (
|
|
48
|
+
<mark
|
|
49
|
+
className={[
|
|
50
|
+
"rounded-[3px] bg-amber-100 px-0.5 py-px text-inherit",
|
|
51
|
+
"shadow-[inset_0_-2px_0_0_rgba(251,191,36,0.35)] ring-1 ring-amber-400/25 ring-inset",
|
|
52
|
+
"transition-[background-color,box-shadow] duration-150",
|
|
53
|
+
].join(" ")}
|
|
54
|
+
>
|
|
55
|
+
{props.children}
|
|
56
|
+
</mark>
|
|
57
|
+
);
|
|
58
|
+
}
|
package/src/node/index.spec.ts
CHANGED
|
@@ -37,6 +37,15 @@ async function readJson<T>(root: string, relativePath: string): Promise<T> {
|
|
|
37
37
|
return JSON.parse(await fs.promises.readFile(path.join(root, relativePath), "utf8"));
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
async function pathExists(root: string, relativePath: string) {
|
|
41
|
+
try {
|
|
42
|
+
await fs.promises.access(path.join(root, relativePath));
|
|
43
|
+
return true;
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
async function createProject() {
|
|
41
50
|
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "messagevisor-catalog-"));
|
|
42
51
|
const interpolationModulePath = path.join(
|
|
@@ -242,7 +251,11 @@ describe("catalog", function () {
|
|
|
242
251
|
expect(manifest.sets).toBe(false);
|
|
243
252
|
expect(manifest.router).toBe("browser");
|
|
244
253
|
expect(manifest.dev).toBeUndefined();
|
|
254
|
+
expect(manifest.features).toEqual({ translationSearch: false });
|
|
245
255
|
expect(manifest.paths.root).toBe("data/root/index.json");
|
|
256
|
+
await expect(pathExists(root, "catalog-out/data/root/translations/77656c.json")).resolves.toBe(
|
|
257
|
+
false,
|
|
258
|
+
);
|
|
246
259
|
expect(index.counts.message).toBe(2);
|
|
247
260
|
expect(
|
|
248
261
|
index.entities.message.find((entry: any) => entry.key === "common.welcome").targets,
|
|
@@ -391,6 +404,26 @@ describe("catalog", function () {
|
|
|
391
404
|
expect(history.entries).toEqual([]);
|
|
392
405
|
});
|
|
393
406
|
|
|
407
|
+
it("exports translation search shards only when opted in", async function () {
|
|
408
|
+
const root = await createProject();
|
|
409
|
+
roots.push(root);
|
|
410
|
+
const projectConfig = getProjectConfig(root);
|
|
411
|
+
const datasource = new Datasource(projectConfig, root);
|
|
412
|
+
|
|
413
|
+
await catalogApi.exportCatalog(root, projectConfig, datasource, {
|
|
414
|
+
outDir: "catalog-out",
|
|
415
|
+
copyAssets: false,
|
|
416
|
+
withTranslationSearch: true,
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const manifest = await readJson<any>(root, "catalog-out/data/manifest.json");
|
|
420
|
+
const shard = await readJson<any>(root, "catalog-out/data/root/translations/77656c.json");
|
|
421
|
+
|
|
422
|
+
expect(manifest.features).toEqual({ translationSearch: true });
|
|
423
|
+
expect(shard["common.welcome"]).toEqual(expect.arrayContaining(["welcome", "welcome pro"]));
|
|
424
|
+
expect(shard["common.draft"]).toEqual(["welcome"]);
|
|
425
|
+
});
|
|
426
|
+
|
|
394
427
|
it("streams Git history into project, entity, and last-modified catalog data", async function () {
|
|
395
428
|
const root = await createProject();
|
|
396
429
|
roots.push(root);
|
|
@@ -668,7 +701,11 @@ describe("catalog", function () {
|
|
|
668
701
|
const admin = await readJson<any>(root, "catalog-out/data/sets/admin/index.json");
|
|
669
702
|
|
|
670
703
|
expect(manifest.sets).toBe(true);
|
|
704
|
+
expect(manifest.features).toEqual({ translationSearch: false });
|
|
671
705
|
expect(manifest.setKeys).toEqual(["admin", "storefront"]);
|
|
706
|
+
await expect(
|
|
707
|
+
pathExists(root, "catalog-out/data/sets/storefront/translations/73746f.json"),
|
|
708
|
+
).resolves.toBe(false);
|
|
672
709
|
const storefrontDuplicates = await readJson<any>(
|
|
673
710
|
root,
|
|
674
711
|
"catalog-out/data/sets/storefront/duplicates/locales/en.json",
|
|
@@ -708,6 +745,45 @@ describe("catalog", function () {
|
|
|
708
745
|
});
|
|
709
746
|
});
|
|
710
747
|
|
|
748
|
+
it("exports set translation search shards when opted in", async function () {
|
|
749
|
+
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "messagevisor-catalog-"));
|
|
750
|
+
roots.push(root);
|
|
751
|
+
|
|
752
|
+
await writeFile(root, "messagevisor.config.js", "module.exports = { sets: true };\n");
|
|
753
|
+
|
|
754
|
+
for (const set of ["storefront", "admin"]) {
|
|
755
|
+
await writeFile(root, `sets/${set}/locales/en.yml`, "description: English\n");
|
|
756
|
+
await writeFile(
|
|
757
|
+
root,
|
|
758
|
+
`sets/${set}/messages/common/welcome.yml`,
|
|
759
|
+
`description: Welcome\ntranslations:\n en: ${set}\n`,
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const projectConfig = getProjectConfig(root);
|
|
764
|
+
const datasource = new Datasource(projectConfig, root);
|
|
765
|
+
|
|
766
|
+
await catalogApi.exportCatalog(root, projectConfig, datasource, {
|
|
767
|
+
outDir: "catalog-out",
|
|
768
|
+
copyAssets: false,
|
|
769
|
+
withTranslationSearch: true,
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
const manifest = await readJson<any>(root, "catalog-out/data/manifest.json");
|
|
773
|
+
const storefrontShard = await readJson<any>(
|
|
774
|
+
root,
|
|
775
|
+
"catalog-out/data/sets/storefront/translations/73746f.json",
|
|
776
|
+
);
|
|
777
|
+
const adminShard = await readJson<any>(
|
|
778
|
+
root,
|
|
779
|
+
"catalog-out/data/sets/admin/translations/61646d.json",
|
|
780
|
+
);
|
|
781
|
+
|
|
782
|
+
expect(manifest.features).toEqual({ translationSearch: true });
|
|
783
|
+
expect(storefrontShard).toEqual({ "common.welcome": ["storefront"] });
|
|
784
|
+
expect(adminShard).toEqual({ "common.welcome": ["admin"] });
|
|
785
|
+
});
|
|
786
|
+
|
|
711
787
|
it("groups streamed Git history by set", async function () {
|
|
712
788
|
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "messagevisor-catalog-"));
|
|
713
789
|
roots.push(root);
|
|
@@ -900,10 +976,46 @@ describe("catalog plugin", function () {
|
|
|
900
976
|
);
|
|
901
977
|
});
|
|
902
978
|
|
|
979
|
+
it("forwards translation search option for dev catalog mode", async function () {
|
|
980
|
+
const { handler } = createPlugin();
|
|
981
|
+
|
|
982
|
+
await handler({ _: ["catalog"], withTranslationSearch: true });
|
|
983
|
+
|
|
984
|
+
expect(exportMock).toHaveBeenLastCalledWith(
|
|
985
|
+
expect.any(String),
|
|
986
|
+
expect.any(Object),
|
|
987
|
+
expect.any(Object),
|
|
988
|
+
expect.objectContaining({ withTranslationSearch: true, dev: true }),
|
|
989
|
+
);
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
it("forwards translation search option for export subcommand", async function () {
|
|
993
|
+
const { handler } = createPlugin();
|
|
994
|
+
|
|
995
|
+
await handler({
|
|
996
|
+
_: ["catalog", "export"],
|
|
997
|
+
subcommand: "export",
|
|
998
|
+
"with-translation-search": true,
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
expect(exportMock).toHaveBeenLastCalledWith(
|
|
1002
|
+
expect.any(String),
|
|
1003
|
+
expect.any(Object),
|
|
1004
|
+
expect.any(Object),
|
|
1005
|
+
expect.objectContaining({ withTranslationSearch: true }),
|
|
1006
|
+
);
|
|
1007
|
+
});
|
|
1008
|
+
|
|
903
1009
|
it("forwards long and short port options for serve subcommand", async function () {
|
|
904
1010
|
const { handler } = createPlugin();
|
|
905
1011
|
|
|
906
1012
|
await handler({ _: ["catalog", "serve"], subcommand: "serve", port: 3103 });
|
|
1013
|
+
expect(serveMock).toHaveBeenLastCalledWith(
|
|
1014
|
+
expect.any(String),
|
|
1015
|
+
expect.any(Object),
|
|
1016
|
+
expect.any(Object),
|
|
1017
|
+
expect.not.objectContaining({ withTranslationSearch: true }),
|
|
1018
|
+
);
|
|
907
1019
|
expect(serveMock).toHaveBeenLastCalledWith(
|
|
908
1020
|
expect.any(String),
|
|
909
1021
|
expect.any(Object),
|