@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,251 @@
1
+ 'use client';
2
+ /**
3
+ * Shared polling hook for the bulk-translate "active batch" endpoint.
4
+ *
5
+ * Owns the 5s visibility-aware poll loop, the offline-banner threshold
6
+ * (F-UX-11: 3 consecutive failures → degraded), and the multi-admin
7
+ * synchronization expected by F-UX-05. The trigger, monitor, and
8
+ * terminal card all subscribe to the same single source of truth.
9
+ *
10
+ * A 404 from the endpoint is treated as "no active batch" — the
11
+ * endpoints are being built in parallel and we don't want the Hub to
12
+ * crash if a consumer integrates the UI before the backend lands.
13
+ *
14
+ * NEW-5 (v1.2.6): the hook previously owned its own `useEffect` +
15
+ * `setInterval` per mount. The Hub renders three consumers in parallel
16
+ * (`Hub.client.tsx` for the monitor/terminal card,
17
+ * `BulkTranslateTrigger.tsx` for the "is a run already active?" check,
18
+ * `BulkTranslatePostEnqueueTransition.tsx` after the modal closes), so
19
+ * the network panel showed pairs of requests every ~5s. The hook now
20
+ * delegates polling to a module-level singleton — one subscriber per
21
+ * `basePath`, ref-counted so the interval shuts down cleanly when the
22
+ * last consumer unmounts — and components receive the shared state via
23
+ * `useSyncExternalStore`. Single request per tick regardless of how
24
+ * many components subscribe.
25
+ */ import { useCallback, useSyncExternalStore } from 'react';
26
+ import { readResponseError } from '../shared/fetch-error-body.js';
27
+ // Active polling cadence — used while a bulk batch is in flight. The
28
+ // trigger / monitor / terminal cards all expect ~5s updates so the
29
+ // progress bar and per-doc status feels live.
30
+ const POLL_INTERVAL_MS = 5000;
31
+ // Idle cadence — used when there is no active batch. Pre-1.2.8 the
32
+ // poll re-queued at POLL_INTERVAL_MS unconditionally, so any admin
33
+ // session with the Translation Hub mounted (or any view that uses
34
+ // `useBulkTranslateActive` — the Bulk Translate trigger button does)
35
+ // kept hitting `/api/translation-hub/bulk-translate/active` every 5s
36
+ // forever. Backing off to 60s when idle still gives the trigger
37
+ // reasonable cross-tab freshness (if user A starts a run, user B's
38
+ // idle tab sees it within a minute) without the constant background
39
+ // chatter.
40
+ const IDLE_POLL_INTERVAL_MS = 60_000;
41
+ const OFFLINE_AFTER_FAILED_POLLS = 3;
42
+ const registry = new Map();
43
+ function getEntry(basePath) {
44
+ let entry = registry.get(basePath);
45
+ if (entry) return entry;
46
+ entry = {
47
+ state: {
48
+ data: null,
49
+ loading: true,
50
+ error: null,
51
+ failedPollStreak: 0
52
+ },
53
+ refCount: 0,
54
+ listeners: new Set(),
55
+ timer: undefined,
56
+ active: false,
57
+ onVisibility: undefined,
58
+ forcePoll: ()=>undefined
59
+ };
60
+ registry.set(basePath, entry);
61
+ return entry;
62
+ }
63
+ function emit(entry) {
64
+ for (const listener of entry.listeners){
65
+ listener();
66
+ }
67
+ }
68
+ function updateState(entry, patch) {
69
+ // Build a fresh state object so `useSyncExternalStore` consumers see
70
+ // a stable reference change and re-render.
71
+ entry.state = {
72
+ ...entry.state,
73
+ ...patch
74
+ };
75
+ emit(entry);
76
+ }
77
+ async function fetchOnce(basePath, entry) {
78
+ if (!entry.active) return;
79
+ try {
80
+ const res = await fetch(`${basePath}/api/translation-hub/bulk-translate/active`, {
81
+ credentials: 'include'
82
+ });
83
+ if (!entry.active) return;
84
+ if (res.status === 404) {
85
+ // Endpoint not yet deployed OR no active batch — both render
86
+ // the same "idle" state in the trigger.
87
+ updateState(entry, {
88
+ data: {
89
+ batch: null
90
+ },
91
+ error: null,
92
+ failedPollStreak: 0,
93
+ loading: false
94
+ });
95
+ return;
96
+ }
97
+ if (!res.ok) {
98
+ throw new Error(await readResponseError(res));
99
+ }
100
+ const json = await res.json();
101
+ if (!entry.active) return;
102
+ updateState(entry, {
103
+ data: json,
104
+ error: null,
105
+ failedPollStreak: 0,
106
+ loading: false
107
+ });
108
+ } catch (e) {
109
+ if (!entry.active) return;
110
+ updateState(entry, {
111
+ error: e instanceof Error ? e.message : String(e),
112
+ failedPollStreak: entry.state.failedPollStreak + 1,
113
+ loading: false
114
+ });
115
+ } finally{
116
+ if (entry.active && typeof document !== 'undefined' && document.visibilityState === 'visible') {
117
+ // Cadence-aware polling: tight (5s) only while a batch is
118
+ // genuinely in flight. The /active endpoint keeps serving
119
+ // TERMINAL batches for the 24h revert-visibility window — those
120
+ // (and error states, where `data.batch` holds its stale value)
121
+ // must poll at the relaxed cadence. Fast-polling a finished
122
+ // batch for 24h multiplied the endpoint's per-poll unit scans
123
+ // across every open Hub tab and helped take prod down on
124
+ // 2026-06-10.
125
+ const batchStatus = entry.state.data?.batch?.status;
126
+ const isInFlight = batchStatus === 'queued' || batchStatus === 'running' || batchStatus === 'cancelling';
127
+ const nextDelay = isInFlight ? POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS;
128
+ entry.timer = setTimeout(()=>fetchOnce(basePath, entry), nextDelay);
129
+ }
130
+ }
131
+ }
132
+ function startPolling(basePath, entry) {
133
+ if (entry.active) return;
134
+ entry.active = true;
135
+ entry.forcePoll = ()=>{
136
+ if (entry.timer) {
137
+ clearTimeout(entry.timer);
138
+ entry.timer = undefined;
139
+ }
140
+ void fetchOnce(basePath, entry);
141
+ };
142
+ entry.onVisibility = ()=>{
143
+ if (typeof document !== 'undefined' && document.visibilityState === 'visible' && entry.active) {
144
+ entry.forcePoll();
145
+ }
146
+ };
147
+ if (typeof document !== 'undefined') {
148
+ document.addEventListener('visibilitychange', entry.onVisibility);
149
+ }
150
+ void fetchOnce(basePath, entry);
151
+ }
152
+ function stopPolling(basePath, entry) {
153
+ entry.active = false;
154
+ if (entry.timer) {
155
+ clearTimeout(entry.timer);
156
+ entry.timer = undefined;
157
+ }
158
+ if (entry.onVisibility && typeof document !== 'undefined') {
159
+ document.removeEventListener('visibilitychange', entry.onVisibility);
160
+ }
161
+ entry.onVisibility = undefined;
162
+ entry.forcePoll = ()=>undefined;
163
+ // Reset state so the next subscriber sees a fresh loading frame
164
+ // instead of stale cached data from a previous mount.
165
+ entry.state = {
166
+ data: null,
167
+ loading: true,
168
+ error: null,
169
+ failedPollStreak: 0
170
+ };
171
+ registry.delete(basePath);
172
+ }
173
+ /**
174
+ * Acquire a subscription slot for `basePath`. Increments the ref-count
175
+ * and starts polling on first subscriber. Returns an unsubscribe
176
+ * function that decrements + stops polling when the last subscriber
177
+ * unmounts. Exported for the test fixture; consumers should call
178
+ * `useBulkTranslateActive`.
179
+ */ export function subscribeToBulkActive(basePath, listener) {
180
+ const entry = getEntry(basePath);
181
+ entry.listeners.add(listener);
182
+ entry.refCount += 1;
183
+ if (entry.refCount === 1) {
184
+ startPolling(basePath, entry);
185
+ }
186
+ return ()=>{
187
+ entry.listeners.delete(listener);
188
+ entry.refCount -= 1;
189
+ if (entry.refCount <= 0) {
190
+ stopPolling(basePath, entry);
191
+ }
192
+ };
193
+ }
194
+ /** Test-only: read the current snapshot for a base path. */ export function getBulkActiveSnapshot(basePath) {
195
+ return getEntry(basePath).state;
196
+ }
197
+ /** Test-only: reset the registry between tests. */ export function __resetBulkActiveRegistryForTests() {
198
+ for (const [basePath, entry] of registry){
199
+ if (entry.timer) clearTimeout(entry.timer);
200
+ if (entry.onVisibility && typeof document !== 'undefined') {
201
+ document.removeEventListener('visibilitychange', entry.onVisibility);
202
+ }
203
+ registry.delete(basePath);
204
+ }
205
+ }
206
+ /** Test-only: snapshot of refcounts per base path. */ export function __getBulkActiveRefCountsForTests() {
207
+ const out = {};
208
+ for (const [basePath, entry] of registry){
209
+ out[basePath] = entry.refCount;
210
+ }
211
+ return out;
212
+ }
213
+ export function useBulkTranslateActive(basePath) {
214
+ // `useSyncExternalStore` is the React-recommended primitive for
215
+ // subscribing to an external store. The subscribe callback wires the
216
+ // refcount; the getSnapshot returns the current state object. React
217
+ // re-renders consumers when emit() fires.
218
+ const subscribe = useCallback((listener)=>subscribeToBulkActive(basePath, listener), [
219
+ basePath
220
+ ]);
221
+ const getSnapshot = useCallback(()=>getEntry(basePath).state, [
222
+ basePath
223
+ ]);
224
+ // SSR-safe server snapshot. The Hub renders client-only but Payload's
225
+ // admin shell hydrates from server-rendered markup, so a stable
226
+ // server snapshot avoids a hydration mismatch flash.
227
+ const getServerSnapshot = useCallback(()=>({
228
+ data: null,
229
+ loading: true,
230
+ error: null,
231
+ failedPollStreak: 0
232
+ }), []);
233
+ const state = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
234
+ const refetch = useCallback(()=>{
235
+ getEntry(basePath).forcePoll();
236
+ }, [
237
+ basePath
238
+ ]);
239
+ return {
240
+ data: state.data,
241
+ loading: state.loading,
242
+ error: state.error,
243
+ isOffline: state.failedPollStreak >= OFFLINE_AFTER_FAILED_POLLS,
244
+ refetch
245
+ };
246
+ }
247
+ /**
248
+ * Exported for tests so they can compare against the same threshold the
249
+ * hook uses internally.
250
+ */ export const BULK_TRANSLATE_OFFLINE_THRESHOLD = OFFLINE_AFTER_FAILED_POLLS;
251
+ export const BULK_TRANSLATE_POLL_INTERVAL_MS = POLL_INTERVAL_MS;
@@ -0,0 +1,6 @@
1
+ export declare function useFocusTrap(containerRef: React.RefObject<HTMLElement | null>, options: {
2
+ /** If true, the trap is active. Skip work when surface is closed. */
3
+ enabled: boolean;
4
+ /** Invoked on Escape key. */
5
+ onEscape?: () => void;
6
+ }): void;
@@ -0,0 +1,81 @@
1
+ 'use client';
2
+ /**
3
+ * Minimal focus-trap implementation for the bulk-translate modal and
4
+ * drawer. Per F-UX-10 the spec requires:
5
+ *
6
+ * - Focus the first interactive element when the surface opens.
7
+ * - Tab / Shift+Tab loops within the surface.
8
+ * - Escape triggers the close callback.
9
+ * - Focus returns to the trigger element on close.
10
+ *
11
+ * We deliberately avoid pulling in a focus-trap library — the surface
12
+ * is small, the requirements are small, and the cms-plugins package
13
+ * already keeps third-party deps to a minimum.
14
+ */ import { useEffect } from 'react';
15
+ const FOCUSABLE = [
16
+ 'a[href]',
17
+ 'button:not([disabled])',
18
+ 'textarea:not([disabled])',
19
+ 'input:not([disabled])',
20
+ 'select:not([disabled])',
21
+ '[tabindex]:not([tabindex="-1"])'
22
+ ].join(',');
23
+ export function useFocusTrap(containerRef, options) {
24
+ const { enabled, onEscape } = options;
25
+ useEffect(()=>{
26
+ if (!enabled) {
27
+ return;
28
+ }
29
+ const node = containerRef.current;
30
+ if (!node) {
31
+ return;
32
+ }
33
+ // Save the element that owned focus before the surface opened so
34
+ // we can restore it on close. Useful for screen-reader users and
35
+ // anyone who triggered the surface via keyboard.
36
+ const previouslyFocused = document.activeElement;
37
+ // Focus first interactive element. RAF defers until paint so
38
+ // refs inside the surface are populated.
39
+ const raf = requestAnimationFrame(()=>{
40
+ const first = node.querySelector(FOCUSABLE);
41
+ if (first) {
42
+ first.focus();
43
+ }
44
+ });
45
+ function onKey(e) {
46
+ if (e.key === 'Escape') {
47
+ onEscape?.();
48
+ return;
49
+ }
50
+ if (e.key !== 'Tab') {
51
+ return;
52
+ }
53
+ const focusable = Array.from(node.querySelectorAll(FOCUSABLE)).filter((el)=>!el.hasAttribute('disabled'));
54
+ if (focusable.length === 0) {
55
+ return;
56
+ }
57
+ const first = focusable[0];
58
+ const last = focusable[focusable.length - 1];
59
+ const active = document.activeElement;
60
+ if (e.shiftKey && active === first) {
61
+ e.preventDefault();
62
+ last.focus();
63
+ } else if (!e.shiftKey && active === last) {
64
+ e.preventDefault();
65
+ first.focus();
66
+ }
67
+ }
68
+ document.addEventListener('keydown', onKey);
69
+ return ()=>{
70
+ cancelAnimationFrame(raf);
71
+ document.removeEventListener('keydown', onKey);
72
+ if (previouslyFocused && document.contains(previouslyFocused)) {
73
+ previouslyFocused.focus();
74
+ }
75
+ };
76
+ }, [
77
+ containerRef,
78
+ enabled,
79
+ onEscape
80
+ ]);
81
+ }
@@ -0,0 +1,77 @@
1
+ export type UsageSummaryRangeKey = {
2
+ kind: '7d' | '30d' | '90d' | 'all';
3
+ } | {
4
+ kind: 'custom';
5
+ from?: string;
6
+ to?: string;
7
+ };
8
+ export interface UsageSummaryShape {
9
+ totals: {
10
+ runs: number;
11
+ succeeded: number;
12
+ preserved: number;
13
+ failed: number;
14
+ inputTokens: number;
15
+ outputTokens: number;
16
+ costUsd: number;
17
+ totalMatching: number;
18
+ truncated: boolean;
19
+ };
20
+ byCollection: Array<{
21
+ slug: string;
22
+ kind: 'collection' | 'global';
23
+ runs: number;
24
+ tokens: number;
25
+ costUsd: number;
26
+ }>;
27
+ byModel: Array<{
28
+ model: string;
29
+ runs: number;
30
+ tokens: number;
31
+ costUsd: number;
32
+ }>;
33
+ byLocale: Array<{
34
+ locale: string;
35
+ runs: number;
36
+ failed: number;
37
+ }>;
38
+ samples: Array<{
39
+ id: number | string;
40
+ createdAt: string;
41
+ slug: string;
42
+ kind: 'collection' | 'global';
43
+ documentId?: string | null;
44
+ status: 'succeeded' | 'failed';
45
+ model?: string | null;
46
+ inputTokens: number;
47
+ outputTokens: number;
48
+ estimatedCostUsd?: number | null;
49
+ durationMs?: number | null;
50
+ error?: string | null;
51
+ failedCount: number;
52
+ succeededCount: number;
53
+ targetLocales?: Array<{
54
+ locale: string;
55
+ status?: 'succeeded' | 'failed' | null;
56
+ }> | null;
57
+ }>;
58
+ }
59
+ export interface UseTranslationHubUsageSummaryResult {
60
+ data: UsageSummaryShape | null;
61
+ loading: boolean;
62
+ error: string | null;
63
+ refetch: () => void;
64
+ }
65
+ interface FetchState {
66
+ data: UsageSummaryShape | null;
67
+ loading: boolean;
68
+ error: string | null;
69
+ }
70
+ export declare function subscribeToTranslationHubUsageSummary(basePath: string, range: UsageSummaryRangeKey, listener: () => void): () => void;
71
+ export declare function getTranslationHubUsageSummarySnapshot(basePath: string, range: UsageSummaryRangeKey): FetchState;
72
+ /** Test-only: reset between tests. */
73
+ export declare function __resetTranslationHubUsageSummaryRegistryForTests(): void;
74
+ /** Test-only: snapshot of refcounts. */
75
+ export declare function __getTranslationHubUsageSummaryRefCountsForTests(): Record<string, number>;
76
+ export declare function useTranslationHubUsageSummary(basePath: string, range: UsageSummaryRangeKey): UseTranslationHubUsageSummaryResult;
77
+ export {};
@@ -0,0 +1,267 @@
1
+ 'use client';
2
+ /**
3
+ * Singleton fetcher for `/api/translation-hub/usage-summary` (ROUND2-4).
4
+ *
5
+ * Mirrors the singleton-registry pattern in `useBulkTranslateActive` —
6
+ * one module-level cache keyed by `(basePath, range)`, ref-counted
7
+ * subscribers, `useSyncExternalStore` for React reactivity. Multiple
8
+ * mounts of `StatusStrip` + `AuditPanel` (same Hub page, same range)
9
+ * share ONE network request instead of each component firing its own.
10
+ *
11
+ * Before this hook:
12
+ * - StatusStrip and AuditPanel each owned their own `useEffect` +
13
+ * `fetch()`.
14
+ * - React 18 StrictMode dev-double-render produced two requests
15
+ * within ~2.8 ms on every mount.
16
+ * - A page that surfaced both consumers (Overview + Audit & Cost on
17
+ * the same tab swap) doubled cost on the backend SUM-aggregation,
18
+ * which runs across `translation_usage` — potentially thousands of
19
+ * rows.
20
+ *
21
+ * With this hook:
22
+ * - `useTranslationHubUsageSummary(basePath, range, from?, to?)`
23
+ * returns `{ data, loading, error, refetch }`.
24
+ * - Concurrent subscribers (same key) share one in-flight request
25
+ * and one cached result.
26
+ * - Range / from / to changes invalidate the cached entry and fire a
27
+ * fresh request.
28
+ * - Refetch is exposed for explicit user-driven reloads (none today
29
+ * — this view is read-only — but matches the API surface of every
30
+ * other hook in this plugin so future "Refresh" affordances drop
31
+ * in cleanly).
32
+ */ import { useCallback, useSyncExternalStore } from 'react';
33
+ import { readResponseError } from '../shared/fetch-error-body.js';
34
+ const registry = new Map();
35
+ /**
36
+ * ROUND3-2: TTL window during which an idle entry (refCount === 0)
37
+ * survives in the registry. Mirrors the staleTime semantics that
38
+ * TanStack Query exposes — when an editor flips Overview → Audit & Cost
39
+ * → Overview within this window the second mount serves from the warm
40
+ * cache instead of paying the SQL aggregation cost again. 60s strikes
41
+ * the same balance as the default TanStack `gcTime` shoulder.
42
+ */ const CACHE_TTL_MS = 60_000;
43
+ /**
44
+ * ROUND3-3: retry policy on `fetchOnce` failures. Exponential backoff
45
+ * with three attempts — 1s, 3s, 9s — matches the upper bound on a
46
+ * transient network blip / 502 retry-after. After the third failure we
47
+ * stop auto-retrying and surface the error to the consumer; the
48
+ * `refetch()` button lets the editor try again on their schedule.
49
+ */ const MAX_RETRY_ATTEMPTS = 3;
50
+ const RETRY_BACKOFF_MS = [
51
+ 1_000,
52
+ 3_000,
53
+ 9_000
54
+ ];
55
+ /**
56
+ * Build a stable key from the URL query params we'd send. We compose
57
+ * `basePath` + URL search so two `custom` ranges with different
58
+ * `from/to` stay isolated.
59
+ */ function buildKey(basePath, range) {
60
+ const params = new URLSearchParams();
61
+ if (range.kind === 'custom') {
62
+ params.set('range', 'custom');
63
+ if (range.from) params.set('from', range.from);
64
+ if (range.to) params.set('to', range.to);
65
+ } else {
66
+ params.set('range', range.kind);
67
+ }
68
+ return `${basePath}?${params.toString()}`;
69
+ }
70
+ function buildUrl(basePath, range) {
71
+ const params = new URLSearchParams();
72
+ if (range.kind === 'custom') {
73
+ params.set('range', 'custom');
74
+ if (range.from) params.set('from', range.from);
75
+ if (range.to) params.set('to', range.to);
76
+ } else {
77
+ params.set('range', range.kind);
78
+ }
79
+ return `${basePath}/api/translation-hub/usage-summary?${params.toString()}`;
80
+ }
81
+ function getEntry(key) {
82
+ let entry = registry.get(key);
83
+ if (entry) return entry;
84
+ entry = {
85
+ state: {
86
+ data: null,
87
+ loading: true,
88
+ error: null
89
+ },
90
+ refCount: 0,
91
+ listeners: new Set(),
92
+ hasFetched: false,
93
+ forceFetch: ()=>undefined,
94
+ evictTimer: null,
95
+ lastFetchedAt: 0,
96
+ failureStreak: 0,
97
+ retryTimer: null
98
+ };
99
+ registry.set(key, entry);
100
+ return entry;
101
+ }
102
+ function emit(entry) {
103
+ for (const listener of entry.listeners){
104
+ listener();
105
+ }
106
+ }
107
+ function updateState(entry, patch) {
108
+ entry.state = {
109
+ ...entry.state,
110
+ ...patch
111
+ };
112
+ emit(entry);
113
+ }
114
+ async function fetchOnce(basePath, range, key) {
115
+ const entry = getEntry(key);
116
+ if (entry.refCount === 0) return;
117
+ try {
118
+ const res = await fetch(buildUrl(basePath, range), {
119
+ credentials: 'include'
120
+ });
121
+ if (!res.ok) {
122
+ throw new Error(await readResponseError(res));
123
+ }
124
+ const json = await res.json();
125
+ if (entry.refCount === 0) return;
126
+ entry.failureStreak = 0;
127
+ entry.lastFetchedAt = Date.now();
128
+ updateState(entry, {
129
+ data: json,
130
+ loading: false,
131
+ error: null
132
+ });
133
+ } catch (e) {
134
+ if (entry.refCount === 0) return;
135
+ const message = e instanceof Error ? e.message : String(e);
136
+ // ROUND3-3: auto-retry transient failures with exponential backoff.
137
+ // After MAX_RETRY_ATTEMPTS we surface the error to the consumer
138
+ // and let the editor decide via the `refetch` affordance.
139
+ entry.failureStreak += 1;
140
+ if (entry.failureStreak < MAX_RETRY_ATTEMPTS) {
141
+ const delay = RETRY_BACKOFF_MS[entry.failureStreak - 1] ?? RETRY_BACKOFF_MS[RETRY_BACKOFF_MS.length - 1] ?? 9_000;
142
+ // Keep `loading: true` so consumers don't flash the error chrome
143
+ // between attempts. The error surfaces only when we run out of
144
+ // retries.
145
+ updateState(entry, {
146
+ loading: true,
147
+ error: null
148
+ });
149
+ if (entry.retryTimer) clearTimeout(entry.retryTimer);
150
+ entry.retryTimer = setTimeout(()=>{
151
+ entry.retryTimer = null;
152
+ if (entry.refCount === 0) return;
153
+ void fetchOnce(basePath, range, key);
154
+ }, delay);
155
+ return;
156
+ }
157
+ updateState(entry, {
158
+ loading: false,
159
+ error: message
160
+ });
161
+ }
162
+ }
163
+ export function subscribeToTranslationHubUsageSummary(basePath, range, listener) {
164
+ const key = buildKey(basePath, range);
165
+ const entry = getEntry(key);
166
+ entry.listeners.add(listener);
167
+ entry.refCount += 1;
168
+ // ROUND3-2: a pending eviction means we were within the TTL window
169
+ // when this new subscriber arrived. Cancel the timer so the cached
170
+ // payload survives and the subscriber gets a warm hit on the next
171
+ // `getSnapshot` call. No new request fires.
172
+ if (entry.evictTimer) {
173
+ clearTimeout(entry.evictTimer);
174
+ entry.evictTimer = null;
175
+ }
176
+ // First subscriber on this key fires the request. Subsequent
177
+ // subscribers re-use the same cached entry — that's the doubling
178
+ // fix.
179
+ if (!entry.hasFetched) {
180
+ entry.hasFetched = true;
181
+ entry.forceFetch = ()=>{
182
+ // Explicit user-driven refetch: reset failure streak so the
183
+ // backoff window starts fresh.
184
+ entry.failureStreak = 0;
185
+ if (entry.retryTimer) {
186
+ clearTimeout(entry.retryTimer);
187
+ entry.retryTimer = null;
188
+ }
189
+ updateState(entry, {
190
+ loading: true,
191
+ error: null
192
+ });
193
+ void fetchOnce(basePath, range, key);
194
+ };
195
+ void fetchOnce(basePath, range, key);
196
+ }
197
+ return ()=>{
198
+ entry.listeners.delete(listener);
199
+ entry.refCount -= 1;
200
+ if (entry.refCount <= 0) {
201
+ // ROUND3-2: defer eviction. Keep the cached entry alive for
202
+ // CACHE_TTL_MS so a quick tab-switch (Overview → Audit → Overview)
203
+ // or range-flip (7d → 30d → 7d) re-uses the warm payload instead
204
+ // of paying the SQL aggregation cost again. Any new subscriber
205
+ // arriving within the window cancels this timer above.
206
+ if (entry.evictTimer) clearTimeout(entry.evictTimer);
207
+ entry.evictTimer = setTimeout(()=>{
208
+ // Double-check refCount in case a subscriber raced in. If a new
209
+ // one is here, leave the entry alone.
210
+ const current = registry.get(key);
211
+ if (!current || current.refCount > 0) return;
212
+ if (current.retryTimer) clearTimeout(current.retryTimer);
213
+ registry.delete(key);
214
+ }, CACHE_TTL_MS);
215
+ }
216
+ };
217
+ }
218
+ export function getTranslationHubUsageSummarySnapshot(basePath, range) {
219
+ return getEntry(buildKey(basePath, range)).state;
220
+ }
221
+ /** Test-only: reset between tests. */ export function __resetTranslationHubUsageSummaryRegistryForTests() {
222
+ for (const entry of registry.values()){
223
+ if (entry.evictTimer) clearTimeout(entry.evictTimer);
224
+ if (entry.retryTimer) clearTimeout(entry.retryTimer);
225
+ }
226
+ registry.clear();
227
+ }
228
+ /** Test-only: snapshot of refcounts. */ export function __getTranslationHubUsageSummaryRefCountsForTests() {
229
+ const out = {};
230
+ for (const [key, entry] of registry){
231
+ out[key] = entry.refCount;
232
+ }
233
+ return out;
234
+ }
235
+ export function useTranslationHubUsageSummary(basePath, range) {
236
+ // `range` may be a fresh object each render — stringify into a stable
237
+ // key the hook subscriptions can dedupe on.
238
+ const key = buildKey(basePath, range);
239
+ const subscribe = useCallback((listener)=>subscribeToTranslationHubUsageSummary(basePath, range, listener), // eslint-disable-next-line react-hooks/exhaustive-deps
240
+ [
241
+ basePath,
242
+ key
243
+ ]);
244
+ const getSnapshot = useCallback(()=>getEntry(key).state, [
245
+ key
246
+ ]);
247
+ // SSR snapshot — Payload's admin shell hydrates from server-rendered
248
+ // markup. Returning a stable "loading" state matches what the
249
+ // client-side render will show on first paint.
250
+ const getServerSnapshot = useCallback(()=>({
251
+ data: null,
252
+ loading: true,
253
+ error: null
254
+ }), []);
255
+ const state = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
256
+ const refetch = useCallback(()=>{
257
+ getEntry(key).forceFetch();
258
+ }, [
259
+ key
260
+ ]);
261
+ return {
262
+ data: state.data,
263
+ loading: state.loading,
264
+ error: state.error,
265
+ refetch
266
+ };
267
+ }