@delmaredigital/payload-experiments 0.3.0-rc.4 → 0.4.0-rc.2

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 (299) hide show
  1. package/README.md +122 -27
  2. package/dist/admin/MetricSourceSelect.js +3 -3
  3. package/dist/admin/PlacementStatus.d.ts +3 -0
  4. package/dist/admin/PlacementStatus.js +54 -0
  5. package/dist/admin/PreviewLinks.d.ts +2 -1
  6. package/dist/admin/PreviewLinks.js +2 -2
  7. package/dist/admin/TestsDashboardCards.d.ts +1 -0
  8. package/dist/admin/TestsDashboardCards.js +4 -8
  9. package/dist/admin/TestsNavLink.d.ts +1 -0
  10. package/dist/admin/TestsNavLink.js +4 -10
  11. package/dist/admin/actions/ConfirmDialog.d.ts +2 -1
  12. package/dist/admin/actions/ConfirmDialog.js +2 -2
  13. package/dist/admin/actions/DecideDialog.d.ts +2 -1
  14. package/dist/admin/actions/DecideDialog.js +2 -2
  15. package/dist/admin/api.d.ts +18 -2
  16. package/dist/admin/api.js +47 -2
  17. package/dist/admin/blocks/BlockPanel.d.ts +4 -0
  18. package/dist/admin/blocks/BlockPanel.js +199 -0
  19. package/dist/admin/blocks/TestBlockField.d.ts +5 -0
  20. package/dist/admin/blocks/TestBlockField.js +163 -0
  21. package/dist/admin/blocks/formApi.d.ts +46 -0
  22. package/dist/admin/blocks/formApi.js +159 -0
  23. package/dist/admin/blocks/inject.d.ts +7 -0
  24. package/dist/admin/blocks/inject.js +41 -0
  25. package/dist/admin/blocks/model.d.ts +17 -0
  26. package/dist/admin/blocks/model.js +45 -0
  27. package/dist/admin/blocks/statusCache.d.ts +9 -0
  28. package/dist/admin/blocks/statusCache.js +92 -0
  29. package/dist/admin/blocks/useBlockForm.d.ts +17 -0
  30. package/dist/admin/blocks/useBlockForm.js +46 -0
  31. package/dist/admin/copy/actions.d.ts +2 -0
  32. package/dist/admin/copy/actions.js +9 -0
  33. package/dist/admin/copy/blocks.d.ts +43 -0
  34. package/dist/admin/copy/blocks.js +64 -0
  35. package/dist/admin/copy/common.d.ts +1 -0
  36. package/dist/admin/copy/common.js +1 -1
  37. package/dist/admin/copy/goals.d.ts +2 -0
  38. package/dist/admin/copy/goals.js +4 -1
  39. package/dist/admin/copy/newTest.d.ts +10 -0
  40. package/dist/admin/copy/newTest.js +10 -0
  41. package/dist/admin/copy/pages.d.ts +24 -0
  42. package/dist/admin/copy/pages.js +24 -0
  43. package/dist/admin/copy/readiness.d.ts +23 -3
  44. package/dist/admin/copy/readiness.js +24 -3
  45. package/dist/admin/copy/results.d.ts +1 -0
  46. package/dist/admin/copy/results.js +1 -0
  47. package/dist/admin/copy/testPage.d.ts +4 -0
  48. package/dist/admin/copy/testPage.js +4 -0
  49. package/dist/admin/goals/XpGoalField.d.ts +4 -0
  50. package/dist/admin/goals/XpGoalField.js +168 -0
  51. package/dist/admin/goals/xpGoalField.css +48 -0
  52. package/dist/admin/metricSource.model.d.ts +2 -0
  53. package/dist/admin/metricSource.model.js +4 -0
  54. package/dist/admin/pageCollections.d.ts +10 -0
  55. package/dist/admin/pageCollections.js +20 -0
  56. package/dist/admin/paths.d.ts +9 -0
  57. package/dist/admin/paths.js +9 -0
  58. package/dist/admin/placementStatus.model.d.ts +2 -0
  59. package/dist/admin/placementStatus.model.js +19 -0
  60. package/dist/admin/placements/placementLabels.d.ts +11 -0
  61. package/dist/admin/placements/placementLabels.js +21 -0
  62. package/dist/admin/placements/placementLabels.model.d.ts +13 -0
  63. package/dist/admin/placements/placementLabels.model.js +12 -0
  64. package/dist/admin/placements/usePlacementLabels.d.ts +12 -0
  65. package/dist/admin/placements/usePlacementLabels.js +102 -0
  66. package/dist/admin/results/DataQualityPanel.js +4 -1
  67. package/dist/admin/results/results.model.d.ts +3 -0
  68. package/dist/admin/results/results.model.js +8 -4
  69. package/dist/admin/surface/TestsHome.js +39 -10
  70. package/dist/admin/surface/TestsSurface.js +7 -6
  71. package/dist/admin/surface/contracts.d.ts +6 -5
  72. package/dist/admin/surface/goals/GoalForm.js +71 -44
  73. package/dist/admin/surface/goals/GoalsList.js +5 -4
  74. package/dist/admin/surface/goals/data.js +4 -1
  75. package/dist/admin/surface/goals/goals.model.d.ts +1 -2
  76. package/dist/admin/surface/goals/goals.model.js +1 -3
  77. package/dist/admin/surface/home.model.d.ts +3 -3
  78. package/dist/admin/surface/home.model.js +8 -4
  79. package/dist/admin/surface/newTest/NewTest.js +56 -17
  80. package/dist/admin/surface/newTest/PageWizard.d.ts +7 -0
  81. package/dist/admin/surface/newTest/PageWizard.js +214 -0
  82. package/dist/admin/surface/pagePicker/CopyRoute.d.ts +6 -0
  83. package/dist/admin/surface/pagePicker/CopyRoute.js +53 -0
  84. package/dist/admin/surface/pagePicker/PageCombobox.d.ts +10 -0
  85. package/dist/admin/surface/pagePicker/PageCombobox.js +395 -0
  86. package/dist/admin/surface/pagePicker/pagePicker.css +10 -0
  87. package/dist/admin/surface/pagePicker/pagePicker.model.d.ts +27 -0
  88. package/dist/admin/surface/pagePicker/pagePicker.model.js +25 -0
  89. package/dist/admin/surface/readiness/ActionDialogs.js +99 -64
  90. package/dist/admin/surface/readiness/Banner.js +30 -6
  91. package/dist/admin/surface/readiness/readiness.model.d.ts +8 -1
  92. package/dist/admin/surface/readiness/readiness.model.js +92 -9
  93. package/dist/admin/surface/results/ReleasePages.d.ts +3 -1
  94. package/dist/admin/surface/results/ReleasePages.js +3 -7
  95. package/dist/admin/surface/results/ResultsBody.js +21 -2
  96. package/dist/admin/surface/results/ResultsHeader.js +5 -13
  97. package/dist/admin/surface/results/useResults.d.ts +20 -1
  98. package/dist/admin/surface/test/AdvancedSection.js +111 -104
  99. package/dist/admin/surface/test/GoalSection.js +94 -14
  100. package/dist/admin/surface/test/LengthSection.js +1 -1
  101. package/dist/admin/surface/test/PageHoldout.d.ts +3 -0
  102. package/dist/admin/surface/test/PageHoldout.js +67 -0
  103. package/dist/admin/surface/test/TestPage.js +5 -9
  104. package/dist/admin/surface/test/VersionsSection.js +12 -5
  105. package/dist/admin/surface/test/fields.d.ts +13 -4
  106. package/dist/admin/surface/test/fields.js +46 -14
  107. package/dist/admin/surface/test/jumpToSection.d.ts +1 -1
  108. package/dist/admin/surface/test/jumpToSection.js +4 -3
  109. package/dist/admin/surface/test/pageParts.d.ts +4 -1
  110. package/dist/admin/surface/test/pageParts.js +49 -1
  111. package/dist/admin/surface/test/testPage.css +3 -1
  112. package/dist/admin/surface/testDocument.model.d.ts +1 -0
  113. package/dist/admin/surface/testDocument.model.js +2 -1
  114. package/dist/admin/surface/useTestDocument.d.ts +15 -2
  115. package/dist/admin/surface/useTestDocument.js +53 -15
  116. package/dist/admin/surfaceUrls.d.ts +7 -0
  117. package/dist/admin/surfaceUrls.js +19 -0
  118. package/dist/admin/theme.css +2 -0
  119. package/dist/admin/useApi.d.ts +21 -2
  120. package/dist/admin/useApi.js +3 -5
  121. package/dist/admin/views/CollectionListRedirect.d.ts +1 -0
  122. package/dist/admin/views/CollectionListRedirect.js +1 -1
  123. package/dist/admin/views/ExperimentsRootView.js +28 -133
  124. package/dist/admin/views/GoalEditRedirect.d.ts +4 -0
  125. package/dist/admin/views/GoalEditRedirect.js +14 -0
  126. package/dist/admin/views/GoalsCollectionListView.d.ts +2 -0
  127. package/dist/admin/views/GoalsCollectionListView.js +4 -0
  128. package/dist/admin/views/TestsCollectionListView.d.ts +2 -0
  129. package/dist/admin/views/TestsCollectionListView.js +4 -0
  130. package/dist/admin/views/TestsNavLinkServer.d.ts +1 -0
  131. package/dist/admin/views/TestsNavLinkServer.js +1 -1
  132. package/dist/admin/views/buildSurfaceProps.d.ts +8 -0
  133. package/dist/admin/views/buildSurfaceProps.js +143 -0
  134. package/dist/admin/views/renderCollectionList.d.ts +2 -0
  135. package/dist/admin/views/renderCollectionList.js +34 -0
  136. package/dist/admin/views/renderTestsSurface.d.ts +3 -0
  137. package/dist/admin/views/renderTestsSurface.js +34 -0
  138. package/dist/blocks/bindings.d.ts +3 -0
  139. package/dist/blocks/bindings.js +34 -0
  140. package/dist/blocks/createExperimentBlock.d.ts +6 -0
  141. package/dist/blocks/createExperimentBlock.js +77 -0
  142. package/dist/blocks/formState.d.ts +31 -0
  143. package/dist/blocks/formState.js +146 -0
  144. package/dist/blocks/model.d.ts +41 -0
  145. package/dist/blocks/model.js +34 -0
  146. package/dist/client/ExperimentGroup.d.ts +6 -0
  147. package/dist/client/ExperimentGroup.js +9 -0
  148. package/dist/client/ExperimentsProvider.js +20 -4
  149. package/dist/client/clickGoals.d.ts +4 -0
  150. package/dist/client/clickGoals.js +43 -0
  151. package/dist/collections/experiments/config.js +21 -7
  152. package/dist/collections/experiments/deleteGuard.d.ts +2 -1
  153. package/dist/collections/experiments/deleteGuard.js +12 -1
  154. package/dist/collections/experiments/editability.js +4 -2
  155. package/dist/collections/experiments/pickSlug.d.ts +2 -0
  156. package/dist/collections/experiments/pickSlug.js +8 -0
  157. package/dist/collections/labels.d.ts +3 -0
  158. package/dist/collections/labels.js +20 -0
  159. package/dist/collections/metrics.js +28 -6
  160. package/dist/collections/phases.js +1 -0
  161. package/dist/collections/serialize.js +7 -0
  162. package/dist/db/clickGoals.d.ts +9 -0
  163. package/dist/db/clickGoals.js +22 -0
  164. package/dist/db/delete.d.ts +1 -0
  165. package/dist/db/delete.js +1 -0
  166. package/dist/db/locks.d.ts +8 -0
  167. package/dist/db/locks.js +27 -4
  168. package/dist/db/names.d.ts +4 -0
  169. package/dist/db/names.js +4 -0
  170. package/dist/db/pageBindings.d.ts +4 -0
  171. package/dist/db/pageBindings.js +17 -0
  172. package/dist/db/pageBusy.d.ts +2 -0
  173. package/dist/db/pageBusy.js +10 -0
  174. package/dist/db/pageContent.d.ts +10 -0
  175. package/dist/db/pageContent.js +79 -0
  176. package/dist/db/placements.d.ts +33 -0
  177. package/dist/db/placements.js +54 -0
  178. package/dist/db/slugs.d.ts +8 -0
  179. package/dist/db/slugs.js +40 -0
  180. package/dist/db/snapshots.d.ts +6 -0
  181. package/dist/db/snapshots.js +8 -5
  182. package/dist/endpoints/clickKeys.d.ts +5 -0
  183. package/dist/endpoints/clickKeys.js +17 -0
  184. package/dist/endpoints/events.js +4 -1
  185. package/dist/endpoints/index.js +28 -0
  186. package/dist/endpoints/pages.d.ts +5 -0
  187. package/dist/endpoints/pages.js +120 -0
  188. package/dist/endpoints/placementStatus.d.ts +6 -0
  189. package/dist/endpoints/placementStatus.js +238 -0
  190. package/dist/endpoints/readBody.d.ts +2 -0
  191. package/dist/endpoints/readBody.js +24 -0
  192. package/dist/endpoints/results.js +5 -1
  193. package/dist/endpoints/summary.js +11 -4
  194. package/dist/endpoints/wrapBlock.d.ts +6 -0
  195. package/dist/endpoints/wrapBlock.js +76 -0
  196. package/dist/errors.d.ts +3 -3
  197. package/dist/errors.js +4 -4
  198. package/dist/events/rows.d.ts +1 -1
  199. package/dist/events/rows.js +5 -3
  200. package/dist/exports/adminClient.d.ts +5 -0
  201. package/dist/exports/adminClient.js +5 -0
  202. package/dist/exports/adminRsc.d.ts +3 -0
  203. package/dist/exports/adminRsc.js +3 -0
  204. package/dist/exports/blocks.d.ts +2 -0
  205. package/dist/exports/blocks.js +1 -0
  206. package/dist/exports/client.d.ts +1 -0
  207. package/dist/exports/client.js +1 -0
  208. package/dist/exports/index.d.ts +6 -1
  209. package/dist/exports/index.js +3 -0
  210. package/dist/exports/puck.d.ts +3 -1
  211. package/dist/exports/puck.js +2 -0
  212. package/dist/exports/puckEditor.d.ts +3 -0
  213. package/dist/exports/puckEditor.js +2 -0
  214. package/dist/exports/rsc.d.ts +3 -0
  215. package/dist/exports/rsc.js +2 -0
  216. package/dist/fields/xpGoal.d.ts +3 -0
  217. package/dist/fields/xpGoal.js +16 -0
  218. package/dist/globals/state.js +10 -0
  219. package/dist/goals/cache.d.ts +3 -0
  220. package/dist/goals/cache.js +35 -0
  221. package/dist/goals/fairness.d.ts +40 -0
  222. package/dist/goals/fairness.js +200 -0
  223. package/dist/goals/key.d.ts +4 -0
  224. package/dist/goals/key.js +3 -0
  225. package/dist/goals/picker/clickGoalPicker.model.d.ts +29 -0
  226. package/dist/goals/picker/clickGoalPicker.model.js +45 -0
  227. package/dist/goals/picker/copy.d.ts +16 -0
  228. package/dist/goals/picker/copy.js +15 -0
  229. package/dist/goals/picker/useClickGoalPicker.d.ts +30 -0
  230. package/dist/goals/picker/useClickGoalPicker.js +220 -0
  231. package/dist/goals/validate.d.ts +3 -0
  232. package/dist/goals/validate.js +14 -0
  233. package/dist/hooks/pageGuard.d.ts +5 -1
  234. package/dist/hooks/pageGuard.js +157 -95
  235. package/dist/hooks/puckGoals.d.ts +3 -0
  236. package/dist/hooks/puckGoals.js +38 -0
  237. package/dist/onInit.js +29 -0
  238. package/dist/options.d.ts +2 -0
  239. package/dist/options.js +41 -1
  240. package/dist/plugin.js +50 -43
  241. package/dist/puck/copy.d.ts +1 -0
  242. package/dist/puck/copy.js +1 -0
  243. package/dist/puck/editor/editor.chrome.css +14 -0
  244. package/dist/puck/editor/editor.css +106 -0
  245. package/dist/puck/editor/editor.model.d.ts +4 -2
  246. package/dist/puck/editor/editor.model.js +6 -3
  247. package/dist/puck/editor/integration.d.ts +10 -0
  248. package/dist/puck/editor/integration.js +30 -0
  249. package/dist/puck/editor/xpGoalPuckEditorField.d.ts +7 -0
  250. package/dist/puck/editor/xpGoalPuckEditorField.js +142 -0
  251. package/dist/puck/integration.d.ts +6 -0
  252. package/dist/puck/integration.js +21 -0
  253. package/dist/puck/xpGoalField.d.ts +3 -0
  254. package/dist/puck/xpGoalField.js +6 -0
  255. package/dist/results/health.d.ts +4 -0
  256. package/dist/results/health.js +27 -0
  257. package/dist/results/snapshot.js +15 -2
  258. package/dist/sanitizeReadinessDetail.js +43 -0
  259. package/dist/server/pageKindSeoContract.d.ts +27 -0
  260. package/dist/server/pageKindSeoContract.js +80 -0
  261. package/dist/server/recordContentChange.js +58 -8
  262. package/dist/server/resolveExperimentBlocks.d.ts +5 -0
  263. package/dist/server/resolveExperimentBlocks.js +54 -0
  264. package/dist/server/runResults.js +56 -15
  265. package/dist/server/toClientAssignments.js +2 -1
  266. package/dist/transitions/addMetric.d.ts +2 -1
  267. package/dist/transitions/addMetric.js +28 -1
  268. package/dist/transitions/checkStart.d.ts +14 -1
  269. package/dist/transitions/checkStart.js +92 -8
  270. package/dist/transitions/decide.js +25 -0
  271. package/dist/transitions/deleteExperiment.d.ts +3 -1
  272. package/dist/transitions/deleteExperiment.js +27 -1
  273. package/dist/transitions/kernel.js +4 -0
  274. package/dist/transitions/makeContent.d.ts +5 -0
  275. package/dist/transitions/makeContent.js +39 -11
  276. package/dist/transitions/pageCollection.d.ts +10 -0
  277. package/dist/transitions/pageCollection.js +20 -0
  278. package/dist/transitions/pageVersions.d.ts +10 -0
  279. package/dist/transitions/pageVersions.js +44 -0
  280. package/dist/transitions/phaseRecord.d.ts +1 -0
  281. package/dist/transitions/placementInteractions.d.ts +40 -0
  282. package/dist/transitions/placementInteractions.js +94 -0
  283. package/dist/transitions/placements.d.ts +8 -0
  284. package/dist/transitions/placements.js +148 -1
  285. package/dist/transitions/preflightData.d.ts +2 -2
  286. package/dist/transitions/preflightData.js +137 -5
  287. package/dist/transitions/resolveCaps.d.ts +1 -0
  288. package/dist/transitions/resolveCaps.js +6 -2
  289. package/dist/transitions/start.js +46 -1
  290. package/dist/types/admin.d.ts +1 -1
  291. package/dist/types/assignments.d.ts +1 -0
  292. package/dist/types/contracts.d.ts +15 -0
  293. package/dist/types/endpoints.d.ts +92 -5
  294. package/dist/types/index.d.ts +3 -0
  295. package/dist/types/puck.d.ts +7 -1
  296. package/dist/types/snapshot.d.ts +17 -4
  297. package/dist/types/surface.d.ts +13 -0
  298. package/dist/ui/ui.css +4 -0
  299. package/package.json +8 -1
@@ -0,0 +1,395 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useEffect, useId, useMemo, useRef, useState } from 'react';
4
+ import { createApi } from '../../api.js';
5
+ import { PAGE_COPY as C, pageFieldCopy, pageNotFoundCopy } from '../../copy/pages.js';
6
+ import { TextInput } from '../../../ui/TextInput.js';
7
+ import { normalisePath } from '../../../runtime/normalisePath.js';
8
+ import { reasonFor } from '../test/fields.js';
9
+ import { disabledPageReason, isLatestPageRequest } from './pagePicker.model.js';
10
+ import './pagePicker.css';
11
+ const display = (row)=>`${row.title ? `${row.title} · ` : ''}${row.path ?? ''}${row.published === false ? ` · ${C.draft}` : ''}`;
12
+ /** One input owns focus; list options are navigated through aria-activedescendant. */ export function PageCombobox({ collection, test, armKey, apiRoute }) {
13
+ const api = useMemo(()=>createApi(apiRoute), [
14
+ apiRoute
15
+ ]);
16
+ const id = useId();
17
+ const root = useRef(null);
18
+ const arm = test.doc?.arms.find((item)=>item.key === armKey);
19
+ const target = arm?.pageTarget || '';
20
+ const matches = (path)=>Boolean(target && path != null && normalisePath(path) === normalisePath(target));
21
+ const remembered = arm && test.chosenPages[arm.key];
22
+ const chosen = remembered && matches(remembered.path) ? remembered : undefined;
23
+ const placement = test.preflight?.pagePlacements?.find((entry)=>entry.arm === armKey && entry.collection === collection.slug && entry.id != null && matches(entry.path));
24
+ const resolved = chosen ?? placement;
25
+ const needsLookup = Boolean(target && !resolved);
26
+ const lookupKey = JSON.stringify([
27
+ collection.slug,
28
+ target
29
+ ]);
30
+ const lookups = useRef({});
31
+ const [, updateLookup] = useState(0);
32
+ const lookupRevision = useRef(0);
33
+ useEffect(()=>{
34
+ if (!needsLookup || lookups.current[lookupKey]) return;
35
+ const controller = new AbortController();
36
+ const request = ++lookupRevision.current;
37
+ const pending = {};
38
+ const discardPending = ()=>{
39
+ if (lookups.current[lookupKey] === pending) delete lookups.current[lookupKey];
40
+ };
41
+ // Defer dispatch so StrictMode's discarded effect cannot cache an aborted attempt.
42
+ void Promise.resolve().then(async ()=>{
43
+ if (controller.signal.aborted) return;
44
+ lookups.current[lookupKey] = pending;
45
+ const result = await api.pages({
46
+ collection: collection.slug,
47
+ path: target
48
+ }, controller.signal);
49
+ if (controller.signal.aborted || !isLatestPageRequest(request, lookupRevision.current)) {
50
+ discardPending();
51
+ return;
52
+ }
53
+ lookups.current[lookupKey] = result.ok && result.data ? {
54
+ row: result.data.docs[0],
55
+ missing: result.data.docs.length === 0
56
+ } : {};
57
+ updateLookup((value)=>value + 1);
58
+ });
59
+ return ()=>{
60
+ controller.abort();
61
+ discardPending();
62
+ };
63
+ }, [
64
+ api,
65
+ collection.slug,
66
+ target,
67
+ lookupKey,
68
+ needsLookup
69
+ ]);
70
+ const lookup = lookups.current[lookupKey];
71
+ const value = resolved ? display(resolved) : !target ? '' : lookup?.missing ? pageNotFoundCopy(target, collection.label) : lookup?.row ? lookup.row.title ? display(lookup.row) : target : target;
72
+ const currentId = resolved?.id ?? lookup?.row?.id;
73
+ const [open, setOpen] = useState(false);
74
+ const [search, setSearch] = useState('');
75
+ const [q, setQuery] = useState('');
76
+ const [page, setPage] = useState(1);
77
+ const [refresh, setRefresh] = useState(0);
78
+ const [rows, setRows] = useState([]);
79
+ const [more, setMore] = useState(false);
80
+ const [reveal, setReveal] = useState(8);
81
+ const [active, setActive] = useState(null);
82
+ const [loading, setLoading] = useState(false);
83
+ const [error, setError] = useState('');
84
+ const revision = useRef(0);
85
+ const firstNew = useRef(null);
86
+ const locked = reasonFor(test, 'arms');
87
+ const saving = test.saving === 'saving';
88
+ const blocked = Boolean(locked || saving);
89
+ const reset = ()=>{
90
+ setPage(1);
91
+ setReveal(8);
92
+ setActive(null);
93
+ firstNew.current = null;
94
+ setRefresh((value)=>value + 1);
95
+ };
96
+ const close = ()=>{
97
+ setOpen(false);
98
+ setActive(null);
99
+ };
100
+ const show = (query = '')=>{
101
+ if (blocked || open) return;
102
+ setSearch(query);
103
+ setQuery(query);
104
+ setRows([]);
105
+ setMore(false);
106
+ setActive(null);
107
+ reset();
108
+ setOpen(true);
109
+ };
110
+ useEffect(()=>{
111
+ if (search === q) return;
112
+ const timer = setTimeout(()=>{
113
+ setQuery(search);
114
+ setPage(1);
115
+ setReveal(8);
116
+ }, 250);
117
+ return ()=>clearTimeout(timer);
118
+ }, [
119
+ search,
120
+ q
121
+ ]);
122
+ useEffect(()=>{
123
+ if (!open) return;
124
+ let lastReturn = -Infinity;
125
+ const focus = ()=>{
126
+ if (document.visibilityState !== 'visible' || Date.now() - lastReturn < 100) return;
127
+ lastReturn = Date.now();
128
+ reset();
129
+ };
130
+ const visible = ()=>{
131
+ if (document.visibilityState === 'visible') focus();
132
+ };
133
+ const outside = (event)=>{
134
+ if (!root.current?.contains(event.target)) close();
135
+ };
136
+ window.addEventListener('focus', focus);
137
+ document.addEventListener('visibilitychange', visible);
138
+ document.addEventListener('pointerdown', outside);
139
+ document.addEventListener('click', outside);
140
+ return ()=>{
141
+ window.removeEventListener('focus', focus);
142
+ document.removeEventListener('visibilitychange', visible);
143
+ document.removeEventListener('pointerdown', outside);
144
+ document.removeEventListener('click', outside);
145
+ };
146
+ }, [
147
+ open
148
+ ]);
149
+ useEffect(()=>{
150
+ if (!open) return;
151
+ const controller = new AbortController();
152
+ const request = ++revision.current;
153
+ setLoading(true);
154
+ setError('');
155
+ void api.pages({
156
+ collection: collection.slug,
157
+ q,
158
+ page
159
+ }, controller.signal).then((result)=>{
160
+ if (controller.signal.aborted || !isLatestPageRequest(request, revision.current)) return;
161
+ if (result.ok && result.data) {
162
+ // These rows belong to this request; a reset/search aborts it. Commit the
163
+ // pending highlight with the rows, before a final page can remove More.
164
+ const combined = page === 1 ? result.data.docs : [
165
+ ...rows,
166
+ ...result.data.docs
167
+ ];
168
+ const nextRows = combined.filter((row, index)=>combined.findIndex((item)=>item.id === row.id) === index);
169
+ setRows(nextRows);
170
+ if (firstNew.current != null) {
171
+ const row = nextRows[firstNew.current];
172
+ setActive(row ? String(row.id) : null);
173
+ firstNew.current = null;
174
+ }
175
+ setMore(result.data.hasNextPage);
176
+ } else {
177
+ setMore(false);
178
+ setError(C.error);
179
+ firstNew.current = null;
180
+ }
181
+ setLoading(false);
182
+ });
183
+ return ()=>controller.abort();
184
+ }, [
185
+ api,
186
+ collection.slug,
187
+ q,
188
+ page,
189
+ refresh,
190
+ open
191
+ ]);
192
+ const visibleRows = rows.slice(0, reveal);
193
+ const hasMore = rows.length > reveal || more;
194
+ const optionKeys = [
195
+ ...visibleRows.map((row)=>String(row.id)),
196
+ ...hasMore ? [
197
+ 'more'
198
+ ] : []
199
+ ];
200
+ const activeKey = active != null && optionKeys.includes(active) ? active : undefined;
201
+ useEffect(()=>{
202
+ if (open && activeKey) root.current?.ownerDocument.getElementById(`${id}-option-${activeKey}`)?.scrollIntoView?.({
203
+ block: 'nearest'
204
+ });
205
+ }, [
206
+ open,
207
+ activeKey,
208
+ id
209
+ ]);
210
+ const disabled = (row)=>disabledPageReason(row, armKey, test.doc?.arms ?? [], test.preflight?.pagePlacements, test.chosenPages, collection.label, collection.slug);
211
+ const choose = (key)=>{
212
+ if (blocked) return;
213
+ if (key === 'more') {
214
+ if (loading) return;
215
+ const offset = visibleRows.length;
216
+ const nextReveal = offset + 8;
217
+ setReveal(nextReveal);
218
+ if (rows[offset]) setActive(String(rows[offset].id));
219
+ else firstNew.current = offset;
220
+ if (more && rows.length < nextReveal) {
221
+ setLoading(true);
222
+ setPage((count)=>count + 1);
223
+ }
224
+ return;
225
+ }
226
+ const row = rows.find((item)=>String(item.id) === key);
227
+ if (!arm || !row || row.path === null || disabled(row)) return;
228
+ void test.choosePage(arm.key, {
229
+ id: row.id,
230
+ path: row.path,
231
+ title: row.title,
232
+ published: row.published
233
+ });
234
+ close();
235
+ root.current?.querySelector('input')?.focus();
236
+ };
237
+ const label = pageFieldCopy(arm?.label ?? armKey);
238
+ return /*#__PURE__*/ _jsxs("div", {
239
+ ref: root,
240
+ className: "xp-page-combobox",
241
+ children: [
242
+ /*#__PURE__*/ _jsx(TextInput, {
243
+ id: id,
244
+ label: label,
245
+ role: "combobox",
246
+ "aria-autocomplete": "list",
247
+ autoComplete: "off",
248
+ "aria-expanded": open,
249
+ "aria-controls": `${id}-list`,
250
+ "aria-activedescendant": open && activeKey ? `${id}-option-${activeKey}` : undefined,
251
+ placeholder: C.choose,
252
+ value: open ? search : value,
253
+ disabled: Boolean(locked),
254
+ readOnly: saving,
255
+ "aria-disabled": saving || undefined,
256
+ onClick: ()=>show(),
257
+ onBlur: (event)=>{
258
+ if (!root.current?.contains(event.relatedTarget)) close();
259
+ },
260
+ onPaste: (event)=>{
261
+ if (blocked || open) return;
262
+ event.preventDefault();
263
+ show(event.clipboardData.getData('text/plain'));
264
+ },
265
+ onChange: (event)=>{
266
+ if (blocked) return;
267
+ if (!open) {
268
+ show(event.nativeEvent.data ?? '');
269
+ return;
270
+ }
271
+ const query = event.currentTarget.value;
272
+ if (query === q) reset();
273
+ setSearch(query);
274
+ setRows([]);
275
+ setMore(false);
276
+ setActive(null);
277
+ setReveal(8);
278
+ setLoading(true);
279
+ setError('');
280
+ firstNew.current = null;
281
+ revision.current++;
282
+ },
283
+ onKeyDown: (event)=>{
284
+ if (event.key === 'Escape' || event.key === 'Tab' || event.altKey && event.key === 'ArrowUp') {
285
+ if (open) {
286
+ if (event.key !== 'Tab') event.preventDefault();
287
+ close();
288
+ }
289
+ return;
290
+ }
291
+ if (blocked) return;
292
+ if (!open && event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
293
+ event.preventDefault();
294
+ show(event.key);
295
+ return;
296
+ }
297
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
298
+ event.preventDefault();
299
+ if (!open) {
300
+ show();
301
+ return;
302
+ }
303
+ const index = activeKey ? optionKeys.indexOf(activeKey) : event.key === 'ArrowDown' ? -1 : optionKeys.length;
304
+ setActive(optionKeys[Math.max(0, Math.min(optionKeys.length - 1, index + (event.key === 'ArrowDown' ? 1 : -1)))] ?? null);
305
+ } else if (open && activeKey && (event.key === 'Home' || event.key === 'End')) {
306
+ event.preventDefault();
307
+ setActive(optionKeys[event.key === 'Home' ? 0 : optionKeys.length - 1] ?? null);
308
+ } else if (open && activeKey && event.key === 'Enter' && !event.nativeEvent.isComposing && event.nativeEvent.keyCode !== 229) {
309
+ event.preventDefault();
310
+ choose(activeKey);
311
+ }
312
+ }
313
+ }),
314
+ open ? /*#__PURE__*/ _jsxs("div", {
315
+ className: "xp-page-combobox-popup",
316
+ children: [
317
+ /*#__PURE__*/ _jsxs("div", {
318
+ id: `${id}-list`,
319
+ role: "listbox",
320
+ "aria-label": label,
321
+ children: [
322
+ visibleRows.map((row)=>{
323
+ const reason = disabled(row);
324
+ const selected = currentId != null ? String(row.id) === String(currentId) : matches(row.path);
325
+ return /*#__PURE__*/ _jsxs("div", {
326
+ role: "option",
327
+ id: `${id}-option-${row.id}`,
328
+ "aria-selected": selected,
329
+ "aria-disabled": Boolean(reason) || undefined,
330
+ "data-active": activeKey === String(row.id) || undefined,
331
+ className: "xp-page-option",
332
+ onMouseDown: (event)=>event.preventDefault(),
333
+ onClick: ()=>choose(String(row.id)),
334
+ children: [
335
+ /*#__PURE__*/ _jsx("strong", {
336
+ children: row.title
337
+ }),
338
+ selected ? /*#__PURE__*/ _jsx("span", {
339
+ className: "xp-page-option-check",
340
+ "aria-hidden": "true",
341
+ children: "✓"
342
+ }) : null,
343
+ /*#__PURE__*/ _jsxs("span", {
344
+ className: "xp-page-option-detail",
345
+ children: [
346
+ row.path,
347
+ row.published === false ? ` · ${C.draft}` : '',
348
+ reason ? /*#__PURE__*/ _jsxs(_Fragment, {
349
+ children: [
350
+ /*#__PURE__*/ _jsx("br", {}),
351
+ reason
352
+ ]
353
+ }) : null
354
+ ]
355
+ })
356
+ ]
357
+ }, row.id);
358
+ }),
359
+ hasMore ? /*#__PURE__*/ _jsx("div", {
360
+ role: "option",
361
+ id: `${id}-option-more`,
362
+ className: "xp-page-option",
363
+ "data-active": activeKey === 'more' || undefined,
364
+ "aria-disabled": loading || undefined,
365
+ onMouseDown: (event)=>event.preventDefault(),
366
+ onClick: ()=>choose('more'),
367
+ children: C.more
368
+ }) : null
369
+ ]
370
+ }),
371
+ loading && !rows.length ? /*#__PURE__*/ _jsx("p", {
372
+ role: "status",
373
+ children: C.loading
374
+ }) : null,
375
+ !loading && !error && !rows.length ? /*#__PURE__*/ _jsx("p", {
376
+ role: "status",
377
+ children: C.empty
378
+ }) : null,
379
+ error ? /*#__PURE__*/ _jsxs("div", {
380
+ role: "alert",
381
+ children: [
382
+ error,
383
+ " ",
384
+ /*#__PURE__*/ _jsx("button", {
385
+ type: "button",
386
+ onClick: reset,
387
+ children: C.retry
388
+ })
389
+ ]
390
+ }) : null
391
+ ]
392
+ }) : null
393
+ ]
394
+ });
395
+ }
@@ -0,0 +1,10 @@
1
+ .xp-page-combobox { position: relative; width: 100%; min-width: 0; }
2
+ .xp-page-combobox input { width: 100%; }
3
+ .xp-page-combobox-popup { position: absolute; inset-inline: 0; z-index: 5; max-height: 24rem; overflow-y: auto; background: var(--xp-surface); color: var(--xp-text); border: 1px solid var(--xp-border-strong); border-radius: var(--xp-radius-control); padding: var(--xp-space-1); }
4
+ .xp-page-option { position: relative; display: block; width: 100%; box-sizing: border-box; text-align: left; padding: var(--xp-space-3); padding-inline-end: var(--xp-space-6); border: 2px solid transparent; border-radius: var(--xp-radius-control); cursor: pointer; color: var(--xp-text); background: var(--xp-surface); overflow-wrap: anywhere; }
5
+ .xp-page-option-detail { display: block; color: var(--xp-text-2); font: var(--xp-text-small); }
6
+ .xp-page-option[aria-selected='true'] { border-color: var(--xp-accent); }
7
+ .xp-page-option-check { position: absolute; inset-inline-end: var(--xp-space-2); top: var(--xp-space-3); color: var(--xp-accent); }
8
+ .xp-page-option[data-active='true'] { background: var(--xp-surface-2); outline: 2px solid var(--xp-focus); outline-offset: -4px; }
9
+ .xp-page-option[aria-disabled='true'] { cursor: not-allowed; border-color: var(--xp-text-2); border-style: dashed; }
10
+ .xp-page-option[aria-disabled='true'] strong { font-weight: 400; }
@@ -0,0 +1,27 @@
1
+ import type { Arm, DocId } from '../../../types/contracts.js';
2
+ import type { PagesResult, PreflightResult } from '../../../types/endpoints.js';
3
+ import type { TestsSurfaceProps } from '../../../types/surface.js';
4
+ export type PageRow = PagesResult['docs'][number];
5
+ export type PageCollection = NonNullable<TestsSurfaceProps['pageCollections']>[number];
6
+ export type PageSelections = Partial<Record<Arm['key'], {
7
+ id: DocId;
8
+ path: string;
9
+ title?: string;
10
+ published?: boolean;
11
+ }>>;
12
+ /** The server resolves legacy collections; the client consumes its placements verbatim. */
13
+ export declare function testPageCollection(collections: readonly PageCollection[] | undefined, doc: {
14
+ pageCollection?: string | null;
15
+ }, placements: PreflightResult['pagePlacements']): {
16
+ slug: string;
17
+ label: string;
18
+ contentChecks: boolean;
19
+ copyHint?: string;
20
+ copyUrl?: string;
21
+ copyLabel?: string;
22
+ } | undefined;
23
+ export declare const controlFirst: (arms: readonly Arm[]) => Arm[];
24
+ /** Identity wins over address, so distinct documents sharing an address get actionable copy. */
25
+ export declare function disabledPageReason(row: PageRow, ownKey: string, arms: readonly Arm[], placements: PreflightResult['pagePlacements'], chosen: PageSelections, collection: string, collectionSlug?: string): string | null;
26
+ /** Pure request revision comparison, also used after an aborted transport resolves. */
27
+ export declare const isLatestPageRequest: (response: number, latest: number) => boolean;
@@ -0,0 +1,25 @@
1
+ import { normalisePath } from '../../../runtime/normalisePath.js';
2
+ import { PAGE_COPY, usedAddressCopy, usedPageCopy } from '../../copy/pages.js';
3
+ import { actionGroupLabel } from '../../actions/actions.model.js';
4
+ /** The server resolves legacy collections; the client consumes its placements verbatim. */ export function testPageCollection(collections, doc, placements) {
5
+ const slug = doc.pageCollection || placements?.find((entry)=>entry.collection)?.collection;
6
+ return collections?.find((entry)=>entry.slug === slug);
7
+ }
8
+ export const controlFirst = (arms)=>[
9
+ ...arms
10
+ ].sort((a, b)=>Number(b.kind === 'control') - Number(a.kind === 'control'));
11
+ /** Identity wins over address, so distinct documents sharing an address get actionable copy. */ export function disabledPageReason(row, ownKey, arms, placements, chosen, collection, collectionSlug) {
12
+ if (row.path === null || row.pathKey === null) return PAGE_COPY.noPath;
13
+ const others = controlFirst(arms).filter((arm)=>arm.key !== ownKey && arm.kind !== 'holdout');
14
+ for (const arm of others){
15
+ const selection = chosen[arm.key];
16
+ const remembered = selection && arm.pageTarget && normalisePath(selection.path) === normalisePath(arm.pageTarget) ? selection.id : undefined;
17
+ const id = remembered ?? placements?.find((entry)=>entry.arm === arm.key && (!collectionSlug || entry.collection === collectionSlug))?.id;
18
+ if (id != null ? String(id) === String(row.id) : Boolean(arm.pageTarget && normalisePath(arm.pageTarget) === row.pathKey)) {
19
+ return usedPageCopy(actionGroupLabel(arm.key, arm.label));
20
+ }
21
+ }
22
+ const same = others.find((arm)=>arm.pageTarget && normalisePath(arm.pageTarget) === row.pathKey);
23
+ return same ? usedAddressCopy(actionGroupLabel(same.key, same.label), row.path, collection) : null;
24
+ }
25
+ /** Pure request revision comparison, also used after an aborted transport resolves. */ export const isLatestPageRequest = (response, latest)=>response === latest;
@@ -1,13 +1,15 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { DocsResponseError, fetchAllDocs } from '../../../lib/fetchAllDocs.js';
4
+ import { usePlacementLabels, placementDetailKey } from '../../placements/usePlacementLabels.js';
5
+ import { placementUncheckedCopy } from '../../copy/readiness.js';
4
6
  import { useEffect, useState } from 'react';
5
7
  import { Button } from '../../../ui/Button.js';
6
8
  import { TextInput } from '../../../ui/Inputs.js';
7
9
  import { Dialog } from '../../../ui/Dialog.js';
8
10
  import { MakeContentResult } from '../../actions/MakeContentResult.js';
9
11
  import { expectedConfirm, matchesConfirm } from '../../../transitions/confirmPhrases.js';
10
- import { ACTIONS_COPY as A } from '../../copy/actions.js';
12
+ import { ACTIONS_COPY as A, addMetricWarningCopy } from '../../copy/actions.js';
11
13
  import { REVIEW_COPY as R } from '../../copy/review.js';
12
14
  import { READINESS_COPY as C } from '../../copy/readiness.js';
13
15
  import { API_MESSAGES, refusalMessage } from '../../copy/common.js';
@@ -187,6 +189,12 @@ import { useTransition } from './useTransition.js';
187
189
  const [loading, setLoading] = useState(true);
188
190
  const [loadError, setLoadError] = useState(null);
189
191
  const [metric, setMetric] = useState('');
192
+ const [warnings, setWarnings] = useState(null);
193
+ const placementLabels = usePlacementLabels({
194
+ details: (warnings ?? []).filter((warning)=>warning.code === 'placement-unchecked').map((warning)=>warning.detail),
195
+ preflight: test.preflight,
196
+ ...surface
197
+ });
190
198
  const [role, setRole] = useState('secondary');
191
199
  const transition = useTransition(test, onClose);
192
200
  useEffect(()=>{
@@ -217,85 +225,112 @@ import { useTransition } from './useTransition.js';
217
225
  const available = metrics.filter((item)=>!attached.has(String(item.id)));
218
226
  const selected = available.find((item)=>String(item.id) === metric);
219
227
  const disabled = transition.busy || test.hasUnsaved || test.saving === 'saving';
228
+ function close() {
229
+ if (transition.busy) return;
230
+ if (warnings) void test.reload();
231
+ onClose();
232
+ }
220
233
  return /*#__PURE__*/ _jsxs(Dialog, {
221
234
  open: true,
222
235
  dismissible: !transition.busy,
223
236
  title: C.addGoal,
224
- onClose: ()=>{
225
- if (!transition.busy) onClose();
226
- },
237
+ onClose: close,
227
238
  children: [
228
- /*#__PURE__*/ _jsx("p", {
229
- children: C.addMetric
230
- }),
231
- loading ? /*#__PURE__*/ _jsx("p", {
232
- role: "status",
233
- children: R.loadingMetrics
234
- }) : null,
235
- /*#__PURE__*/ _jsxs("label", {
239
+ warnings ? warnings.map((warning, index)=>{
240
+ const placement = warning.code === 'placement-unchecked' ? placementLabels.get(placementDetailKey(warning.detail)) : undefined;
241
+ return /*#__PURE__*/ _jsx("p", {
242
+ children: placement ? placement.href ? /*#__PURE__*/ _jsx("a", {
243
+ href: placement.href,
244
+ children: placementUncheckedCopy(placement.title)
245
+ }) : placementUncheckedCopy(placement.title) : addMetricWarningCopy(warning)
246
+ }, index);
247
+ }) : /*#__PURE__*/ _jsxs(_Fragment, {
236
248
  children: [
237
- R.role,
238
- /*#__PURE__*/ _jsxs("select", {
239
- value: role,
240
- disabled: disabled,
241
- onChange: (event)=>setRole(event.target.value),
249
+ /*#__PURE__*/ _jsx("p", {
250
+ children: C.addMetric
251
+ }),
252
+ loading ? /*#__PURE__*/ _jsx("p", {
253
+ role: "status",
254
+ children: R.loadingMetrics
255
+ }) : null,
256
+ /*#__PURE__*/ _jsxs("label", {
242
257
  children: [
243
- /*#__PURE__*/ _jsx("option", {
244
- value: "secondary",
245
- children: R.supporting
246
- }),
247
- /*#__PURE__*/ _jsx("option", {
248
- value: "guardrail",
249
- children: R.guardrail
258
+ R.role,
259
+ /*#__PURE__*/ _jsxs("select", {
260
+ value: role,
261
+ disabled: disabled,
262
+ onChange: (event)=>setRole(event.target.value),
263
+ children: [
264
+ /*#__PURE__*/ _jsx("option", {
265
+ value: "secondary",
266
+ children: R.supporting
267
+ }),
268
+ /*#__PURE__*/ _jsx("option", {
269
+ value: "guardrail",
270
+ children: R.guardrail
271
+ })
272
+ ]
250
273
  })
251
274
  ]
252
- })
253
- ]
254
- }),
255
- /*#__PURE__*/ _jsxs("label", {
256
- children: [
257
- R.metric,
258
- /*#__PURE__*/ _jsxs("select", {
259
- value: metric,
260
- disabled: disabled || loading,
261
- onChange: (event)=>setMetric(event.target.value),
275
+ }),
276
+ /*#__PURE__*/ _jsxs("label", {
262
277
  children: [
263
- /*#__PURE__*/ _jsx("option", {
264
- value: "",
265
- children: R.chooseMetric
266
- }),
267
- available.map((item)=>/*#__PURE__*/ _jsx("option", {
268
- value: String(item.id),
269
- children: item.name
270
- }, item.id))
278
+ R.metric,
279
+ /*#__PURE__*/ _jsxs("select", {
280
+ value: metric,
281
+ disabled: disabled || loading,
282
+ onChange: (event)=>setMetric(event.target.value),
283
+ children: [
284
+ /*#__PURE__*/ _jsx("option", {
285
+ value: "",
286
+ children: R.chooseMetric
287
+ }),
288
+ available.map((item)=>/*#__PURE__*/ _jsx("option", {
289
+ value: String(item.id),
290
+ children: item.name
291
+ }, item.id))
292
+ ]
293
+ })
271
294
  ]
295
+ }),
296
+ !loading && !loadError && !available.length ? /*#__PURE__*/ _jsx("p", {
297
+ children: R.noMetrics
298
+ }) : null,
299
+ loadError || transition.error ? /*#__PURE__*/ _jsx("p", {
300
+ role: "alert",
301
+ children: loadError ?? transition.error
302
+ }) : null,
303
+ /*#__PURE__*/ _jsx(Button, {
304
+ disabled: disabled || loading || !!loadError || !selected,
305
+ onClick: ()=>{
306
+ // Goal and phase lookups share not-found; prefer the more likely missing goal without reading server text.
307
+ if (selected && test.doc) void transition.run(async ()=>{
308
+ const result = await createApi(surface.apiRoute).addMetric(test.doc.id, {
309
+ metric: selected.id,
310
+ role
311
+ }, {
312
+ notFound: API_MESSAGES.goalNotFound
313
+ });
314
+ if (result.ok) {
315
+ if (result.data?.warnings?.length) setWarnings(result.data.warnings);
316
+ else {
317
+ await test.reload();
318
+ onClose();
319
+ }
320
+ }
321
+ return result;
322
+ }, {
323
+ reloadOnSuccess: false
324
+ });
325
+ },
326
+ children: C.addGoal
272
327
  })
273
328
  ]
274
329
  }),
275
- !loading && !loadError && !available.length ? /*#__PURE__*/ _jsx("p", {
276
- children: R.noMetrics
277
- }) : null,
278
- loadError || transition.error ? /*#__PURE__*/ _jsx("p", {
279
- role: "alert",
280
- children: loadError ?? transition.error
281
- }) : null,
282
- /*#__PURE__*/ _jsx(Button, {
283
- disabled: disabled || loading || !!loadError || !selected,
284
- onClick: ()=>{
285
- // Goal and phase lookups share not-found; prefer the more likely missing goal without reading server text.
286
- if (selected && test.doc) void transition.run(()=>createApi(surface.apiRoute).addMetric(test.doc.id, {
287
- metric: selected.id,
288
- role
289
- }, {
290
- notFound: API_MESSAGES.goalNotFound
291
- }));
292
- },
293
- children: C.addGoal
294
- }),
295
330
  /*#__PURE__*/ _jsx(Button, {
296
331
  disabled: transition.busy,
297
- onClick: onClose,
298
- children: A.cancel
332
+ onClick: close,
333
+ children: warnings ? A.done : A.cancel
299
334
  })
300
335
  ]
301
336
  });