@delmaredigital/payload-experiments 0.1.0-rc.7 → 0.2.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 (207) hide show
  1. package/README.md +28 -0
  2. package/dist/admin/LayerUsage.d.ts +1 -1
  3. package/dist/admin/LayerUsage.js +23 -11
  4. package/dist/admin/MetricSourceSelect.js +2 -1
  5. package/dist/admin/TestsNavLink.d.ts +1 -0
  6. package/dist/admin/TestsNavLink.js +30 -0
  7. package/dist/admin/allocation.model.js +1 -1
  8. package/dist/admin/copy/actions.d.ts +0 -2
  9. package/dist/admin/copy/actions.js +0 -2
  10. package/dist/admin/copy/advanced.d.ts +55 -0
  11. package/dist/admin/copy/advanced.js +55 -0
  12. package/dist/admin/copy/armSelect.js +1 -1
  13. package/dist/admin/copy/arms.d.ts +0 -13
  14. package/dist/admin/copy/arms.js +0 -13
  15. package/dist/admin/copy/{who.d.ts → audience.d.ts} +1 -9
  16. package/dist/admin/copy/{who.js → audience.js} +1 -9
  17. package/dist/admin/copy/history.d.ts +0 -4
  18. package/dist/admin/copy/history.js +3 -7
  19. package/dist/admin/copy/metrics.d.ts +2 -2
  20. package/dist/admin/copy/metrics.js +2 -2
  21. package/dist/admin/copy/newTest.d.ts +32 -0
  22. package/dist/admin/copy/newTest.js +32 -0
  23. package/dist/admin/copy/readiness.d.ts +45 -0
  24. package/dist/admin/copy/readiness.js +50 -0
  25. package/dist/admin/copy/recommendation.d.ts +26 -0
  26. package/dist/admin/copy/recommendation.js +25 -0
  27. package/dist/admin/copy/results.d.ts +0 -1
  28. package/dist/admin/copy/results.js +0 -1
  29. package/dist/admin/copy/review.d.ts +0 -17
  30. package/dist/admin/copy/review.js +1 -112
  31. package/dist/admin/copy/setting.d.ts +10 -0
  32. package/dist/admin/copy/setting.js +10 -0
  33. package/dist/admin/copy/surface.d.ts +36 -0
  34. package/dist/admin/copy/surface.js +47 -0
  35. package/dist/admin/copy/testPage.d.ts +88 -0
  36. package/dist/admin/copy/testPage.js +88 -0
  37. package/dist/admin/paths.d.ts +5 -13
  38. package/dist/admin/paths.js +5 -13
  39. package/dist/admin/review/review.model.d.ts +3 -26
  40. package/dist/admin/review/review.model.js +3 -30
  41. package/dist/admin/surface/ResultsRoute.d.ts +7 -0
  42. package/dist/admin/surface/ResultsRoute.js +58 -0
  43. package/dist/admin/surface/SurfaceShell.d.ts +6 -0
  44. package/dist/admin/surface/SurfaceShell.js +35 -0
  45. package/dist/admin/surface/TestsHome.d.ts +4 -0
  46. package/dist/admin/surface/TestsHome.js +212 -0
  47. package/dist/admin/surface/TestsSurface.d.ts +2 -0
  48. package/dist/admin/surface/TestsSurface.js +36 -0
  49. package/dist/admin/surface/contracts.d.ts +48 -0
  50. package/dist/admin/surface/contracts.js +8 -0
  51. package/dist/admin/surface/home.model.d.ts +41 -0
  52. package/dist/admin/surface/home.model.js +59 -0
  53. package/dist/admin/surface/newTest/NewTest.d.ts +7 -0
  54. package/dist/admin/surface/newTest/NewTest.js +355 -0
  55. package/dist/admin/surface/newTest/newTest.css +9 -0
  56. package/dist/admin/surface/pageBlock/pageBlock.model.d.ts +6 -0
  57. package/dist/admin/surface/pageBlock/pageBlock.model.js +8 -0
  58. package/dist/admin/surface/pageBlock/parts.d.ts +2 -0
  59. package/dist/admin/surface/pageBlock/parts.js +64 -0
  60. package/dist/admin/surface/readiness/ActionDialogs.d.ts +15 -0
  61. package/dist/admin/surface/readiness/ActionDialogs.js +274 -0
  62. package/dist/admin/surface/readiness/Banner.d.ts +2 -0
  63. package/dist/admin/surface/readiness/Banner.js +138 -0
  64. package/dist/admin/surface/readiness/SaveStatus.d.ts +4 -0
  65. package/dist/admin/surface/readiness/SaveStatus.js +50 -0
  66. package/dist/admin/surface/readiness/parts.d.ts +2 -0
  67. package/dist/admin/surface/readiness/parts.js +9 -0
  68. package/dist/admin/surface/readiness/readiness.model.d.ts +22 -0
  69. package/dist/admin/surface/readiness/readiness.model.js +107 -0
  70. package/dist/admin/surface/readiness/useTransition.d.ts +10 -0
  71. package/dist/admin/surface/readiness/useTransition.js +34 -0
  72. package/dist/admin/surface/results/ResultsBody.d.ts +7 -0
  73. package/dist/admin/surface/results/ResultsBody.js +262 -0
  74. package/dist/admin/surface/results/recommendation.model.d.ts +12 -0
  75. package/dist/admin/surface/results/recommendation.model.js +59 -0
  76. package/dist/admin/surface/results/useResults.d.ts +56 -0
  77. package/dist/admin/surface/results/useResults.js +183 -0
  78. package/dist/admin/surface/setting/parts.d.ts +3 -0
  79. package/dist/admin/surface/setting/parts.js +263 -0
  80. package/dist/admin/surface/setting/setting.css +29 -0
  81. package/dist/admin/surface/setting/setting.model.d.ts +8 -0
  82. package/dist/admin/surface/setting/setting.model.js +25 -0
  83. package/dist/admin/surface/surface.css +50 -0
  84. package/dist/admin/surface/test/AdvancedSection.d.ts +2 -0
  85. package/dist/admin/surface/test/AdvancedSection.js +153 -0
  86. package/dist/admin/surface/test/GoalSection.d.ts +2 -0
  87. package/dist/admin/surface/test/GoalSection.js +198 -0
  88. package/dist/admin/surface/test/LengthSection.d.ts +2 -0
  89. package/dist/admin/surface/test/LengthSection.js +87 -0
  90. package/dist/admin/surface/test/Section.d.ts +7 -0
  91. package/dist/admin/surface/test/Section.js +27 -0
  92. package/dist/admin/surface/test/TargetingSection.d.ts +2 -0
  93. package/dist/admin/surface/test/TargetingSection.js +164 -0
  94. package/dist/admin/surface/test/TestHeader.d.ts +4 -0
  95. package/dist/admin/surface/test/TestHeader.js +69 -0
  96. package/dist/admin/surface/test/TestPage.d.ts +7 -0
  97. package/dist/admin/surface/test/TestPage.js +84 -0
  98. package/dist/admin/surface/test/VersionsSection.d.ts +4 -0
  99. package/dist/admin/surface/test/VersionsSection.js +208 -0
  100. package/dist/admin/surface/test/fields.d.ts +55 -0
  101. package/dist/admin/surface/test/fields.js +185 -0
  102. package/dist/admin/surface/test/pageParts.d.ts +2 -0
  103. package/dist/admin/surface/test/pageParts.js +39 -0
  104. package/dist/admin/surface/test/testPage.css +23 -0
  105. package/dist/admin/surface/test/testPage.model.d.ts +17 -0
  106. package/dist/admin/surface/test/testPage.model.js +53 -0
  107. package/dist/admin/surface/testDocument.model.d.ts +39 -0
  108. package/dist/admin/surface/testDocument.model.js +61 -0
  109. package/dist/admin/surface/useTestDocument.d.ts +22 -0
  110. package/dist/admin/surface/useTestDocument.js +345 -0
  111. package/dist/admin/targeting/targeting.model.js +2 -2
  112. package/dist/admin/useLayerUsage.d.ts +0 -5
  113. package/dist/admin/useLayerUsage.js +1 -39
  114. package/dist/admin/views/CollectionEditRedirect.d.ts +4 -0
  115. package/dist/admin/views/CollectionEditRedirect.js +14 -0
  116. package/dist/admin/views/CollectionListRedirect.d.ts +2 -0
  117. package/dist/admin/views/CollectionListRedirect.js +8 -0
  118. package/dist/admin/views/ExperimentsRootView.d.ts +2 -0
  119. package/dist/admin/views/ExperimentsRootView.js +175 -0
  120. package/dist/admin/views/access.model.d.ts +7 -0
  121. package/dist/admin/views/access.model.js +5 -0
  122. package/dist/collections/experiments/config.js +18 -125
  123. package/dist/collections/experiments/createDefaults.js +6 -0
  124. package/dist/collections/layers.js +43 -12
  125. package/dist/collections/metrics.js +70 -14
  126. package/dist/collections/serialize.js +4 -1
  127. package/dist/db/snapshots.d.ts +2 -1
  128. package/dist/db/snapshots.js +2 -2
  129. package/dist/endpoints/results.js +7 -0
  130. package/dist/endpoints/summary.d.ts +1 -1
  131. package/dist/endpoints/summary.js +37 -1
  132. package/dist/exports/adminClient.d.ts +2 -13
  133. package/dist/exports/adminClient.js +2 -13
  134. package/dist/exports/index.d.ts +1 -1
  135. package/dist/exports/rsc.d.ts +3 -0
  136. package/dist/exports/rsc.js +3 -0
  137. package/dist/options.js +29 -0
  138. package/dist/plugin.js +25 -0
  139. package/dist/puck/editor/CreateExperimentDrawer.d.ts +2 -1
  140. package/dist/puck/editor/CreateExperimentDrawer.js +17 -57
  141. package/dist/puck/editor/ExperimentSlotEditor.js +6 -0
  142. package/dist/puck/editor/copy.d.ts +10 -9
  143. package/dist/puck/editor/copy.js +11 -10
  144. package/dist/puck/editor/createTest.d.ts +7 -0
  145. package/dist/puck/editor/createTest.js +50 -0
  146. package/dist/puck/editor/editor.model.d.ts +28 -1
  147. package/dist/puck/editor/editor.model.js +61 -0
  148. package/dist/results/analyse.js +4 -1
  149. package/dist/results/verdict.d.ts +1 -0
  150. package/dist/results/verdict.js +7 -4
  151. package/dist/transitions/checkStart.d.ts +1 -0
  152. package/dist/transitions/placements.d.ts +5 -1
  153. package/dist/transitions/placements.js +14 -7
  154. package/dist/transitions/preflightData.d.ts +7 -1
  155. package/dist/transitions/preflightData.js +199 -148
  156. package/dist/transitions/start.js +2 -2
  157. package/dist/types/admin.d.ts +1 -0
  158. package/dist/types/contracts.d.ts +17 -0
  159. package/dist/types/endpoints.d.ts +11 -1
  160. package/dist/types/snapshot.d.ts +1 -0
  161. package/dist/types/surface.d.ts +29 -0
  162. package/dist/types/surface.js +1 -0
  163. package/package.json +1 -1
  164. package/dist/access.d.ts +0 -1
  165. package/dist/access.js +0 -2
  166. package/dist/admin/AllocationField.d.ts +0 -2
  167. package/dist/admin/AllocationField.js +0 -80
  168. package/dist/admin/FlagPointSelect.d.ts +0 -2
  169. package/dist/admin/FlagPointSelect.js +0 -54
  170. package/dist/admin/LockedFieldNotice.d.ts +0 -2
  171. package/dist/admin/LockedFieldNotice.js +0 -26
  172. package/dist/admin/PhaseHistory.d.ts +0 -2
  173. package/dist/admin/PhaseHistory.js +0 -76
  174. package/dist/admin/StatusCell.d.ts +0 -3
  175. package/dist/admin/StatusCell.js +0 -19
  176. package/dist/admin/SummaryCell.d.ts +0 -2
  177. package/dist/admin/SummaryCell.js +0 -32
  178. package/dist/admin/actions/AddMetricDialog.d.ts +0 -6
  179. package/dist/admin/actions/AddMetricDialog.js +0 -138
  180. package/dist/admin/actions/AllocateDialog.d.ts +0 -6
  181. package/dist/admin/actions/AllocateDialog.js +0 -81
  182. package/dist/admin/actions/ExperimentActions.d.ts +0 -3
  183. package/dist/admin/actions/ExperimentActions.js +0 -110
  184. package/dist/admin/arms/ArmsField.d.ts +0 -2
  185. package/dist/admin/arms/ArmsField.js +0 -310
  186. package/dist/admin/arms/ParamsEditor.d.ts +0 -16
  187. package/dist/admin/arms/ParamsEditor.js +0 -79
  188. package/dist/admin/copy/tabs.d.ts +0 -7
  189. package/dist/admin/copy/tabs.js +0 -7
  190. package/dist/admin/permissions.model.d.ts +0 -10
  191. package/dist/admin/permissions.model.js +0 -4
  192. package/dist/admin/phaseHistory.model.d.ts +0 -19
  193. package/dist/admin/phaseHistory.model.js +0 -19
  194. package/dist/admin/results/ResultsView.d.ts +0 -3
  195. package/dist/admin/results/ResultsView.js +0 -312
  196. package/dist/admin/review/ReviewPanel.d.ts +0 -2
  197. package/dist/admin/review/ReviewPanel.js +0 -268
  198. package/dist/admin/summaryBatcher.d.ts +0 -8
  199. package/dist/admin/summaryBatcher.js +0 -33
  200. package/dist/admin/targeting/TargetingAttributeField.d.ts +0 -2
  201. package/dist/admin/targeting/TargetingAttributeField.js +0 -31
  202. package/dist/admin/targeting/TargetingOpField.d.ts +0 -2
  203. package/dist/admin/targeting/TargetingOpField.js +0 -45
  204. package/dist/admin/targeting/TargetingValueField.d.ts +0 -2
  205. package/dist/admin/targeting/TargetingValueField.js +0 -142
  206. package/dist/index.d.ts +0 -1
  207. package/dist/index.js +0 -4
@@ -7,7 +7,13 @@ export function metricsCollection(options) {
7
7
  slug: 'xp-metrics',
8
8
  graphQL: false,
9
9
  disableDuplicate: true,
10
+ // group:false hides navigation while keeping collection routes reachable (hidden causes 404s).
11
+ labels: {
12
+ singular: 'Goal',
13
+ plural: 'Goals'
14
+ },
10
15
  admin: {
16
+ group: false,
11
17
  useAsTitle: 'name'
12
18
  },
13
19
  access: managedAccess(options),
@@ -30,21 +36,31 @@ export function metricsCollection(options) {
30
36
  fields: [
31
37
  {
32
38
  name: 'name',
39
+ label: 'Name',
33
40
  type: 'text',
34
- required: true
41
+ required: true,
42
+ admin: {
43
+ description: 'Give this goal a name people will recognize.'
44
+ }
35
45
  },
36
46
  {
37
47
  name: 'slug',
48
+ label: 'Short name (used in code)',
38
49
  type: 'text',
39
50
  required: true,
40
- unique: true
51
+ unique: true,
52
+ admin: {
53
+ description: 'Use a unique short name for code that refers to this goal.'
54
+ }
41
55
  },
42
56
  {
43
57
  name: 'source',
58
+ label: 'Measured by',
44
59
  type: 'text',
45
60
  required: true,
46
- validate: (value)=>sources.some((source)=>source.id === value) || 'Choose a registered metric source.',
61
+ validate: (value)=>sources.some((source)=>source.id === value) || 'Choose what this goal is measured by.',
47
62
  admin: {
63
+ description: 'Choose the outcome this goal measures.',
48
64
  components: {
49
65
  Field: XP_METRIC_SOURCE_SELECT
50
66
  },
@@ -55,60 +71,100 @@ export function metricsCollection(options) {
55
71
  },
56
72
  {
57
73
  name: 'direction',
74
+ label: 'Better when it goes',
58
75
  type: 'select',
59
76
  required: true,
60
77
  defaultValue: 'increase',
61
78
  options: [
62
- 'increase',
63
- 'decrease'
64
- ]
79
+ {
80
+ label: 'Goes up',
81
+ value: 'increase'
82
+ },
83
+ {
84
+ label: 'Goes down',
85
+ value: 'decrease'
86
+ }
87
+ ],
88
+ admin: {
89
+ description: 'Choose whether a higher or lower result is better.'
90
+ }
65
91
  },
66
92
  {
67
93
  name: 'attributionWindowHours',
94
+ label: 'Count results for (hours)',
68
95
  type: 'number',
69
96
  defaultValue: 168,
70
- min: 1
97
+ min: 1,
98
+ admin: {
99
+ description: 'Count outcomes during this many hours after someone sees a version.'
100
+ }
71
101
  },
72
102
  {
73
103
  name: 'capping',
104
+ label: 'Limit very large values',
74
105
  type: 'group',
106
+ admin: {
107
+ description: 'Limit unusually large amounts when comparing results while keeping totals in full.'
108
+ },
75
109
  fields: [
76
110
  {
77
111
  name: 'type',
112
+ label: 'Limit by',
78
113
  type: 'select',
79
114
  options: [
80
- 'percentile',
81
- 'absolute'
115
+ {
116
+ label: 'Based on past amounts',
117
+ value: 'percentile'
118
+ },
119
+ {
120
+ label: 'Fixed amount',
121
+ value: 'absolute'
122
+ }
82
123
  ],
124
+ admin: {
125
+ description: 'Choose whether the limit comes from past amounts or a fixed amount.'
126
+ },
83
127
  validate: (value, { data, siblingData })=>{
84
128
  const hasCapping = value != null || siblingData?.value != null;
85
129
  const source = sources.find((source)=>source.id === data?.source);
86
- if (hasCapping && source && source.kind !== 'revenue') return 'Count and conversion metrics cannot be capped.';
87
- return !hasCapping || value === 'percentile' || value === 'absolute' || 'Choose a capping type.';
130
+ if (hasCapping && source && source.kind !== 'revenue') return 'Count and yes-or-no goals cannot use a limit.';
131
+ return !hasCapping || value === 'percentile' || value === 'absolute' || 'Choose how to set the limit.';
88
132
  }
89
133
  },
90
134
  {
91
135
  name: 'value',
136
+ label: 'Limit value',
92
137
  type: 'number',
93
138
  min: 0,
139
+ admin: {
140
+ description: 'For past amounts, enter how much of the usual range to keep, such as 0.99 to limit only the largest 1%; or enter a fixed amount.'
141
+ },
94
142
  validate: (value, { siblingData })=>{
95
143
  if (value == null && !siblingData?.type) return true;
96
- return typeof value === 'number' && Number.isFinite(value) && value >= 0 && (siblingData?.type === 'absolute' || siblingData?.type === 'percentile' && value > 0 && value <= 1) || 'Enter a valid cap (percentiles must be greater than 0 and at most 1).';
144
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 && (siblingData?.type === 'absolute' || siblingData?.type === 'percentile' && value > 0 && value <= 1) || 'Enter a valid limit (for past amounts, use a number greater than 0 and at most 1).';
97
145
  }
98
146
  }
99
147
  ]
100
148
  },
101
149
  {
102
150
  name: 'minSampleSize',
151
+ label: 'Minimum completions before deciding',
103
152
  type: 'number',
104
153
  defaultValue: 150,
105
- min: 1
154
+ min: 1,
155
+ admin: {
156
+ description: 'Wait until each version has at least this many people who completed the goal before showing a result.'
157
+ }
106
158
  },
107
159
  {
108
160
  name: 'guardrailThreshold',
161
+ label: 'Warn when it gets worse by',
109
162
  type: 'number',
110
163
  defaultValue: 0.05,
111
- min: 0
164
+ min: 0,
165
+ admin: {
166
+ description: 'When this goal is a safety check, warn if it gets worse by this fraction, such as 0.05 for 5%.'
167
+ }
112
168
  }
113
169
  ]
114
170
  };
@@ -1,7 +1,10 @@
1
1
  export function serializeFlagPoints(options) {
2
- return (options.flagPoints ?? []).map(({ id, label, params, defaultParams })=>({
2
+ return (options.flagPoints ?? []).map(({ id, label, description, params, defaultParams })=>({
3
3
  id,
4
4
  label,
5
+ ...description === undefined ? {} : {
6
+ description
7
+ },
5
8
  params,
6
9
  defaultParams,
7
10
  effectiveDefaults: defaultParams
@@ -40,8 +40,9 @@ export declare function writeSnapshots(exec: Executor, snapshots: Snapshot[]): P
40
40
  * with req/overrideAccess:false. Called behind the readResults/manage gate. */
41
41
  export declare function newestSnapshotIds(exec: Executor, ids: DocId[], per: 'phase' | 'experiment'): Promise<DocId[]>;
42
42
  /** Called only behind the endpoint's readResults gate. Experiment collection
43
- * reads require manage, so read only the decision pointer needed by results. */
43
+ * reads require manage, so read only the decision metadata needed by results. */
44
44
  export declare function resultsMetadata(exec: Executor, id: DocId): Promise<{
45
45
  status: string;
46
46
  snapshotId: DocId | null;
47
+ winnerArm: string | null;
47
48
  } | null>;
@@ -80,9 +80,9 @@ export async function writeSnapshot(exec, snapshot) {
80
80
  return rows.map((row)=>row.id);
81
81
  }
82
82
  /** Called only behind the endpoint's readResults gate. Experiment collection
83
- * reads require manage, so read only the decision pointer needed by results. */ export async function resultsMetadata(exec, id) {
83
+ * reads require manage, so read only the decision metadata needed by results. */ export async function resultsMetadata(exec, id) {
84
84
  const c = COLUMNS.experiments;
85
- const { rows } = await exec.query(`SELECT ${q(c.status)} AS "status", ${q(c.decisionSnapshotId)} AS "snapshotId" FROM ${qualifiedTable(exec, TABLES.experiments)} WHERE ${q(c.id)} = $1`, [
85
+ const { rows } = await exec.query(`SELECT ${q(c.status)} AS "status", ${q(c.decisionSnapshotId)} AS "snapshotId", ${q(c.decisionWinnerArm)} AS "winnerArm" FROM ${qualifiedTable(exec, TABLES.experiments)} WHERE ${q(c.id)} = $1`, [
86
86
  id
87
87
  ]);
88
88
  return rows[0] ?? null;
@@ -116,6 +116,13 @@ export async function handleResults(req, _ctx) {
116
116
  decisionSnapshot: decision
117
117
  } : {}
118
118
  };
119
+ const decisionPhase = decision?.phase ?? records.filter((record)=>record.phaseKind === 'test').at(-1)?.phase;
120
+ if (typeof metadata.winnerArm === 'string' && metadata.winnerArm.length > 0 && decisionPhase !== undefined) {
121
+ response.decision = {
122
+ phase: decisionPhase,
123
+ winnerArm: metadata.winnerArm
124
+ };
125
+ }
119
126
  if (params.get('series') === '1') {
120
127
  const series = phase === undefined ? [] : await readSnapshots(req, [
121
128
  id
@@ -4,4 +4,4 @@ export declare function handleSummary<TUser extends {
4
4
  id: string | number;
5
5
  } = {
6
6
  id: string | number;
7
- }>(req: PayloadRequest, _ctx: EndpointContext<TUser>): Promise<Response>;
7
+ }>(req: PayloadRequest, { options }: EndpointContext<TUser>): Promise<Response>;
@@ -1,5 +1,7 @@
1
+ import { checkStart } from '../transitions/checkStart.js';
2
+ import { loadStartInputsBatch } from '../transitions/preflightData.js';
1
3
  import { readSnapshots, resultsError } from './results.js';
2
- export async function handleSummary(req, _ctx) {
4
+ export async function handleSummary(req, { options }) {
3
5
  let ids;
4
6
  try {
5
7
  // Split before decoding: an opaque id can itself contain a comma.
@@ -32,6 +34,40 @@ export async function handleSummary(req, _ctx) {
32
34
  verdict: null
33
35
  };
34
36
  }
37
+ try {
38
+ if (await options.access.manage({
39
+ req
40
+ }) === true) {
41
+ const found = await req.payload.find({
42
+ collection: 'xp-experiments',
43
+ req,
44
+ overrideAccess: false,
45
+ depth: 0,
46
+ pagination: false,
47
+ limit: ids.length,
48
+ where: {
49
+ id: {
50
+ in: [
51
+ ...new Set(ids)
52
+ ]
53
+ }
54
+ }
55
+ });
56
+ const drafts = found.docs.filter((e)=>e.status === 'draft' || e.status === 'stopped');
57
+ const inputs = await loadStartInputsBatch(req, options, drafts, true);
58
+ for (const id of ids){
59
+ // Payload normalises numeric ids (for example 01 to 1); preserve the requested response key.
60
+ const experiment = drafts.find((e)=>String(e.id) === id || typeof e.id === 'number' && Number(id) === e.id);
61
+ if (!experiment) continue;
62
+ const input = inputs.get(String(experiment.id));
63
+ try {
64
+ response[id].ready = !!input && checkStart(input).every((check)=>check.ok);
65
+ } catch {
66
+ response[id].ready = false;
67
+ }
68
+ }
69
+ }
70
+ } catch {}
35
71
  return Response.json(response, {
36
72
  headers: {
37
73
  'Cache-Control': 'no-store'
@@ -1,16 +1,5 @@
1
- export { AllocationField } from '../admin/AllocationField.js';
2
- export { FlagPointSelect } from '../admin/FlagPointSelect.js';
3
1
  export { LayerUsage } from '../admin/LayerUsage.js';
4
- export { LockedFieldNotice } from '../admin/LockedFieldNotice.js';
5
2
  export { MetricSourceSelect } from '../admin/MetricSourceSelect.js';
6
- export { PhaseHistory } from '../admin/PhaseHistory.js';
7
- export { StatusCell } from '../admin/StatusCell.js';
8
- export { SummaryCell } from '../admin/SummaryCell.js';
9
- export { ExperimentActions } from '../admin/actions/ExperimentActions.js';
10
3
  export { ArmSelectField } from '../admin/armSelect/ArmSelectField.js';
11
- export { ArmsField } from '../admin/arms/ArmsField.js';
12
- export { ResultsView } from '../admin/results/ResultsView.js';
13
- export { ReviewPanel } from '../admin/review/ReviewPanel.js';
14
- export { TargetingAttributeField } from '../admin/targeting/TargetingAttributeField.js';
15
- export { TargetingOpField } from '../admin/targeting/TargetingOpField.js';
16
- export { TargetingValueField } from '../admin/targeting/TargetingValueField.js';
4
+ export { TestsNavLink } from '../admin/TestsNavLink.js';
5
+ export { TestsSurface } from '../admin/surface/TestsSurface.js';
@@ -1,17 +1,6 @@
1
1
  'use client';
2
- export { AllocationField } from '../admin/AllocationField.js';
3
- export { FlagPointSelect } from '../admin/FlagPointSelect.js';
4
2
  export { LayerUsage } from '../admin/LayerUsage.js';
5
- export { LockedFieldNotice } from '../admin/LockedFieldNotice.js';
6
3
  export { MetricSourceSelect } from '../admin/MetricSourceSelect.js';
7
- export { PhaseHistory } from '../admin/PhaseHistory.js';
8
- export { StatusCell } from '../admin/StatusCell.js';
9
- export { SummaryCell } from '../admin/SummaryCell.js';
10
- export { ExperimentActions } from '../admin/actions/ExperimentActions.js';
11
4
  export { ArmSelectField } from '../admin/armSelect/ArmSelectField.js';
12
- export { ArmsField } from '../admin/arms/ArmsField.js';
13
- export { ResultsView } from '../admin/results/ResultsView.js';
14
- export { ReviewPanel } from '../admin/review/ReviewPanel.js';
15
- export { TargetingAttributeField } from '../admin/targeting/TargetingAttributeField.js';
16
- export { TargetingOpField } from '../admin/targeting/TargetingOpField.js';
17
- export { TargetingValueField } from '../admin/targeting/TargetingValueField.js';
5
+ export { TestsNavLink } from '../admin/TestsNavLink.js';
6
+ export { TestsSurface } from '../admin/surface/TestsSurface.js';
@@ -1,4 +1,4 @@
1
1
  export { createExperimentsPlugin } from '../plugin.js';
2
2
  export { defineFlagPoint, defineMetricSource } from '../define.js';
3
3
  export { createArmSelectField } from '../fields/armSelect.js';
4
- export type { SerializedFlagPoint, SerializedMetricSource, SerializedAttributeKeys, EditableField, Assignment, Assignments, FlagPointResult, ClientAssignments, RenderedExposure, DocId, Attributes, ExposurePolicy, ExperimentKind, Status, UnitType, ArmKey, ArmKind, TargetingRule, Arm, ParamField, MetricSource, FlagPoint, RateLimiter, ExperimentsAccess, ExperimentsPluginOptions, ArmIssueCode, RefusalCode, ErrorEnvelope, TransitionSuccess, AllocateBody, AddMetricBody, DecideBody, ArchiveBody, MakeContentResult, PreflightResult, PreviewTokenResult, PhaseSummary, ResultsResponse, SummaryResponse, EventBody, FlagDefault, Manifest, ManifestExperiment, ArmSlot, ExperimentRef, ExperimentSlotProps, ExperimentsProxyRuntimeOptions, ExperimentsProxyRuntime, ExperimentsServerRuntimeOptions, ExperimentsServerRuntime, VerdictCode, Snapshot, } from '../types/index.js';
4
+ export type { SerializedFlagPoint, SerializedMetricSource, SerializedAttributeKeys, EditableField, Assignment, Assignments, FlagPointResult, ClientAssignments, RenderedExposure, DocId, Attributes, ExposurePolicy, ExperimentKind, Status, UnitType, ArmKey, ArmKind, TargetingRule, Arm, ParamField, MetricSource, FlagPoint, RateLimiter, ExperimentsAccess, ExperimentsPluginOptions, ArmIssueCode, RefusalCode, ReadinessCode, ErrorEnvelope, TransitionSuccess, AllocateBody, AddMetricBody, DecideBody, ArchiveBody, MakeContentResult, PreflightResult, PreviewTokenResult, PhaseSummary, ResultsResponse, SummaryResponse, EventBody, FlagDefault, Manifest, ManifestExperiment, ArmSlot, ExperimentRef, ExperimentSlotProps, ExperimentsProxyRuntimeOptions, ExperimentsProxyRuntime, ExperimentsServerRuntimeOptions, ExperimentsServerRuntime, VerdictCode, Snapshot, } from '../types/index.js';
@@ -11,3 +11,6 @@ export { experimentSitemapFilter } from '../server/sitemap.js';
11
11
  export { experimentMetadata } from '../server/metadata.js';
12
12
  export { toClientAssignments } from '../server/toClientAssignments.js';
13
13
  export { contentChangesBeforeHook as captureContentBaseline } from '../hooks/contentChanges.js';
14
+ export { ExperimentsRootView } from '../admin/views/ExperimentsRootView.js';
15
+ export { CollectionListRedirect } from '../admin/views/CollectionListRedirect.js';
16
+ export { CollectionEditRedirect } from '../admin/views/CollectionEditRedirect.js';
@@ -11,3 +11,6 @@ export { experimentSitemapFilter } from '../server/sitemap.js';
11
11
  export { experimentMetadata } from '../server/metadata.js';
12
12
  export { toClientAssignments } from '../server/toClientAssignments.js';
13
13
  export { contentChangesBeforeHook as captureContentBaseline } from '../hooks/contentChanges.js';
14
+ export { ExperimentsRootView } from '../admin/views/ExperimentsRootView.js';
15
+ export { CollectionListRedirect } from '../admin/views/CollectionListRedirect.js';
16
+ export { CollectionEditRedirect } from '../admin/views/CollectionEditRedirect.js';
package/dist/options.js CHANGED
@@ -26,9 +26,38 @@ export function resolveOptions(input) {
26
26
  })){
27
27
  if (typeof access !== 'function') throw new Error(`Experiments access.${name} must be a function`);
28
28
  }
29
+ if (input.admin !== undefined) {
30
+ const admin = input.admin;
31
+ if (!admin || typeof admin !== 'object' || Array.isArray(admin)) throw new Error('Experiments admin must be an object');
32
+ for (const key of Object.keys(admin)){
33
+ if (![
34
+ 'editorUrl',
35
+ 'previewUrl',
36
+ 'descriptions'
37
+ ].includes(key)) throw new Error(`Unknown experiments admin option: ${key}`);
38
+ }
39
+ for (const key of [
40
+ 'editorUrl',
41
+ 'previewUrl'
42
+ ]){
43
+ if (admin[key] !== undefined && typeof admin[key] !== 'function') throw new Error(`Experiments admin.${key} must be a function`);
44
+ }
45
+ if (admin.descriptions !== undefined) {
46
+ if (!admin.descriptions || typeof admin.descriptions !== 'object' || Array.isArray(admin.descriptions)) throw new Error('Experiments admin.descriptions must be an object');
47
+ for (const [key, value] of Object.entries(admin.descriptions)){
48
+ if (![
49
+ 'block',
50
+ 'document',
51
+ 'setting'
52
+ ].includes(key)) throw new Error(`Unknown experiments admin.descriptions option: ${key}`);
53
+ if (value !== undefined && typeof value !== 'string') throw new Error(`Experiments admin.descriptions.${key} must be a string`);
54
+ }
55
+ }
56
+ }
29
57
  const ids = new Set();
30
58
  for (const point of input.flagPoints ?? []){
31
59
  if (typeof point.id !== 'string' || !point.id || ids.has(point.id)) throw new Error('Experiments flag point ids must be unique nonempty strings');
60
+ if (point.description !== undefined && typeof point.description !== 'string') throw new Error('Experiments flag point description must be a string');
32
61
  ids.add(point.id);
33
62
  }
34
63
  // Record keys are unique by construction; validate each declared key and type.
package/dist/plugin.js CHANGED
@@ -3,6 +3,7 @@ import { buildEndpoints } from './endpoints/index.js';
3
3
  import { contentChangesBeforeHook, contentChangesHook } from './hooks/contentChanges.js';
4
4
  import { createOnInit } from './onInit.js';
5
5
  import { puckCollections, resolveOptions } from './options.js';
6
+ import * as paths from './admin/paths.js';
6
7
  export function createExperimentsPlugin(input) {
7
8
  const options = resolveOptions(input);
8
9
  return (config)=>{
@@ -16,6 +17,30 @@ export function createExperimentsPlugin(input) {
16
17
  const onInit = createOnInit(options);
17
18
  return {
18
19
  ...config,
20
+ admin: {
21
+ ...config.admin,
22
+ dependencies: {
23
+ ...config.admin?.dependencies,
24
+ xpTestsSurface: {
25
+ path: paths.XP_TESTS_SURFACE,
26
+ type: 'component'
27
+ }
28
+ },
29
+ components: {
30
+ ...config.admin?.components,
31
+ views: {
32
+ ...config.admin?.components?.views,
33
+ xpTests: {
34
+ Component: paths.XP_TESTS_VIEW,
35
+ path: '/experiments/:segments*'
36
+ }
37
+ },
38
+ afterNavLinks: [
39
+ ...config.admin?.components?.afterNavLinks ?? [],
40
+ paths.XP_TESTS_NAV_LINK
41
+ ]
42
+ }
43
+ },
19
44
  collections: [
20
45
  ...(config.collections ?? []).map((collection)=>{
21
46
  const puck = options.puck?.enabled && registrations.some((entry)=>entry.slug === collection.slug);
@@ -1,5 +1,6 @@
1
1
  import type { ExperimentRef } from '../../types/puck.js';
2
- export declare function CreateExperimentDrawer({ onCreate, onClose }: {
2
+ export declare function CreateExperimentDrawer({ onCreate, onClose, initialName }: {
3
+ initialName?: string;
3
4
  onCreate: (experiment: ExperimentRef) => void;
4
5
  onClose: () => void;
5
6
  }): import("react").JSX.Element;
@@ -1,50 +1,31 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useId, useState } from 'react';
4
- import { slugify } from './editor.model.js';
3
+ import { useId, useRef, useState } from 'react';
4
+ import { CreateRefused, createTest } from './createTest.js';
5
5
  import { editorCopy } from './copy.js';
6
- export function CreateExperimentDrawer({ onCreate, onClose }) {
6
+ export function CreateExperimentDrawer({ onCreate, onClose, initialName = '' }) {
7
7
  const titleId = useId();
8
- const [name, setName] = useState('');
9
- const [slug, setSlug] = useState('');
10
- const [slugEdited, setSlugEdited] = useState(false);
8
+ const [name, setName] = useState(initialName);
9
+ const inFlight = useRef(false);
11
10
  const [pending, setPending] = useState(false);
12
11
  const [error, setError] = useState('');
13
12
  async function submitNew() {
13
+ if (inFlight.current || !name.trim()) return;
14
+ inFlight.current = true;
14
15
  setPending(true);
15
16
  setError('');
16
17
  try {
17
- const response = await fetch('/api/xp-experiments', {
18
- method: 'POST',
19
- credentials: 'include',
20
- headers: {
21
- 'Content-Type': 'application/json'
22
- },
23
- body: JSON.stringify({
24
- name,
25
- slug,
26
- kind: 'block',
27
- unitType: 'visitor'
28
- })
29
- });
30
- const body = await response.json();
31
- if (!response.ok) {
32
- setError(typeof body?.errors?.[0]?.message === 'string' ? body.errors[0].message : editorCopy.createError);
33
- return;
34
- }
35
- const doc = body?.doc;
36
- if (!doc || typeof doc.id !== 'string' && typeof doc.id !== 'number' || typeof doc.slug !== 'string') {
37
- setError(editorCopy.createError);
38
- return;
39
- }
40
- onCreate({
41
- id: doc.id,
42
- slug: doc.slug
18
+ const experiment = await createTest('/api', {
19
+ name,
20
+ kind: 'block',
21
+ unitType: 'visitor'
43
22
  });
23
+ onCreate(experiment);
44
24
  onClose();
45
- } catch {
46
- setError(editorCopy.createError);
25
+ } catch (cause) {
26
+ setError(cause instanceof CreateRefused ? cause.message : editorCopy.createError);
47
27
  } finally{
28
+ inFlight.current = false;
48
29
  setPending(false);
49
30
  }
50
31
  }
@@ -72,28 +53,7 @@ export function CreateExperimentDrawer({ onCreate, onClose }) {
72
53
  /*#__PURE__*/ _jsx("input", {
73
54
  value: name,
74
55
  disabled: pending,
75
- onChange: (event)=>{
76
- setName(event.currentTarget.value);
77
- if (!slugEdited) setSlug(slugify(event.currentTarget.value));
78
- }
79
- })
80
- ]
81
- }),
82
- /*#__PURE__*/ _jsxs("label", {
83
- style: {
84
- display: 'block',
85
- marginBottom: 8
86
- },
87
- children: [
88
- editorCopy.slug,
89
- /*#__PURE__*/ _jsx("input", {
90
- value: slug,
91
- maxLength: 64,
92
- disabled: pending,
93
- onChange: (event)=>{
94
- setSlugEdited(true);
95
- setSlug(event.currentTarget.value);
96
- }
56
+ onChange: (event)=>setName(event.currentTarget.value)
97
57
  })
98
58
  ]
99
59
  }),
@@ -103,7 +63,7 @@ export function CreateExperimentDrawer({ onCreate, onClose }) {
103
63
  }) : null,
104
64
  /*#__PURE__*/ _jsx("button", {
105
65
  type: "button",
106
- disabled: pending || !name.trim() || !slug.trim(),
66
+ disabled: pending || !name.trim(),
107
67
  onClick: ()=>{
108
68
  void submitNew();
109
69
  },
@@ -31,6 +31,7 @@ function EditorRender(props) {
31
31
  const [creating, setCreating] = useState(false);
32
32
  // A non-empty variant is only overwritten after an explicit second confirmation.
33
33
  const [confirming, setConfirming] = useState(null);
34
+ const pageTitle = usePuckSelector((state)=>typeof state.appState.data.root.props?.title === 'string' ? state.appState.data.root.props.title : '');
34
35
  const selected = usePuckSelector((state)=>state.selectedItem?.props.id === props.id);
35
36
  const originalEmpty = usePuckSelector((state)=>{
36
37
  const content = state.selectedItem?.props.control;
@@ -138,9 +139,14 @@ function EditorRender(props) {
138
139
  children: editorCopy.createLabel
139
140
  }),
140
141
  creating ? /*#__PURE__*/ _jsx(CreateExperimentDrawer, {
142
+ initialName: pageTitle,
141
143
  onCreate: bind,
142
144
  onClose: ()=>setCreating(false)
143
145
  }) : null,
146
+ experiment?.status === 'draft' ? /*#__PURE__*/ _jsx("a", {
147
+ href: `/admin/experiments/${encodeURIComponent(String(experimentId))}`,
148
+ children: editorCopy.setup
149
+ }) : null,
144
150
  /*#__PURE__*/ _jsx("div", {
145
151
  style: {
146
152
  display: 'flex',
@@ -5,21 +5,22 @@ export declare const editorCopy: {
5
5
  readonly variantB: "Variant B";
6
6
  readonly variantC: "Variant C";
7
7
  };
8
- readonly unbound: "Choose or create an experiment to show different versions.";
9
- readonly draft: "This block shows the Original to everyone until the experiment starts.";
8
+ readonly unbound: "Choose or create a test to show different versions.";
9
+ readonly draft: "This block shows the Original to everyone until the test starts.";
10
10
  readonly running: "Changing this content mid-test mixes two versions in one result";
11
11
  readonly decided: (winner: string) => string;
12
- readonly archived: "This experiment is archived; the winning content is shown to everyone.";
12
+ readonly archived: "This test is archived; the winning content is shown to everyone.";
13
13
  readonly copy: (label: string) => string;
14
14
  readonly replaceConfirm: (label: string) => string;
15
15
  readonly replace: "Replace";
16
- readonly createLabel: "Create experiment";
16
+ readonly createLabel: "Test this block";
17
17
  readonly creating: "Creating…";
18
18
  readonly cancel: "Cancel";
19
19
  readonly name: "Name";
20
- readonly slug: "Slug";
21
- readonly createError: "Couldn't create the experiment. Try again.";
22
- readonly loading: "Loading experiment…";
23
- readonly loadError: "Couldn't load this experiment. Try selecting it again.";
24
- readonly tabs: "Experiment versions";
20
+ readonly setup: "Set up the test";
21
+ readonly nameExists: "This name is too close to an existing test. Choose a different name.";
22
+ readonly createError: "Couldn't create the test. Try again.";
23
+ readonly loading: "Loading test…";
24
+ readonly loadError: "Couldn't load this test. Try selecting it again.";
25
+ readonly tabs: "Test versions";
25
26
  };
@@ -5,21 +5,22 @@ export const editorCopy = {
5
5
  variantB: 'Variant B',
6
6
  variantC: 'Variant C'
7
7
  },
8
- unbound: 'Choose or create an experiment to show different versions.',
9
- draft: 'This block shows the Original to everyone until the experiment starts.',
8
+ unbound: 'Choose or create a test to show different versions.',
9
+ draft: 'This block shows the Original to everyone until the test starts.',
10
10
  running: 'Changing this content mid-test mixes two versions in one result',
11
- decided: (winner)=>`Decided: ${winner}. Use 'Make it the content' on the experiment to finish.`,
12
- archived: 'This experiment is archived; the winning content is shown to everyone.',
11
+ decided: (winner)=>`Decided: ${winner}. Use 'Make it the content' on the test to finish.`,
12
+ archived: 'This test is archived; the winning content is shown to everyone.',
13
13
  copy: (label)=>`Copy Original → ${label}`,
14
14
  replaceConfirm: (label)=>`${label} already has content. Replace it with a copy of the Original?`,
15
15
  replace: 'Replace',
16
- createLabel: 'Create experiment',
16
+ createLabel: 'Test this block',
17
17
  creating: 'Creating…',
18
18
  cancel: 'Cancel',
19
19
  name: 'Name',
20
- slug: 'Slug',
21
- createError: "Couldn't create the experiment. Try again.",
22
- loading: 'Loading experiment…',
23
- loadError: "Couldn't load this experiment. Try selecting it again.",
24
- tabs: 'Experiment versions'
20
+ setup: 'Set up the test',
21
+ nameExists: "This name is too close to an existing test. Choose a different name.",
22
+ createError: "Couldn't create the test. Try again.",
23
+ loading: 'Loading test…',
24
+ loadError: "Couldn't load this test. Try selecting it again.",
25
+ tabs: 'Test versions'
25
26
  };
@@ -0,0 +1,7 @@
1
+ import type { ExperimentRef } from '../../types/puck.js';
2
+ import { newTestBody } from './editor.model.js';
3
+ /** Only a server refusal may supply editor-facing error text. */
4
+ export declare class CreateRefused extends Error {
5
+ }
6
+ /** Shared lookup, complete draft creation and one retry for a racing slug collision. */
7
+ export declare function createTest(apiRoute: string, input: Omit<Parameters<typeof newTestBody>[0], 'slug'>): Promise<ExperimentRef>;