@purposeinplay/payload-ai-translate 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.
Files changed (301) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +714 -0
  3. package/dist/alerts-collection.d.ts +21 -0
  4. package/dist/alerts-collection.js +159 -0
  5. package/dist/api.d.ts +4 -0
  6. package/dist/api.js +918 -0
  7. package/dist/bulk-translate-batches-collection.d.ts +29 -0
  8. package/dist/bulk-translate-batches-collection.js +404 -0
  9. package/dist/bulk-translate-units-collection.d.ts +35 -0
  10. package/dist/bulk-translate-units-collection.js +310 -0
  11. package/dist/client/estimated-cost-cell.d.ts +6 -0
  12. package/dist/client/estimated-cost-cell.js +12 -0
  13. package/dist/client/excluded-fields-field.d.ts +45 -0
  14. package/dist/client/excluded-fields-field.js +553 -0
  15. package/dist/client/field-translate-button.d.ts +6 -0
  16. package/dist/client/field-translate-button.js +199 -0
  17. package/dist/client/index.d.ts +6 -0
  18. package/dist/client/index.js +6 -0
  19. package/dist/client/lib/use-global-kill-switches.d.ts +20 -0
  20. package/dist/client/lib/use-global-kill-switches.js +58 -0
  21. package/dist/client/translate-button.d.ts +2 -0
  22. package/dist/client/translate-button.js +228 -0
  23. package/dist/client/translate-modal.d.ts +16 -0
  24. package/dist/client/translate-modal.js +549 -0
  25. package/dist/client/translation-progress.d.ts +10 -0
  26. package/dist/client/translation-progress.js +297 -0
  27. package/dist/components/TranslationNavGroup.d.ts +45 -0
  28. package/dist/components/TranslationNavGroup.js +104 -0
  29. package/dist/defaults.d.ts +11 -0
  30. package/dist/defaults.js +16 -0
  31. package/dist/endpoints/client-config.d.ts +44 -0
  32. package/dist/endpoints/client-config.js +145 -0
  33. package/dist/endpoints/estimate.d.ts +5 -0
  34. package/dist/endpoints/estimate.js +237 -0
  35. package/dist/endpoints/progress.d.ts +2 -0
  36. package/dist/endpoints/progress.js +314 -0
  37. package/dist/endpoints/translate.d.ts +11 -0
  38. package/dist/endpoints/translate.js +376 -0
  39. package/dist/endpoints/translation-hub/_helpers.d.ts +140 -0
  40. package/dist/endpoints/translation-hub/_helpers.js +297 -0
  41. package/dist/endpoints/translation-hub/active.d.ts +21 -0
  42. package/dist/endpoints/translation-hub/active.js +220 -0
  43. package/dist/endpoints/translation-hub/cancel.d.ts +22 -0
  44. package/dist/endpoints/translation-hub/cancel.js +233 -0
  45. package/dist/endpoints/translation-hub/enqueue.d.ts +70 -0
  46. package/dist/endpoints/translation-hub/enqueue.js +529 -0
  47. package/dist/endpoints/translation-hub/failures.d.ts +12 -0
  48. package/dist/endpoints/translation-hub/failures.js +67 -0
  49. package/dist/endpoints/translation-hub/force-reset.d.ts +20 -0
  50. package/dist/endpoints/translation-hub/force-reset.js +144 -0
  51. package/dist/endpoints/translation-hub/index.d.ts +21 -0
  52. package/dist/endpoints/translation-hub/index.js +20 -0
  53. package/dist/endpoints/translation-hub/list.d.ts +40 -0
  54. package/dist/endpoints/translation-hub/list.js +182 -0
  55. package/dist/endpoints/translation-hub/preflight.d.ts +19 -0
  56. package/dist/endpoints/translation-hub/preflight.js +141 -0
  57. package/dist/endpoints/translation-hub/retry-failed.d.ts +38 -0
  58. package/dist/endpoints/translation-hub/retry-failed.js +235 -0
  59. package/dist/endpoints/translation-hub/revert.d.ts +88 -0
  60. package/dist/endpoints/translation-hub/revert.js +405 -0
  61. package/dist/endpoints/translation-hub/status.d.ts +45 -0
  62. package/dist/endpoints/translation-hub/status.js +391 -0
  63. package/dist/endpoints/translation-hub/usage-summary.d.ts +114 -0
  64. package/dist/endpoints/translation-hub/usage-summary.js +481 -0
  65. package/dist/exports/client.d.ts +6 -0
  66. package/dist/exports/client.js +6 -0
  67. package/dist/exports/components.d.ts +6 -0
  68. package/dist/exports/components.js +5 -0
  69. package/dist/exports/index.d.ts +8 -0
  70. package/dist/exports/index.js +7 -0
  71. package/dist/exports/providers.d.ts +9 -0
  72. package/dist/exports/providers.js +5 -0
  73. package/dist/exports/views-client.d.ts +23 -0
  74. package/dist/exports/views-client.js +22 -0
  75. package/dist/exports/views.d.ts +30 -0
  76. package/dist/exports/views.js +29 -0
  77. package/dist/hooks/after-change-global.d.ts +4 -0
  78. package/dist/hooks/after-change-global.js +109 -0
  79. package/dist/hooks/after-change.d.ts +16 -0
  80. package/dist/hooks/after-change.js +205 -0
  81. package/dist/hooks/after-delete.d.ts +30 -0
  82. package/dist/hooks/after-delete.js +95 -0
  83. package/dist/index.d.ts +5 -0
  84. package/dist/index.js +5 -0
  85. package/dist/jobs-collection.d.ts +17 -0
  86. package/dist/jobs-collection.js +139 -0
  87. package/dist/lexical/classifier.d.ts +3 -0
  88. package/dist/lexical/classifier.js +108 -0
  89. package/dist/lexical/deserializer.d.ts +4 -0
  90. package/dist/lexical/deserializer.js +263 -0
  91. package/dist/lexical/placeholder-integrity.d.ts +6 -0
  92. package/dist/lexical/placeholder-integrity.js +21 -0
  93. package/dist/lexical/placeholders.d.ts +21 -0
  94. package/dist/lexical/placeholders.js +117 -0
  95. package/dist/lexical/serializer.d.ts +21 -0
  96. package/dist/lexical/serializer.js +233 -0
  97. package/dist/lexical/types.d.ts +32 -0
  98. package/dist/lexical/types.js +1 -0
  99. package/dist/lib/auth-diagnostics.d.ts +14 -0
  100. package/dist/lib/auth-diagnostics.js +19 -0
  101. package/dist/lib/batch-counts.d.ts +58 -0
  102. package/dist/lib/batch-counts.js +105 -0
  103. package/dist/lib/bulk-translate-migrations.d.ts +92 -0
  104. package/dist/lib/bulk-translate-migrations.js +153 -0
  105. package/dist/lib/coalescing-queue.d.ts +38 -0
  106. package/dist/lib/coalescing-queue.js +69 -0
  107. package/dist/lib/content-extractor.d.ts +16 -0
  108. package/dist/lib/content-extractor.js +410 -0
  109. package/dist/lib/content-hash.d.ts +1 -0
  110. package/dist/lib/content-hash.js +19 -0
  111. package/dist/lib/content-patcher.d.ts +15 -0
  112. package/dist/lib/content-patcher.js +293 -0
  113. package/dist/lib/cost-guards.d.ts +2 -0
  114. package/dist/lib/cost-guards.js +18 -0
  115. package/dist/lib/daily-spend-cap.d.ts +58 -0
  116. package/dist/lib/daily-spend-cap.js +233 -0
  117. package/dist/lib/effective-locales.d.ts +181 -0
  118. package/dist/lib/effective-locales.js +302 -0
  119. package/dist/lib/error-messages.d.ts +245 -0
  120. package/dist/lib/error-messages.js +626 -0
  121. package/dist/lib/events.d.ts +39 -0
  122. package/dist/lib/events.js +146 -0
  123. package/dist/lib/exclude-fields.d.ts +3 -0
  124. package/dist/lib/exclude-fields.js +64 -0
  125. package/dist/lib/field-breadcrumb.d.ts +31 -0
  126. package/dist/lib/field-breadcrumb.js +227 -0
  127. package/dist/lib/field-diff.d.ts +1 -0
  128. package/dist/lib/field-diff.js +25 -0
  129. package/dist/lib/field-empty.d.ts +2 -0
  130. package/dist/lib/field-empty.js +68 -0
  131. package/dist/lib/field-resolver.d.ts +3 -0
  132. package/dist/lib/field-resolver.js +164 -0
  133. package/dist/lib/group-soft-skips.d.ts +39 -0
  134. package/dist/lib/group-soft-skips.js +45 -0
  135. package/dist/lib/locale-merge.d.ts +44 -0
  136. package/dist/lib/locale-merge.js +357 -0
  137. package/dist/lib/locale-row-check.d.ts +30 -0
  138. package/dist/lib/locale-row-check.js +64 -0
  139. package/dist/lib/logger.d.ts +74 -0
  140. package/dist/lib/logger.js +97 -0
  141. package/dist/lib/manual-edit-guard.d.ts +128 -0
  142. package/dist/lib/manual-edit-guard.js +393 -0
  143. package/dist/lib/output-validation.d.ts +48 -0
  144. package/dist/lib/output-validation.js +148 -0
  145. package/dist/lib/payload-read.d.ts +16 -0
  146. package/dist/lib/payload-read.js +51 -0
  147. package/dist/lib/per-doc-claim.d.ts +90 -0
  148. package/dist/lib/per-doc-claim.js +140 -0
  149. package/dist/lib/per-doc-lock.d.ts +94 -0
  150. package/dist/lib/per-doc-lock.js +119 -0
  151. package/dist/lib/persist-usage.d.ts +91 -0
  152. package/dist/lib/persist-usage.js +116 -0
  153. package/dist/lib/progress-store.d.ts +103 -0
  154. package/dist/lib/progress-store.js +314 -0
  155. package/dist/lib/rate-limiter.d.ts +3 -0
  156. package/dist/lib/rate-limiter.js +53 -0
  157. package/dist/lib/snapshot-select.d.ts +43 -0
  158. package/dist/lib/snapshot-select.js +108 -0
  159. package/dist/lib/translate-prompt.d.ts +31 -0
  160. package/dist/lib/translate-prompt.js +66 -0
  161. package/dist/lib/translation-token-bucket.d.ts +57 -0
  162. package/dist/lib/translation-token-bucket.js +365 -0
  163. package/dist/lib/truncate-source-value.d.ts +1 -0
  164. package/dist/lib/truncate-source-value.js +27 -0
  165. package/dist/manual-edit-collection.d.ts +22 -0
  166. package/dist/manual-edit-collection.js +124 -0
  167. package/dist/plugin.d.ts +3 -0
  168. package/dist/plugin.js +934 -0
  169. package/dist/providers/ai-sdk-adapter.d.ts +35 -0
  170. package/dist/providers/ai-sdk-adapter.js +100 -0
  171. package/dist/providers/anthropic.d.ts +31 -0
  172. package/dist/providers/anthropic.js +66 -0
  173. package/dist/providers/custom.d.ts +36 -0
  174. package/dist/providers/custom.js +24 -0
  175. package/dist/providers/gemini.d.ts +20 -0
  176. package/dist/providers/gemini.js +48 -0
  177. package/dist/providers/mock.d.ts +2 -0
  178. package/dist/providers/mock.js +29 -0
  179. package/dist/providers/openai.d.ts +28 -0
  180. package/dist/providers/openai.js +69 -0
  181. package/dist/settings-global.d.ts +74 -0
  182. package/dist/settings-global.js +216 -0
  183. package/dist/tasks/bulk-translate-coordinator.d.ts +115 -0
  184. package/dist/tasks/bulk-translate-coordinator.js +708 -0
  185. package/dist/tasks/bulk-translate-doc-task.d.ts +142 -0
  186. package/dist/tasks/bulk-translate-doc-task.js +1000 -0
  187. package/dist/tasks/bulk-translate-janitor.d.ts +87 -0
  188. package/dist/tasks/bulk-translate-janitor.js +311 -0
  189. package/dist/tasks/translate-job-task.d.ts +51 -0
  190. package/dist/tasks/translate-job-task.js +154 -0
  191. package/dist/translate.d.ts +113 -0
  192. package/dist/translate.js +911 -0
  193. package/dist/translation-daily-spend-collection.d.ts +24 -0
  194. package/dist/translation-daily-spend-collection.js +133 -0
  195. package/dist/translation-rate-limits-collection.d.ts +30 -0
  196. package/dist/translation-rate-limits-collection.js +144 -0
  197. package/dist/types.d.ts +672 -0
  198. package/dist/types.js +1 -0
  199. package/dist/usage-collection.d.ts +14 -0
  200. package/dist/usage-collection.js +377 -0
  201. package/dist/views/BulkRunsHub/BatchRow.d.ts +32 -0
  202. package/dist/views/BulkRunsHub/BatchRow.js +1222 -0
  203. package/dist/views/BulkRunsHub/BucketRow.d.ts +62 -0
  204. package/dist/views/BulkRunsHub/BucketRow.js +982 -0
  205. package/dist/views/BulkRunsHub/BulkRunsHub.client.d.ts +18 -0
  206. package/dist/views/BulkRunsHub/BulkRunsHub.client.js +331 -0
  207. package/dist/views/BulkRunsHub/EmptyState.d.ts +6 -0
  208. package/dist/views/BulkRunsHub/EmptyState.js +64 -0
  209. package/dist/views/BulkRunsHub/FilterBar.d.ts +16 -0
  210. package/dist/views/BulkRunsHub/FilterBar.js +284 -0
  211. package/dist/views/BulkRunsHub/InFlightBanner.d.ts +14 -0
  212. package/dist/views/BulkRunsHub/InFlightBanner.js +59 -0
  213. package/dist/views/BulkRunsHub/StatusBadge.d.ts +64 -0
  214. package/dist/views/BulkRunsHub/StatusBadge.js +248 -0
  215. package/dist/views/BulkRunsHub/SummaryStrip.d.ts +22 -0
  216. package/dist/views/BulkRunsHub/SummaryStrip.js +249 -0
  217. package/dist/views/BulkRunsHub/bucket-grouping.d.ts +200 -0
  218. package/dist/views/BulkRunsHub/bucket-grouping.js +344 -0
  219. package/dist/views/BulkRunsHub/bucketFailureSummary.d.ts +9 -0
  220. package/dist/views/BulkRunsHub/bucketFailureSummary.js +36 -0
  221. package/dist/views/BulkRunsHub/dedupedStatusFetch.d.ts +5 -0
  222. package/dist/views/BulkRunsHub/dedupedStatusFetch.js +45 -0
  223. package/dist/views/BulkRunsHub/index.d.ts +17 -0
  224. package/dist/views/BulkRunsHub/index.js +80 -0
  225. package/dist/views/BulkRunsHub/urlFilters.d.ts +14 -0
  226. package/dist/views/BulkRunsHub/urlFilters.js +50 -0
  227. package/dist/views/BulkRunsHub/useBulkRunsList.d.ts +26 -0
  228. package/dist/views/BulkRunsHub/useBulkRunsList.js +204 -0
  229. package/dist/views/BulkRunsHub/useUrlFilters.d.ts +10 -0
  230. package/dist/views/BulkRunsHub/useUrlFilters.js +88 -0
  231. package/dist/views/TranslationHub/ActiveJobs.d.ts +6 -0
  232. package/dist/views/TranslationHub/ActiveJobs.js +320 -0
  233. package/dist/views/TranslationHub/AdvancedPanel.d.ts +17 -0
  234. package/dist/views/TranslationHub/AdvancedPanel.js +996 -0
  235. package/dist/views/TranslationHub/AlertBanner.d.ts +6 -0
  236. package/dist/views/TranslationHub/AlertBanner.js +568 -0
  237. package/dist/views/TranslationHub/AuditPanel.d.ts +6 -0
  238. package/dist/views/TranslationHub/AuditPanel.helpers.d.ts +44 -0
  239. package/dist/views/TranslationHub/AuditPanel.helpers.js +71 -0
  240. package/dist/views/TranslationHub/AuditPanel.js +1367 -0
  241. package/dist/views/TranslationHub/BulkTranslate.types.d.ts +242 -0
  242. package/dist/views/TranslationHub/BulkTranslate.types.js +36 -0
  243. package/dist/views/TranslationHub/BulkTranslateFailureDrawer.d.ts +19 -0
  244. package/dist/views/TranslationHub/BulkTranslateFailureDrawer.js +332 -0
  245. package/dist/views/TranslationHub/BulkTranslateMonitor.d.ts +28 -0
  246. package/dist/views/TranslationHub/BulkTranslateMonitor.js +305 -0
  247. package/dist/views/TranslationHub/BulkTranslateNarrowViewportBanner.d.ts +3 -0
  248. package/dist/views/TranslationHub/BulkTranslateNarrowViewportBanner.js +42 -0
  249. package/dist/views/TranslationHub/BulkTranslatePostEnqueueTransition.d.ts +26 -0
  250. package/dist/views/TranslationHub/BulkTranslatePostEnqueueTransition.js +95 -0
  251. package/dist/views/TranslationHub/BulkTranslatePreflightModal.d.ts +22 -0
  252. package/dist/views/TranslationHub/BulkTranslatePreflightModal.js +879 -0
  253. package/dist/views/TranslationHub/BulkTranslateTerminalCard.d.ts +29 -0
  254. package/dist/views/TranslationHub/BulkTranslateTerminalCard.js +445 -0
  255. package/dist/views/TranslationHub/BulkTranslateTrigger.d.ts +66 -0
  256. package/dist/views/TranslationHub/BulkTranslateTrigger.js +161 -0
  257. package/dist/views/TranslationHub/EditorRecentRunsPanel.d.ts +33 -0
  258. package/dist/views/TranslationHub/EditorRecentRunsPanel.js +290 -0
  259. package/dist/views/TranslationHub/Hub.client.d.ts +74 -0
  260. package/dist/views/TranslationHub/Hub.client.js +357 -0
  261. package/dist/views/TranslationHub/ModelCombobox.d.ts +14 -0
  262. package/dist/views/TranslationHub/ModelCombobox.js +415 -0
  263. package/dist/views/TranslationHub/PerCollectionConfig.d.ts +10 -0
  264. package/dist/views/TranslationHub/PerCollectionConfig.helpers.d.ts +16 -0
  265. package/dist/views/TranslationHub/PerCollectionConfig.helpers.js +19 -0
  266. package/dist/views/TranslationHub/PerCollectionConfig.js +759 -0
  267. package/dist/views/TranslationHub/SettingsRail.d.ts +11 -0
  268. package/dist/views/TranslationHub/SettingsRail.js +382 -0
  269. package/dist/views/TranslationHub/StatusStrip.d.ts +6 -0
  270. package/dist/views/TranslationHub/StatusStrip.js +451 -0
  271. package/dist/views/TranslationHub/UsageTable.d.ts +6 -0
  272. package/dist/views/TranslationHub/UsageTable.helpers.d.ts +69 -0
  273. package/dist/views/TranslationHub/UsageTable.helpers.js +49 -0
  274. package/dist/views/TranslationHub/UsageTable.js +1240 -0
  275. package/dist/views/TranslationHub/alertGrouping.d.ts +70 -0
  276. package/dist/views/TranslationHub/alertGrouping.js +99 -0
  277. package/dist/views/TranslationHub/index.d.ts +20 -0
  278. package/dist/views/TranslationHub/index.js +109 -0
  279. package/dist/views/TranslationHub/tabNavigation.d.ts +53 -0
  280. package/dist/views/TranslationHub/tabNavigation.js +74 -0
  281. package/dist/views/TranslationHub/terminalBannerVisibility.d.ts +33 -0
  282. package/dist/views/TranslationHub/terminalBannerVisibility.js +124 -0
  283. package/dist/views/TranslationHub/useBulkTranslateActive.d.ts +49 -0
  284. package/dist/views/TranslationHub/useBulkTranslateActive.js +251 -0
  285. package/dist/views/TranslationHub/useFocusTrap.d.ts +6 -0
  286. package/dist/views/TranslationHub/useFocusTrap.js +81 -0
  287. package/dist/views/TranslationHub/useTranslationHubUsageSummary.d.ts +77 -0
  288. package/dist/views/TranslationHub/useTranslationHubUsageSummary.js +267 -0
  289. package/dist/views/shared/EditorError.d.ts +97 -0
  290. package/dist/views/shared/EditorError.js +205 -0
  291. package/dist/views/shared/ModelCell.d.ts +18 -0
  292. package/dist/views/shared/ModelCell.js +31 -0
  293. package/dist/views/shared/docHref.d.ts +16 -0
  294. package/dist/views/shared/docHref.js +26 -0
  295. package/dist/views/shared/fetch-error-body.d.ts +25 -0
  296. package/dist/views/shared/fetch-error-body.js +42 -0
  297. package/dist/views/shared/filterPillStyle.d.ts +35 -0
  298. package/dist/views/shared/filterPillStyle.js +40 -0
  299. package/dist/views/shared/format.d.ts +75 -0
  300. package/dist/views/shared/format.js +131 -0
  301. package/package.json +141 -0
@@ -0,0 +1,1222 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { categorizeFailure } from '../../lib/error-messages.js';
5
+ import { docHref } from '../shared/docHref.js';
6
+ import { EditorError } from '../shared/EditorError.js';
7
+ import { readResponseError } from '../shared/fetch-error-body.js';
8
+ import { filterPillColors } from '../shared/filterPillStyle.js';
9
+ import { formatCost, formatDuration } from '../shared/format.js';
10
+ import { isActiveStatus } from '../TranslationHub/BulkTranslate.types.js';
11
+ import { RevertConfirmModal } from '../TranslationHub/BulkTranslateTerminalCard.js';
12
+ import { BucketRow } from './BucketRow.js';
13
+ import { groupJobsIntoBuckets, shouldBucketBeExpandedByDefault, shouldBucketBeVisibleUnderFilter } from './bucket-grouping.js';
14
+ import { StatusBadge, UnitStatusBadge } from './StatusBadge.js';
15
+ // ---------------------------------------------------------------------------
16
+ // Constants
17
+ // ---------------------------------------------------------------------------
18
+ const PROGRESS_TRACK_STYLE = {
19
+ height: '3px',
20
+ width: '48px',
21
+ background: 'var(--theme-elevation-100)',
22
+ borderRadius: '2px',
23
+ overflow: 'hidden',
24
+ display: 'inline-block',
25
+ verticalAlign: 'middle'
26
+ };
27
+ const TD_STYLE = {
28
+ padding: '0.55rem 0.5rem',
29
+ borderTop: '1px solid var(--theme-elevation-100)',
30
+ whiteSpace: 'nowrap',
31
+ fontSize: '0.8125rem',
32
+ color: 'var(--theme-elevation-800)',
33
+ verticalAlign: 'middle'
34
+ };
35
+ const TH_STYLE = {
36
+ textAlign: 'left',
37
+ padding: '0.4rem 0.5rem',
38
+ fontSize: '0.7rem',
39
+ fontWeight: 600,
40
+ textTransform: 'uppercase',
41
+ letterSpacing: '0.05em',
42
+ color: 'var(--theme-elevation-500)',
43
+ borderBottom: '1px solid var(--theme-elevation-150)',
44
+ whiteSpace: 'nowrap'
45
+ };
46
+ // Unit-level filter chips. `'completed'` is the API's status param value
47
+ // for the success bucket — UI labels it "Succeeded" since "Completed"
48
+ // reads as ambiguous with "finished, regardless of outcome." See
49
+ // CHIP_LABELS below for the display mapping.
50
+ const UNIT_FILTER_CHIPS = [
51
+ 'all',
52
+ 'pending',
53
+ 'running',
54
+ 'completed',
55
+ 'failed',
56
+ 'skipped',
57
+ 'reverted'
58
+ ];
59
+ const CHIP_LABELS = {
60
+ all: 'All',
61
+ pending: 'Pending',
62
+ running: 'Running',
63
+ completed: 'Succeeded',
64
+ failed: 'Failed',
65
+ skipped: 'Skipped',
66
+ reverted: 'Reverted'
67
+ };
68
+ // ---------------------------------------------------------------------------
69
+ // Helpers
70
+ // ---------------------------------------------------------------------------
71
+ // ROUND3-6: per-batch `/status` fetch dedup lives in its own module so
72
+ // it can be unit-tested without dragging the React component in. See
73
+ // `dedupedStatusFetch.ts` for the rationale.
74
+ import { dedupedStatusFetch } from './dedupedStatusFetch.js';
75
+ function relTime(iso) {
76
+ const diff = Date.now() - new Date(iso).getTime();
77
+ const s = Math.floor(diff / 1000);
78
+ if (s < 86_400) {
79
+ if (s < 60) return `${s}s ago`;
80
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
81
+ return `${Math.floor(s / 3600)}h ago`;
82
+ }
83
+ return new Date(iso).toLocaleDateString(undefined, {
84
+ month: 'short',
85
+ day: 'numeric'
86
+ });
87
+ }
88
+ /**
89
+ * Renders a wallclock span between two ISO timestamps. Wrapper around
90
+ * `formatDuration(ms)` for sites that have start / end strings rather
91
+ * than a raw duration. Open-ended spans (no `endIso`) use `Date.now()`
92
+ * so a running batch's wallclock keeps ticking.
93
+ */ function fmtWallclock(startIso, endIso) {
94
+ if (!startIso) return '—';
95
+ const end = endIso ? new Date(endIso) : new Date();
96
+ const ms = end.getTime() - new Date(startIso).getTime();
97
+ return formatDuration(ms);
98
+ }
99
+ export function describeBatchProgress(completedUnits, totalUnits) {
100
+ if (totalUnits <= 0) return {
101
+ kind: 'empty'
102
+ };
103
+ const completed = Math.max(0, completedUnits);
104
+ const percent = Math.min(100, completed / totalUnits * 100);
105
+ return {
106
+ kind: 'fraction',
107
+ completed,
108
+ total: totalUnits,
109
+ percent
110
+ };
111
+ }
112
+ function truncateId(id, max = 12) {
113
+ if (id.length <= max) return id;
114
+ return `${id.slice(0, max)}…`;
115
+ }
116
+ /** Revert-window countdown string: "22h left", "45m left", or "expired". */ function revertCountdown(expiresAt) {
117
+ if (!expiresAt) return null;
118
+ const ms = new Date(expiresAt).getTime() - Date.now();
119
+ if (ms <= 0) return null;
120
+ const h = Math.floor(ms / 3_600_000);
121
+ const m = Math.floor(ms % 3_600_000 / 60_000);
122
+ if (h > 0) return `${h}h ${m}m left`;
123
+ return `${m}m left`;
124
+ }
125
+ /** Map public filter chip value to API status param. */ function chipToApiStatus(chip) {
126
+ if (chip === 'all') return undefined;
127
+ // 'completed' maps to the API's status param value
128
+ return chip;
129
+ }
130
+ const DrillDown = ({ basePath, batch, onAfterAction })=>{
131
+ const [unitFilter, setUnitFilter] = useState(batch.failedUnits > 0 ? 'failed' : 'all');
132
+ const [statusData, setStatusData] = useState(null);
133
+ const [statusError, setStatusError] = useState(null);
134
+ const [nextCursor, setNextCursor] = useState(null);
135
+ const [loadingMore, setLoadingMore] = useState(false);
136
+ const [expandedUnits, setExpandedUnits] = useState(new Set());
137
+ const [revertOpen, setRevertOpen] = useState(false);
138
+ const [cancelling, setCancelling] = useState(false);
139
+ const [cancelError, setCancelError] = useState(null);
140
+ const [retryingFailed, setRetryingFailed] = useState(false);
141
+ const [retryError, setRetryError] = useState(null);
142
+ const cancelledRef = useRef(false);
143
+ const timerRef = useRef(undefined);
144
+ // Track which bucket collections have been auto-loaded via
145
+ // onLoadBucketDocs. Declared at the top of the component so the
146
+ // polling effect can reset it when filter changes.
147
+ const loadedBucketsRef = useRef(new Set());
148
+ // Per-bucket pagination cursor for the "Load more in this bucket"
149
+ // button. Map value: opaque cursor string for the NEXT page, or
150
+ // `null` if this bucket has no more pages. Missing key = never
151
+ // fetched. Reset on filter change.
152
+ const bucketCursorsRef = useRef(new Map());
153
+ // De-dupe re-entrant calls to onLoadBucketDocs for the same
154
+ // collection while a request is in flight.
155
+ const loadingBucketRef = useRef(new Set());
156
+ // Bump on every bucket load to force a re-render so BucketRow
157
+ // can read fresh cursor + loading state via the helpers below.
158
+ const [bucketLoadingCount, setBucketLoadingCount] = useState(0);
159
+ void bucketLoadingCount; // referenced via helper closures
160
+ // v1.2.7: track which buckets are currently expanded so the filter-
161
+ // change effect can re-fetch them under the new filter. Without this,
162
+ // clicking "Failed" while `pages` is expanded leaves an empty expanded
163
+ // bucket (no rows match because the bucket was originally loaded with
164
+ // `?status=all`, and the polling page doesn't include the bucket's
165
+ // slice). Set bumps via `notifyBucketExpanded` from BucketRow.
166
+ const expandedBucketsRef = useRef(new Set());
167
+ // v1.2.7: per-bucket AbortController so a stale in-flight fetch from
168
+ // the previous filter can't bleed its rows into the new filter's
169
+ // view. Each call to `onLoadBucketDocs` registers a controller keyed
170
+ // by `${collection}:${unitFilter}`; the filter-change effect aborts
171
+ // every controller before resetting state.
172
+ const bucketAbortControllersRef = useRef(new Map());
173
+ // v1.2.7: filter epoch — bumped on every filter change. Late-arriving
174
+ // bucket fetch responses tagged with a stale epoch are dropped before
175
+ // they can populate the new filter's bucket state. Belt-and-braces
176
+ // alongside the AbortController above so a non-abortable fetch
177
+ // (mocked test, browser back-compat) can't race the new filter.
178
+ const filterEpochRef = useRef(0);
179
+ // Fetch / poll unit status when drill-down is open.
180
+ //
181
+ // v1.2.5 fix (the "expanding any bucket shows nothing" bug): the
182
+ // polling tick used to call `setStatusData(json.data)` — a full
183
+ // replacement. That wiped out the bucket-loaded jobs from
184
+ // `onLoadBucketDocs` (which appends collection-scoped pages to
185
+ // `statusData.jobs` outside the main pagination). Every 5 seconds
186
+ // the polling response wiped them away and the bucket went empty,
187
+ // while `loadedBucketsRef` still said "already loaded" so the
188
+ // auto-load couldn't re-fire. Stuck-empty until filter change.
189
+ //
190
+ // The fix splits "first fetch" from "subsequent polls":
191
+ // - First fetch (filter changed / drill-down opened) REPLACES —
192
+ // a clean reset is what the user expects.
193
+ // - Subsequent polls MERGE — new jobs override matching unitIds,
194
+ // prev jobs not in the new response survive. Bucket-loaded
195
+ // pages persist across polling ticks.
196
+ //
197
+ // We also reset `loadedBucketsRef` on filter change so each bucket
198
+ // can re-auto-load against the new filter scope.
199
+ useEffect(()=>{
200
+ cancelledRef.current = false;
201
+ loadedBucketsRef.current = new Set();
202
+ bucketCursorsRef.current = new Map();
203
+ // v1.2.7: abort any in-flight bucket fetches still riding the
204
+ // previous filter so they can't append stale rows to the new
205
+ // filter's bucket view. Then bump the epoch so any non-abortable
206
+ // late response is dropped server-side via the epoch check inside
207
+ // `onLoadBucketDocs`.
208
+ for (const ctrl of bucketAbortControllersRef.current.values()){
209
+ ctrl.abort();
210
+ }
211
+ bucketAbortControllersRef.current = new Map();
212
+ filterEpochRef.current += 1;
213
+ let isFirstFetch = true;
214
+ async function fetchStatus(cursor) {
215
+ const apiStatus = chipToApiStatus(unitFilter);
216
+ const params = new URLSearchParams({
217
+ limit: '20'
218
+ });
219
+ if (apiStatus) params.set('status', apiStatus);
220
+ if (cursor) params.set('cursor', cursor);
221
+ // ROUND2-1: Payload's collection-list router intercepts requests
222
+ // whose only query string is `?limit=N`, 308-redirecting them to a
223
+ // locale-prefixed canonical URL that 404s our custom endpoint.
224
+ // `useBulkRunsList.buildUrl` got the same fix for the runs list;
225
+ // the per-batch status fetcher also needs it for the "All" chip
226
+ // (no `status=…` filter, no `cursor=…`). `depth=0` is a no-op for
227
+ // our handler but makes the URL non-bare.
228
+ params.set('depth', '0');
229
+ try {
230
+ // ROUND3-6: route through the module-level dedup so StrictMode
231
+ // dev double-mount collapses to a single network call instead
232
+ // of the paired-burst pattern editors saw on every row-expand.
233
+ const res = await dedupedStatusFetch(`${basePath}/api/translation-hub/bulk-translate/${batch.id}/status?${params}`);
234
+ if (cancelledRef.current) return;
235
+ if (!res.ok) throw new Error(await readResponseError(res));
236
+ const json = await res.json();
237
+ if (cancelledRef.current) return;
238
+ if (isFirstFetch) {
239
+ setStatusData(json.data);
240
+ isFirstFetch = false;
241
+ // v1.2.7: re-fetch any buckets that were expanded under the
242
+ // previous filter so the expanded panel doesn't go empty
243
+ // when the filter changes. The first-page response only
244
+ // covers ONE bucket's slice (pagination starts at offset 0)
245
+ // — without an explicit re-fetch every other previously-
246
+ // expanded bucket would render with zero rows while the
247
+ // header still claimed N matching docs. Skip buckets that
248
+ // have zero docs under the new filter (they get hidden
249
+ // anyway via `shouldBucketBeVisibleUnderFilter`).
250
+ const statusKey = unitFilter === 'all' ? 'all' : unitFilter;
251
+ for (const collection of expandedBucketsRef.current){
252
+ const counts = json.data.countsByCollection?.[collection]?.docCountsByStatus;
253
+ const matching = counts ? counts[statusKey] ?? 0 : 0;
254
+ if (matching > 0) {
255
+ // Fire-and-forget — `onLoadBucketDocs` de-dupes via
256
+ // loadingBucketRef and guards against epoch drift.
257
+ void onLoadBucketDocs(collection);
258
+ }
259
+ }
260
+ } else {
261
+ // Merge: keep prev.jobs that aren't in the new response
262
+ // (these are bucket-loaded extras the polling page doesn't
263
+ // see). Server-side aggregates (countsByCollection,
264
+ // nextCursor, etc.) come from the fresh response.
265
+ setStatusData((prev)=>{
266
+ if (!prev) return json.data;
267
+ const newJobsById = new Map(json.data.jobs.map((j)=>[
268
+ j.unitId,
269
+ j
270
+ ]));
271
+ const preserved = prev.jobs.filter((j)=>!newJobsById.has(j.unitId));
272
+ return {
273
+ ...json.data,
274
+ jobs: [
275
+ ...json.data.jobs,
276
+ ...preserved
277
+ ]
278
+ };
279
+ });
280
+ }
281
+ setNextCursor(json.data.nextCursor);
282
+ setStatusError(null);
283
+ } catch (e) {
284
+ if (!cancelledRef.current) {
285
+ setStatusError(e instanceof Error ? e.message : String(e));
286
+ }
287
+ } finally{
288
+ if (!cancelledRef.current && isActiveStatus(batch.status)) {
289
+ if (document.visibilityState === 'visible') {
290
+ timerRef.current = setTimeout(()=>fetchStatus(), 5000);
291
+ }
292
+ }
293
+ }
294
+ }
295
+ function onVisibility() {
296
+ if (document.visibilityState === 'visible' && !cancelledRef.current) {
297
+ if (timerRef.current) clearTimeout(timerRef.current);
298
+ fetchStatus();
299
+ }
300
+ }
301
+ document.addEventListener('visibilitychange', onVisibility);
302
+ fetchStatus();
303
+ return ()=>{
304
+ cancelledRef.current = true;
305
+ if (timerRef.current) clearTimeout(timerRef.current);
306
+ document.removeEventListener('visibilitychange', onVisibility);
307
+ };
308
+ }, [
309
+ basePath,
310
+ batch.id,
311
+ batch.status,
312
+ unitFilter
313
+ ]);
314
+ async function onLoadMore() {
315
+ if (!nextCursor || loadingMore) return;
316
+ setLoadingMore(true);
317
+ const apiStatus = chipToApiStatus(unitFilter);
318
+ const params = new URLSearchParams({
319
+ limit: '20',
320
+ cursor: nextCursor
321
+ });
322
+ if (apiStatus) params.set('status', apiStatus);
323
+ // ROUND2-1: see fetchStatus above — `depth=0` defeats the Payload
324
+ // collection-list redirect heuristic on `?limit=N` URLs.
325
+ params.set('depth', '0');
326
+ try {
327
+ const res = await dedupedStatusFetch(`${basePath}/api/translation-hub/bulk-translate/${batch.id}/status?${params}`);
328
+ if (!res.ok) throw new Error(await readResponseError(res));
329
+ const json = await res.json();
330
+ setStatusData((prev)=>prev ? {
331
+ ...json.data,
332
+ jobs: [
333
+ ...prev.jobs,
334
+ ...json.data.jobs
335
+ ]
336
+ } : json.data);
337
+ setNextCursor(json.data.nextCursor);
338
+ } catch {
339
+ // Non-fatal.
340
+ } finally{
341
+ setLoadingMore(false);
342
+ }
343
+ }
344
+ // Bucket-on-expand + Load More within bucket. Tracks per-bucket
345
+ // pagination state via `bucketCursors` so callers can fetch
346
+ // additional pages of the same collection. The first call (no
347
+ // existing cursor) is the auto-load fired by BucketRow on expand;
348
+ // subsequent calls are the per-bucket "Load N more" button.
349
+ //
350
+ // De-duplicates against existing jobs by unitId so re-firing doesn't
351
+ // double up. Keeps the batch-level `nextCursor` untouched — this
352
+ // pagination is parallel to the batch-level main cursor.
353
+ async function onLoadBucketDocs(collection) {
354
+ if (loadingBucketRef.current.has(collection)) return;
355
+ loadingBucketRef.current.add(collection);
356
+ setBucketLoadingCount((n)=>n + 1);
357
+ const existingCursor = bucketCursorsRef.current.get(collection);
358
+ // If this bucket previously hit "no more pages" (cursor=null), the
359
+ // map still has the entry → skip. `has` distinguishes from
360
+ // `get === undefined` (never fetched).
361
+ if (bucketCursorsRef.current.has(collection) && existingCursor === null) {
362
+ loadingBucketRef.current.delete(collection);
363
+ setBucketLoadingCount((n)=>n - 1);
364
+ return;
365
+ }
366
+ const params = new URLSearchParams({
367
+ limit: '100',
368
+ collection
369
+ });
370
+ if (existingCursor) params.set('cursor', existingCursor);
371
+ const apiStatus = chipToApiStatus(unitFilter);
372
+ if (apiStatus) params.set('status', apiStatus);
373
+ // ROUND2-1: the `collection=` segment already breaks the bare-`limit`
374
+ // heuristic, but we set `depth=0` defensively to keep all status
375
+ // URLs in the same shape — and so any future refactor that drops
376
+ // `collection=` doesn't reintroduce the regression.
377
+ params.set('depth', '0');
378
+ // v1.2.7: register an AbortController + capture the current filter
379
+ // epoch so the filter-change effect can abort in-flight requests
380
+ // before they pollute the new filter's bucket state. The dedup
381
+ // module currently doesn't forward the signal; we abort the
382
+ // controller anyway, and the epoch guard inside the success path
383
+ // drops the response if the filter changed while the request was
384
+ // riding the wire.
385
+ const controller = new AbortController();
386
+ bucketAbortControllersRef.current.set(collection, controller);
387
+ const requestEpoch = filterEpochRef.current;
388
+ try {
389
+ const res = await dedupedStatusFetch(`${basePath}/api/translation-hub/bulk-translate/${batch.id}/status?${params}`);
390
+ // Filter changed while this request was in flight — drop the
391
+ // response so its rows don't bleed into the new filter view.
392
+ if (controller.signal.aborted || requestEpoch !== filterEpochRef.current) {
393
+ return;
394
+ }
395
+ if (!res.ok) throw new Error(await readResponseError(res));
396
+ const json = await res.json();
397
+ if (controller.signal.aborted || requestEpoch !== filterEpochRef.current) {
398
+ return;
399
+ }
400
+ bucketCursorsRef.current.set(collection, json.data.nextCursor);
401
+ loadedBucketsRef.current.add(collection);
402
+ setStatusData((prev)=>{
403
+ if (!prev) return json.data;
404
+ const seen = new Set(prev.jobs.map((j)=>j.unitId));
405
+ const newJobs = json.data.jobs.filter((j)=>!seen.has(j.unitId));
406
+ return {
407
+ ...prev,
408
+ jobs: [
409
+ ...prev.jobs,
410
+ ...newJobs
411
+ ]
412
+ };
413
+ });
414
+ } finally{
415
+ // Only clear the controller entry if it's still the latest one
416
+ // for this bucket — a later call may have overwritten it.
417
+ if (bucketAbortControllersRef.current.get(collection) === controller) {
418
+ bucketAbortControllersRef.current.delete(collection);
419
+ }
420
+ loadingBucketRef.current.delete(collection);
421
+ setBucketLoadingCount((n)=>n - 1);
422
+ }
423
+ }
424
+ /**
425
+ * Notification hook for BucketRow — tracks which buckets are expanded
426
+ * so the filter-change effect can re-fetch them under the new filter.
427
+ * Idempotent: collapsing a bucket then re-expanding refreshes the
428
+ * tracked state without piling duplicate fetches.
429
+ */ function notifyBucketExpanded(collection, expanded) {
430
+ if (expanded) {
431
+ expandedBucketsRef.current.add(collection);
432
+ } else {
433
+ expandedBucketsRef.current.delete(collection);
434
+ }
435
+ }
436
+ async function onCancel() {
437
+ setCancelling(true);
438
+ setCancelError(null);
439
+ try {
440
+ const res = await fetch(`${basePath}/api/translation-hub/bulk-translate/${batch.id}/cancel`, {
441
+ method: 'POST',
442
+ credentials: 'include'
443
+ });
444
+ if (!res.ok) throw new Error(await readResponseError(res));
445
+ onAfterAction();
446
+ } catch (e) {
447
+ setCancelError(e instanceof Error ? e.message : String(e));
448
+ } finally{
449
+ setCancelling(false);
450
+ }
451
+ }
452
+ async function onRetryFailed() {
453
+ setRetryingFailed(true);
454
+ setRetryError(null);
455
+ try {
456
+ const res = await fetch(`${basePath}/api/translation-hub/bulk-translate/${batch.id}/retry`, {
457
+ method: 'POST',
458
+ credentials: 'include',
459
+ headers: {
460
+ 'Content-Type': 'application/json'
461
+ },
462
+ body: JSON.stringify({
463
+ allFailed: true
464
+ })
465
+ });
466
+ if (!res.ok) throw new Error(await readResponseError(res));
467
+ onAfterAction();
468
+ } catch (e) {
469
+ setRetryError(e instanceof Error ? e.message : String(e));
470
+ } finally{
471
+ setRetryingFailed(false);
472
+ }
473
+ }
474
+ // ----- Per-bucket retry -----
475
+ // Filed by the BucketRow inside the expanded DrillDown. Calls the
476
+ // existing retry-failed endpoint with a `collection` body field so
477
+ // only that bucket's failed units get requeued. Tracks a separate
478
+ // "currently retrying" collection slug so the right bucket button
479
+ // is the only one disabled while the request is in flight.
480
+ const [retryingBucket, setRetryingBucket] = useState(null);
481
+ async function onRetryBucket(collection) {
482
+ setRetryingBucket(collection);
483
+ setRetryError(null);
484
+ try {
485
+ const res = await fetch(`${basePath}/api/translation-hub/bulk-translate/${batch.id}/retry`, {
486
+ method: 'POST',
487
+ credentials: 'include',
488
+ headers: {
489
+ 'Content-Type': 'application/json'
490
+ },
491
+ body: JSON.stringify({
492
+ allFailed: true,
493
+ collection
494
+ })
495
+ });
496
+ if (!res.ok) throw new Error(await readResponseError(res));
497
+ onAfterAction();
498
+ } catch (e) {
499
+ setRetryError(e instanceof Error ? e.message : String(e));
500
+ } finally{
501
+ setRetryingBucket(null);
502
+ }
503
+ }
504
+ // ----- Per-doc retry -----
505
+ // Requested by user after the UX-designer-recommended omit was tried
506
+ // and felt insufficient. Retries every failed locale on a specific
507
+ // (collection, documentId). Tracks the in-flight doc as
508
+ // `${collection}/${documentId}` so the row's button is the only one
509
+ // disabled during the round-trip.
510
+ const [retryingDocKey, setRetryingDocKey] = useState(null);
511
+ async function onRetryDoc(collection, documentId) {
512
+ setRetryingDocKey(`${collection}/${documentId}`);
513
+ setRetryError(null);
514
+ try {
515
+ const res = await fetch(`${basePath}/api/translation-hub/bulk-translate/${batch.id}/retry`, {
516
+ method: 'POST',
517
+ credentials: 'include',
518
+ headers: {
519
+ 'Content-Type': 'application/json'
520
+ },
521
+ body: JSON.stringify({
522
+ allFailed: true,
523
+ collection,
524
+ documentId
525
+ })
526
+ });
527
+ if (!res.ok) throw new Error(await readResponseError(res));
528
+ onAfterAction();
529
+ } catch (e) {
530
+ setRetryError(e instanceof Error ? e.message : String(e));
531
+ } finally{
532
+ setRetryingDocKey(null);
533
+ }
534
+ }
535
+ function toggleUnit(unitId) {
536
+ setExpandedUnits((prev)=>{
537
+ const next = new Set(prev);
538
+ if (next.has(unitId)) {
539
+ next.delete(unitId);
540
+ } else {
541
+ next.add(unitId);
542
+ }
543
+ return next;
544
+ });
545
+ }
546
+ const counts = statusData?.counts ?? {
547
+ total: batch.totalUnits,
548
+ pending: 0,
549
+ running: 0,
550
+ completed: batch.completedUnits,
551
+ failed: batch.failedUnits,
552
+ skipped: 0,
553
+ reverted: 0
554
+ };
555
+ // Revert affordance stripped from the UI per editor feedback ("I asked
556
+ // for retry IF something fails. as well as retry/doc"). The backend
557
+ // endpoint stays in case engineering ever needs it via the API.
558
+ // `canRevert`, `revertCountdown`, `RevertConfirmModal` are still imported
559
+ // and used by `BulkTranslateTerminalCard` in the older Translation Hub —
560
+ // not removed here to avoid breaking that surface in the same change.
561
+ return /*#__PURE__*/ _jsxs("div", {
562
+ style: {
563
+ padding: '1rem 1.25rem',
564
+ background: 'var(--theme-elevation-50)',
565
+ borderTop: '1px solid var(--theme-elevation-100)'
566
+ },
567
+ children: [
568
+ /*#__PURE__*/ _jsxs("div", {
569
+ style: {
570
+ display: 'flex',
571
+ alignItems: 'flex-start',
572
+ justifyContent: 'space-between',
573
+ gap: '1rem',
574
+ marginBottom: '0.75rem',
575
+ flexWrap: 'wrap'
576
+ },
577
+ children: [
578
+ /*#__PURE__*/ _jsx("div", {
579
+ children: /*#__PURE__*/ _jsxs("p", {
580
+ style: {
581
+ margin: 0,
582
+ fontSize: '0.8125rem',
583
+ color: 'var(--theme-elevation-700)'
584
+ },
585
+ children: [
586
+ counts.completed,
587
+ " succeeded · ",
588
+ counts.failed,
589
+ " failed ·",
590
+ ' ',
591
+ counts.pending + counts.running,
592
+ " pending/running · ",
593
+ counts.skipped,
594
+ " skipped"
595
+ ]
596
+ })
597
+ }),
598
+ isActiveStatus(batch.status) && /*#__PURE__*/ _jsxs("div", {
599
+ style: {
600
+ display: 'flex',
601
+ flexDirection: 'column',
602
+ alignItems: 'flex-end',
603
+ gap: '0.25rem'
604
+ },
605
+ children: [
606
+ /*#__PURE__*/ _jsx("button", {
607
+ type: "button",
608
+ disabled: cancelling || batch.status === 'cancelling',
609
+ onClick: onCancel,
610
+ style: {
611
+ padding: '0.3rem 0.75rem',
612
+ background: 'transparent',
613
+ border: '1px solid var(--theme-elevation-300)',
614
+ borderRadius: '4px',
615
+ color: 'var(--theme-elevation-700)',
616
+ fontSize: '0.8125rem',
617
+ cursor: cancelling || batch.status === 'cancelling' ? 'not-allowed' : 'pointer'
618
+ },
619
+ children: batch.status === 'cancelling' ? 'Cancelling…' : 'Cancel run'
620
+ }),
621
+ cancelError && /*#__PURE__*/ _jsx("span", {
622
+ style: {
623
+ fontSize: '0.75rem',
624
+ color: 'var(--theme-error-500, #b91c1c)'
625
+ },
626
+ children: cancelError
627
+ })
628
+ ]
629
+ })
630
+ ]
631
+ }),
632
+ /*#__PURE__*/ _jsxs("div", {
633
+ role: "group",
634
+ "aria-label": "Filter units inside this batch by status",
635
+ style: {
636
+ display: 'flex',
637
+ alignItems: 'center',
638
+ gap: '0.5rem',
639
+ flexWrap: 'wrap',
640
+ marginBottom: '0.75rem'
641
+ },
642
+ children: [
643
+ /*#__PURE__*/ _jsx("span", {
644
+ style: {
645
+ fontSize: '0.6875rem',
646
+ fontWeight: 600,
647
+ textTransform: 'uppercase',
648
+ letterSpacing: '0.05em',
649
+ color: 'var(--theme-elevation-500)',
650
+ marginRight: '0.25rem'
651
+ },
652
+ children: "Units in this batch"
653
+ }),
654
+ UNIT_FILTER_CHIPS.map((chip)=>{
655
+ const isActive = unitFilter === chip;
656
+ const colors = filterPillColors(isActive);
657
+ const count = chip === 'all' ? counts.total : chip === 'completed' ? counts.completed : counts[chip];
658
+ return /*#__PURE__*/ _jsxs("button", {
659
+ "aria-pressed": isActive,
660
+ type: "button",
661
+ onClick: ()=>setUnitFilter(chip),
662
+ style: {
663
+ display: 'inline-flex',
664
+ alignItems: 'center',
665
+ gap: '0.3rem',
666
+ padding: '0.2rem 0.55rem',
667
+ fontSize: '0.75rem',
668
+ borderRadius: '4px',
669
+ border: colors.border,
670
+ background: colors.background,
671
+ color: colors.color,
672
+ cursor: 'pointer',
673
+ fontWeight: isActive ? 600 : 400
674
+ },
675
+ children: [
676
+ CHIP_LABELS[chip],
677
+ count !== undefined && count >= 0 && /*#__PURE__*/ _jsx("span", {
678
+ style: {
679
+ padding: '0 0.3rem',
680
+ // Inner count badge: contrast with the pill body, not
681
+ // the page. Inverted on active pills (light badge on
682
+ // dark pill) so the count stays legible against the
683
+ // near-black background.
684
+ background: isActive ? 'var(--theme-elevation-800)' : 'var(--theme-elevation-150)',
685
+ color: isActive ? 'var(--theme-elevation-50)' : 'var(--theme-elevation-800)',
686
+ borderRadius: '10px',
687
+ fontSize: '0.65rem',
688
+ fontWeight: 600
689
+ },
690
+ children: count
691
+ })
692
+ ]
693
+ }, chip);
694
+ })
695
+ ]
696
+ }),
697
+ statusError && /*#__PURE__*/ _jsxs("p", {
698
+ style: {
699
+ margin: '0 0 0.5rem',
700
+ fontSize: '0.8125rem',
701
+ color: 'var(--theme-error-500, #b91c1c)'
702
+ },
703
+ children: [
704
+ "Failed to load unit detail: ",
705
+ statusError
706
+ ]
707
+ }),
708
+ statusData && /*#__PURE__*/ _jsxs("div", {
709
+ style: {
710
+ overflowX: 'auto'
711
+ },
712
+ children: [
713
+ /*#__PURE__*/ _jsx("table", {
714
+ style: {
715
+ width: '100%',
716
+ borderCollapse: 'collapse',
717
+ fontSize: '0.8125rem',
718
+ color: 'var(--theme-elevation-800)'
719
+ },
720
+ children: /*#__PURE__*/ _jsx("tbody", {
721
+ children: (()=>{
722
+ // v1.2.7: filter-aware bucket list. Build the full
723
+ // bucket set, then hide buckets that have zero docs
724
+ // under the active filter so the bucket header doesn't
725
+ // contradict the doc rows. When the filter is `all`,
726
+ // every bucket renders.
727
+ const allBuckets = groupJobsIntoBuckets(statusData.jobs, statusData.countsByCollection ?? {});
728
+ const visibleBuckets = allBuckets.filter((b)=>shouldBucketBeVisibleUnderFilter(b, unitFilter));
729
+ return visibleBuckets.map((bucket)=>{
730
+ // Per-bucket pagination state. `bucketCursor`:
731
+ // undefined → never fetched (auto-load fires on
732
+ // first expand)
733
+ // string → more pages available (Load More)
734
+ // null → no more pages (Load More hidden)
735
+ const bucketCursor = bucketCursorsRef.current.get(bucket.collection);
736
+ const hasMoreInBucket = bucket.loadedDocs < bucket.totalDocs && (bucketCursor === undefined || bucketCursor !== null);
737
+ const isLoadingMoreInBucket = loadingBucketRef.current.has(bucket.collection);
738
+ return /*#__PURE__*/ _jsx(BucketRow, {
739
+ activeStatusFilter: unitFilter,
740
+ basePath: basePath,
741
+ batchId: String(batch.id),
742
+ batchStatus: batch.status,
743
+ bucket: bucket,
744
+ initialExpanded: shouldBucketBeExpandedByDefault(bucket),
745
+ isRetrying: retryingBucket === bucket.collection,
746
+ onRetryBucket: onRetryBucket,
747
+ retryingDocKey: retryingDocKey,
748
+ onRetryDoc: onRetryDoc,
749
+ onLoadBucketDocs: onLoadBucketDocs,
750
+ onExpandedChange: notifyBucketExpanded,
751
+ hasMoreInBucket: hasMoreInBucket,
752
+ isLoadingMoreInBucket: isLoadingMoreInBucket
753
+ }, bucket.collection);
754
+ });
755
+ })()
756
+ })
757
+ }),
758
+ (()=>{
759
+ // v1.2.7: the global "No units match" message ONLY renders
760
+ // when EVERY bucket is hidden under the active filter.
761
+ // Previously it rendered any time the top-level
762
+ // `jobs.length === 0` — which fired even when the bucket
763
+ // headers above were still showing pre-filter unit counts
764
+ // (the contradiction Bug 1 / Bug 3). Now the empty state
765
+ // is the sole signal when the filter excludes everything.
766
+ const allBuckets = groupJobsIntoBuckets(statusData.jobs, statusData.countsByCollection ?? {});
767
+ const anyVisible = allBuckets.some((b)=>shouldBucketBeVisibleUnderFilter(b, unitFilter));
768
+ if (anyVisible) return null;
769
+ const label = unitFilter === 'all' ? '' : CHIP_LABELS[unitFilter];
770
+ return /*#__PURE__*/ _jsx("p", {
771
+ style: {
772
+ padding: '0.75rem 0',
773
+ margin: 0,
774
+ fontSize: '0.8125rem',
775
+ color: 'var(--theme-elevation-500)'
776
+ },
777
+ children: unitFilter === 'all' ? 'No units in this batch.' : `No ${label.toLowerCase()} units in this batch.`
778
+ });
779
+ })()
780
+ ]
781
+ }),
782
+ batch.failedUnits > 0 && !isActiveStatus(batch.status) && /*#__PURE__*/ _jsxs("div", {
783
+ style: {
784
+ marginTop: '0.75rem',
785
+ display: 'flex',
786
+ alignItems: 'center',
787
+ gap: '0.75rem'
788
+ },
789
+ children: [
790
+ /*#__PURE__*/ _jsx("button", {
791
+ type: "button",
792
+ disabled: retryingFailed,
793
+ onClick: onRetryFailed,
794
+ style: {
795
+ padding: '0.35rem 0.75rem',
796
+ background: 'transparent',
797
+ border: '1px solid var(--theme-elevation-300)',
798
+ borderRadius: '4px',
799
+ color: 'var(--theme-elevation-800)',
800
+ fontSize: '0.8125rem',
801
+ cursor: retryingFailed ? 'not-allowed' : 'pointer'
802
+ },
803
+ children: retryingFailed ? 'Retrying…' : `Retry ${batch.failedUnits} failed unit${batch.failedUnits === 1 ? '' : 's'}`
804
+ }),
805
+ retryError && /*#__PURE__*/ _jsx("span", {
806
+ style: {
807
+ fontSize: '0.75rem',
808
+ color: 'var(--theme-error-500, #b91c1c)'
809
+ },
810
+ children: retryError
811
+ })
812
+ ]
813
+ }),
814
+ revertOpen && /*#__PURE__*/ _jsx(RevertConfirmModal, {
815
+ basePath: basePath,
816
+ batchId: batch.id,
817
+ onClose: ()=>setRevertOpen(false),
818
+ onSuccess: ()=>{
819
+ setRevertOpen(false);
820
+ onAfterAction();
821
+ }
822
+ })
823
+ ]
824
+ });
825
+ };
826
+ const UnitRow = ({ basePath, job, isExpanded, onToggle })=>{
827
+ const hasFailed = job.status === 'failed';
828
+ const unitDocHref = job.collection && job.documentId ? docHref(basePath, job.collection, job.documentId, job.locale) : null;
829
+ // Editor-facing duration is the provider's actual LLM call time, not
830
+ // the wallclock delta — that would include throttle wait. Falls back
831
+ // to wallclock for legacy rows that lack the column.
832
+ const duration = job.processingDurationMs != null ? formatDuration(job.processingDurationMs) : fmtWallclock(job.startedAt, job.completedAt);
833
+ const durationTooltip = job.processingDurationMs != null ? 'AI call time. Excludes throttle wait — the batch took longer overall because of rate-limit queuing.' : 'Legacy row: showing wallclock between worker pickup and completion. Includes throttle wait.';
834
+ return /*#__PURE__*/ _jsxs(_Fragment, {
835
+ children: [
836
+ /*#__PURE__*/ _jsxs("tr", {
837
+ onClick: hasFailed ? onToggle : undefined,
838
+ style: {
839
+ cursor: hasFailed ? 'pointer' : undefined,
840
+ background: isExpanded ? 'var(--theme-elevation-100)' : undefined
841
+ },
842
+ children: [
843
+ /*#__PURE__*/ _jsx("td", {
844
+ style: TD_STYLE,
845
+ children: /*#__PURE__*/ _jsx("code", {
846
+ style: {
847
+ fontSize: '0.75rem'
848
+ },
849
+ children: job.collection || '—'
850
+ })
851
+ }),
852
+ /*#__PURE__*/ _jsxs("td", {
853
+ style: TD_STYLE,
854
+ children: [
855
+ hasFailed && /*#__PURE__*/ _jsx("span", {
856
+ "aria-hidden": "true",
857
+ style: {
858
+ display: 'inline-block',
859
+ width: '1rem',
860
+ color: 'var(--theme-elevation-500)',
861
+ fontSize: '0.6rem',
862
+ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
863
+ transition: 'transform 80ms ease',
864
+ marginRight: '0.25rem'
865
+ },
866
+ children: "▶"
867
+ }),
868
+ unitDocHref ? /*#__PURE__*/ _jsx("a", {
869
+ href: unitDocHref,
870
+ onClick: (e)=>e.stopPropagation(),
871
+ rel: "noopener noreferrer",
872
+ style: {
873
+ fontFamily: 'monospace',
874
+ fontSize: '0.75rem',
875
+ color: 'var(--theme-success-500, #16a34a)',
876
+ textDecoration: 'none'
877
+ },
878
+ target: "_blank",
879
+ title: `Open ${job.collection} #${job.documentId} in ${job.locale}`,
880
+ children: truncateId(job.documentId)
881
+ }) : /*#__PURE__*/ _jsx("span", {
882
+ title: job.documentId,
883
+ style: {
884
+ fontFamily: 'monospace',
885
+ fontSize: '0.75rem'
886
+ },
887
+ children: truncateId(job.documentId)
888
+ })
889
+ ]
890
+ }),
891
+ /*#__PURE__*/ _jsx("td", {
892
+ style: TD_STYLE,
893
+ children: /*#__PURE__*/ _jsx("span", {
894
+ style: {
895
+ display: 'inline-block',
896
+ padding: '0.1rem 0.35rem',
897
+ background: 'var(--theme-elevation-100)',
898
+ borderRadius: '4px',
899
+ fontSize: '0.7rem',
900
+ fontFamily: 'monospace',
901
+ color: 'var(--theme-elevation-800)'
902
+ },
903
+ children: job.locale
904
+ })
905
+ }),
906
+ /*#__PURE__*/ _jsx("td", {
907
+ style: TD_STYLE,
908
+ children: /*#__PURE__*/ _jsx(UnitStatusBadge, {
909
+ status: job.status,
910
+ small: true
911
+ })
912
+ }),
913
+ /*#__PURE__*/ _jsx("td", {
914
+ style: {
915
+ ...TD_STYLE,
916
+ textAlign: 'right',
917
+ color: 'var(--theme-elevation-600)'
918
+ },
919
+ children: job.attempts
920
+ }),
921
+ /*#__PURE__*/ _jsx("td", {
922
+ style: {
923
+ ...TD_STYLE,
924
+ textAlign: 'right',
925
+ fontFamily: 'monospace',
926
+ fontSize: '0.8125rem'
927
+ },
928
+ children: formatCost(job.costUsd)
929
+ }),
930
+ /*#__PURE__*/ _jsx("td", {
931
+ style: {
932
+ ...TD_STYLE,
933
+ textAlign: 'right',
934
+ color: 'var(--theme-elevation-600)'
935
+ },
936
+ title: durationTooltip,
937
+ children: duration
938
+ })
939
+ ]
940
+ }),
941
+ isExpanded && hasFailed && /*#__PURE__*/ _jsx("tr", {
942
+ children: /*#__PURE__*/ _jsxs("td", {
943
+ colSpan: 7,
944
+ style: {
945
+ ...TD_STYLE,
946
+ background: 'var(--theme-elevation-50)',
947
+ whiteSpace: 'normal',
948
+ padding: '0.75rem 1.25rem',
949
+ borderTop: 'none'
950
+ },
951
+ children: [
952
+ (()=>{
953
+ // NEW-12 (v1.2.6): route through `categorizeFailure` so
954
+ // cancel-mid-flight, mis-classified config-as-provider
955
+ // (COST_UNDEFINED), and bare "Not Found" rows all surface
956
+ // the right copy instead of the catch-all "Translation
957
+ // system is not set up" message. UnitRow doesn't currently
958
+ // get the parent batch status — pass `undefined` so the
959
+ // categorizer only flips to `cancelled-mid-flight` when
960
+ // we wire batchStatus through (call sites must opt in).
961
+ if (!job.failureCode && !job.failureMessage) return null;
962
+ const categorized = categorizeFailure({
963
+ failureCode: job.failureCode,
964
+ failureMessage: job.failureMessage,
965
+ attemptCount: job.attempts
966
+ });
967
+ return /*#__PURE__*/ _jsx("div", {
968
+ style: {
969
+ margin: '0 0 0.5rem'
970
+ },
971
+ children: /*#__PURE__*/ _jsx(EditorError, {
972
+ code: categorized.editorCode,
973
+ compact: true,
974
+ details: categorized.details
975
+ })
976
+ });
977
+ })(),
978
+ /*#__PURE__*/ _jsxs("p", {
979
+ style: {
980
+ margin: '0 0 0.4rem',
981
+ fontSize: '0.75rem',
982
+ color: 'var(--theme-elevation-600)'
983
+ },
984
+ children: [
985
+ "Attempts: ",
986
+ job.attempts
987
+ ]
988
+ }),
989
+ unitDocHref && /*#__PURE__*/ _jsx("a", {
990
+ href: unitDocHref,
991
+ rel: "noopener noreferrer",
992
+ style: {
993
+ fontSize: '0.75rem',
994
+ color: 'var(--theme-success-500, #16a34a)',
995
+ textDecoration: 'none'
996
+ },
997
+ target: "_blank",
998
+ children: "Open in Payload →"
999
+ })
1000
+ ]
1001
+ })
1002
+ })
1003
+ ]
1004
+ });
1005
+ };
1006
+ export const BatchRow = ({ basePath, batch, isExpanded, onToggle, onAfterAction, colCount })=>{
1007
+ const isActive = isActiveStatus(batch.status);
1008
+ const progressDescriptor = describeBatchProgress(batch.completedUnits, batch.totalUnits);
1009
+ const countdown = revertCountdown(batch.revertExpiresAt);
1010
+ const showRowRevertNote = countdown !== null && batch.status !== 'reverted' && batch.status !== 'cancelled';
1011
+ // Minute ticker for revert-countdown string — updates every 60s.
1012
+ const [, setTick] = useState(0);
1013
+ useEffect(()=>{
1014
+ if (!showRowRevertNote) return;
1015
+ const id = setInterval(()=>setTick((n)=>n + 1), 60_000);
1016
+ return ()=>clearInterval(id);
1017
+ }, [
1018
+ showRowRevertNote
1019
+ ]);
1020
+ // Click on the row (outside interactive elements) toggles drill-down.
1021
+ function handleRowClick(e) {
1022
+ const target = e.target;
1023
+ if (target.closest('a, button, input, select, textarea')) return;
1024
+ onToggle();
1025
+ }
1026
+ const rowStyle = {
1027
+ cursor: 'pointer',
1028
+ background: isActive ? 'var(--theme-elevation-50)' : isExpanded ? 'var(--theme-elevation-100)' : undefined,
1029
+ transition: 'background 100ms ease'
1030
+ };
1031
+ return /*#__PURE__*/ _jsxs(_Fragment, {
1032
+ children: [
1033
+ /*#__PURE__*/ _jsxs("tr", {
1034
+ onClick: handleRowClick,
1035
+ style: rowStyle,
1036
+ children: [
1037
+ /*#__PURE__*/ _jsx("td", {
1038
+ style: {
1039
+ ...TD_STYLE,
1040
+ width: '140px',
1041
+ paddingLeft: isActive ? '0' : TD_STYLE.padding,
1042
+ borderLeft: isActive ? '3px solid var(--theme-success-500, #16a34a)' : undefined
1043
+ },
1044
+ children: /*#__PURE__*/ _jsx("div", {
1045
+ style: {
1046
+ paddingLeft: isActive ? '0.35rem' : undefined
1047
+ },
1048
+ children: /*#__PURE__*/ _jsx(StatusBadge, {
1049
+ status: batch.status
1050
+ })
1051
+ })
1052
+ }),
1053
+ /*#__PURE__*/ _jsx("td", {
1054
+ style: {
1055
+ ...TD_STYLE,
1056
+ minWidth: '160px'
1057
+ },
1058
+ children: /*#__PURE__*/ _jsxs("div", {
1059
+ style: {
1060
+ display: 'flex',
1061
+ alignItems: 'center',
1062
+ gap: '0.35rem'
1063
+ },
1064
+ children: [
1065
+ /*#__PURE__*/ _jsx("span", {
1066
+ "aria-hidden": "true",
1067
+ style: {
1068
+ fontSize: '0.6rem',
1069
+ color: 'var(--theme-elevation-400)',
1070
+ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
1071
+ display: 'inline-block',
1072
+ transition: 'transform 80ms ease'
1073
+ },
1074
+ children: "▶"
1075
+ }),
1076
+ /*#__PURE__*/ _jsxs("div", {
1077
+ children: [
1078
+ /*#__PURE__*/ _jsx("span", {
1079
+ style: {
1080
+ display: 'block',
1081
+ fontFamily: 'monospace',
1082
+ fontSize: '0.75rem',
1083
+ color: 'var(--theme-elevation-900)'
1084
+ },
1085
+ title: batch.id,
1086
+ children: truncateId(batch.id, 14)
1087
+ }),
1088
+ batch.triggeredByEmail && /*#__PURE__*/ _jsx("span", {
1089
+ style: {
1090
+ display: 'block',
1091
+ fontSize: '0.7rem',
1092
+ color: 'var(--theme-elevation-500)',
1093
+ marginTop: '0.1rem'
1094
+ },
1095
+ children: batch.triggeredByEmail
1096
+ })
1097
+ ]
1098
+ })
1099
+ ]
1100
+ })
1101
+ }),
1102
+ /*#__PURE__*/ _jsx("td", {
1103
+ style: {
1104
+ ...TD_STYLE,
1105
+ width: '120px',
1106
+ color: 'var(--theme-elevation-600)',
1107
+ fontSize: '0.75rem'
1108
+ },
1109
+ children: /*#__PURE__*/ _jsx("code", {
1110
+ children: batch.mode
1111
+ })
1112
+ }),
1113
+ /*#__PURE__*/ _jsx("td", {
1114
+ style: {
1115
+ ...TD_STYLE,
1116
+ width: '160px'
1117
+ },
1118
+ children: progressDescriptor.kind === 'empty' ? /*#__PURE__*/ _jsx("span", {
1119
+ style: {
1120
+ fontSize: '0.8rem',
1121
+ color: 'var(--theme-elevation-600)',
1122
+ fontStyle: 'italic'
1123
+ },
1124
+ title: "This batch finished without enqueueing any units — typically a cancelled-before-start run or an empty-scope selection.",
1125
+ children: "No units enqueued"
1126
+ }) : /*#__PURE__*/ _jsxs("div", {
1127
+ style: {
1128
+ display: 'flex',
1129
+ alignItems: 'center',
1130
+ gap: '0.4rem'
1131
+ },
1132
+ children: [
1133
+ /*#__PURE__*/ _jsxs("span", {
1134
+ style: {
1135
+ fontSize: '0.8rem',
1136
+ color: 'var(--theme-elevation-800)'
1137
+ },
1138
+ children: [
1139
+ progressDescriptor.completed,
1140
+ " / ",
1141
+ progressDescriptor.total
1142
+ ]
1143
+ }),
1144
+ /*#__PURE__*/ _jsx("div", {
1145
+ role: "progressbar",
1146
+ "aria-valuemin": 0,
1147
+ "aria-valuemax": progressDescriptor.total,
1148
+ "aria-valuenow": progressDescriptor.completed,
1149
+ "aria-label": `${progressDescriptor.completed} of ${progressDescriptor.total} units`,
1150
+ style: PROGRESS_TRACK_STYLE,
1151
+ children: /*#__PURE__*/ _jsx("div", {
1152
+ style: {
1153
+ width: `${progressDescriptor.percent}%`,
1154
+ height: '100%',
1155
+ background: 'var(--theme-success-500, #16a34a)'
1156
+ }
1157
+ })
1158
+ })
1159
+ ]
1160
+ })
1161
+ }),
1162
+ /*#__PURE__*/ _jsx("td", {
1163
+ style: {
1164
+ ...TD_STYLE,
1165
+ width: '80px',
1166
+ textAlign: 'right',
1167
+ fontFamily: 'monospace',
1168
+ fontSize: '0.8125rem'
1169
+ },
1170
+ children: formatCost(batch.actualCostUsd ?? batch.estimatedCostUsd)
1171
+ }),
1172
+ /*#__PURE__*/ _jsxs("td", {
1173
+ style: {
1174
+ ...TD_STYLE,
1175
+ width: '70px',
1176
+ textAlign: 'right',
1177
+ color: 'var(--theme-elevation-600)'
1178
+ },
1179
+ title: "Wallclock from when the worker first picked up this batch to when it finished. Includes time spent queued waiting for rate-limit slots — expand the row for an AI-active vs queue-wait split per bucket.",
1180
+ children: [
1181
+ fmtWallclock(batch.startedAt ?? null, batch.completedAt ?? null),
1182
+ /*#__PURE__*/ _jsx("span", {
1183
+ "aria-hidden": "true",
1184
+ style: {
1185
+ marginLeft: '0.25rem',
1186
+ color: 'var(--theme-elevation-400)',
1187
+ fontSize: '0.65rem',
1188
+ cursor: 'help'
1189
+ },
1190
+ children: "ⓘ"
1191
+ })
1192
+ ]
1193
+ }),
1194
+ /*#__PURE__*/ _jsx("td", {
1195
+ style: {
1196
+ ...TD_STYLE,
1197
+ width: '90px',
1198
+ color: 'var(--theme-elevation-500)',
1199
+ fontSize: '0.75rem'
1200
+ },
1201
+ title: new Date(batch.createdAt).toISOString(),
1202
+ children: relTime(batch.createdAt)
1203
+ })
1204
+ ]
1205
+ }),
1206
+ isExpanded && /*#__PURE__*/ _jsx("tr", {
1207
+ children: /*#__PURE__*/ _jsx("td", {
1208
+ colSpan: colCount,
1209
+ style: {
1210
+ padding: 0,
1211
+ borderTop: 'none'
1212
+ },
1213
+ children: /*#__PURE__*/ _jsx(DrillDown, {
1214
+ basePath: basePath,
1215
+ batch: batch,
1216
+ onAfterAction: onAfterAction
1217
+ })
1218
+ })
1219
+ })
1220
+ ]
1221
+ });
1222
+ };