@parhelia/localization 0.1.12907 → 0.1.12910

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.
Files changed (77) hide show
  1. package/dist/LocalizeItemCommand.d.ts +7 -1
  2. package/dist/LocalizeItemCommand.d.ts.map +1 -1
  3. package/dist/LocalizeItemCommand.js +31 -18
  4. package/dist/LocalizeItemDialog.d.ts.map +1 -1
  5. package/dist/LocalizeItemDialog.js +97 -35
  6. package/dist/LocalizeItemUtils.d.ts +1 -2
  7. package/dist/LocalizeItemUtils.d.ts.map +1 -1
  8. package/dist/LocalizeItemUtils.js +78 -36
  9. package/dist/api/discovery.d.ts +25 -0
  10. package/dist/api/discovery.d.ts.map +1 -1
  11. package/dist/api/discovery.js +106 -2
  12. package/dist/hooks/useTranslationWizard.d.ts.map +1 -1
  13. package/dist/hooks/useTranslationWizard.js +3 -3
  14. package/dist/index.d.ts +10 -11
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +36 -32
  17. package/dist/services/translationService.d.ts +41 -10
  18. package/dist/services/translationService.d.ts.map +1 -1
  19. package/dist/services/translationService.js +48 -6
  20. package/dist/settings/TranslationServicesPanel.d.ts.map +1 -1
  21. package/dist/settings/TranslationServicesPanel.js +21 -36
  22. package/dist/setup/LocalizationSetupStep.d.ts.map +1 -1
  23. package/dist/setup/LocalizationSetupStep.js +29 -18
  24. package/dist/sidebar/TranslationSidebar.d.ts.map +1 -1
  25. package/dist/sidebar/TranslationSidebar.js +20 -10
  26. package/dist/steps/ItemSelectionStep.d.ts +3 -0
  27. package/dist/steps/ItemSelectionStep.d.ts.map +1 -0
  28. package/dist/steps/ItemSelectionStep.js +24 -0
  29. package/dist/steps/ItemSelectionTree.d.ts +13 -0
  30. package/dist/steps/ItemSelectionTree.d.ts.map +1 -0
  31. package/dist/steps/ItemSelectionTree.js +327 -0
  32. package/dist/steps/PromptCustomizationStep.d.ts +1 -1
  33. package/dist/steps/PromptCustomizationStep.d.ts.map +1 -1
  34. package/dist/steps/PromptCustomizationStep.js +159 -56
  35. package/dist/steps/ServiceLanguageSelectionStep.d.ts +6 -1
  36. package/dist/steps/ServiceLanguageSelectionStep.d.ts.map +1 -1
  37. package/dist/steps/ServiceLanguageSelectionStep.js +92 -165
  38. package/dist/steps/WizardStepShell.d.ts +17 -0
  39. package/dist/steps/WizardStepShell.d.ts.map +1 -0
  40. package/dist/steps/WizardStepShell.js +11 -0
  41. package/dist/steps/types.d.ts +17 -1
  42. package/dist/steps/types.d.ts.map +1 -1
  43. package/dist/translation-center/TranslationBatches.d.ts +2 -0
  44. package/dist/translation-center/TranslationBatches.d.ts.map +1 -0
  45. package/dist/translation-center/TranslationBatches.js +1218 -0
  46. package/dist/translation-center/TranslationManagement.d.ts.map +1 -1
  47. package/dist/translation-center/TranslationManagement.js +25 -15
  48. package/dist/translation-center/TranslationsTitlebar.d.ts +6 -0
  49. package/dist/translation-center/TranslationsTitlebar.d.ts.map +1 -0
  50. package/dist/translation-center/TranslationsTitlebar.js +25 -0
  51. package/dist/translationEvents.d.ts +6 -0
  52. package/dist/translationEvents.d.ts.map +1 -0
  53. package/dist/translationEvents.js +4 -0
  54. package/dist/types.d.ts +1 -0
  55. package/dist/types.d.ts.map +1 -1
  56. package/package.json +1 -1
  57. package/dist/constants.d.ts +0 -15
  58. package/dist/constants.d.ts.map +0 -1
  59. package/dist/constants.js +0 -21
  60. package/dist/steps/MetadataInputStep.d.ts +0 -4
  61. package/dist/steps/MetadataInputStep.d.ts.map +0 -1
  62. package/dist/steps/MetadataInputStep.js +0 -41
  63. package/dist/steps/SubitemDiscoveryStep.d.ts +0 -3
  64. package/dist/steps/SubitemDiscoveryStep.d.ts.map +0 -1
  65. package/dist/steps/SubitemDiscoveryStep.js +0 -313
  66. package/dist/steps/index.d.ts +0 -5
  67. package/dist/steps/index.d.ts.map +0 -1
  68. package/dist/steps/index.js +0 -4
  69. package/dist/translation-center/BatchTranslationView.d.ts +0 -8
  70. package/dist/translation-center/BatchTranslationView.d.ts.map +0 -1
  71. package/dist/translation-center/BatchTranslationView.js +0 -870
  72. package/dist/translation-center/RecentTranslations.d.ts +0 -2
  73. package/dist/translation-center/RecentTranslations.d.ts.map +0 -1
  74. package/dist/translation-center/RecentTranslations.js +0 -309
  75. package/dist/utils/createVersions.d.ts +0 -14
  76. package/dist/utils/createVersions.d.ts.map +0 -1
  77. package/dist/utils/createVersions.js +0 -26
@@ -0,0 +1,327 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { ItemCollectionEditor, } from "@parhelia/core";
4
+ import { streamSubitemCount } from "../api/discovery";
5
+ const SITECORE_ROOT = "11111111-1111-1111-1111-111111111111";
6
+ function fullItemFromDescriptor(descriptor) {
7
+ return {
8
+ id: descriptor.id,
9
+ language: descriptor.language,
10
+ version: descriptor.version,
11
+ descriptor: {
12
+ id: descriptor.id,
13
+ language: descriptor.language,
14
+ version: descriptor.version,
15
+ name: descriptor.name,
16
+ displayName: descriptor.displayName,
17
+ path: descriptor.path,
18
+ },
19
+ name: descriptor.name ?? "",
20
+ templateId: descriptor.templateId ?? "",
21
+ templateName: "",
22
+ icon: descriptor.icon ?? "",
23
+ largeIcon: descriptor.icon ?? "",
24
+ thumbnail: "",
25
+ path: descriptor.path ?? "",
26
+ fields: [],
27
+ canWriteItem: false,
28
+ canWriteLanguage: false,
29
+ canWriteWorkflow: false,
30
+ canRename: false,
31
+ canDelete: false,
32
+ canCreate: false,
33
+ workflowState: "",
34
+ hasLock: false,
35
+ hasLayout: true,
36
+ idPath: descriptor.idPath ?? "",
37
+ canLock: false,
38
+ lockedBy: "",
39
+ isInheritedFromMasterLanguage: false,
40
+ hasChildren: descriptor.hasChildren ?? false,
41
+ };
42
+ }
43
+ function fullItemToWithSubtree(item, includeSubitems) {
44
+ return {
45
+ descriptor: {
46
+ id: item.descriptor?.id ?? item.id,
47
+ language: item.descriptor?.language ?? item.language,
48
+ version: item.descriptor?.version ?? item.version,
49
+ name: item.descriptor?.name ?? item.name,
50
+ displayName: item.descriptor?.displayName ?? item.displayName,
51
+ path: item.descriptor?.path ?? item.path,
52
+ },
53
+ includeSubitems,
54
+ };
55
+ }
56
+ export function ItemSelectionTree({ data, setData, editContext, isActive = true, onSelectionValidChange, height = 360, }) {
57
+ const language = useMemo(() => editContext?.item?.language ??
58
+ editContext?.currentItemDescriptor?.language ??
59
+ "en", [editContext?.item?.language, editContext?.currentItemDescriptor?.language]);
60
+ // Seed selection: prefer previously saved tree items, then discoveredItems,
61
+ // then the wizard's initial `items`. Default includeSubitems = legacy flag.
62
+ const initialItems = useMemo(() => {
63
+ if (data.selectionTreeItems && data.selectionTreeItems.length > 0) {
64
+ return data.selectionTreeItems;
65
+ }
66
+ const seed = data.discoveredItems && data.discoveredItems.length > 0
67
+ ? data.discoveredItems
68
+ : data.items;
69
+ return seed.map((item) => fullItemToWithSubtree(item, !!data.includeSubitems));
70
+ // First mount only — once the user edits, wizard data is downstream.
71
+ // eslint-disable-next-line react-hooks/exhaustive-deps
72
+ }, []);
73
+ const [items, setItems] = useState(initialItems);
74
+ const [selectedInTree, setSelectedInTree] = useState([]);
75
+ const [selectedFromList, setSelectedFromList] = useState([]);
76
+ // Async subitem counts keyed by item id. Filtered server-side via
77
+ // IItemExportFilter (see TranslationController.DiscoverItemsTree). The
78
+ // running count climbs as chunks arrive; `complete` flips true on the
79
+ // final chunk so consumers know when to drop the "still counting" hint.
80
+ const [subitemCounts, setSubitemCounts] = useState(() => data.subitemCounts ?? {});
81
+ // Mirror local subitemCounts into wizardData so later steps (e.g. the
82
+ // Start Translation button) can show a live total.
83
+ const dataRef = useRef(data);
84
+ dataRef.current = data;
85
+ useEffect(() => {
86
+ setData({ ...dataRef.current, subitemCounts });
87
+ }, [subitemCounts, setData]);
88
+ // Keep a cache of the richest FullItem we've ever seen for each id so we
89
+ // can rebuild discoveredItems without re-fetching.
90
+ const itemCacheRef = useRef(new Map());
91
+ if (itemCacheRef.current.size === 0) {
92
+ for (const it of data.items)
93
+ itemCacheRef.current.set(it.id, it);
94
+ for (const it of data.discoveredItems || [])
95
+ itemCacheRef.current.set(it.id, it);
96
+ }
97
+ // Build a combined idPath that expands every parent of the current
98
+ // selection (but not the items themselves). ContentTree's parser
99
+ // extracts each {guid} segment, so concatenating multiple paths works.
100
+ const expandIdPath = useMemo(() => {
101
+ const segments = [];
102
+ for (const entry of items) {
103
+ const cached = itemCacheRef.current.get(entry.descriptor.id);
104
+ const idPath = cached?.idPath;
105
+ if (!idPath)
106
+ continue;
107
+ const parts = idPath.split("/").filter((s) => s.length > 0);
108
+ // Drop the last segment (the item itself) — we only want parents.
109
+ parts.pop();
110
+ for (const part of parts)
111
+ segments.push(part);
112
+ }
113
+ if (segments.length === 0)
114
+ return undefined;
115
+ return "/" + segments.join("/");
116
+ }, [items]);
117
+ const commitToWizard = useCallback((nextItems) => {
118
+ const cache = itemCacheRef.current;
119
+ const discovered = nextItems.map((entry) => cache.get(entry.descriptor.id) ??
120
+ fullItemFromDescriptor(entry.descriptor));
121
+ setData({
122
+ ...data,
123
+ selectionTreeItems: nextItems,
124
+ discoveredItems: discovered,
125
+ includeSubitems: nextItems.some((i) => i.includeSubitems),
126
+ });
127
+ }, [data, setData]);
128
+ // Commit current state on first mount so wizard data reflects the seed.
129
+ const didInitialCommitRef = useRef(false);
130
+ useEffect(() => {
131
+ if (didInitialCommitRef.current)
132
+ return;
133
+ didInitialCommitRef.current = true;
134
+ commitToWizard(items);
135
+ // eslint-disable-next-line react-hooks/exhaustive-deps
136
+ }, []);
137
+ useEffect(() => {
138
+ if (isActive)
139
+ onSelectionValidChange?.(items.length > 0);
140
+ }, [isActive, onSelectionValidChange, items.length]);
141
+ // Stream subitem counts in the background for items whose
142
+ // includeSubitems flag is on. Filtering happens server-side (see
143
+ // TranslationController.DiscoverSubitemCountStream). The count grows in
144
+ // real time as the backend walks the subtree. Cached results survive
145
+ // toggle-off/on; in-flight requests are aborted if the user turns the
146
+ // flag back off.
147
+ const sessionId = editContext?.sessionId;
148
+ const inFlightRef = useRef(new Map());
149
+ useEffect(() => {
150
+ const inFlight = inFlightRef.current;
151
+ const enabledIds = new Set(items.filter((it) => it.includeSubitems).map((it) => it.descriptor.id));
152
+ // Cancel any streams whose items no longer have the flag on.
153
+ for (const [id, ctrl] of inFlight) {
154
+ if (!enabledIds.has(id)) {
155
+ ctrl.abort();
156
+ inFlight.delete(id);
157
+ }
158
+ }
159
+ // Start a stream for every newly-enabled item that doesn't already
160
+ // have a count or an active stream. Cached complete results survive;
161
+ // a partial (non-complete) object means a previous stream was aborted
162
+ // mid-flight — restart it.
163
+ const toStart = items
164
+ .filter((it) => it.includeSubitems)
165
+ .map((it) => it.descriptor)
166
+ .filter((d) => {
167
+ if (inFlight.has(d.id))
168
+ return false;
169
+ const c = subitemCounts[d.id];
170
+ if (c === undefined || c === "error")
171
+ return true;
172
+ if (c === "loading")
173
+ return false;
174
+ return !c.complete;
175
+ });
176
+ if (toStart.length === 0)
177
+ return;
178
+ setSubitemCounts((prev) => {
179
+ const next = { ...prev };
180
+ for (const d of toStart)
181
+ next[d.id] = "loading";
182
+ return next;
183
+ });
184
+ for (const descriptor of toStart) {
185
+ const controller = new AbortController();
186
+ inFlight.set(descriptor.id, controller);
187
+ void (async () => {
188
+ try {
189
+ const finalCount = await streamSubitemCount({ itemId: descriptor.id, language: descriptor.language }, ({ count, complete }) => {
190
+ setSubitemCounts((prev) => ({
191
+ ...prev,
192
+ [descriptor.id]: { count, complete },
193
+ }));
194
+ }, { sessionId, signal: controller.signal });
195
+ // Backend may close the stream without explicitly sending
196
+ // complete=true on the last line — mark complete on a clean exit.
197
+ setSubitemCounts((prev) => {
198
+ const existing = prev[descriptor.id];
199
+ const count = existing && typeof existing === "object"
200
+ ? existing.count
201
+ : finalCount;
202
+ return {
203
+ ...prev,
204
+ [descriptor.id]: { count, complete: true },
205
+ };
206
+ });
207
+ }
208
+ catch (err) {
209
+ if (err?.name === "AbortError")
210
+ return;
211
+ setSubitemCounts((prev) => {
212
+ // Don't overwrite a partial count with "error" — keep what we have.
213
+ const existing = prev[descriptor.id];
214
+ if (existing && typeof existing === "object")
215
+ return prev;
216
+ return { ...prev, [descriptor.id]: "error" };
217
+ });
218
+ }
219
+ finally {
220
+ if (inFlight.get(descriptor.id) === controller) {
221
+ inFlight.delete(descriptor.id);
222
+ }
223
+ }
224
+ })();
225
+ }
226
+ }, [items, subitemCounts, sessionId]);
227
+ // Abort any in-flight streams on unmount.
228
+ useEffect(() => {
229
+ const inFlight = inFlightRef.current;
230
+ return () => {
231
+ for (const ctrl of inFlight.values())
232
+ ctrl.abort();
233
+ inFlight.clear();
234
+ };
235
+ }, []);
236
+ const cacheTreeNode = useCallback((node) => {
237
+ const id = node.id;
238
+ if (!id)
239
+ return;
240
+ const cache = itemCacheRef.current;
241
+ if (cache.has(id))
242
+ return;
243
+ const anyNode = node;
244
+ cache.set(id, fullItemFromDescriptor({
245
+ id,
246
+ language: node.language ?? language,
247
+ version: node.version ?? 1,
248
+ name: node.name,
249
+ displayName: node.displayName,
250
+ path: node.path,
251
+ idPath: anyNode.idPath,
252
+ icon: anyNode.icon,
253
+ templateId: anyNode.templateId,
254
+ hasChildren: anyNode.hasChildren,
255
+ }));
256
+ }, [language]);
257
+ const handleAddToList = useCallback(async (itemsToAdd) => {
258
+ const source = itemsToAdd ?? selectedInTree;
259
+ if (!source || source.length === 0)
260
+ return;
261
+ const existing = new Set(items.map((i) => i.descriptor.id));
262
+ const next = [...items];
263
+ for (const node of source) {
264
+ if (!node.id || existing.has(node.id))
265
+ continue;
266
+ cacheTreeNode(node);
267
+ next.push({
268
+ descriptor: {
269
+ id: node.id,
270
+ language: node.language ?? language,
271
+ version: node.version ?? 1,
272
+ name: node.name,
273
+ displayName: node.displayName,
274
+ path: node.path,
275
+ },
276
+ includeSubitems: false,
277
+ });
278
+ existing.add(node.id);
279
+ }
280
+ setItems(next);
281
+ commitToWizard(next);
282
+ setSelectedInTree([]);
283
+ setSelectedFromList([]);
284
+ }, [items, selectedInTree, language, cacheTreeNode, commitToWizard]);
285
+ const handleAddItem = useCallback(async (item) => {
286
+ const id = item.id;
287
+ if (!id || items.some((i) => i.descriptor.id === id))
288
+ return;
289
+ cacheTreeNode(item);
290
+ const next = [
291
+ ...items,
292
+ {
293
+ descriptor: {
294
+ id,
295
+ language: item.language ?? language,
296
+ version: item.version ?? 1,
297
+ name: item.name,
298
+ displayName: item.displayName,
299
+ path: item.path,
300
+ },
301
+ includeSubitems: false,
302
+ },
303
+ ];
304
+ setItems(next);
305
+ commitToWizard(next);
306
+ setSelectedFromList([]);
307
+ }, [items, language, cacheTreeNode, commitToWizard]);
308
+ const handleRemoveFromList = useCallback(() => {
309
+ const ids = new Set(selectedFromList.map((i) => i.descriptor.id));
310
+ const next = items.filter((i) => !ids.has(i.descriptor.id));
311
+ setItems(next);
312
+ commitToWizard(next);
313
+ setSelectedFromList([]);
314
+ }, [items, selectedFromList, commitToWizard]);
315
+ const handleRemoveItem = useCallback((index) => {
316
+ const next = items.filter((_, i) => i !== index);
317
+ setItems(next);
318
+ commitToWizard(next);
319
+ setSelectedFromList([]);
320
+ }, [items, commitToWizard]);
321
+ const handleToggleSubitems = useCallback((index) => {
322
+ const next = items.map((it, i) => i === index ? { ...it, includeSubitems: !it.includeSubitems } : it);
323
+ setItems(next);
324
+ commitToWizard(next);
325
+ }, [items, commitToWizard]);
326
+ return (_jsx(ItemCollectionEditor, { items: items, selectedInTree: selectedInTree, onSelectedInTreeChange: setSelectedInTree, selectedFromList: selectedFromList, onSelectedFromListChange: setSelectedFromList, onAddToList: handleAddToList, onRemoveFromList: handleRemoveFromList, onAddItem: handleAddItem, onRemoveItem: handleRemoveItem, onToggleSubitems: handleToggleSubitems, language: language, rootItemIds: [SITECORE_ROOT], selectedItemsLabel: "Items to Translate", emptyMessage: "No items selected", emptyHint: "Browse or search to add items", height: height, subitemCounts: subitemCounts, expandIdPath: expandIdPath, localStorageKey: "translation-wizard.itemCollectionSplitter" }));
327
+ }
@@ -1,3 +1,3 @@
1
1
  import { TranslationStepProps } from "./types";
2
- export declare function PromptCustomizationStep({ stepIndex, isActive, data, setData, onStepCompleted, editContext, }: TranslationStepProps): import("react/jsx-runtime").JSX.Element;
2
+ export declare function PromptCustomizationStep({ isActive, data, setData, onStepCompleted, editContext, }: TranslationStepProps): import("react/jsx-runtime").JSX.Element;
3
3
  //# sourceMappingURL=PromptCustomizationStep.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"PromptCustomizationStep.d.ts","sourceRoot":"","sources":["../../src/steps/PromptCustomizationStep.tsx"],"names":[],"mappings":"AACA,OAAO,EAAE,oBAAoB,EAAyB,MAAM,SAAS,CAAC;AAEtE,wBAAgB,uBAAuB,CAAC,EACtC,SAAS,EACT,QAAe,EACf,IAAI,EACJ,OAAO,EACP,eAAe,EACf,WAAW,GACZ,EAAE,oBAAoB,2CAqRtB"}
1
+ {"version":3,"file":"PromptCustomizationStep.d.ts","sourceRoot":"","sources":["../../src/steps/PromptCustomizationStep.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,oBAAoB,EAAyB,MAAM,SAAS,CAAC;AAItE,wBAAgB,uBAAuB,CAAC,EACtC,QAAe,EACf,IAAI,EACJ,OAAO,EACP,eAAe,EACf,WAAW,GACZ,EAAE,oBAAoB,2CAsctB"}
@@ -1,15 +1,77 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useState, useEffect, useMemo, useRef, useCallback } from "react";
3
- export function PromptCustomizationStep({ stepIndex, isActive = true, data, setData, onStepCompleted, editContext, }) {
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { Input, Select, Spinner, Textarea } from "@parhelia/core";
4
+ import { WizardStepShell } from "./WizardStepShell";
5
+ import { suggestBatchName } from "../api/discovery";
6
+ export function PromptCustomizationStep({ isActive = true, data, setData, onStepCompleted, editContext, }) {
4
7
  const [customPrompt, setCustomPrompt] = useState("");
5
8
  const [customizationType, setCustomizationType] = useState("extend");
6
- // Use refs to track current values without triggering re-renders
9
+ // Local mirror of the wizard's batch name so typing is responsive.
10
+ const [batchName, setBatchName] = useState(data.batchName ?? "");
11
+ const [isSuggesting, setIsSuggesting] = useState(false);
12
+ // Whether the user has manually edited the name. If so, we never overwrite
13
+ // it with a fresh AI suggestion.
14
+ const userTouchedNameRef = useRef(!!data.batchName);
15
+ const dataRef = useRef(data);
16
+ useEffect(() => {
17
+ dataRef.current = data;
18
+ }, [data]);
19
+ // Push name changes back into wizard data (debounced via simple equality).
20
+ useEffect(() => {
21
+ if ((dataRef.current.batchName ?? "") === batchName)
22
+ return;
23
+ setData({ ...dataRef.current, batchName });
24
+ }, [batchName, setData]);
25
+ // Auto-suggest a name when the step becomes active and the user hasn't
26
+ // already provided/edited one. Fires once per item+language combination so
27
+ // navigating away and back doesn't re-fetch.
28
+ const lastSuggestionKeyRef = useRef("");
29
+ const sessionId = editContext?.sessionId;
30
+ useEffect(() => {
31
+ if (!isActive)
32
+ return;
33
+ if (userTouchedNameRef.current)
34
+ return;
35
+ const itemIds = (data.selectionTreeItems && data.selectionTreeItems.length > 0
36
+ ? data.selectionTreeItems.map((s) => s.descriptor.id)
37
+ : data.items.map((i) => i.descriptor.id)).filter(Boolean);
38
+ const langs = [...data.targetLanguages].sort();
39
+ if (itemIds.length === 0 || langs.length === 0)
40
+ return;
41
+ const key = JSON.stringify({ itemIds: [...itemIds].sort(), langs });
42
+ if (lastSuggestionKeyRef.current === key)
43
+ return;
44
+ lastSuggestionKeyRef.current = key;
45
+ let cancelled = false;
46
+ setIsSuggesting(true);
47
+ void (async () => {
48
+ try {
49
+ const includeSubitems = !!data.selectionTreeItems?.some((s) => s.includeSubitems);
50
+ const name = await suggestBatchName({ itemIds, targetLanguages: langs, includeSubitems }, sessionId);
51
+ if (cancelled || userTouchedNameRef.current)
52
+ return;
53
+ if (name)
54
+ setBatchName(name);
55
+ }
56
+ finally {
57
+ if (!cancelled)
58
+ setIsSuggesting(false);
59
+ }
60
+ })();
61
+ return () => {
62
+ cancelled = true;
63
+ };
64
+ }, [
65
+ isActive,
66
+ sessionId,
67
+ data.selectionTreeItems,
68
+ data.items,
69
+ data.targetLanguages,
70
+ ]);
7
71
  const customPromptRef = useRef(customPrompt);
8
72
  const customizationTypeRef = useRef(customizationType);
9
- // Track if we've initialized from parent data to prevent re-initialization during typing
10
73
  const hasInitializedRef = useRef(false);
11
74
  const lastProviderRef = useRef(data.translationProvider);
12
- // Debounce timer ref for parent updates
13
75
  const updateTimerRef = useRef(null);
14
76
  useEffect(() => {
15
77
  customPromptRef.current = customPrompt;
@@ -17,36 +79,32 @@ export function PromptCustomizationStep({ stepIndex, isActive = true, data, setD
17
79
  useEffect(() => {
18
80
  customizationTypeRef.current = customizationType;
19
81
  }, [customizationType]);
20
- // Get service-specific custom data for the selected provider
82
+ const selectedProvider = useMemo(() => data.translationProviders.find((p) => p.name === data.translationProvider), [data.translationProviders, data.translationProvider]);
21
83
  const serviceData = useMemo(() => {
22
84
  if (!data.serviceCustomData || !data.translationProvider)
23
85
  return null;
24
86
  return data.serviceCustomData.get(data.translationProvider);
25
87
  }, [data.serviceCustomData, data.translationProvider]);
26
88
  const enableCustomPrompt = serviceData?.enableCustomPrompt === true;
27
- // Get default prompt from provider settings (no fallback)
89
+ const supportsPromptCustomization = data.translationProvider === "AI";
28
90
  const defaultPrompt = useMemo(() => {
29
- const provider = data.translationProviders.find(p => p.name === data.translationProvider);
30
- const prompt = provider?.defaultPrompt;
31
- // Return null if prompt is null, undefined, or empty string
91
+ const prompt = selectedProvider?.defaultPrompt;
32
92
  return prompt && prompt.trim() ? prompt : null;
33
- }, [data.translationProviders, data.translationProvider]);
34
- const hasDefaultPrompt = defaultPrompt != null && defaultPrompt.trim().length > 0;
35
- // Initialize from existing data - only on mount or provider change
93
+ }, [selectedProvider?.defaultPrompt]);
94
+ const hasDefaultPrompt = defaultPrompt != null && defaultPrompt.length > 0;
36
95
  useEffect(() => {
37
- // Reset initialization when provider changes
38
96
  if (lastProviderRef.current !== data.translationProvider) {
39
97
  hasInitializedRef.current = false;
40
98
  lastProviderRef.current = data.translationProvider;
41
99
  }
42
- // Skip if already initialized (prevents re-init during typing)
43
- if (hasInitializedRef.current) {
100
+ if (hasInitializedRef.current)
44
101
  return;
45
- }
46
- const nextCustomizationType = serviceData?.promptCustomizationType || "extend";
102
+ const nextCustomizationType = serviceData?.promptCustomizationType ||
103
+ "extend";
47
104
  let nextCustomPrompt = serviceData?.customPrompt || "";
48
- // If stored prompt already includes default (legacy), strip it for editing
49
- if (hasDefaultPrompt && nextCustomizationType === "extend" && nextCustomPrompt.startsWith(defaultPrompt || "")) {
105
+ if (hasDefaultPrompt &&
106
+ nextCustomizationType === "extend" &&
107
+ nextCustomPrompt.startsWith(defaultPrompt || "")) {
50
108
  nextCustomPrompt = nextCustomPrompt.slice((defaultPrompt || "").length);
51
109
  nextCustomPrompt = nextCustomPrompt.replace(/^\s*\n\s*\n?/, "");
52
110
  }
@@ -60,29 +118,70 @@ export function PromptCustomizationStep({ stepIndex, isActive = true, data, setD
60
118
  }
61
119
  hasInitializedRef.current = true;
62
120
  }, [
121
+ data.translationProvider,
122
+ defaultPrompt,
63
123
  enableCustomPrompt,
64
- serviceData,
65
124
  hasDefaultPrompt,
66
- defaultPrompt,
67
- data.translationProvider,
125
+ serviceData,
68
126
  ]);
69
- // Preview of final prompt
127
+ useEffect(() => {
128
+ if (!isActive)
129
+ return;
130
+ onStepCompleted(!!data.translationProvider);
131
+ }, [data.translationProvider, isActive, onStepCompleted]);
132
+ const handleProviderChange = (e) => {
133
+ const newProvider = e.target.value;
134
+ const newServiceCustomData = new Map();
135
+ data.serviceCustomData?.forEach((value, key) => {
136
+ if (key !== "AI" || newProvider === "AI") {
137
+ newServiceCustomData.set(key, value);
138
+ }
139
+ });
140
+ const newData = {
141
+ ...data,
142
+ translationProvider: newProvider,
143
+ serviceCustomData: newServiceCustomData,
144
+ };
145
+ setData(newData);
146
+ };
147
+ const handleCustomPromptToggle = (enabled) => {
148
+ const newServiceCustomData = new Map(data.serviceCustomData || new Map());
149
+ if (enabled) {
150
+ newServiceCustomData.set(data.translationProvider, {
151
+ enableCustomPrompt: true,
152
+ customPrompt: "",
153
+ promptCustomizationType: "extend",
154
+ });
155
+ }
156
+ else {
157
+ newServiceCustomData.delete(data.translationProvider);
158
+ }
159
+ setData({
160
+ ...data,
161
+ serviceCustomData: newServiceCustomData,
162
+ });
163
+ };
70
164
  const previewPrompt = useMemo(() => {
71
165
  if (!enableCustomPrompt || !customPrompt.trim()) {
72
166
  return defaultPrompt || "";
73
167
  }
74
- // If no default prompt, just show custom prompt
75
168
  if (!hasDefaultPrompt) {
76
169
  return customPrompt;
77
170
  }
78
- // If default prompt exists, show based on customization type
79
171
  if (customizationType === "replace") {
80
172
  return customPrompt;
81
173
  }
82
174
  return `${defaultPrompt}\n\n${customPrompt}`;
83
- }, [enableCustomPrompt, customPrompt, customizationType, defaultPrompt, hasDefaultPrompt]);
84
- // Debounced update to parent - prevents rapid state updates during typing
175
+ }, [
176
+ customPrompt,
177
+ customizationType,
178
+ defaultPrompt,
179
+ enableCustomPrompt,
180
+ hasDefaultPrompt,
181
+ ]);
85
182
  const updateParentData = useCallback(() => {
183
+ if (!data.translationProvider)
184
+ return;
86
185
  const trimmedCustomPrompt = customPromptRef.current.trim();
87
186
  const currentCustomizationType = customizationTypeRef.current;
88
187
  const newServiceCustomData = new Map(data.serviceCustomData || new Map());
@@ -95,33 +194,28 @@ export function PromptCustomizationStep({ stepIndex, isActive = true, data, setD
95
194
  };
96
195
  const isSame = currentServiceData?.enableCustomPrompt === true &&
97
196
  (currentServiceData.customPrompt || "") === trimmedCustomPrompt &&
98
- (currentServiceData.promptCustomizationType || "extend") === currentCustomizationType;
99
- if (isSame) {
197
+ (currentServiceData.promptCustomizationType || "extend") ===
198
+ currentCustomizationType;
199
+ if (isSame)
100
200
  return;
101
- }
102
201
  newServiceCustomData.set(data.translationProvider, nextServiceData);
103
202
  }
104
203
  else {
105
- if (!currentServiceData) {
204
+ if (!currentServiceData)
106
205
  return;
107
- }
108
206
  newServiceCustomData.delete(data.translationProvider);
109
207
  }
110
- const newData = {
208
+ setData({
111
209
  ...data,
112
210
  serviceCustomData: newServiceCustomData,
113
- };
114
- setData(newData);
115
- }, [enableCustomPrompt, data, setData]);
116
- // Update wizard data when settings change - debounced to prevent rapid fire
211
+ });
212
+ }, [data, enableCustomPrompt, setData]);
117
213
  useEffect(() => {
118
- if (!isActive)
214
+ if (!isActive || !supportsPromptCustomization)
119
215
  return;
120
- // Clear any pending update
121
216
  if (updateTimerRef.current) {
122
217
  clearTimeout(updateTimerRef.current);
123
218
  }
124
- // Debounce the update to parent (100ms delay)
125
219
  updateTimerRef.current = setTimeout(() => {
126
220
  updateParentData();
127
221
  }, 100);
@@ -131,24 +225,33 @@ export function PromptCustomizationStep({ stepIndex, isActive = true, data, setD
131
225
  }
132
226
  };
133
227
  }, [
134
- enableCustomPrompt,
135
228
  customPrompt,
136
229
  customizationType,
137
230
  isActive,
231
+ supportsPromptCustomization,
138
232
  updateParentData,
139
233
  ]);
140
- // Update completion status
141
- useEffect(() => {
142
- if (!isActive)
143
- return;
144
- // Step is always complete (it's optional)
145
- onStepCompleted(true);
146
- }, [isActive, onStepCompleted]);
147
- // Skip condition: hide step when checkbox is disabled
148
- // This is handled by skipCondition in wizard config
149
- return (_jsxs("div", { className: "p-6 space-y-6 h-full flex flex-col", "data-testid": "prompt-customization-step", children: [_jsxs("div", { children: [_jsx("h2", { className: "text-xl font-semibold text-[var(--color-dark)] mb-2", children: "Customize Translation Prompt" }), _jsxs("p", { className: "text-sm text-[var(--color-gray-2)] mb-6", children: ["Optionally customize the prompt used for translation. This allows you to provide specific instructions or context for the translation service.", _jsx("br", {}), _jsx("span", { className: "text-xs text-[var(--color-gray-2)] mt-1 block", children: "Note: Your custom prompt will be appended to the system instructions that ensure proper translation structure." })] })] }), _jsxs("div", { className: "space-y-6 flex-1", children: [!enableCustomPrompt && (_jsx("div", { className: "border border-[var(--color-gray-3)] rounded-lg p-4 bg-[var(--color-gray-5)]", children: _jsx("p", { className: "text-sm text-[var(--color-gray-2)]", children: "Enable \"Customize translation prompt\" in the previous step to customize the prompt." }) })), enableCustomPrompt && (_jsxs(_Fragment, { children: [hasDefaultPrompt && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "bg-background rounded-lg border border-[var(--color-gray-3)] p-4", children: [_jsx("h3", { className: "text-sm font-medium text-[var(--color-dark)] mb-2", children: "Default Prompt" }), _jsx("div", { className: "border border-[var(--color-gray-3)] rounded-md p-3 bg-[var(--color-gray-5)]", children: _jsx("pre", { className: "text-xs text-[var(--color-gray-1)] whitespace-pre-wrap font-mono", children: defaultPrompt }) })] }), _jsxs("div", { className: "bg-background rounded-lg border border-[var(--color-gray-3)] p-4", children: [_jsx("h3", { className: "text-sm font-medium text-[var(--color-dark)] mb-3", children: "Customization Type" }), _jsxs("div", { className: "space-y-3", children: [_jsxs("label", { className: "flex items-center cursor-pointer py-1.5 px-2 rounded-md hover:bg-[var(--color-gray-5)] transition-colors", children: [_jsx("input", { type: "radio", name: "customizationType", value: "extend", checked: customizationType === "extend", onChange: () => setCustomizationType("extend"), className: "h-4 w-4 text-[#9650fb] focus:ring-[#9650fb] border-[var(--color-gray-3)] accent-[#9650fb]", "data-testid": "customization-type-extend" }), _jsx("span", { className: "ml-2 text-sm text-[var(--color-dark)]", children: "Extend (append to default)" })] }), _jsxs("label", { className: "flex items-center cursor-pointer py-1.5 px-2 rounded-md hover:bg-[var(--color-gray-5)] transition-colors", children: [_jsx("input", { type: "radio", name: "customizationType", value: "replace", checked: customizationType === "replace", onChange: () => setCustomizationType("replace"), className: "h-4 w-4 text-[#9650fb] focus:ring-[#9650fb] border-[var(--color-gray-3)] accent-[#9650fb]", "data-testid": "customization-type-replace" }), _jsx("span", { className: "ml-2 text-sm text-[var(--color-dark)]", children: "Replace (use custom prompt only)" })] })] })] })] })), _jsxs("div", { className: "bg-background rounded-lg border border-[var(--color-gray-3)] p-4", children: [_jsx("h3", { className: "text-sm font-medium text-[var(--color-dark)] mb-2", children: "Custom Prompt" }), _jsx("textarea", { value: customPrompt, onChange: (e) => setCustomPrompt(e.target.value), className: "w-full px-3 py-2.5 border border-[var(--color-gray-3)] rounded-md bg-[var(--color-gray-5)] text-[var(--color-dark)] focus:outline-none focus:ring-2 focus:ring-[#9650fb] focus:border-[#9650fb] text-sm font-mono transition-colors", rows: 6, placeholder: "Enter your custom prompt instructions here...", "data-testid": "custom-prompt-textarea" }), _jsx("p", { className: "text-xs text-[var(--color-gray-2)] mt-2", children: hasDefaultPrompt
150
- ? customizationType === "extend"
151
- ? "This will be appended to the default prompt. The final prompt (default + custom) will then be appended to the system instructions on the backend."
152
- : "This will replace the default prompt. The custom prompt will then be appended to the system instructions on the backend."
153
- : "Your custom prompt will be appended to the system instructions on the backend." })] }), hasDefaultPrompt && _jsxs("div", { className: "bg-background rounded-lg border border-[var(--color-gray-3)] p-4", children: [_jsx("h3", { className: "text-sm font-medium text-[var(--color-dark)] mb-2", children: "Preview" }), _jsx("div", { className: "border border-[var(--color-gray-3)] rounded-md p-3 bg-[var(--color-gray-5)] max-h-64 overflow-y-auto", children: _jsx("pre", { className: "text-xs text-[var(--color-gray-1)] whitespace-pre-wrap font-mono", children: previewPrompt }) }), _jsx("p", { className: "text-xs text-[var(--color-gray-2)] mt-2", children: "This is how the final prompt will look when sent to the translation service." })] })] }))] })] }));
234
+ return (_jsx(WizardStepShell, { fillHeight: true, testId: "prompt-customization-step", children: _jsxs("div", { className: "mx-auto flex min-h-0 w-full max-w-3xl flex-1 flex-col gap-6 overflow-y-auto", children: [_jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("label", { htmlFor: "translation-batch-name", className: "text-[11px] font-bold tracking-wider text-neutral-grey-50 ", children: "Name" }), isSuggesting && (_jsx("div", { role: "status", "aria-label": "Suggesting name", className: "text-muted-foreground flex h-3.5 w-3.5 items-center justify-center", children: _jsx(Spinner, { size: "xs" }) }))] }), _jsx("p", { className: "text-muted-foreground mt-1 text-xs", children: "A short label for this translation batch." }), _jsx(Input, { id: "translation-batch-name", type: "text", value: batchName, placeholder: "Translation batch", onChange: (e) => {
235
+ userTouchedNameRef.current = true;
236
+ setBatchName(e.target.value);
237
+ }, className: "mt-2", "data-testid": "translation-batch-name-input" })] }), _jsxs("div", { children: [_jsx("label", { className: "text-[11px] font-bold tracking-wider text-neutral-grey-50 ", children: "Translation provider" }), _jsx("p", { className: "text-muted-foreground mt-1 text-xs", children: "\"Create Versions\" will create new language versions without automatic translation." }), _jsx(Select, { value: data.translationProvider || "", onValueChange: (value) => handleProviderChange({
238
+ target: { value },
239
+ }), options: data.translationProviders.map((provider) => ({
240
+ value: provider.name,
241
+ label: provider.displayName || provider.name,
242
+ })), placeholder: "Select a provider\u2026", size: "sm", className: "mt-2 w-full", "data-testid": "translation-provider-select" })] }), supportsPromptCustomization && (_jsxs(_Fragment, { children: [_jsxs("label", { className: "-mb-2 flex cursor-pointer items-start gap-2.5", children: [_jsx("input", { type: "checkbox", checked: enableCustomPrompt, onChange: (e) => handleCustomPromptToggle(e.target.checked), className: "mt-0.5 h-3.5 w-3.5 rounded border-border-default text-[var(--color-highlight-100)] accent-[var(--color-highlight-100)] focus:ring-[var(--color-highlight-100)]", "data-testid": "enable-custom-prompt-checkbox" }), _jsxs("div", { className: "min-w-0", children: [_jsx("span", { className: "block text-[13px] font-medium text-neutral-grey-100", children: "Customize translation prompt" }), _jsx("span", { className: "text-muted-foreground mt-0.5 block text-xs", children: "Override or extend the provider's default instructions." })] })] }), enableCustomPrompt && (_jsxs(_Fragment, { children: [hasDefaultPrompt && (_jsxs(_Fragment, { children: [_jsxs("div", { children: [_jsx("span", { className: "text-[11px] font-bold tracking-wider text-neutral-grey-50 ", children: "Default prompt" }), _jsx("pre", { className: "mt-2 max-h-40 overflow-y-auto rounded-md bg-neutral-grey-5/70 p-3 font-mono text-xs leading-relaxed whitespace-pre-wrap text-neutral-grey-100", children: defaultPrompt })] }), _jsxs("div", { children: [_jsx("span", { className: "text-[11px] font-bold tracking-wider text-neutral-grey-50 ", children: "Customization type" }), _jsx("div", { className: "mt-2 grid gap-2 sm:grid-cols-2", children: [
243
+ {
244
+ value: "extend",
245
+ title: "Extend",
246
+ hint: "Append to the default prompt.",
247
+ },
248
+ {
249
+ value: "replace",
250
+ title: "Replace",
251
+ hint: "Use the custom prompt only.",
252
+ },
253
+ ].map((opt) => {
254
+ const selected = customizationType === opt.value;
255
+ return (_jsxs("label", { className: `group flex cursor-pointer items-start gap-2.5 rounded-lg p-2.5 transition-colors ${selected ? "bg-primary/5" : "hover:bg-neutral-grey-5"}`, children: [_jsx("input", { type: "radio", name: "customizationType", value: opt.value, checked: selected, onChange: () => setCustomizationType(opt.value), className: "mt-0.5 h-3.5 w-3.5 border-border-default text-[var(--color-highlight-100)] accent-[var(--color-highlight-100)] focus:ring-[var(--color-highlight-100)]", "data-testid": `customization-type-${opt.value}` }), _jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "text-xs font-medium text-neutral-grey-100", children: opt.title }), _jsx("div", { className: "text-muted-foreground mt-0.5 text-[11px]", children: opt.hint })] })] }, opt.value));
256
+ }) })] })] })), _jsxs("div", { children: [_jsx("span", { className: "text-[11px] font-bold tracking-wider text-neutral-grey-50 ", children: "Custom prompt" }), _jsx(Textarea, { value: customPrompt, onChange: (e) => setCustomPrompt(e.target.value), className: "mt-2 font-mono", rows: 6, placeholder: "Enter your custom prompt instructions here\u2026", "data-testid": "custom-prompt-textarea" })] }), hasDefaultPrompt && (_jsxs("div", { children: [_jsx("span", { className: "text-[11px] font-bold tracking-wider text-neutral-grey-50 ", children: "Preview" }), _jsx("pre", { className: "mt-2 max-h-52 overflow-y-auto rounded-md bg-neutral-grey-5/70 p-3 font-mono text-xs leading-relaxed whitespace-pre-wrap text-neutral-grey-100", children: previewPrompt })] }))] }))] }))] }) }));
154
257
  }