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

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 (265) hide show
  1. package/README.md +113 -20
  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/actions/ConfirmDialog.d.ts +2 -1
  8. package/dist/admin/actions/ConfirmDialog.js +2 -2
  9. package/dist/admin/actions/DecideDialog.d.ts +2 -1
  10. package/dist/admin/actions/DecideDialog.js +2 -2
  11. package/dist/admin/api.d.ts +11 -2
  12. package/dist/admin/api.js +20 -2
  13. package/dist/admin/blocks/BlockPanel.d.ts +4 -0
  14. package/dist/admin/blocks/BlockPanel.js +153 -0
  15. package/dist/admin/blocks/TestBlockField.d.ts +5 -0
  16. package/dist/admin/blocks/TestBlockField.js +163 -0
  17. package/dist/admin/blocks/formApi.d.ts +43 -0
  18. package/dist/admin/blocks/formApi.js +153 -0
  19. package/dist/admin/blocks/inject.d.ts +7 -0
  20. package/dist/admin/blocks/inject.js +41 -0
  21. package/dist/admin/blocks/model.d.ts +17 -0
  22. package/dist/admin/blocks/model.js +45 -0
  23. package/dist/admin/blocks/statusCache.d.ts +7 -0
  24. package/dist/admin/blocks/statusCache.js +83 -0
  25. package/dist/admin/blocks/useBlockForm.d.ts +17 -0
  26. package/dist/admin/blocks/useBlockForm.js +46 -0
  27. package/dist/admin/copy/actions.d.ts +2 -0
  28. package/dist/admin/copy/actions.js +9 -0
  29. package/dist/admin/copy/blocks.d.ts +27 -0
  30. package/dist/admin/copy/blocks.js +38 -0
  31. package/dist/admin/copy/goals.d.ts +2 -0
  32. package/dist/admin/copy/goals.js +4 -1
  33. package/dist/admin/copy/newTest.d.ts +10 -0
  34. package/dist/admin/copy/newTest.js +10 -0
  35. package/dist/admin/copy/pages.d.ts +26 -0
  36. package/dist/admin/copy/pages.js +26 -0
  37. package/dist/admin/copy/readiness.d.ts +22 -3
  38. package/dist/admin/copy/readiness.js +23 -3
  39. package/dist/admin/copy/results.d.ts +1 -0
  40. package/dist/admin/copy/results.js +1 -0
  41. package/dist/admin/copy/testPage.d.ts +4 -0
  42. package/dist/admin/copy/testPage.js +4 -0
  43. package/dist/admin/goals/XpGoalField.d.ts +2 -0
  44. package/dist/admin/goals/XpGoalField.js +152 -0
  45. package/dist/admin/metricSource.model.d.ts +2 -0
  46. package/dist/admin/metricSource.model.js +4 -0
  47. package/dist/admin/pageCollections.d.ts +10 -0
  48. package/dist/admin/pageCollections.js +20 -0
  49. package/dist/admin/paths.d.ts +6 -0
  50. package/dist/admin/paths.js +6 -0
  51. package/dist/admin/placementStatus.model.d.ts +2 -0
  52. package/dist/admin/placementStatus.model.js +19 -0
  53. package/dist/admin/placements/placementLabels.d.ts +11 -0
  54. package/dist/admin/placements/placementLabels.js +21 -0
  55. package/dist/admin/placements/placementLabels.model.d.ts +13 -0
  56. package/dist/admin/placements/placementLabels.model.js +12 -0
  57. package/dist/admin/placements/usePlacementLabels.d.ts +12 -0
  58. package/dist/admin/placements/usePlacementLabels.js +102 -0
  59. package/dist/admin/results/DataQualityPanel.js +4 -1
  60. package/dist/admin/results/results.model.d.ts +3 -0
  61. package/dist/admin/results/results.model.js +8 -4
  62. package/dist/admin/surface/TestsHome.js +37 -6
  63. package/dist/admin/surface/TestsSurface.js +5 -1
  64. package/dist/admin/surface/contracts.d.ts +6 -5
  65. package/dist/admin/surface/goals/GoalForm.js +67 -34
  66. package/dist/admin/surface/goals/GoalsList.js +3 -3
  67. package/dist/admin/surface/goals/data.js +4 -1
  68. package/dist/admin/surface/goals/goals.model.d.ts +1 -2
  69. package/dist/admin/surface/goals/goals.model.js +1 -3
  70. package/dist/admin/surface/home.model.d.ts +1 -1
  71. package/dist/admin/surface/home.model.js +8 -4
  72. package/dist/admin/surface/newTest/NewTest.js +54 -13
  73. package/dist/admin/surface/newTest/PageWizard.d.ts +7 -0
  74. package/dist/admin/surface/newTest/PageWizard.js +209 -0
  75. package/dist/admin/surface/pagePicker/CopyRoute.d.ts +6 -0
  76. package/dist/admin/surface/pagePicker/CopyRoute.js +53 -0
  77. package/dist/admin/surface/pagePicker/PagePicker.d.ts +10 -0
  78. package/dist/admin/surface/pagePicker/PagePicker.js +172 -0
  79. package/dist/admin/surface/pagePicker/pagePicker.model.d.ts +25 -0
  80. package/dist/admin/surface/pagePicker/pagePicker.model.js +25 -0
  81. package/dist/admin/surface/readiness/ActionDialogs.js +99 -64
  82. package/dist/admin/surface/readiness/Banner.js +30 -6
  83. package/dist/admin/surface/readiness/readiness.model.d.ts +8 -1
  84. package/dist/admin/surface/readiness/readiness.model.js +91 -9
  85. package/dist/admin/surface/results/ReleasePages.d.ts +3 -1
  86. package/dist/admin/surface/results/ReleasePages.js +3 -7
  87. package/dist/admin/surface/results/ResultsBody.js +21 -2
  88. package/dist/admin/surface/results/ResultsHeader.js +2 -5
  89. package/dist/admin/surface/results/useResults.d.ts +10 -1
  90. package/dist/admin/surface/test/AdvancedSection.js +111 -104
  91. package/dist/admin/surface/test/GoalSection.js +90 -7
  92. package/dist/admin/surface/test/LengthSection.js +1 -1
  93. package/dist/admin/surface/test/PageHoldout.d.ts +3 -0
  94. package/dist/admin/surface/test/PageHoldout.js +67 -0
  95. package/dist/admin/surface/test/TestPage.js +2 -1
  96. package/dist/admin/surface/test/VersionsSection.js +12 -5
  97. package/dist/admin/surface/test/fields.d.ts +13 -4
  98. package/dist/admin/surface/test/fields.js +46 -14
  99. package/dist/admin/surface/test/jumpToSection.d.ts +1 -1
  100. package/dist/admin/surface/test/jumpToSection.js +4 -3
  101. package/dist/admin/surface/test/pageParts.d.ts +4 -1
  102. package/dist/admin/surface/test/pageParts.js +79 -2
  103. package/dist/admin/surface/test/testPage.css +8 -1
  104. package/dist/admin/surface/testDocument.model.d.ts +1 -0
  105. package/dist/admin/surface/testDocument.model.js +2 -1
  106. package/dist/admin/surface/useTestDocument.d.ts +11 -2
  107. package/dist/admin/surface/useTestDocument.js +53 -15
  108. package/dist/admin/useApi.d.ts +11 -2
  109. package/dist/admin/useApi.js +3 -5
  110. package/dist/admin/views/ExperimentsRootView.js +58 -16
  111. package/dist/blocks/bindings.d.ts +3 -0
  112. package/dist/blocks/bindings.js +34 -0
  113. package/dist/blocks/createExperimentBlock.d.ts +6 -0
  114. package/dist/blocks/createExperimentBlock.js +77 -0
  115. package/dist/blocks/formState.d.ts +31 -0
  116. package/dist/blocks/formState.js +146 -0
  117. package/dist/blocks/model.d.ts +41 -0
  118. package/dist/blocks/model.js +34 -0
  119. package/dist/client/ExperimentGroup.d.ts +6 -0
  120. package/dist/client/ExperimentGroup.js +9 -0
  121. package/dist/client/ExperimentsProvider.js +20 -4
  122. package/dist/client/clickGoals.d.ts +4 -0
  123. package/dist/client/clickGoals.js +43 -0
  124. package/dist/collections/experiments/config.js +12 -2
  125. package/dist/collections/experiments/deleteGuard.d.ts +2 -1
  126. package/dist/collections/experiments/deleteGuard.js +12 -1
  127. package/dist/collections/experiments/editability.js +4 -2
  128. package/dist/collections/experiments/pickSlug.d.ts +2 -0
  129. package/dist/collections/experiments/pickSlug.js +8 -0
  130. package/dist/collections/labels.d.ts +3 -0
  131. package/dist/collections/labels.js +20 -0
  132. package/dist/collections/metrics.js +12 -2
  133. package/dist/collections/phases.js +1 -0
  134. package/dist/collections/serialize.js +7 -0
  135. package/dist/db/clickGoals.d.ts +9 -0
  136. package/dist/db/clickGoals.js +22 -0
  137. package/dist/db/delete.d.ts +1 -0
  138. package/dist/db/delete.js +1 -0
  139. package/dist/db/locks.d.ts +8 -0
  140. package/dist/db/locks.js +27 -4
  141. package/dist/db/names.d.ts +4 -0
  142. package/dist/db/names.js +4 -0
  143. package/dist/db/pageBindings.d.ts +4 -0
  144. package/dist/db/pageBindings.js +17 -0
  145. package/dist/db/pageBusy.d.ts +2 -0
  146. package/dist/db/pageBusy.js +10 -0
  147. package/dist/db/pageContent.d.ts +10 -0
  148. package/dist/db/pageContent.js +79 -0
  149. package/dist/db/placements.d.ts +33 -0
  150. package/dist/db/placements.js +54 -0
  151. package/dist/db/slugs.d.ts +8 -0
  152. package/dist/db/slugs.js +40 -0
  153. package/dist/db/snapshots.d.ts +6 -0
  154. package/dist/db/snapshots.js +8 -5
  155. package/dist/endpoints/clickKeys.d.ts +5 -0
  156. package/dist/endpoints/clickKeys.js +17 -0
  157. package/dist/endpoints/events.js +4 -1
  158. package/dist/endpoints/index.js +28 -0
  159. package/dist/endpoints/pages.d.ts +5 -0
  160. package/dist/endpoints/pages.js +120 -0
  161. package/dist/endpoints/placementStatus.d.ts +6 -0
  162. package/dist/endpoints/placementStatus.js +238 -0
  163. package/dist/endpoints/readBody.d.ts +2 -0
  164. package/dist/endpoints/readBody.js +24 -0
  165. package/dist/endpoints/results.js +5 -1
  166. package/dist/endpoints/summary.js +11 -4
  167. package/dist/endpoints/wrapBlock.d.ts +6 -0
  168. package/dist/endpoints/wrapBlock.js +76 -0
  169. package/dist/errors.d.ts +3 -3
  170. package/dist/errors.js +4 -4
  171. package/dist/events/rows.d.ts +1 -1
  172. package/dist/events/rows.js +5 -3
  173. package/dist/exports/adminClient.d.ts +5 -0
  174. package/dist/exports/adminClient.js +5 -0
  175. package/dist/exports/blocks.d.ts +2 -0
  176. package/dist/exports/blocks.js +1 -0
  177. package/dist/exports/client.d.ts +1 -0
  178. package/dist/exports/client.js +1 -0
  179. package/dist/exports/index.d.ts +6 -1
  180. package/dist/exports/index.js +3 -0
  181. package/dist/exports/puck.d.ts +3 -1
  182. package/dist/exports/puck.js +2 -0
  183. package/dist/exports/puckEditor.d.ts +3 -0
  184. package/dist/exports/puckEditor.js +2 -0
  185. package/dist/exports/rsc.d.ts +3 -0
  186. package/dist/exports/rsc.js +2 -0
  187. package/dist/fields/xpGoal.d.ts +3 -0
  188. package/dist/fields/xpGoal.js +28 -0
  189. package/dist/globals/state.js +10 -0
  190. package/dist/goals/cache.d.ts +3 -0
  191. package/dist/goals/cache.js +35 -0
  192. package/dist/goals/fairness.d.ts +40 -0
  193. package/dist/goals/fairness.js +200 -0
  194. package/dist/goals/key.d.ts +4 -0
  195. package/dist/goals/key.js +3 -0
  196. package/dist/goals/picker/clickGoalPicker.model.d.ts +29 -0
  197. package/dist/goals/picker/clickGoalPicker.model.js +45 -0
  198. package/dist/goals/picker/copy.d.ts +16 -0
  199. package/dist/goals/picker/copy.js +15 -0
  200. package/dist/goals/picker/useClickGoalPicker.d.ts +30 -0
  201. package/dist/goals/picker/useClickGoalPicker.js +220 -0
  202. package/dist/hooks/pageGuard.d.ts +5 -1
  203. package/dist/hooks/pageGuard.js +157 -95
  204. package/dist/onInit.js +29 -0
  205. package/dist/options.d.ts +2 -0
  206. package/dist/options.js +41 -1
  207. package/dist/plugin.js +41 -28
  208. package/dist/puck/copy.d.ts +1 -0
  209. package/dist/puck/copy.js +1 -0
  210. package/dist/puck/editor/editor.chrome.css +1 -0
  211. package/dist/puck/editor/editor.css +1 -0
  212. package/dist/puck/editor/editor.model.d.ts +4 -2
  213. package/dist/puck/editor/editor.model.js +6 -3
  214. package/dist/puck/editor/integration.d.ts +10 -0
  215. package/dist/puck/editor/integration.js +30 -0
  216. package/dist/puck/editor/xpGoalPuckEditorField.d.ts +7 -0
  217. package/dist/puck/editor/xpGoalPuckEditorField.js +134 -0
  218. package/dist/puck/integration.d.ts +6 -0
  219. package/dist/puck/integration.js +21 -0
  220. package/dist/puck/xpGoalField.d.ts +3 -0
  221. package/dist/puck/xpGoalField.js +6 -0
  222. package/dist/results/health.d.ts +4 -0
  223. package/dist/results/health.js +27 -0
  224. package/dist/results/snapshot.js +15 -2
  225. package/dist/sanitizeReadinessDetail.js +41 -0
  226. package/dist/server/pageKindSeoContract.d.ts +27 -0
  227. package/dist/server/pageKindSeoContract.js +80 -0
  228. package/dist/server/recordContentChange.js +58 -8
  229. package/dist/server/resolveExperimentBlocks.d.ts +5 -0
  230. package/dist/server/resolveExperimentBlocks.js +54 -0
  231. package/dist/server/runResults.js +56 -15
  232. package/dist/server/toClientAssignments.js +2 -1
  233. package/dist/transitions/addMetric.d.ts +2 -1
  234. package/dist/transitions/addMetric.js +28 -1
  235. package/dist/transitions/checkStart.d.ts +13 -1
  236. package/dist/transitions/checkStart.js +89 -7
  237. package/dist/transitions/decide.js +24 -0
  238. package/dist/transitions/deleteExperiment.d.ts +3 -1
  239. package/dist/transitions/deleteExperiment.js +27 -1
  240. package/dist/transitions/kernel.js +4 -0
  241. package/dist/transitions/makeContent.d.ts +5 -0
  242. package/dist/transitions/makeContent.js +39 -11
  243. package/dist/transitions/pageCollection.d.ts +10 -0
  244. package/dist/transitions/pageCollection.js +20 -0
  245. package/dist/transitions/pageVersions.d.ts +10 -0
  246. package/dist/transitions/pageVersions.js +44 -0
  247. package/dist/transitions/phaseRecord.d.ts +1 -0
  248. package/dist/transitions/placementInteractions.d.ts +40 -0
  249. package/dist/transitions/placementInteractions.js +94 -0
  250. package/dist/transitions/placements.d.ts +8 -0
  251. package/dist/transitions/placements.js +148 -1
  252. package/dist/transitions/preflightData.d.ts +2 -2
  253. package/dist/transitions/preflightData.js +137 -5
  254. package/dist/transitions/resolveCaps.d.ts +1 -0
  255. package/dist/transitions/resolveCaps.js +6 -2
  256. package/dist/transitions/start.js +45 -1
  257. package/dist/types/admin.d.ts +1 -1
  258. package/dist/types/assignments.d.ts +1 -0
  259. package/dist/types/contracts.d.ts +15 -0
  260. package/dist/types/endpoints.d.ts +92 -5
  261. package/dist/types/index.d.ts +3 -0
  262. package/dist/types/puck.d.ts +7 -1
  263. package/dist/types/snapshot.d.ts +17 -4
  264. package/dist/types/surface.d.ts +13 -0
  265. package/package.json +8 -1
@@ -0,0 +1,163 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { Button, SelectInput, useAuth, useConfig, useDocumentInfo, useForm, useFormFields, useFormModified, useServerFunctions } from '@payloadcms/ui';
4
+ import { formatAdminURL } from 'payload/shared';
5
+ import { useMemo, useRef, useState } from 'react';
6
+ import { hostBlocks } from '../../blocks/model.js';
7
+ import { wrapExperimentBlock } from '../../blocks/formState.js';
8
+ import { CreateRefused, createTest } from '../../puck/editor/createTest.js';
9
+ import { createApi } from '../api.js';
10
+ import { BLOCK_COPY, blockSaveNotice, blockTestName } from '../copy/blocks.js';
11
+ import { API_MESSAGES } from '../copy/common.js';
12
+ import { adaptClientBlocksField, BlockChangedError, dataAt, experimentBlockSlug, findClientBlocksField, replaceBlockRow, wrapMode } from './formApi.js';
13
+ import { blockChoices, labelText } from './model.js';
14
+ export function TestBlockField(props) {
15
+ const { permissions } = useAuth();
16
+ const testPermissions = permissions?.collections?.['xp-experiments'];
17
+ return testPermissions?.update === true ? /*#__PURE__*/ _jsx(ManagedTestBlockField, {
18
+ ...props
19
+ }) : null;
20
+ }
21
+ function ManagedTestBlockField({ blocksField, blockSlug: suppliedSlug }) {
22
+ const form = useForm(), modified = useFormModified(), document = useDocumentInfo();
23
+ const { config, getEntityConfig } = useConfig(), { getFormState } = useServerFunctions();
24
+ useFormFields(([fields])=>fields);
25
+ const collection = document.collectionSlug ? getEntityConfig({
26
+ collectionSlug: document.collectionSlug
27
+ }) : undefined;
28
+ const clientField = collection ? findClientBlocksField(collection.fields, blocksField) : undefined;
29
+ const field = useMemo(()=>clientField ? adaptClientBlocksField(clientField, config) : undefined, [
30
+ clientField,
31
+ config
32
+ ]);
33
+ const [choice, setChoice] = useState(), [busy, setBusy] = useState(false), [error, setError] = useState();
34
+ const wrapping = useRef(false);
35
+ if (!field || !collection || !document.collectionSlug) return null;
36
+ const blockSlug = suppliedSlug ?? experimentBlockSlug(field);
37
+ const rows = hostBlocks(form.getDataByPath(blocksField)), choices = blockChoices(rows, field, blockSlug);
38
+ const wrappers = rows.filter((block)=>block.blockType === blockSlug && typeof block.experiment === 'string');
39
+ async function wrap() {
40
+ if (!field || wrapping.current || !document.collectionSlug || !collection) return;
41
+ setError(undefined);
42
+ if (wrapMode === 'fallback' && modified) {
43
+ setError(BLOCK_COPY.saveFirst);
44
+ return;
45
+ }
46
+ const selected = blockChoices(form.getDataByPath(blocksField), field, blockSlug).find((item)=>String(item.index) === choice);
47
+ if (!selected) return;
48
+ const title = collection.admin?.useAsTitle ? form.getDataByPath(collection.admin.useAsTitle) : undefined;
49
+ const name = blockTestName(typeof title === 'string' && title.trim() ? title : labelText(collection.labels.singular) ?? collection.slug, selected.label);
50
+ const beforeCreate = JSON.stringify(form.getData());
51
+ wrapping.current = true;
52
+ setBusy(true);
53
+ try {
54
+ const test = await createTest(config.routes.api, {
55
+ kind: 'block',
56
+ name,
57
+ unitType: 'visitor'
58
+ });
59
+ if (wrapMode === 'fallback') {
60
+ if (JSON.stringify(form.getData()) !== beforeCreate) {
61
+ setError(BLOCK_COPY.saveFirst);
62
+ return;
63
+ }
64
+ if (document.id == null || !selected.block.id) {
65
+ setError(BLOCK_COPY.wrapRefusals['wrap-target-missing']);
66
+ return;
67
+ }
68
+ const result = await createApi(config.routes.api).wrapBlock({
69
+ collection: document.collectionSlug,
70
+ id: document.id,
71
+ field: blocksField,
72
+ blockId: selected.block.id,
73
+ experiment: test.slug
74
+ });
75
+ if (!result.ok) {
76
+ setError(result.code && Object.hasOwn(BLOCK_COPY.wrapRefusals, result.code) ? BLOCK_COPY.wrapRefusals[result.code] : API_MESSAGES.failed);
77
+ return;
78
+ }
79
+ window.location.reload();
80
+ } else {
81
+ await replaceBlockRow({
82
+ form,
83
+ getFormState,
84
+ collectionSlug: document.collectionSlug,
85
+ id: document.id,
86
+ field: blocksField,
87
+ index: selected.index,
88
+ expectedBlock: selected.block,
89
+ rows: [
90
+ wrapExperimentBlock(selected.block, test.slug, field, {}, blockSlug)
91
+ ],
92
+ docPermissions: document.docPermissions,
93
+ docPreferences: await document.getDocPreferences?.()
94
+ });
95
+ }
96
+ setChoice(undefined);
97
+ } catch (cause) {
98
+ setError(cause instanceof CreateRefused || cause instanceof BlockChangedError ? cause.message : API_MESSAGES.failed);
99
+ } finally{
100
+ wrapping.current = false;
101
+ setBusy(false);
102
+ }
103
+ }
104
+ return /*#__PURE__*/ _jsxs("div", {
105
+ children: [
106
+ /*#__PURE__*/ _jsx("h3", {
107
+ children: BLOCK_COPY.testBlock
108
+ }),
109
+ choices.length ? /*#__PURE__*/ _jsxs(_Fragment, {
110
+ children: [
111
+ /*#__PURE__*/ _jsx(SelectInput, {
112
+ name: "xpBlockChoice",
113
+ path: "xpBlockChoice",
114
+ label: BLOCK_COPY.pick,
115
+ options: choices.map((item)=>({
116
+ label: item.option,
117
+ value: String(item.index)
118
+ })),
119
+ value: choice,
120
+ readOnly: busy,
121
+ onChange: (option)=>{
122
+ if (!Array.isArray(option)) setChoice(option?.value);
123
+ }
124
+ }),
125
+ /*#__PURE__*/ _jsx(Button, {
126
+ disabled: busy || choice === undefined,
127
+ onClick: ()=>void wrap(),
128
+ children: BLOCK_COPY.testBlock
129
+ })
130
+ ]
131
+ }) : /*#__PURE__*/ _jsx("p", {
132
+ children: BLOCK_COPY.empty
133
+ }),
134
+ error ? /*#__PURE__*/ _jsx("p", {
135
+ role: "alert",
136
+ children: error
137
+ }) : null,
138
+ wrappers.map((block)=>{
139
+ const slug = block.experiment;
140
+ const inSavedDocument = hostBlocks(dataAt(document.data, blocksField)).some((row)=>row.blockType === blockSlug && row.experiment === slug);
141
+ const saved = !modified && inSavedDocument;
142
+ return /*#__PURE__*/ _jsxs("p", {
143
+ children: [
144
+ !inSavedDocument ? /*#__PURE__*/ _jsxs(_Fragment, {
145
+ children: [
146
+ blockSaveNotice(Boolean(collection.versions?.drafts)),
147
+ ' '
148
+ ]
149
+ }) : null,
150
+ /*#__PURE__*/ _jsx("a", {
151
+ "aria-disabled": !saved,
152
+ href: saved ? formatAdminURL({
153
+ adminRoute: config.routes.admin,
154
+ path: `/experiments/by-slug/${encodeURIComponent(slug)}`
155
+ }) : undefined,
156
+ children: BLOCK_COPY.setup
157
+ })
158
+ ]
159
+ }, block.id ?? slug);
160
+ })
161
+ ]
162
+ });
163
+ }
@@ -0,0 +1,43 @@
1
+ import type { ClientConfig, ClientField, Field } from 'payload';
2
+ import type { useForm, useServerFunctions } from '@payloadcms/ui';
3
+ import { type HostBlock } from '../../blocks/model.js';
4
+ /** The only form-state failure with block-specific editor copy. */
5
+ export declare class BlockChangedError extends Error {
6
+ constructor();
7
+ }
8
+ export type RowsField = Extract<Field, {
9
+ type: 'blocks';
10
+ }>;
11
+ export declare const wrapMode: 'form-api' | 'fallback';
12
+ /** Client twin: unnamed layout containers do not contribute to a data path. */
13
+ export declare function findClientBlocksField(fields: readonly ClientField[], name: string): Extract<ClientField, {
14
+ type: 'blocks';
15
+ }> | undefined;
16
+ /** Only schema structure is needed by the pure helpers, never server validators.
17
+ * Resolve references at every depth, including inside the Experiment slots field.
18
+ */
19
+ export declare function adaptClientBlocksField(field: Extract<ClientField, {
20
+ type: 'blocks';
21
+ }>, config: Pick<ClientConfig, 'blocks'>): RowsField;
22
+ export declare function experimentBlockSlug(field: RowsField): string;
23
+ export declare function rowLocation(path: string): {
24
+ field: string;
25
+ index: number;
26
+ } | undefined;
27
+ export declare function dataAt(data: unknown, path: string): unknown;
28
+ /** Payload 3.90.2 and 4 beta.6 use the same row APIs. Sync replaces the whole
29
+ * wrapper so add/drop/reorder and the server-rendered component slots stay together.
30
+ * N=0 needs UPDATE: removeFieldRow alone serializes the empty field as number 0.
31
+ */
32
+ export declare function replaceBlockRow(args: {
33
+ form: ReturnType<typeof useForm>;
34
+ getFormState: ReturnType<typeof useServerFunctions>['getFormState'];
35
+ collectionSlug: string;
36
+ id?: string | number;
37
+ field: string;
38
+ index: number;
39
+ rows: HostBlock[];
40
+ expectedBlock?: HostBlock;
41
+ docPermissions?: Parameters<ReturnType<typeof useServerFunctions>['getFormState']>[0]['docPermissions'];
42
+ docPreferences?: Parameters<ReturnType<typeof useServerFunctions>['getFormState']>[0]['docPreferences'];
43
+ }): Promise<void>;
@@ -0,0 +1,153 @@
1
+ import { EXPERIMENT_BLOCK_SLUG, hostBlocks } from '../../blocks/model.js';
2
+ import { BLOCK_COPY } from '../copy/blocks.js';
3
+ /** The only form-state failure with block-specific editor copy. */ export class BlockChangedError extends Error {
4
+ constructor(){
5
+ super(BLOCK_COPY.blockChanged);
6
+ }
7
+ }
8
+ export const wrapMode = 'form-api';
9
+ /** Client twin: unnamed layout containers do not contribute to a data path. */ export function findClientBlocksField(fields, name) {
10
+ for (const field of fields){
11
+ if (field.type === 'blocks' && field.name === name) return field;
12
+ if (field.type === 'row' || field.type === 'collapsible') {
13
+ const found = findClientBlocksField(field.fields, name);
14
+ if (found) return found;
15
+ }
16
+ if (field.type === 'tabs') for (const tab of field.tabs){
17
+ if ('name' in tab && tab.name) continue;
18
+ const found = findClientBlocksField(tab.fields, name);
19
+ if (found) return found;
20
+ }
21
+ }
22
+ }
23
+ /** Only schema structure is needed by the pure helpers, never server validators.
24
+ * Resolve references at every depth, including inside the Experiment slots field.
25
+ */ export function adaptClientBlocksField(field, config) {
26
+ function block(block) {
27
+ return {
28
+ ...block,
29
+ fields: block.fields.map(adapt)
30
+ };
31
+ }
32
+ function adapt(field) {
33
+ if (field.type === 'blocks') return {
34
+ ...field,
35
+ blocks: [
36
+ ...field.blocks.map(block),
37
+ ...(field.blockReferences ?? []).flatMap((reference)=>{
38
+ const resolved = typeof reference === 'string' ? config.blocks?.find((item)=>item.slug === reference) : reference;
39
+ return resolved ? [
40
+ block(resolved)
41
+ ] : [];
42
+ })
43
+ ],
44
+ blockReferences: []
45
+ };
46
+ if ('fields' in field) return {
47
+ ...field,
48
+ fields: field.fields.map(adapt)
49
+ };
50
+ if (field.type === 'tabs') return {
51
+ ...field,
52
+ tabs: field.tabs.map((tab)=>({
53
+ ...tab,
54
+ fields: tab.fields.map(adapt)
55
+ }))
56
+ };
57
+ return field;
58
+ }
59
+ return adapt(field);
60
+ }
61
+ export function experimentBlockSlug(field) {
62
+ return field.blocks.find((block)=>block.fields.some((field)=>'name' in field && field.name === 'xpBlockStatus'))?.slug ?? EXPERIMENT_BLOCK_SLUG;
63
+ }
64
+ export function rowLocation(path) {
65
+ const match = /^([^.]+)\.(\d+)(?:\.|$)/.exec(path);
66
+ return match ? {
67
+ field: match[1],
68
+ index: Number(match[2])
69
+ } : undefined;
70
+ }
71
+ export function dataAt(data, path) {
72
+ return path.split('.').reduce((value, key)=>value && typeof value === 'object' ? value[key] : undefined, data);
73
+ }
74
+ /** Payload 3.90.2 and 4 beta.6 use the same row APIs. Sync replaces the whole
75
+ * wrapper so add/drop/reorder and the server-rendered component slots stay together.
76
+ * N=0 needs UPDATE: removeFieldRow alone serializes the empty field as number 0.
77
+ */ export async function replaceBlockRow(args) {
78
+ const { form, getFormState, collectionSlug, id, field, index, rows } = args;
79
+ const data = form.getData(), before = hostBlocks(data[field]);
80
+ if (args.expectedBlock && JSON.stringify(before[index]) !== JSON.stringify(args.expectedBlock)) throw new BlockChangedError();
81
+ const original = JSON.stringify(data[field]);
82
+ const next = [
83
+ ...before.slice(0, index),
84
+ ...rows,
85
+ ...before.slice(index + 1)
86
+ ];
87
+ const result = await getFormState({
88
+ collectionSlug,
89
+ id,
90
+ schemaPath: collectionSlug,
91
+ data: {
92
+ ...data,
93
+ [field]: next
94
+ },
95
+ docPermissions: args.docPermissions,
96
+ docPreferences: args.docPreferences ?? {
97
+ fields: {}
98
+ },
99
+ operation: id == null ? 'create' : 'update',
100
+ renderAllFields: true,
101
+ skipValidation: true
102
+ });
103
+ if (JSON.stringify(form.getDataByPath(field)) !== original) throw new BlockChangedError();
104
+ if (!result.state) throw new Error('Missing server form state');
105
+ const state = result.state;
106
+ function subFieldState(rowIndex) {
107
+ const prefix = `${field}.${rowIndex}.`;
108
+ return Object.fromEntries(Object.entries(state).filter(([path])=>path.startsWith(prefix)).map(([path, value])=>[
109
+ path.slice(prefix.length),
110
+ value
111
+ ]));
112
+ }
113
+ if (!rows.length) {
114
+ form.removeFieldRow({
115
+ path: field,
116
+ rowIndex: index
117
+ });
118
+ if (next.length === 0) form.dispatchFields({
119
+ type: 'UPDATE',
120
+ path: field,
121
+ value: [],
122
+ disableFormData: false
123
+ });
124
+ return;
125
+ }
126
+ form.replaceFieldRow({
127
+ path: field,
128
+ rowIndex: index,
129
+ schemaPath: `${collectionSlug}.${field}`,
130
+ blockType: rows[0].blockType,
131
+ subFieldState: subFieldState(index)
132
+ });
133
+ for(let offset = 1; offset < rows.length; offset++)form.addFieldRow({
134
+ path: field,
135
+ rowIndex: index + offset,
136
+ schemaPath: `${collectionSlug}.${field}`,
137
+ blockType: rows[offset].blockType,
138
+ subFieldState: subFieldState(index + offset)
139
+ });
140
+ // REPLACE_ROW invents a metadata id even when the data id is unchanged.
141
+ // Keep server row metadata (including custom labels) and the wrapper React key:
142
+ // otherwise a sync remounts the status field and loses its save notice.
143
+ const oldRows = form.getFields()[field]?.rows ?? [];
144
+ const serverRows = state[field]?.rows;
145
+ if (serverRows) form.dispatchFields({
146
+ type: 'UPDATE',
147
+ path: field,
148
+ rows: serverRows.map((row)=>({
149
+ ...oldRows.find((old)=>old.id === row.id),
150
+ ...row
151
+ }))
152
+ });
153
+ }
@@ -0,0 +1,7 @@
1
+ import type { Field } from 'payload';
2
+ /** Copy the coordinates supplied by CORE-L2, including every containing array. */
3
+ export declare function injectTestBlock(fields: Field[], registration: {
4
+ field: string;
5
+ blockSlug?: string;
6
+ appliesTo?: (doc: Record<string, unknown>) => boolean;
7
+ }): Field[];
@@ -0,0 +1,41 @@
1
+ import { findRegisteredBlocksField } from '../../blocks/formState.js';
2
+ import { XP_TEST_BLOCK } from '../paths.js';
3
+ /** Copy the coordinates supplied by CORE-L2, including every containing array. */ export function injectTestBlock(fields, registration) {
4
+ const found = findRegisteredBlocksField(fields, registration.field);
5
+ if (!found) return fields;
6
+ const ui = {
7
+ name: 'xpTestBlock',
8
+ type: 'ui',
9
+ admin: {
10
+ condition: (data)=>{
11
+ try {
12
+ return registration.appliesTo ? registration.appliesTo(data) === true : true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ },
17
+ components: {
18
+ Field: {
19
+ path: XP_TEST_BLOCK,
20
+ clientProps: {
21
+ blocksField: registration.field,
22
+ blockSlug: registration.blockSlug
23
+ }
24
+ }
25
+ }
26
+ }
27
+ };
28
+ function insert(value, path) {
29
+ const [key, ...rest] = path;
30
+ if (key === undefined) return value;
31
+ const copy = Array.isArray(value) ? [
32
+ ...value
33
+ ] : {
34
+ ...value
35
+ };
36
+ if (!rest.length) copy.splice(Number(key) + 1, 0, ui);
37
+ else copy[key] = insert(value[key], rest);
38
+ return copy;
39
+ }
40
+ return insert(fields, found.path);
41
+ }
@@ -0,0 +1,17 @@
1
+ import type { PlacementStatusResult } from '../../types/endpoints.js';
2
+ import type { RowsField } from './formApi.js';
3
+ export type BlockStatus = PlacementStatusResult['slugs'][string];
4
+ export type LiveBlockStatus = Extract<BlockStatus, {
5
+ status: string;
6
+ }>;
7
+ export declare function liveStatus(status: BlockStatus | null): status is LiveBlockStatus;
8
+ export declare function slotLabel(slot: string, status: BlockStatus | null): string;
9
+ export declare function statusSentence(status: BlockStatus): string;
10
+ export declare function isOrphan(status: BlockStatus): boolean;
11
+ export declare function labelText(value: unknown): string | undefined;
12
+ export declare function blockChoices(value: unknown, field: RowsField, blockSlug: string): {
13
+ block: import("../../blocks/model.js").HostBlock;
14
+ index: number;
15
+ label: string;
16
+ option: string;
17
+ }[];
@@ -0,0 +1,45 @@
1
+ import { hostBlocks } from '../../blocks/model.js';
2
+ import { BLOCK_COPY, blockDecidedStatus, blockDraftStatus, blockFinishedStatus, blockPosition } from '../copy/blocks.js';
3
+ export function liveStatus(status) {
4
+ return !!status && 'status' in status;
5
+ }
6
+ export function slotLabel(slot, status) {
7
+ return (liveStatus(status) ? status.arms.find((arm)=>arm.key !== 'holdout' && (arm.slot ?? arm.key) === slot)?.label : undefined) || BLOCK_COPY.slotLabels[slot] || slot;
8
+ }
9
+ export function statusSentence(status) {
10
+ if (!liveStatus(status)) return BLOCK_COPY.deleted;
11
+ const winner = status.arms.find((arm)=>arm.key === status.winnerArm);
12
+ const winnerLabel = winner?.label || BLOCK_COPY.slotLabels[status.winnerArm ?? 'control'] || status.winnerArm || BLOCK_COPY.slotLabels.control;
13
+ switch(status.status){
14
+ case 'draft':
15
+ return blockDraftStatus(status.name);
16
+ case 'running':
17
+ return BLOCK_COPY.running;
18
+ case 'stopped':
19
+ return BLOCK_COPY.stopped;
20
+ case 'decided':
21
+ return blockDecidedStatus(winnerLabel, status.rollout);
22
+ case 'archived':
23
+ return status.winnerArm ? blockFinishedStatus(winnerLabel) : BLOCK_COPY.discarded;
24
+ }
25
+ }
26
+ export function isOrphan(status) {
27
+ return !liveStatus(status) || status.status === 'archived' && !status.winnerArm;
28
+ }
29
+ export function labelText(value) {
30
+ return typeof value === 'string' ? value : value && typeof value === 'object' ? Object.values(value).find((item)=>typeof item === 'string') : undefined;
31
+ }
32
+ export function blockChoices(value, field, blockSlug) {
33
+ return hostBlocks(value).flatMap((block, index)=>{
34
+ if (block.blockType === blockSlug) return [];
35
+ const label = labelText(field.blocks.find((config)=>config.slug === block.blockType)?.labels?.singular) || block.blockType;
36
+ return [
37
+ {
38
+ block,
39
+ index,
40
+ label,
41
+ option: blockPosition(label, index + 1)
42
+ }
43
+ ];
44
+ });
45
+ }
@@ -0,0 +1,7 @@
1
+ import type { BlockStatus } from './model.js';
2
+ export declare const validBlockSlug: (slug: unknown) => slug is string;
3
+ /** Only status fields own reads. StrictMode replays the same mount token; a
4
+ * different owner after the last cleanup starts fresh, even in the same commit.
5
+ * Deferred eviction removes every entry with no live owner after that commit.
6
+ */
7
+ export declare function useBlockStatus(api: string, slug: unknown, owner?: boolean): BlockStatus | null;
@@ -0,0 +1,83 @@
1
+ import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react';
2
+ import { createApi } from '../api.js';
3
+ const cache = new Map();
4
+ // Subscribers do not create status entries: a label alone owns no cached data.
5
+ const listeners = new Map();
6
+ const keyOf = (api, slug)=>JSON.stringify([
7
+ api,
8
+ slug
9
+ ]);
10
+ const publish = (key)=>listeners.get(key)?.callbacks.forEach((listener)=>listener());
11
+ export const validBlockSlug = (slug)=>typeof slug === 'string' && slug.length > 0 && slug.length <= 64;
12
+ /** Only status fields own reads. StrictMode replays the same mount token; a
13
+ * different owner after the last cleanup starts fresh, even in the same commit.
14
+ * Deferred eviction removes every entry with no live owner after that commit.
15
+ */ export function useBlockStatus(api, slug, owner = false) {
16
+ const key = validBlockSlug(slug) ? keyOf(api, slug) : undefined;
17
+ const token = useRef({});
18
+ const subscribe = useCallback((listener)=>{
19
+ if (!key) return ()=>{};
20
+ let current = listeners.get(key);
21
+ if (!current) {
22
+ current = {
23
+ callbacks: new Set()
24
+ };
25
+ listeners.set(key, current);
26
+ }
27
+ current.callbacks.add(listener);
28
+ return ()=>{
29
+ current.callbacks = new Set([
30
+ ...current.callbacks
31
+ ].filter((item)=>item !== listener));
32
+ if (!current.callbacks.size) listeners.delete(key);
33
+ };
34
+ }, [
35
+ key
36
+ ]);
37
+ const status = useSyncExternalStore(subscribe, ()=>{
38
+ const current = key ? cache.get(key) : undefined;
39
+ // A new owner must not sync with the previous mount's snapshot before its
40
+ // effect registers ownership (or starts the fresh read).
41
+ return current && (!owner || current.mounts.has(token.current)) ? current.value : null;
42
+ }, ()=>null);
43
+ useEffect(()=>{
44
+ if (!key || !owner || !validBlockSlug(slug)) return;
45
+ let current = cache.get(key);
46
+ if (!current || !current.owners && !current.mounts.has(token.current)) {
47
+ current = {
48
+ value: null,
49
+ owners: 0,
50
+ mounts: new WeakSet()
51
+ };
52
+ cache.set(key, current);
53
+ }
54
+ const owned = current;
55
+ owned.owners++;
56
+ owned.mounts.add(token.current);
57
+ publish(key);
58
+ if (!owned.pending) owned.pending = createApi(api).placementStatus({
59
+ slugs: [
60
+ slug
61
+ ]
62
+ }).then((result)=>{
63
+ if (cache.get(key) !== owned) return;
64
+ owned.value = result.ok ? result.data?.slugs[slug] ?? null : null;
65
+ publish(key);
66
+ });
67
+ return ()=>{
68
+ owned.owners--;
69
+ queueMicrotask(()=>{
70
+ if (!owned.owners && cache.get(key) === owned) {
71
+ cache.delete(key);
72
+ publish(key);
73
+ }
74
+ });
75
+ };
76
+ }, [
77
+ api,
78
+ key,
79
+ slug,
80
+ owner
81
+ ]);
82
+ return status;
83
+ }
@@ -0,0 +1,17 @@
1
+ import { useConfig, useDocumentInfo, useForm } from '@payloadcms/ui';
2
+ import { adaptClientBlocksField, rowLocation } from './formApi.js';
3
+ import type { ClientCollectionConfig } from 'payload';
4
+ import type { ExperimentBlock, HostBlock } from '../../blocks/model.js';
5
+ /** Declared so the d.ts build can name Payload UI's form types through public exports. */
6
+ export type BlockFormApi = {
7
+ form: ReturnType<typeof useForm>;
8
+ document: ReturnType<typeof useDocumentInfo>;
9
+ config: ReturnType<typeof useConfig>['config'];
10
+ collection: ClientCollectionConfig | undefined;
11
+ field: ReturnType<typeof adaptClientBlocksField> | undefined;
12
+ block: ExperimentBlock | undefined;
13
+ manager: boolean;
14
+ replace: (rows: HostBlock[]) => Promise<void>;
15
+ location: ReturnType<typeof rowLocation>;
16
+ };
17
+ export declare function useBlockForm(path: string): BlockFormApi;
@@ -0,0 +1,46 @@
1
+ import { useMemo } from 'react';
2
+ import { useAuth, useConfig, useDocumentInfo, useForm, useFormFields, useServerFunctions } from '@payloadcms/ui';
3
+ import { adaptClientBlocksField, findClientBlocksField, replaceBlockRow, rowLocation } from './formApi.js';
4
+ export function useBlockForm(path) {
5
+ const form = useForm(), document = useDocumentInfo(), { config, getEntityConfig } = useConfig();
6
+ const { getFormState } = useServerFunctions(), { permissions } = useAuth();
7
+ // useForm's getters are stable; subscribe so changed slug/row paths are read again.
8
+ useFormFields(([fields])=>fields);
9
+ const location = rowLocation(path);
10
+ const collection = document.collectionSlug ? getEntityConfig({
11
+ collectionSlug: document.collectionSlug
12
+ }) : undefined;
13
+ const clientField = location && collection ? findClientBlocksField(collection.fields, location.field) : undefined;
14
+ const field = useMemo(()=>clientField ? adaptClientBlocksField(clientField, config) : undefined, [
15
+ clientField,
16
+ config
17
+ ]);
18
+ const block = location ? form.getDataByPath(`${location.field}.${location.index}`) : undefined;
19
+ const testPermissions = permissions?.collections?.['xp-experiments'];
20
+ const manager = testPermissions?.update === true;
21
+ async function replace(rows) {
22
+ if (!location || !document.collectionSlug) return;
23
+ await replaceBlockRow({
24
+ form,
25
+ getFormState,
26
+ collectionSlug: document.collectionSlug,
27
+ id: document.id,
28
+ ...location,
29
+ rows,
30
+ expectedBlock: block,
31
+ docPermissions: document.docPermissions,
32
+ docPreferences: await document.getDocPreferences?.()
33
+ });
34
+ }
35
+ return {
36
+ form,
37
+ document,
38
+ config,
39
+ collection,
40
+ field,
41
+ block,
42
+ manager,
43
+ replace,
44
+ location
45
+ };
46
+ }
@@ -1,3 +1,4 @@
1
+ import type { AddMetricWarning } from '../../types/endpoints.js';
1
2
  export declare const ACTIONS_COPY: {
2
3
  readonly start: "Start";
3
4
  readonly restart: "Start a new round";
@@ -70,3 +71,4 @@ export declare const CONTENT_ENTRY_REFUSALS: {
70
71
  };
71
72
  readonly fallback: "This page can't be changed right now. Reload to see its current state.";
72
73
  };
74
+ export declare function addMetricWarningCopy(warning: AddMetricWarning): string;
@@ -1,3 +1,4 @@
1
+ import { asymmetricGoalsCopy, placementUncheckedCopy } from './readiness.js';
1
2
  export const ACTIONS_COPY = {
2
3
  start: 'Start',
3
4
  restart: 'Start a new round',
@@ -70,3 +71,11 @@ export const CONTENT_ENTRY_REFUSALS = {
70
71
  },
71
72
  fallback: ACTIONS_COPY.contentEntryFallback
72
73
  };
74
+ export function addMetricWarningCopy(warning) {
75
+ if (warning.code === 'goal-added-mid-test') return "Clicks before this goal was added aren't counted (the same for every version).";
76
+ if (warning.code === 'placement-unchecked') {
77
+ const path = warning.detail?.path;
78
+ return placementUncheckedCopy(typeof path === 'string' ? path : 'This page');
79
+ }
80
+ return asymmetricGoalsCopy(warning.detail);
81
+ }