@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
@@ -2,7 +2,11 @@ import type { PayloadRequest } from 'payload';
2
2
  import type { Arm, DocId, FlagPoint, PreviewPlacement, ResolvedOptions } from '../types/contracts.js';
3
3
  import type { ArmSlot } from '../types/puck.js';
4
4
  export interface BlockPlacement extends PreviewPlacement {
5
+ editor?: 'puck' | 'blocks' | 'page';
6
+ doc?: Record<string, unknown>;
5
7
  slots: Record<ArmSlot, number>;
8
+ duplicateRows?: boolean;
9
+ identicalSlots?: string[];
6
10
  locked?: boolean;
7
11
  localized?: boolean;
8
12
  }
@@ -13,10 +17,14 @@ export type FlagPlacement = Awaited<ReturnType<NonNullable<FlagPoint['placements
13
17
  export declare function scanBlockPlacements(req: PayloadRequest, args: {
14
18
  slug: string;
15
19
  puckCollections: NonNullable<ResolvedOptions['puck']>['collections'];
20
+ blocksCollections: NonNullable<ResolvedOptions['blocks']>['collections'];
21
+ compareVersions?: boolean;
16
22
  }): Promise<BlockPlacement[]>;
17
23
  export declare function scanBlockPlacements(req: PayloadRequest, args: {
18
24
  slugs: ReadonlySet<string>;
19
25
  puckCollections: NonNullable<ResolvedOptions['puck']>['collections'];
26
+ blocksCollections: NonNullable<ResolvedOptions['blocks']>['collections'];
27
+ compareVersions?: boolean;
20
28
  }): Promise<Map<string, BlockPlacement[]>>;
21
29
  export declare function flagPlacements<TUser extends {
22
30
  id: DocId;
@@ -1,5 +1,10 @@
1
+ import { BLOCK_SLOTS, EXPERIMENT_BLOCK_SLUG, hostBlocks, slotBlocks } from '../blocks/model.js';
2
+ import { safeError } from '../log.js';
3
+ import { pageVersionContent } from '../db/pageContent.js';
4
+ import { comparableContent } from './pageVersions.js';
5
+ import { isDeepStrictEqual } from 'node:util';
1
6
  import { hasLocalizedFields } from './contentFields.js';
2
- import { puckCollections as normalisePuckCollections } from '../options.js';
7
+ import { documentEditor, puckCollections as normalisePuckCollections } from '../options.js';
3
8
  const slots = [
4
9
  'control',
5
10
  'variantA',
@@ -23,6 +28,7 @@ export async function scanBlockPlacements(req, args) {
23
28
  collections: puckCollections
24
29
  }
25
30
  })){
31
+ const editors = new Map();
26
32
  const publishedIds = new Set();
27
33
  const publishedSlugs = new Map();
28
34
  const draftsEnabled = req.payload.config.collections?.some((config)=>config.slug === collection && config.versions && config.versions.drafts);
@@ -53,9 +59,28 @@ export async function scanBlockPlacements(req, args) {
53
59
  if (!draft && doc._status === 'draft') continue;
54
60
  if (draft && doc._status !== 'draft' && publishedIds.has(String(doc.id))) continue;
55
61
  if (!draft) {
62
+ editors.set(String(doc.id), documentEditor({
63
+ puck: {
64
+ enabled: true,
65
+ collections: puckCollections
66
+ },
67
+ blocks: {
68
+ collections: args.blocksCollections
69
+ }
70
+ }, collection, doc));
56
71
  publishedIds.add(String(doc.id));
57
72
  if (typeof doc.slug === 'string') publishedSlugs.set(String(doc.id), doc.slug);
58
73
  }
74
+ const editor = editors.get(String(doc.id)) ?? documentEditor({
75
+ puck: {
76
+ enabled: true,
77
+ collections: puckCollections
78
+ },
79
+ blocks: {
80
+ collections: args.blocksCollections
81
+ }
82
+ }, collection, doc);
83
+ if (editor !== 'puck') continue;
59
84
  function walk(value) {
60
85
  if (!value || typeof value !== 'object') return;
61
86
  if (Array.isArray(value)) {
@@ -70,6 +95,8 @@ export async function scanBlockPlacements(req, args) {
70
95
  placements.get(experiment).push({
71
96
  collection,
72
97
  id: doc.id,
98
+ editor: 'puck',
99
+ doc,
73
100
  title: String(doc.title ?? doc.name ?? doc.id),
74
101
  published: !draft || doc._status === 'published',
75
102
  ...publishedSlugs.has(String(doc.id)) ? {
@@ -87,7 +114,115 @@ export async function scanBlockPlacements(req, args) {
87
114
  }
88
115
  Object.values(node).forEach(walk);
89
116
  }
117
+ const offsets = new Map([
118
+ ...placements
119
+ ].map(([slug, list])=>[
120
+ slug,
121
+ list.length
122
+ ]));
90
123
  walk(doc[field]);
124
+ if (args.compareVersions && !draft && [
125
+ ...placements
126
+ ].some(([slug, list])=>list.length > offsets.get(slug))) {
127
+ const raw = await comparisonContent(req, collection, doc.id);
128
+ const rawSlots = new Map();
129
+ const collect = (value)=>{
130
+ if (Array.isArray(value)) {
131
+ value.forEach(collect);
132
+ return;
133
+ }
134
+ if (!value || typeof value !== 'object') return;
135
+ const node = value, props = node.props;
136
+ const ref = props?.experiment;
137
+ const slug = typeof ref === 'string' ? ref : ref && typeof ref === 'object' && 'slug' in ref ? ref.slug : undefined;
138
+ if (node.type === 'ExperimentSlot' && props && typeof slug === 'string') {
139
+ const list = rawSlots.get(slug) ?? [];
140
+ list.push(props);
141
+ rawSlots.set(slug, list);
142
+ }
143
+ Object.values(node).forEach(collect);
144
+ };
145
+ if (raw && raw._status !== 'draft') collect(raw[field]);
146
+ for (const [slug, list] of placements)list.slice(offsets.get(slug)).forEach((placement, index)=>{
147
+ const props = rawSlots.get(slug)?.[index];
148
+ if (props) placement.identicalSlots = slots.filter((slot)=>slot !== 'control' && Array.isArray(props[slot]) && props[slot].length > 0 && isDeepStrictEqual(comparableContent(props.control ?? []), comparableContent(props[slot])));
149
+ });
150
+ }
151
+ }
152
+ if (!result.hasNextPage) break;
153
+ page++;
154
+ }
155
+ }
156
+ }
157
+ for (const registration of args.blocksCollections){
158
+ const { slug: collection, field } = registration;
159
+ const published = new Map();
160
+ const draftsEnabled = req.payload.config.collections?.some((c)=>c.slug === collection && c.versions && c.versions.drafts);
161
+ for (const draft of [
162
+ false,
163
+ true
164
+ ]){
165
+ let page = 1;
166
+ while(true){
167
+ const result = await req.payload.find({
168
+ collection,
169
+ req,
170
+ overrideAccess: false,
171
+ depth: 0,
172
+ limit: 100,
173
+ page,
174
+ draft,
175
+ includeLockStatus: true,
176
+ ...!draft && draftsEnabled ? {
177
+ where: {
178
+ _status: {
179
+ equals: 'published'
180
+ }
181
+ }
182
+ } : {}
183
+ });
184
+ for (const doc of result.docs){
185
+ if (!draft && doc._status === 'draft') continue;
186
+ if (!draft) published.set(String(doc.id), doc);
187
+ else if (doc._status !== 'draft' && published.has(String(doc.id))) continue;
188
+ const baseline = published.get(String(doc.id)) ?? doc;
189
+ if (documentEditor({
190
+ blocks: {
191
+ collections: args.blocksCollections
192
+ },
193
+ puck: {
194
+ enabled: true,
195
+ collections: puckCollections
196
+ }
197
+ }, collection, baseline) !== 'blocks') continue;
198
+ const raw = args.compareVersions && !draft && hostBlocks(doc[field]).some((block)=>block.blockType === (registration.blockSlug ?? EXPERIMENT_BLOCK_SLUG) && typeof block.experiment === 'string' && slugs.has(block.experiment)) ? await comparisonContent(req, collection, doc.id) : undefined;
199
+ for (const [index, block] of hostBlocks(doc[field]).entries()){
200
+ if (block.blockType !== (registration.blockSlug ?? EXPERIMENT_BLOCK_SLUG) || typeof block.experiment !== 'string' || !slugs.has(block.experiment)) continue;
201
+ const stored = raw ? hostBlocks(raw[field]).find((row)=>row.id === block.id) ?? hostBlocks(raw[field])[index] ?? block : block;
202
+ const rows = Array.isArray(stored.slots) ? stored.slots : [];
203
+ const keys = rows.map((row)=>row?.slot);
204
+ placements.get(block.experiment).push({
205
+ collection,
206
+ id: doc.id,
207
+ editor: 'blocks',
208
+ doc,
209
+ title: String(doc.title ?? doc.name ?? doc.id),
210
+ published: !draft || doc._status === 'published',
211
+ ...typeof baseline.slug === 'string' ? {
212
+ slug: baseline.slug
213
+ } : {},
214
+ slots: Object.fromEntries(BLOCK_SLOTS.map((slot)=>[
215
+ slot,
216
+ slotBlocks(block, slot).length
217
+ ])),
218
+ duplicateRows: new Set(keys).size !== keys.length,
219
+ identicalSlots: raw ? BLOCK_SLOTS.filter((slot)=>slot !== 'control' && slotBlocks(block, slot).length > 0 && isDeepStrictEqual(comparableContent(slotBlocks(stored, 'control')), comparableContent(slotBlocks(stored, slot)))) : [],
220
+ locked: doc._isLocked === true || doc.locked === true,
221
+ ...hasLocalizedFields(req.payload.config.collections?.find((c)=>c.slug === collection)?.fields ?? [], req.payload.config) ? {
222
+ localized: true
223
+ } : {}
224
+ });
225
+ }
91
226
  }
92
227
  if (!result.hasNextPage) break;
93
228
  page++;
@@ -105,3 +240,15 @@ export async function flagPlacements(options, experiment, req) {
105
240
  req
106
241
  });
107
242
  }
243
+ /** A warning-only comparison must never turn an adapter failure into a Start refusal. */ async function comparisonContent(req, collection, id) {
244
+ try {
245
+ return await pageVersionContent(req, collection, id);
246
+ } catch (cause) {
247
+ req.payload.logger.error({
248
+ err: safeError(cause),
249
+ collection,
250
+ id
251
+ }, 'Experiment version comparison failed');
252
+ return undefined;
253
+ }
254
+ }
@@ -17,10 +17,10 @@ export declare function loadStartInputs<TUser extends {
17
17
  * Start's locked recheck. Raw SQL on the request's transaction when there is one. */
18
18
  export declare function loadPageTestsInUse(req: PayloadRequest): Promise<NonNullable<CheckStartInput['pageTestsInUse']>>;
19
19
  /** Page targets no registered collection reports as published. A lookup that throws counts as
20
- * unpublished (logged). Start calls it again under its page-path locks on a detached request. */
20
+ * unpublished (logged) unless propagateErrors is requested by decide. Start calls it again under its page-path locks on a detached request. */
21
21
  export declare function unpublishedPageTargets<TUser extends {
22
22
  id: DocId;
23
- }>(req: PayloadRequest, options: ResolvedOptions<TUser>, experiment: Pick<StartExperiment, 'id' | 'arms'>): Promise<string[]>;
23
+ }>(req: PayloadRequest, options: ResolvedOptions<TUser>, experiment: Pick<StartExperiment, 'id' | 'arms' | 'pageCollection'>, propagateErrors?: boolean): Promise<string[]>;
24
24
  /** Shared, request-scoped reads for summary; a failed row is null in tolerant mode.
25
25
  * Preflight/Start use the same builder and retain their throwing read behaviour.
26
26
  */
@@ -1,4 +1,9 @@
1
+ import { goalContent, OUTSIDE_GOAL } from '../goals/fairness.js';
2
+ import { placementInteractions, placementPaths, uniquePlacementDocs } from './placementInteractions.js';
1
3
  import { Forbidden, NotFound } from 'payload';
4
+ import { normalisePath } from '../runtime/normalisePath.js';
5
+ import { resolvePageCollection } from './pageCollection.js';
6
+ import { identicalPageVersions } from './pageVersions.js';
2
7
  import { safeError } from '../log.js';
3
8
  import { powerMetricWeeks } from '@delmaredigital/experiments-stats';
4
9
  import { checkStart, relationshipId } from './checkStart.js';
@@ -35,13 +40,18 @@ const failureDetail = (cause)=>{
35
40
  return routedPageTests(await req.transactionID != null ? txExecutor(req) : pooledExecutor(req.payload));
36
41
  }
37
42
  /** Page targets no registered collection reports as published. A lookup that throws counts as
38
- * unpublished (logged). Start calls it again under its page-path locks on a detached request. */ export async function unpublishedPageTargets(req, options, experiment) {
43
+ * unpublished (logged) unless propagateErrors is requested by decide. Start calls it again under its page-path locks on a detached request. */ export async function unpublishedPageTargets(req, options, experiment, propagateErrors = false) {
44
+ const resolved = await resolvePageCollection(req, options, experiment, propagateErrors);
45
+ const collections = resolved ? [
46
+ resolved
47
+ ] : [];
39
48
  const targets = await Promise.all(experiment.arms.filter((arm)=>arm.kind !== 'holdout' && arm.pageTarget?.trim()).map(async (arm)=>{
40
- for (const collection of options.pages?.collections ?? []){
49
+ for (const collection of collections){
41
50
  try {
42
51
  const page = await collection.findByPath(arm.pageTarget, req);
43
52
  if (page?.published === true) return null;
44
53
  } catch (cause) {
54
+ if (propagateErrors) throw cause;
45
55
  req.payload.logger.error({
46
56
  err: safeError(cause),
47
57
  experimentId: experiment.id,
@@ -114,13 +124,19 @@ const metricIds = (experiment)=>[
114
124
  pageTestsInUse ??= loadPageTestsInUse(req);
115
125
  input.pageTestsInUse = await pageTestsInUse;
116
126
  input.pageTargetsUnchecked = options.pages === undefined;
117
- if (options.pages) input.unpublishedPageTargets = await unpublishedPageTargets(req, options, experiment);
127
+ input.unknownPageCollection = Boolean(experiment.pageCollection && !options.pages?.collections.some((entry)=>entry.slug === experiment.pageCollection));
128
+ if (options.pages) {
129
+ input.unpublishedPageTargets = await unpublishedPageTargets(req, options, experiment);
130
+ if (options.pages.collections.some((entry)=>entry.contentOf)) input.identicalVersions = await identicalPageVersions(req, await resolvePageCollection(req, options, experiment), experiment.arms);
131
+ }
118
132
  }
119
133
  if (experiment.kind === 'block') {
120
134
  try {
121
135
  blocks ??= scanBlockPlacements(req, {
122
136
  slugs: blockSlugs,
123
- puckCollections: options.puck?.collections ?? []
137
+ compareVersions: true,
138
+ puckCollections: options.puck?.collections ?? [],
139
+ blocksCollections: options.blocks?.collections ?? []
124
140
  });
125
141
  input.placements = (await blocks).get(experiment.slug) ?? [];
126
142
  } catch (cause) {
@@ -250,6 +266,13 @@ const metricIds = (experiment)=>[
250
266
  })());
251
267
  input.layer = await layers.get(key);
252
268
  }
269
+ if ((experiment.kind === 'page' || experiment.kind === 'block') && options.pages?.collections.length) {
270
+ const paths = experiment.kind === 'page' ? experiment.arms.flatMap((a)=>typeof a.pageTarget === 'string' ? [
271
+ a.pageTarget
272
+ ] : []) : await placementPaths(req, options, uniquePlacementDocs(input.placements ?? []));
273
+ input.interactions = await placementInteractions(req, await req.transactionID != null ? txExecutor(req) : pooledExecutor(req.payload), options, experiment, paths.map(normalisePath));
274
+ }
275
+ if (input.metrics.some((metric)=>metric.source.startsWith('click:'))) input.goalContent = await goalContent(req, options, experiment, input.placements ? uniquePlacementDocs(input.placements.filter((placement)=>placement.published)) : undefined);
253
276
  inputs.set(String(experiment.id), input);
254
277
  } catch (cause) {
255
278
  if (!tolerateErrors) throw cause;
@@ -279,7 +302,9 @@ async function placementUrls(options, placement, pendingPreviews, onFailure, pre
279
302
  const url = options.admin?.editorUrl?.({
280
303
  collection,
281
304
  id,
282
- draft: !published
305
+ draft: !published,
306
+ editor: placement.editor ?? 'puck',
307
+ doc: placement.doc
283
308
  });
284
309
  if (typeof url === 'string') result.editorUrl = url;
285
310
  } catch {}
@@ -476,9 +501,116 @@ export async function preflightData(req, { experimentId, options }, { now = new
476
501
  detail: placement.detail
477
502
  } : {}
478
503
  });
504
+ const pagePlacements = [];
505
+ if (e.kind === 'page') {
506
+ const collection = await resolvePageCollection(req, options, e);
507
+ for (const arm of e.arms.filter((arm)=>arm.kind !== 'holdout')){
508
+ const path = arm.pageTarget ?? '';
509
+ let page = null;
510
+ try {
511
+ page = path && collection ? await collection.findByPath(path, req) : null;
512
+ } catch (cause) {
513
+ onPreviewFailure(cause, {
514
+ collection: collection.slug,
515
+ id: path,
516
+ title: path,
517
+ published: false,
518
+ slots: {
519
+ control: 0,
520
+ variantA: 0,
521
+ variantB: 0,
522
+ variantC: 0
523
+ }
524
+ });
525
+ }
526
+ if (!page || !collection) {
527
+ pagePlacements.push({
528
+ arm: arm.key,
529
+ path,
530
+ published: false,
531
+ id: null,
532
+ ...collection ? {
533
+ collection: collection.slug
534
+ } : {}
535
+ });
536
+ continue;
537
+ }
538
+ let doc;
539
+ try {
540
+ doc = await req.payload.findByID({
541
+ collection: collection.slug,
542
+ id: page.id,
543
+ req,
544
+ overrideAccess: false,
545
+ depth: 0,
546
+ draft: !page.published
547
+ });
548
+ } catch (cause) {
549
+ onPreviewFailure(cause, {
550
+ collection: collection.slug,
551
+ id: page.id,
552
+ title: path,
553
+ published: false,
554
+ slots: {
555
+ control: 0,
556
+ variantA: 0,
557
+ variantB: 0,
558
+ variantC: 0
559
+ }
560
+ });
561
+ pagePlacements.push({
562
+ arm: arm.key,
563
+ path,
564
+ collection: collection.slug,
565
+ published: false,
566
+ id: null
567
+ });
568
+ continue;
569
+ }
570
+ const titleField = req.payload.config.collections?.find((entry)=>entry.slug === collection.slug)?.admin.useAsTitle ?? 'title';
571
+ const placement = {
572
+ collection: collection.slug,
573
+ id: page.id,
574
+ title: String(doc[titleField] ?? doc.id),
575
+ published: page.published,
576
+ ...page.published && typeof doc.slug === 'string' ? {
577
+ slug: doc.slug
578
+ } : {},
579
+ slots: {
580
+ control: 0,
581
+ variantA: 0,
582
+ variantB: 0,
583
+ variantC: 0
584
+ },
585
+ editor: 'page',
586
+ doc
587
+ };
588
+ pagePlacements.push({
589
+ arm: arm.key,
590
+ path,
591
+ collection: collection.slug,
592
+ id: page.id,
593
+ title: placement.title,
594
+ published: placement.published,
595
+ ...placement.slug ? {
596
+ slug: placement.slug
597
+ } : {},
598
+ ...await placementUrls(options, placement, pendingPreviews, onPreviewFailure, previews)
599
+ });
600
+ }
601
+ }
479
602
  return {
480
603
  checks: checkStart(input),
604
+ ...input.goalContent ? {
605
+ goalNotes: input.metrics.filter((m)=>m.source.startsWith('click:') && input.goalContent && !input.goalContent.skipped && !input.goalContent.unchecked.length && !input.goalContent.keys.some((k)=>`click:${k.key}` === m.source)).map((m)=>({
606
+ metricId: m.id,
607
+ message: OUTSIDE_GOAL
608
+ }))
609
+ } : {},
481
610
  placements,
611
+ ...e.kind === 'page' ? {
612
+ pagePlacements
613
+ } : {},
482
614
  baseline,
483
615
  trafficPerDay,
484
616
  estimate: power
@@ -2,6 +2,7 @@ import type { DocId, MetricSource } from '../types/contracts.js';
2
2
  export interface MetricDefinition extends Record<string, unknown> {
3
3
  id: DocId;
4
4
  source: string;
5
+ direction?: 'increase' | 'decrease';
5
6
  attributionWindowHours?: number | null;
6
7
  minSampleSize?: number | null;
7
8
  guardrailThreshold?: number | null;
@@ -8,7 +8,7 @@ import { perUnitHistory } from '../results/history.js';
8
8
  const from = new Date(start.getTime() - 90 * 86400000);
9
9
  for (const metric of metrics){
10
10
  const source = sources.find((source)=>source.id === metric.source);
11
- const kind = metric.source.startsWith('events:') ? 'binomial' : source?.kind;
11
+ const kind = metric.source.startsWith('events:') || metric.source.startsWith('click:') ? 'binomial' : source?.kind;
12
12
  const definition = {
13
13
  ...structuredClone(metric),
14
14
  ...source ? {
@@ -19,11 +19,15 @@ import { perUnitHistory } from '../results/history.js';
19
19
  historyUnits: undefined,
20
20
  capNotice: undefined
21
21
  };
22
+ if (metric.source.startsWith('click:')) {
23
+ definition.direction = 'increase';
24
+ definition.capping = null;
25
+ }
22
26
  const cap = metric.capping?.type ? metric.capping : kind === 'revenue' ? {
23
27
  type: 'percentile',
24
28
  value: 0.99
25
29
  } : null;
26
- if (kind === 'revenue' || !source && cap && !metric.source.startsWith('events:')) {
30
+ if (kind === 'revenue' || !source && cap && !(metric.source.startsWith('events:') || metric.source.startsWith('click:'))) {
27
31
  definition.capping = cap;
28
32
  if (cap?.type === 'absolute' && typeof cap.value === 'number' && Number.isFinite(cap.value) && cap.value >= 0) {
29
33
  definition.resolvedCap = cap.value;
@@ -1,8 +1,12 @@
1
+ import { goalContent } from '../goals/fairness.js';
2
+ import { detached } from './placementInteractions.js';
3
+ import { lockAndRescanPlacements, placementInteractions } from './placementInteractions.js';
1
4
  import { lockPagePaths } from '../db/locks.js';
2
5
  import { lockMetricsForFreeze } from '../db/metricUsage.js';
3
6
  import { DEFAULT_SEQUENTIAL_TUNING_PARAMETER } from '../constants.js';
4
7
  import { claimSlices } from '../db/slices.js';
5
- import { pageInUseCopy, unpublishedPageCopy } from '../admin/copy/readiness.js';
8
+ import { READINESS_COPY, asymmetricGoalsCopy, sameAddressCopy, interactingTestsCopy, pageInUseCopy, unpublishedPageCopy } from '../admin/copy/readiness.js';
9
+ import { GROUP_LABELS } from '../admin/copy/arms.js';
6
10
  import { XpRefusal } from '../errors.js';
7
11
  import { isOpValid } from '../targeting/ops.js';
8
12
  import { newSeed } from '../runtime/seed.js';
@@ -31,7 +35,20 @@ export async function readExperiment(req, id) {
31
35
  }
32
36
  export function refuse(code, detail) {
33
37
  const messages = {
38
+ 'goal-in-some-versions': asymmetricGoalsCopy(detail),
39
+ 'placements-changed': "This test's pages changed while it was starting. Try again.",
40
+ 'page-busy': "One of this test's pages is being saved right now. Try again in a moment.",
41
+ 'interacting-tests': (()=>{
42
+ const d = detail?.[0];
43
+ return d ? interactingTestsCopy(d.path, d.status, d.name, d.version) : 'Finish one of the tests first.';
44
+ })(),
45
+ 'unknown-collection': "That page type isn't set up for tests.",
46
+ 'placement-removed': READINESS_COPY.placementRemoved,
34
47
  'page-in-use': pageInUseCopy(detail?.paths?.[0] ?? 'this page'),
48
+ 'version-same-address': (()=>{
49
+ const d = detail;
50
+ return sameAddressCopy(GROUP_LABELS[d?.arm ?? 'variantA'], GROUP_LABELS[d?.otherArm ?? 'control'], d?.path ?? 'this address');
51
+ })(),
35
52
  'page-target-unpublished': unpublishedPageCopy(typeof detail === 'string' ? detail : 'This page'),
36
53
  'invalid-status': 'This action is not available in the current status.',
37
54
  'stale-draft': 'The experiment changed. Review it again before starting.',
@@ -46,6 +63,9 @@ export function refuse(code, detail) {
46
63
  'entry-not-dismissed': 'That page is not dismissed.'
47
64
  };
48
65
  const conflict = [
66
+ 'page-busy',
67
+ 'placements-changed',
68
+ 'interacting-tests',
49
69
  'invalid-status',
50
70
  'stale-draft',
51
71
  'page-in-use',
@@ -110,6 +130,17 @@ export async function start(req, { experimentId, options }) {
110
130
  const current = await readExperiment(req, experimentId);
111
131
  if (current.updatedAt !== updatedAt) refuse('stale-draft');
112
132
  if (current.status !== 'draft' && current.status !== 'stopped') refuse('invalid-status');
133
+ let placementDocs;
134
+ if (current.kind === 'block') {
135
+ const rescan = await lockAndRescanPlacements(req, exec, options, current, input.placements ?? []);
136
+ placementDocs = rescan.docs;
137
+ const failure = checkStart({
138
+ ...input,
139
+ experiment: current,
140
+ placements: rescan.placements
141
+ }).find((check)=>!check.ok);
142
+ if (failure && failure.code !== 'page-targets-unchecked' && failure.code !== 'make-content-unsupported') refuse(failure.code, failure.detail);
143
+ }
113
144
  if (current.kind === 'page') {
114
145
  // Independent experiment/layer locks do not serialize overlapping pages.
115
146
  // Re-read occupancy after acquiring sorted path locks; hold through commit.
@@ -121,6 +152,8 @@ export async function start(req, { experimentId, options }) {
121
152
  pageTestsInUse: await loadPageTestsInUse(req)
122
153
  }).find((check)=>check.code === 'page-in-use' && !check.ok);
123
154
  if (recheck) refuse('page-in-use', recheck.detail);
155
+ const conflicts = await placementInteractions(req, exec, options, current, pageTargetPaths(current.arms));
156
+ if (conflicts.length) refuse('interacting-tests', conflicts);
124
157
  if (options.pages) {
125
158
  // The page guard takes the same path locks, so a delete, unpublish or rename that committed
126
159
  // while Start waited is visible now. Read it on a detached request (committed state, outside
@@ -181,6 +214,15 @@ export async function start(req, { experimentId, options }) {
181
214
  }).find((check)=>!check.ok);
182
215
  if (recheck && recheck.code !== 'page-targets-unchecked' && recheck.code !== 'make-content-unsupported') refuse(recheck.code, recheck.detail);
183
216
  }
217
+ if (input.metrics.some((metric)=>metric.source.startsWith('click:'))) {
218
+ const content = await detached(req, ()=>goalContent(req, options, current, placementDocs));
219
+ const failure = checkStart({
220
+ ...input,
221
+ experiment: current,
222
+ goalContent: content
223
+ }).find((check)=>check.code === 'goal-in-some-versions' && !check.ok);
224
+ if (failure) refuse('goal-in-some-versions', failure.detail);
225
+ }
184
226
  const lockedMetrics = await lockMetricsForFreeze(exec, caps.definitions.map((metric)=>metric.id));
185
227
  if (!lockedMetrics) {
186
228
  throw new XpRefusal('stale-draft', 'A goal in this test was deleted. Review the goals before starting.', undefined, 409);
@@ -233,6 +275,9 @@ export async function start(req, { experimentId, options }) {
233
275
  allocationChanges: [],
234
276
  targeting,
235
277
  metricDefinitions: definitions,
278
+ ...placementDocs ? {
279
+ placementDocs
280
+ } : {},
236
281
  analysis: {
237
282
  ...current.analysis,
238
283
  method: current.analysis?.method ?? 'sequential',
@@ -18,4 +18,4 @@ export type SerializedAttributeKeys = Record<string, {
18
18
  type: 'string' | 'number' | 'boolean';
19
19
  values?: string[];
20
20
  }>;
21
- export type EditableField = 'name' | 'hypothesis' | 'arms' | 'targeting' | 'allocation' | 'flagPoint' | 'metrics' | 'analysis' | 'exposurePolicy';
21
+ export type EditableField = 'name' | 'hypothesis' | 'arms' | 'targeting' | 'allocation' | 'flagPoint' | 'metrics' | 'analysis' | 'exposurePolicy' | 'pageCollection';
@@ -32,6 +32,7 @@ export interface FlagPointResult {
32
32
  exposable: boolean;
33
33
  }
34
34
  export interface ClientAssignments {
35
+ clickGoalsArmed?: boolean;
35
36
  v: 1;
36
37
  revision: number;
37
38
  apiRoute: string;
@@ -103,6 +103,10 @@ export type ExperimentsAccess = (args: {
103
103
  }) => boolean | Promise<boolean>;
104
104
  export interface ExperimentsPageCollection {
105
105
  slug: string;
106
+ contentOf?: (doc: Record<string, unknown>) => unknown;
107
+ copyHint?: string;
108
+ copyUrl?: string;
109
+ copyLabel?: string;
106
110
  pathOf: (doc: Record<string, unknown>) => string | null;
107
111
  /** Lookup with the supplied req and overrideAccess: false. Under an anonymous req (results checks) only published pages are visible, so a hit is { published: true } or null. Under a req that can read drafts (Start), a draft-only page may be reported as { published: false }. */
108
112
  findByPath: (path: string, req: PayloadRequest) => Promise<{
@@ -130,9 +134,12 @@ export interface ExperimentsPluginOptions<TUser extends {
130
134
  collection: string;
131
135
  id: DocId;
132
136
  draft?: boolean;
137
+ editor: 'puck' | 'blocks' | 'page';
138
+ doc?: Record<string, unknown>;
133
139
  }) => string;
134
140
  previewUrl?: (placement: PreviewPlacement) => string | null | undefined | Promise<string | null | undefined>;
135
141
  descriptions?: {
142
+ page?: string;
136
143
  block?: string;
137
144
  document?: string;
138
145
  setting?: string;
@@ -151,6 +158,14 @@ export interface ExperimentsPluginOptions<TUser extends {
151
158
  pages?: {
152
159
  collections: ExperimentsPageCollection[];
153
160
  };
161
+ blocks?: {
162
+ collections: Array<{
163
+ slug: string;
164
+ field: string;
165
+ blockSlug?: string;
166
+ appliesTo?: (doc: Record<string, unknown>) => boolean;
167
+ }>;
168
+ };
154
169
  puck?: {
155
170
  enabled: boolean;
156
171
  collections: Array<string | {