@messagevisor/catalog 0.7.0 → 0.9.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.
@@ -22,6 +22,8 @@ interface EntityListHighlightTerms {
22
22
  lastModified: string[];
23
23
  }
24
24
 
25
+ const LIST_SEARCH_QUERY_DEBOUNCE_MS = 450;
26
+
25
27
  function matchesQuery(
26
28
  entity: EntitySummary,
27
29
  parsed: ParsedQuery,
@@ -260,6 +262,168 @@ function setSearchParam(searchParams: URLSearchParams, key: string, value?: stri
260
262
  return next;
261
263
  }
262
264
 
265
+ const EntityListSearchControls = React.memo(function EntityListSearchControls({
266
+ type,
267
+ query,
268
+ firstTargetKey,
269
+ firstLocaleKey,
270
+ translationSearchEnabled,
271
+ onQueryCommit,
272
+ onHintClick,
273
+ }: {
274
+ type: EntityType;
275
+ query: string;
276
+ firstTargetKey: string | undefined;
277
+ firstLocaleKey: string | undefined;
278
+ translationSearchEnabled: boolean;
279
+ onQueryCommit: (value: string) => void;
280
+ onHintClick: (hint: string) => void;
281
+ }) {
282
+ const [inputValue, setInputValue] = React.useState(query);
283
+ const [showHints, setShowHints] = React.useState(false);
284
+ const debounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
285
+ const idleRef = React.useRef<number | null>(null);
286
+ const animationFrameRef = React.useRef<number | null>(null);
287
+ const postPaintTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
288
+ const hasHintsDefined =
289
+ getQueryHints(type, firstTargetKey, firstLocaleKey, translationSearchEnabled) !== null;
290
+
291
+ const clearPendingCommit = React.useCallback(() => {
292
+ if (debounceRef.current) {
293
+ clearTimeout(debounceRef.current);
294
+ debounceRef.current = null;
295
+ }
296
+
297
+ if (idleRef.current !== null && "cancelIdleCallback" in window) {
298
+ window.cancelIdleCallback(idleRef.current);
299
+ idleRef.current = null;
300
+ }
301
+
302
+ if (animationFrameRef.current !== null) {
303
+ window.cancelAnimationFrame(animationFrameRef.current);
304
+ animationFrameRef.current = null;
305
+ }
306
+
307
+ if (postPaintTimeoutRef.current) {
308
+ clearTimeout(postPaintTimeoutRef.current);
309
+ postPaintTimeoutRef.current = null;
310
+ }
311
+ }, []);
312
+
313
+ const commitAfterBrowserWork = React.useCallback(
314
+ (value: string) => {
315
+ if ("requestIdleCallback" in window) {
316
+ idleRef.current = window.requestIdleCallback(
317
+ () => {
318
+ idleRef.current = null;
319
+ onQueryCommit(value);
320
+ },
321
+ { timeout: 700 },
322
+ );
323
+ return;
324
+ }
325
+
326
+ animationFrameRef.current = window.requestAnimationFrame(() => {
327
+ animationFrameRef.current = null;
328
+ postPaintTimeoutRef.current = setTimeout(() => {
329
+ postPaintTimeoutRef.current = null;
330
+ onQueryCommit(value);
331
+ }, 0);
332
+ });
333
+ },
334
+ [onQueryCommit],
335
+ );
336
+
337
+ const scheduleQueryCommit = React.useCallback(
338
+ (value: string) => {
339
+ clearPendingCommit();
340
+ debounceRef.current = setTimeout(() => {
341
+ debounceRef.current = null;
342
+ commitAfterBrowserWork(value);
343
+ }, LIST_SEARCH_QUERY_DEBOUNCE_MS);
344
+ },
345
+ [clearPendingCommit, commitAfterBrowserWork],
346
+ );
347
+
348
+ const flushQueryCommit = React.useCallback(
349
+ (value: string) => {
350
+ clearPendingCommit();
351
+ onQueryCommit(value);
352
+ },
353
+ [clearPendingCommit, onQueryCommit],
354
+ );
355
+
356
+ React.useEffect(() => {
357
+ clearPendingCommit();
358
+ setInputValue(query);
359
+ }, [query, clearPendingCommit]);
360
+
361
+ React.useEffect(() => {
362
+ return () => {
363
+ clearPendingCommit();
364
+ };
365
+ }, [clearPendingCommit]);
366
+
367
+ return (
368
+ <div>
369
+ <div className="relative">
370
+ <Input
371
+ value={inputValue}
372
+ onChange={(event) => {
373
+ const val = event.target.value;
374
+ setInputValue(val);
375
+ scheduleQueryCommit(val);
376
+ }}
377
+ onBlur={(event) => {
378
+ flushQueryCommit(event.target.value);
379
+ }}
380
+ onKeyDown={(event) => {
381
+ if (event.key === "Enter") {
382
+ flushQueryCommit(event.currentTarget.value);
383
+ }
384
+ }}
385
+ placeholder={`Search ${entityLabels[type].plural.toLowerCase()}...`}
386
+ className={hasHintsDefined ? "pr-10" : ""}
387
+ />
388
+ {hasHintsDefined && (
389
+ <button
390
+ type="button"
391
+ onClick={() => setShowHints((v) => !v)}
392
+ aria-label={showHints ? "Hide advanced search hints" : "Show advanced search hints"}
393
+ className={[
394
+ "absolute right-3 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded-full border text-xs font-bold transition-colors",
395
+ showHints
396
+ ? "border-primary bg-primary/10 text-primary"
397
+ : "border-border bg-surface text-muted hover:border-primary hover:text-primary",
398
+ ].join(" ")}
399
+ >
400
+ ?
401
+ </button>
402
+ )}
403
+ </div>
404
+
405
+ {/* Animated slide-down hints panel, aligned to input's inner text area */}
406
+ <div
407
+ className={[
408
+ "grid transition-all duration-200 ease-in-out",
409
+ showHints ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
410
+ ].join(" ")}
411
+ >
412
+ <div className="overflow-hidden pl-5">
413
+ <QueryHints
414
+ type={type}
415
+ query={query}
416
+ firstTargetKey={firstTargetKey}
417
+ firstLocaleKey={firstLocaleKey}
418
+ translationSearchEnabled={translationSearchEnabled}
419
+ onHintClick={onHintClick}
420
+ />
421
+ </div>
422
+ </div>
423
+ </div>
424
+ );
425
+ });
426
+
263
427
  // ---- Component ----
264
428
 
265
429
  export function EntityList(props: {
@@ -271,23 +435,39 @@ export function EntityList(props: {
271
435
  }) {
272
436
  const [searchParams, setSearchParams] = useSearchParams();
273
437
  const [showAll, setShowAll] = React.useState(false);
274
- const [showHints, setShowHints] = React.useState(false);
275
438
  const [translationShard, setTranslationShard] = React.useState<TranslationShard | null>(null);
276
439
  const [loadedShardKey, setLoadedShardKey] = React.useState<string | null>(null);
277
- const debounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
440
+ const translationShardDebounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
278
441
  const query = searchParams.get("q") || "";
279
- const [inputValue, setInputValue] = React.useState(query);
442
+ const searchParamsRef = React.useRef(searchParams);
280
443
 
281
- // Sync input display when the URL query changes externally (hint clicks, navigation)
282
444
  React.useEffect(() => {
283
- setInputValue(query);
284
- }, [query]);
445
+ searchParamsRef.current = searchParams;
446
+ }, [searchParams]);
447
+
448
+ const commitSearchQuery = React.useCallback(
449
+ (value: string) => {
450
+ const nextQuery = value.trim() ? value : undefined;
451
+ if ((searchParamsRef.current.get("q") || "") === (nextQuery || "")) return;
452
+ React.startTransition(() => {
453
+ setSearchParams(setSearchParam(searchParamsRef.current, "q", nextQuery));
454
+ });
455
+ },
456
+ [setSearchParams],
457
+ );
458
+
459
+ React.useEffect(() => {
460
+ return () => {
461
+ if (translationShardDebounceRef.current) {
462
+ clearTimeout(translationShardDebounceRef.current);
463
+ translationShardDebounceRef.current = null;
464
+ }
465
+ };
466
+ }, []);
285
467
 
286
468
  const firstTargetKey = props.allEntities?.target?.find((e) => !e.archived)?.key;
287
469
  const firstLocaleKey = props.allEntities?.locale?.find((e) => !e.archived)?.key;
288
470
  const translationSearchEnabled = props.translationSearchEnabled === true;
289
- const hasHintsDefined =
290
- getQueryHints(props.type, firstTargetKey, firstLocaleKey, translationSearchEnabled) !== null;
291
471
 
292
472
  // Compute the 3-char shard prefix needed for the current query
293
473
  const _translationQual = parseQuery(query).qualifiers.find((q) => q.key === "translation");
@@ -298,7 +478,9 @@ export function EntityList(props: {
298
478
 
299
479
  // Debounced fetch: only triggers when the 3-char prefix changes
300
480
  React.useEffect(() => {
301
- if (debounceRef.current) clearTimeout(debounceRef.current);
481
+ if (translationShardDebounceRef.current) {
482
+ clearTimeout(translationShardDebounceRef.current);
483
+ }
302
484
 
303
485
  if (!neededShardKey) {
304
486
  setTranslationShard(null);
@@ -308,8 +490,8 @@ export function EntityList(props: {
308
490
 
309
491
  if (neededShardKey === loadedShardKey) return;
310
492
 
311
- debounceRef.current = setTimeout(() => {
312
- debounceRef.current = null;
493
+ translationShardDebounceRef.current = setTimeout(() => {
494
+ translationShardDebounceRef.current = null;
313
495
  fetchTranslationShard(neededShardKey, props.setKey).then((data) => {
314
496
  setTranslationShard(data);
315
497
  setLoadedShardKey(neededShardKey);
@@ -317,7 +499,10 @@ export function EntityList(props: {
317
499
  }, 300);
318
500
 
319
501
  return () => {
320
- if (debounceRef.current) clearTimeout(debounceRef.current);
502
+ if (translationShardDebounceRef.current) {
503
+ clearTimeout(translationShardDebounceRef.current);
504
+ translationShardDebounceRef.current = null;
505
+ }
321
506
  };
322
507
  }, [neededShardKey, loadedShardKey, props.setKey]);
323
508
  const sortDirection = getSortDirection(searchParams.get("sort"));
@@ -359,7 +544,7 @@ export function EntityList(props: {
359
544
  : current
360
545
  ? `${current} ${hint}`
361
546
  : hint;
362
- setSearchParams(setSearchParam(searchParams, "q", next || undefined));
547
+ setSearchParams(setSearchParam(searchParamsRef.current, "q", next || undefined));
363
548
  }
364
549
 
365
550
  return (
@@ -367,56 +552,15 @@ export function EntityList(props: {
367
552
  <div className="px-6 pt-1">
368
553
  <div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
369
554
  {/* Input + slide-down hints, confined to the first column */}
370
- <div>
371
- <div className="relative">
372
- <Input
373
- value={inputValue}
374
- onChange={(event) => {
375
- const val = event.target.value;
376
- setInputValue(val);
377
- setSearchParams(setSearchParam(searchParams, "q", val.trim() ? val : undefined));
378
- }}
379
- placeholder={`Search ${entityLabels[props.type].plural.toLowerCase()}...`}
380
- className={hasHintsDefined ? "pr-10" : ""}
381
- />
382
- {hasHintsDefined && (
383
- <button
384
- type="button"
385
- onClick={() => setShowHints((v) => !v)}
386
- aria-label={
387
- showHints ? "Hide advanced search hints" : "Show advanced search hints"
388
- }
389
- className={[
390
- "absolute right-3 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded-full border text-xs font-bold transition-colors",
391
- showHints
392
- ? "border-primary bg-primary/10 text-primary"
393
- : "border-border bg-surface text-muted hover:border-primary hover:text-primary",
394
- ].join(" ")}
395
- >
396
- ?
397
- </button>
398
- )}
399
- </div>
400
-
401
- {/* Animated slide-down hints panel, aligned to input's inner text area */}
402
- <div
403
- className={[
404
- "grid transition-all duration-200 ease-in-out",
405
- showHints ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
406
- ].join(" ")}
407
- >
408
- <div className="overflow-hidden pl-5">
409
- <QueryHints
410
- type={props.type}
411
- query={query}
412
- firstTargetKey={firstTargetKey}
413
- firstLocaleKey={firstLocaleKey}
414
- translationSearchEnabled={translationSearchEnabled}
415
- onHintClick={handleHintClick}
416
- />
417
- </div>
418
- </div>
419
- </div>
555
+ <EntityListSearchControls
556
+ type={props.type}
557
+ query={query}
558
+ firstTargetKey={firstTargetKey}
559
+ firstLocaleKey={firstLocaleKey}
560
+ translationSearchEnabled={translationSearchEnabled}
561
+ onQueryCommit={commitSearchQuery}
562
+ onHintClick={handleHintClick}
563
+ />
420
564
 
421
565
  <button
422
566
  type="button"
@@ -9,7 +9,12 @@ import { Datasource } from "../../../core/src/datasource";
9
9
  import { resolveExamples } from "../../../core/src/examples";
10
10
  import { findDuplicateTranslations } from "../../../core/src/find-duplicates";
11
11
  import { getProjectSetExecutions } from "../../../core/src/sets";
12
- import { createCatalogApi, createCatalogPlugin, type CatalogRuntime } from "./index";
12
+ import {
13
+ __catalogDevInternals,
14
+ createCatalogApi,
15
+ createCatalogPlugin,
16
+ type CatalogRuntime,
17
+ } from "./index";
13
18
 
14
19
  const catalogApi = createCatalogApi({
15
20
  mergeFormats,
@@ -1174,6 +1179,104 @@ describe("catalog", function () {
1174
1179
 
1175
1180
  expect(viteConfigSource).toContain('base: "/"');
1176
1181
  });
1182
+
1183
+ it("watches only catalog input roots in dev mode", async function () {
1184
+ const root = await createProject();
1185
+ roots.push(root);
1186
+ const projectConfig = getProjectConfig(root);
1187
+
1188
+ const watchPaths = __catalogDevInternals.getCatalogInputWatchPaths(root, projectConfig);
1189
+
1190
+ expect(watchPaths).toEqual(
1191
+ expect.arrayContaining([
1192
+ path.join(root, "messagevisor.config.js"),
1193
+ projectConfig.localesDirectoryPath,
1194
+ projectConfig.messagesDirectoryPath,
1195
+ projectConfig.attributesDirectoryPath,
1196
+ projectConfig.segmentsDirectoryPath,
1197
+ projectConfig.targetsDirectoryPath,
1198
+ projectConfig.testsDirectoryPath,
1199
+ ]),
1200
+ );
1201
+ expect(watchPaths).not.toContain(projectConfig.catalogDirectoryPath);
1202
+ expect(watchPaths).not.toContain(path.join(root, "node_modules"));
1203
+ });
1204
+
1205
+ it("plans incremental message rebuilds only for safe dev changes", async function () {
1206
+ const root = await createProject();
1207
+ roots.push(root);
1208
+ const projectConfig = getProjectConfig(root);
1209
+ const messagePath = path.join(root, "messages/common/welcome.yml");
1210
+ const localePath = path.join(root, "locales/en-US.yml");
1211
+
1212
+ expect(
1213
+ __catalogDevInternals.classifyCatalogDevChanges(root, projectConfig, [messagePath], {
1214
+ withTranslationSearch: false,
1215
+ withDuplicates: false,
1216
+ }),
1217
+ ).toEqual(
1218
+ expect.objectContaining({
1219
+ kind: "message",
1220
+ messageKeys: ["common.welcome"],
1221
+ }),
1222
+ );
1223
+
1224
+ expect(
1225
+ __catalogDevInternals.classifyCatalogDevChanges(root, projectConfig, [messagePath], {
1226
+ withTranslationSearch: true,
1227
+ withDuplicates: false,
1228
+ }),
1229
+ ).toEqual(expect.objectContaining({ kind: "full" }));
1230
+
1231
+ expect(
1232
+ __catalogDevInternals.classifyCatalogDevChanges(root, projectConfig, [localePath], {
1233
+ withTranslationSearch: false,
1234
+ withDuplicates: false,
1235
+ }),
1236
+ ).toEqual(expect.objectContaining({ kind: "full" }));
1237
+ });
1238
+
1239
+ it("plans set-scoped dev rebuilds for set project input changes", async function () {
1240
+ const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "messagevisor-catalog-"));
1241
+ roots.push(root);
1242
+
1243
+ await writeFile(root, "messagevisor.config.js", "module.exports = { sets: true };\n");
1244
+ await writeFile(root, "sets/storefront/locales/en.yml", "description: English\n");
1245
+ await writeFile(
1246
+ root,
1247
+ "sets/storefront/messages/common/welcome.yml",
1248
+ "description: Welcome\ntranslations:\n en: Welcome\n",
1249
+ );
1250
+
1251
+ const projectConfig = getProjectConfig(root);
1252
+ const localePath = path.join(root, "sets/storefront/locales/en.yml");
1253
+ const messagePath = path.join(root, "sets/storefront/messages/common/welcome.yml");
1254
+
1255
+ expect(__catalogDevInternals.getCatalogInputWatchPaths(root, projectConfig)).toEqual(
1256
+ expect.arrayContaining([
1257
+ path.join(root, "messagevisor.config.js"),
1258
+ projectConfig.setsDirectoryPath,
1259
+ ]),
1260
+ );
1261
+ expect(
1262
+ __catalogDevInternals.classifyCatalogDevChanges(root, projectConfig, [messagePath], {
1263
+ withTranslationSearch: false,
1264
+ withDuplicates: false,
1265
+ }),
1266
+ ).toEqual(
1267
+ expect.objectContaining({
1268
+ kind: "message",
1269
+ set: "storefront",
1270
+ messageKeys: ["common.welcome"],
1271
+ }),
1272
+ );
1273
+ expect(
1274
+ __catalogDevInternals.classifyCatalogDevChanges(root, projectConfig, [localePath], {
1275
+ withTranslationSearch: false,
1276
+ withDuplicates: false,
1277
+ }),
1278
+ ).toEqual(expect.objectContaining({ kind: "set", set: "storefront" }));
1279
+ });
1177
1280
  });
1178
1281
 
1179
1282
  describe("catalog plugin", function () {