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

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 (124) hide show
  1. package/README.md +74 -16
  2. package/dist/admin/PreviewLinks.js +45 -24
  3. package/dist/admin/TestsNavLink.d.ts +3 -1
  4. package/dist/admin/TestsNavLink.js +2 -2
  5. package/dist/admin/armSelect/ArmSelectField.js +14 -22
  6. package/dist/admin/arms/arms.model.d.ts +1 -21
  7. package/dist/admin/arms/arms.model.js +1 -153
  8. package/dist/admin/copy/arms.d.ts +1 -6
  9. package/dist/admin/copy/arms.js +1 -16
  10. package/dist/admin/copy/readiness.d.ts +4 -1
  11. package/dist/admin/copy/readiness.js +3 -1
  12. package/dist/admin/copy/results.d.ts +2 -0
  13. package/dist/admin/copy/results.js +3 -1
  14. package/dist/admin/copy/review.d.ts +1 -1
  15. package/dist/admin/copy/review.js +1 -1
  16. package/dist/admin/copy/surface.d.ts +0 -1
  17. package/dist/admin/copy/surface.js +0 -1
  18. package/dist/admin/paths.d.ts +2 -1
  19. package/dist/admin/paths.js +2 -1
  20. package/dist/admin/previewLinks.model.d.ts +2 -0
  21. package/dist/admin/previewLinks.model.js +3 -0
  22. package/dist/admin/results/legacy.d.ts +16 -0
  23. package/dist/admin/results/legacy.js +27 -0
  24. package/dist/admin/results/results.model.d.ts +6 -6
  25. package/dist/admin/results/results.model.js +14 -5
  26. package/dist/admin/results/snapshot.d.ts +14 -0
  27. package/dist/admin/results/snapshot.js +3 -0
  28. package/dist/admin/surface/TestsHome.js +6 -9
  29. package/dist/admin/surface/goals/data.js +16 -16
  30. package/dist/admin/surface/home.model.d.ts +1 -0
  31. package/dist/admin/surface/newTest/NewTest.js +4 -4
  32. package/dist/admin/surface/readiness/ActionDialogs.js +4 -5
  33. package/dist/admin/surface/readiness/readiness.model.js +8 -1
  34. package/dist/admin/surface/results/ResultsBody.js +2 -1
  35. package/dist/admin/surface/results/recommendation.model.d.ts +3 -2
  36. package/dist/admin/surface/results/recommendation.model.js +10 -3
  37. package/dist/admin/surface/surface.css +0 -1
  38. package/dist/admin/surface/test/GoalSection.js +7 -15
  39. package/dist/admin/surface/test/testPage.model.d.ts +2 -3
  40. package/dist/admin/surface/test/testPage.model.js +1 -21
  41. package/dist/admin/surface/useTestDocument.js +2 -1
  42. package/dist/admin/useLayerUsage.js +14 -23
  43. package/dist/admin/views/TestsNavLinkServer.d.ts +2 -0
  44. package/dist/admin/views/TestsNavLinkServer.js +43 -0
  45. package/dist/arms/copy.d.ts +6 -0
  46. package/dist/arms/copy.js +16 -0
  47. package/dist/arms/model.d.ts +23 -0
  48. package/dist/arms/model.js +173 -0
  49. package/dist/db/appends.d.ts +6 -2
  50. package/dist/db/appends.js +10 -4
  51. package/dist/db/locks.d.ts +6 -0
  52. package/dist/db/locks.js +13 -1
  53. package/dist/db/pageOccupancy.d.ts +12 -0
  54. package/dist/db/pageOccupancy.js +45 -0
  55. package/dist/db/snapshots.js +4 -3
  56. package/dist/exports/adminRsc.d.ts +1 -0
  57. package/dist/exports/adminRsc.js +1 -0
  58. package/dist/exports/index.d.ts +1 -1
  59. package/dist/exports/puck.d.ts +2 -1
  60. package/dist/exports/puck.js +1 -1
  61. package/dist/exports/puckEditor.d.ts +3 -1
  62. package/dist/exports/puckEditor.js +2 -1
  63. package/dist/exports/rsc.d.ts +1 -0
  64. package/dist/hooks/pageGuard.js +21 -3
  65. package/dist/lib/fetchAllDocs.d.ts +16 -0
  66. package/dist/lib/fetchAllDocs.js +56 -0
  67. package/dist/options.js +2 -9
  68. package/dist/plugin.js +4 -0
  69. package/dist/puck/ExperimentPicker.js +47 -16
  70. package/dist/puck/ExperimentSlot.d.ts +51 -1
  71. package/dist/puck/ExperimentSlot.js +23 -12
  72. package/dist/puck/copy.d.ts +5 -4
  73. package/dist/puck/copy.js +5 -4
  74. package/dist/puck/editor/CreateExperimentDrawer.js +46 -56
  75. package/dist/puck/editor/ExperimentSlotEditor.d.ts +52 -1
  76. package/dist/puck/editor/ExperimentSlotEditor.js +292 -137
  77. package/dist/puck/editor/ResultsReadout.d.ts +6 -0
  78. package/dist/puck/editor/ResultsReadout.js +99 -0
  79. package/dist/puck/editor/VersionBuilder.d.ts +34 -0
  80. package/dist/puck/editor/VersionBuilder.js +317 -0
  81. package/dist/puck/editor/armWrites.d.ts +7 -0
  82. package/dist/puck/editor/armWrites.js +71 -0
  83. package/dist/puck/editor/armsCache.d.ts +14 -0
  84. package/dist/puck/editor/armsCache.js +78 -0
  85. package/dist/puck/editor/copy.d.ts +33 -1
  86. package/dist/puck/editor/copy.js +45 -8
  87. package/dist/puck/editor/createTest.d.ts +1 -1
  88. package/dist/puck/editor/createTest.js +25 -14
  89. package/dist/puck/editor/editor.chrome.css +47 -161
  90. package/dist/puck/editor/editor.css +47 -161
  91. package/dist/puck/editor/editor.model.d.ts +4 -1
  92. package/dist/puck/editor/editor.model.js +10 -7
  93. package/dist/puck/editor/experimentOutlinePlugin.d.ts +3 -0
  94. package/dist/puck/editor/experimentOutlinePlugin.js +89 -0
  95. package/dist/puck/experimentSlotField.d.ts +2 -0
  96. package/dist/puck/experimentSlotField.js +11 -3
  97. package/dist/puck/hostContext.d.ts +10 -0
  98. package/dist/puck/hostContext.js +12 -0
  99. package/dist/puck/routes.d.ts +12 -0
  100. package/dist/puck/routes.js +11 -0
  101. package/dist/puck/slotConfig.d.ts +1 -1
  102. package/dist/puck/slotConfig.js +2 -2
  103. package/dist/sanitizeReadinessDetail.js +10 -1
  104. package/dist/server/metadata.js +9 -2
  105. package/dist/server/normaliseContentRelationships.d.ts +7 -0
  106. package/dist/server/normaliseContentRelationships.js +62 -0
  107. package/dist/server/recordContentChange.d.ts +1 -1
  108. package/dist/server/recordContentChange.js +17 -8
  109. package/dist/server/runResults.js +36 -4
  110. package/dist/transitions/checkStart.d.ts +5 -1
  111. package/dist/transitions/checkStart.js +10 -0
  112. package/dist/transitions/decide.js +16 -1
  113. package/dist/transitions/placements.d.ts +2 -6
  114. package/dist/transitions/placements.js +8 -1
  115. package/dist/transitions/preflightData.d.ts +8 -0
  116. package/dist/transitions/preflightData.js +116 -36
  117. package/dist/transitions/start.js +33 -3
  118. package/dist/types/contracts.d.ts +19 -13
  119. package/dist/types/endpoints.d.ts +1 -1
  120. package/dist/ui/Tabs.d.ts +4 -2
  121. package/dist/ui/Tabs.js +33 -26
  122. package/dist/ui/accent.d.ts +22 -3
  123. package/dist/ui/accent.js +74 -16
  124. package/package.json +5 -2
@@ -1,13 +1,24 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { createUsePuck, registerOverlayPortal, useGetPuck } from '@puckeditor/core';
4
- import { useEffect, useId, useState } from 'react';
5
- import { pickerCopy } from '../copy.js';
4
+ import { useEffect, useRef, useState } from 'react';
5
+ import { Button } from '../../ui/Button.js';
6
+ import { StatusChip } from '../../ui/StatusChip.js';
7
+ import { Banner } from '../../ui/Banner.js';
8
+ import { Tabs } from '../../ui/Tabs.js';
9
+ import { accentVariables, deriveAccent, validateAccent } from '../../ui/accent.js';
10
+ import { createExperimentSlotField } from '../experimentSlotField.js';
11
+ import { resolvePuckRoutes } from '../routes.js';
12
+ import { PuckHostProvider, usePuckRoutes } from '../hostContext.js';
13
+ import { pickerCopy, slotLabels } from '../copy.js';
6
14
  import { experimentSlotDefaultProps, experimentSlotFields, experimentSlotLabel } from '../slotConfig.js';
7
15
  import { bannerFor, tabsFor } from './editor.model.js';
8
16
  import { cloneSlot } from './cloneSlot.js';
9
17
  import { CreateExperimentDrawer } from './CreateExperimentDrawer.js';
10
18
  import { editorCopy } from './copy.js';
19
+ import { acquireArms, loadArms, setArms, useArms } from './armsCache.js';
20
+ import { AddVariant, RemoveVersion, VersionActions, VersionWeights, useArmWrites } from './VersionBuilder.js';
21
+ import { ResultsReadout } from './ResultsReadout.js';
11
22
  import './editor.css';
12
23
  const usePuckSelector = createUsePuck();
13
24
  // Public Puck registration enables native controls inside the editor canvas.
@@ -25,53 +36,141 @@ function isExperiment(value) {
25
36
  }
26
37
  function EditorRender(props) {
27
38
  const getPuck = useGetPuck();
28
- const tabId = useId();
39
+ const { admin, api } = usePuckRoutes();
40
+ const root = useRef(null);
41
+ const [mode, setMode] = useState('light');
42
+ useEffect(()=>{
43
+ const node = root.current;
44
+ if (!node || !props.accent) return;
45
+ const update = ()=>setMode(node.closest('[data-theme="dark"], .dark') ? 'dark' : 'light');
46
+ update();
47
+ const observer = new MutationObserver(update);
48
+ for(let ancestor = node.parentElement; ancestor; ancestor = ancestor.parentElement){
49
+ observer.observe(ancestor, {
50
+ attributes: true,
51
+ attributeFilter: [
52
+ 'class',
53
+ 'data-theme'
54
+ ]
55
+ });
56
+ }
57
+ return ()=>observer.disconnect();
58
+ }, [
59
+ props.accent
60
+ ]);
29
61
  const experimentId = props.experiment?.id;
62
+ const bindingKey = JSON.stringify([
63
+ api,
64
+ props.id,
65
+ experimentId
66
+ ]);
67
+ const binding = useRef({
68
+ key: bindingKey,
69
+ live: true
70
+ });
71
+ binding.current.key = bindingKey;
72
+ useEffect(()=>{
73
+ binding.current.live = true;
74
+ return ()=>{
75
+ binding.current.live = false;
76
+ };
77
+ }, []);
78
+ const isCurrentBinding = ()=>binding.current.live && binding.current.key === bindingKey;
79
+ const knownArms = useArms(experimentId);
30
80
  const [loaded, setLoaded] = useState();
31
81
  const [active, setActive] = useState();
82
+ const [openMenu, setOpenMenu] = useState(null);
83
+ const [clearError, setClearError] = useState(null);
32
84
  const [creating, setCreating] = useState(false);
33
85
  // A non-empty variant is only overwritten after an explicit second confirmation.
34
86
  const [confirming, setConfirming] = useState(null);
87
+ // Keep a failed removal open even if the recovery read no longer contains that version.
88
+ const [removing, setRemoving] = useState(null);
35
89
  const pageTitle = usePuckSelector((state)=>typeof state.appState.data.root.props?.title === 'string' ? state.appState.data.root.props.title : '');
36
90
  const selected = usePuckSelector((state)=>state.selectedItem?.props.id === props.id);
37
91
  const originalEmpty = usePuckSelector((state)=>{
38
92
  const content = state.getItemById(props.id)?.props.control;
39
93
  return !Array.isArray(content) || content.length === 0;
40
94
  });
95
+ const orphanSlots = usePuckSelector((state)=>{
96
+ const item = state.getItemById(props.id);
97
+ return [
98
+ 'variantA',
99
+ 'variantB',
100
+ 'variantC'
101
+ ].filter((slot)=>{
102
+ const content = item?.props[slot];
103
+ return knownArms && !knownArms.arms.includes(slot) && Array.isArray(content) && content.length > 0;
104
+ }).join(',');
105
+ }).split(',').filter(Boolean);
41
106
  useEffect(()=>{
42
107
  if (experimentId === undefined) return;
43
108
  const controller = new AbortController();
44
109
  let current = true;
110
+ let request = 0;
111
+ const releaseArms = acquireArms(experimentId);
45
112
  async function load() {
113
+ const revision = ++request;
46
114
  try {
47
- const response = await fetch(`/api/xp-experiments/${encodeURIComponent(experimentId)}?depth=0`, {
48
- credentials: 'include',
49
- signal: controller.signal
50
- });
51
- if (!response.ok) throw new Error('load');
52
- const doc = await response.json();
53
- if (!isExperiment(doc)) throw new Error('document');
54
- if (current) setLoaded({
55
- id: experimentId,
56
- doc
57
- });
115
+ const doc = await loadArms(experimentId, async (signal)=>{
116
+ const response = await fetch(`${api}/xp-experiments/${encodeURIComponent(experimentId)}?depth=0`, {
117
+ credentials: 'include',
118
+ signal
119
+ });
120
+ if (!response.ok) throw new Error('load');
121
+ const doc = await response.json();
122
+ if (!isExperiment(doc)) throw new Error('document');
123
+ return doc;
124
+ }, controller.signal);
125
+ if (current && revision === request) {
126
+ setLoaded({
127
+ id: experimentId,
128
+ doc
129
+ });
130
+ }
58
131
  } catch {
59
- if (current) setLoaded({
60
- id: experimentId,
61
- error: true
62
- });
132
+ if (current && revision === request) {
133
+ setLoaded({
134
+ id: experimentId,
135
+ error: true
136
+ });
137
+ }
63
138
  }
64
139
  }
65
140
  void load();
141
+ // The linked test opens in another tab; refresh this same document source on return.
142
+ const onVisible = ()=>{
143
+ if (document.visibilityState === 'visible') void load();
144
+ };
145
+ document.addEventListener('visibilitychange', onVisible);
66
146
  return ()=>{
67
147
  current = false;
68
148
  controller.abort();
149
+ releaseArms();
150
+ document.removeEventListener('visibilitychange', onVisible);
69
151
  };
70
152
  }, [
71
- experimentId
153
+ experimentId,
154
+ api
72
155
  ]);
73
156
  const result = experimentId === undefined || loaded?.id !== experimentId ? undefined : loaded;
74
157
  const experiment = result?.doc;
158
+ const editable = experiment?.status === 'draft' || experiment?.status === 'stopped';
159
+ const [removed, setRemoved] = useState(null);
160
+ useEffect(()=>{
161
+ // Dialog cleanup must close the native modal before an outside tab can receive focus.
162
+ if (removed?.key === bindingKey) root.current?.querySelector('[role="tab"]')?.focus();
163
+ }, [
164
+ removed,
165
+ bindingKey
166
+ ]);
167
+ const writes = useArmWrites(`${api}/xp-experiments/${encodeURIComponent(String(experimentId))}`, (doc)=>{
168
+ setArms(experimentId, doc.arms.map((arm)=>arm.key));
169
+ if (isCurrentBinding()) setLoaded({
170
+ id: experimentId,
171
+ doc
172
+ });
173
+ }, isCurrentBinding, ()=>setClearError(null));
75
174
  const tabs = tabsFor(experiment);
76
175
  const activeSlot = active && active.id === experimentId ? active.slot : undefined;
77
176
  const slot = tabs.find((tab)=>tab.slot === activeSlot)?.slot ?? tabs[0]?.slot ?? 'control';
@@ -107,6 +206,39 @@ function EditorRender(props) {
107
206
  }
108
207
  });
109
208
  }
209
+ function removeContent(slot) {
210
+ if (!isCurrentBinding()) return;
211
+ const { getItemById, dispatch, getSelectorForId } = getPuck();
212
+ const item = getItemById(props.id), selector = getSelectorForId(props.id);
213
+ if (!item || !selector) {
214
+ setClearError({
215
+ key: bindingKey,
216
+ message: editorCopy.removeUncleared
217
+ });
218
+ return;
219
+ }
220
+ setClearError(null);
221
+ dispatch({
222
+ type: 'replace',
223
+ destinationIndex: selector.index,
224
+ destinationZone: selector.zone,
225
+ data: {
226
+ ...item,
227
+ props: {
228
+ ...item.props,
229
+ [slot]: []
230
+ }
231
+ }
232
+ });
233
+ setRemoved((current)=>({
234
+ key: bindingKey,
235
+ revision: (current?.revision ?? 0) + 1
236
+ }));
237
+ setActive({
238
+ id: experimentId,
239
+ slot: 'control'
240
+ });
241
+ }
110
242
  function bind(experiment) {
111
243
  const { getItemById, dispatch, getSelectorForId } = getPuck();
112
244
  const item = getItemById(props.id), selector = getSelectorForId(props.id);
@@ -125,7 +257,9 @@ function EditorRender(props) {
125
257
  });
126
258
  }
127
259
  return /*#__PURE__*/ _jsxs("section", {
260
+ ref: root,
128
261
  className: "xp-slot-root xp-slot-editor",
262
+ style: props.accent ? accentVariables(props.accent[mode]) : undefined,
129
263
  children: [
130
264
  /*#__PURE__*/ _jsxs("div", {
131
265
  className: "xp-slot-chrome",
@@ -136,57 +270,84 @@ function EditorRender(props) {
136
270
  children: [
137
271
  /*#__PURE__*/ _jsx("h3", {
138
272
  className: "xp-slot-title",
139
- children: experiment?.name ?? pickerCopy.label
273
+ children: experiment?.name ?? experimentSlotLabel
140
274
  }),
141
- experiment ? /*#__PURE__*/ _jsx("span", {
142
- className: "xp-slot-status",
143
- "data-status": experiment.status,
144
- children: pickerCopy.statuses[experiment.status]
145
- }) : null,
146
- /*#__PURE__*/ _jsxs("div", {
147
- className: "xp-slot-actions",
148
- children: [
149
- experimentId === undefined ? /*#__PURE__*/ _jsx("button", {
150
- className: "xp-slot-button xp-slot-button-primary",
151
- type: "button",
152
- disabled: !selected,
153
- onClick: ()=>setCreating(true),
154
- children: editorCopy.createLabel
155
- }) : null,
156
- experiment ? /*#__PURE__*/ _jsx("a", {
157
- className: "xp-slot-button xp-slot-button-primary",
158
- href: `/admin/experiments/${encodeURIComponent(String(experimentId))}${experiment.status === 'draft' ? '' : '/results'}`,
159
- target: "_blank",
160
- rel: "noopener noreferrer",
161
- children: experiment.status === 'draft' ? editorCopy.setup : editorCopy.viewResults
162
- }) : null
163
- ]
164
- })
275
+ experiment ? /*#__PURE__*/ _jsx(StatusChip, {
276
+ status: experiment.status
277
+ }) : experimentId === undefined ? /*#__PURE__*/ _jsx("span", {
278
+ className: "xp-ui-status",
279
+ children: editorCopy.notSetUp
280
+ }) : null
165
281
  ]
166
282
  }),
167
- /*#__PURE__*/ _jsxs("p", {
168
- className: experiment?.status === 'running' ? 'xp-slot-notice xp-slot-warning' : 'xp-slot-notice',
169
- role: "status",
283
+ /*#__PURE__*/ _jsx(Banner, {
284
+ tone: result?.error ? 'error' : experiment?.status === 'running' ? 'warning' : 'info',
285
+ children: experimentId === undefined ? bannerFor() : result?.error ? editorCopy.loadError : experiment ? bannerFor(experiment.status, winner) : editorCopy.loading
286
+ }),
287
+ orphanSlots.map((slot)=>/*#__PURE__*/ _jsx(Banner, {
288
+ tone: "warning",
289
+ children: editorCopy.orphan(slotLabels[slot])
290
+ }, slot)),
291
+ /*#__PURE__*/ _jsxs("div", {
292
+ className: "xp-ui-button-group",
170
293
  children: [
171
- experiment?.status === 'running' ? /*#__PURE__*/ _jsxs("svg", {
172
- className: "xp-slot-warning-icon",
173
- "aria-hidden": "true",
174
- viewBox: "0 0 24 24",
175
- fill: "none",
176
- stroke: "currentColor",
177
- strokeWidth: "2",
178
- children: [
179
- /*#__PURE__*/ _jsx("path", {
180
- d: "M12 3 2 21h20L12 3Z"
181
- }),
182
- /*#__PURE__*/ _jsx("path", {
183
- d: "M12 9v5m0 3v1"
184
- })
185
- ]
294
+ experimentId === undefined ? /*#__PURE__*/ _jsx(Button, {
295
+ variant: "primary",
296
+ disabled: !selected,
297
+ onClick: ()=>setCreating(true),
298
+ children: editorCopy.createLabel
299
+ }) : null,
300
+ experiment ? /*#__PURE__*/ _jsx(Button, {
301
+ href: `${admin}/experiments/${encodeURIComponent(String(experimentId))}${experiment.status === 'draft' ? '' : '/results'}`,
302
+ target: "_blank",
303
+ rel: "noopener noreferrer",
304
+ children: experiment.status === 'draft' ? editorCopy.setup : editorCopy.viewResults
186
305
  }) : null,
187
- experimentId === undefined ? bannerFor() : result?.error ? editorCopy.loadError : experiment ? bannerFor(experiment.status, winner) : editorCopy.loading
306
+ experiment && editable ? /*#__PURE__*/ _jsx(AddVariant, {
307
+ experiment: experiment,
308
+ writes: writes,
309
+ onSelect: (slot)=>{
310
+ if (isCurrentBinding()) setActive({
311
+ id: experimentId,
312
+ slot
313
+ });
314
+ }
315
+ }) : null
188
316
  ]
189
317
  }),
318
+ experiment && !editable ? /*#__PURE__*/ _jsx("p", {
319
+ className: "xp-ui-help",
320
+ children: editorCopy.locked[experiment.status]
321
+ }) : null,
322
+ experiment && editable ? /*#__PURE__*/ _jsx(VersionWeights, {
323
+ experiment: experiment,
324
+ writes: writes
325
+ }, `weights-${experimentId}`) : null,
326
+ writes.error ? /*#__PURE__*/ _jsx("p", {
327
+ role: "alert",
328
+ className: "xp-ui-error",
329
+ children: writes.error
330
+ }) : null,
331
+ clearError?.key === bindingKey ? /*#__PURE__*/ _jsx("p", {
332
+ role: "alert",
333
+ className: "xp-ui-error",
334
+ children: clearError.message
335
+ }) : null,
336
+ removed?.key === bindingKey ? /*#__PURE__*/ _jsx("p", {
337
+ className: "xp-ui-help",
338
+ children: editorCopy.removed
339
+ }) : null,
340
+ experiment && experiment.status !== 'draft' ? /*#__PURE__*/ _jsx(ResultsReadout, {
341
+ id: experimentId,
342
+ api: api,
343
+ experiment: experiment
344
+ }, `results-${experimentId}`) : null,
345
+ removing?.key === bindingKey ? /*#__PURE__*/ _jsx(RemoveVersion, {
346
+ arm: removing.arm,
347
+ writes: writes,
348
+ onRemove: removeContent,
349
+ onClose: ()=>setRemoving(null)
350
+ }) : null,
190
351
  creating ? /*#__PURE__*/ _jsx(CreateExperimentDrawer, {
191
352
  initialName: pageTitle,
192
353
  onCreate: bind,
@@ -197,96 +358,90 @@ function EditorRender(props) {
197
358
  role: "alert",
198
359
  children: [
199
360
  /*#__PURE__*/ _jsx("p", {
200
- className: "xp-slot-notice",
201
361
  children: editorCopy.replaceConfirm(tabs.find((tab)=>tab.slot === confirming)?.label ?? confirming)
202
362
  }),
203
363
  /*#__PURE__*/ _jsxs("div", {
204
- className: "xp-slot-actions",
364
+ className: "xp-ui-button-group",
205
365
  children: [
206
- /*#__PURE__*/ _jsx("button", {
207
- className: "xp-slot-button xp-slot-button-primary",
208
- type: "button",
366
+ /*#__PURE__*/ _jsx(Button, {
367
+ variant: "primary",
209
368
  onClick: ()=>copyTo(confirming),
210
369
  children: editorCopy.replace
211
370
  }),
212
- /*#__PURE__*/ _jsx("button", {
213
- className: "xp-slot-button",
214
- type: "button",
371
+ /*#__PURE__*/ _jsx(Button, {
215
372
  onClick: ()=>setConfirming(null),
216
373
  children: editorCopy.cancel
217
374
  })
218
375
  ]
219
376
  })
220
377
  ]
221
- }) : null,
222
- /*#__PURE__*/ _jsxs("div", {
223
- className: "xp-slot-toolbar",
224
- children: [
225
- /*#__PURE__*/ _jsx("div", {
226
- className: "xp-slot-tabs",
227
- role: "tablist",
228
- "aria-label": editorCopy.tabs,
229
- children: tabs.map((tab, index)=>/*#__PURE__*/ _jsx("button", {
230
- className: "xp-slot-tab",
231
- type: "button",
232
- role: "tab",
233
- id: `${tabId}-${tab.slot}`,
234
- "aria-selected": tab.slot === slot,
235
- tabIndex: tab.slot === slot ? 0 : -1,
236
- "aria-controls": `${tabId}-panel`,
237
- onClick: ()=>setActive({
238
- id: experimentId,
239
- slot: tab.slot
240
- }),
241
- onKeyDown: (event)=>{
242
- const next = event.key === 'ArrowRight' ? (index + 1) % tabs.length : event.key === 'ArrowLeft' ? (index + tabs.length - 1) % tabs.length : event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : undefined;
243
- if (next === undefined) return;
244
- event.preventDefault();
245
- setActive({
246
- id: experimentId,
247
- slot: tabs[next].slot
248
- });
249
- const buttons = event.currentTarget.parentElement?.querySelectorAll('[role="tab"]');
250
- buttons?.[next]?.focus();
251
- },
252
- children: tab.label
253
- }, tab.slot))
254
- }),
255
- slot !== 'control' ? /*#__PURE__*/ _jsx("div", {
256
- className: "xp-slot-actions",
257
- children: /*#__PURE__*/ _jsx("button", {
258
- className: "xp-slot-button",
259
- type: "button",
260
- disabled: !selected || originalEmpty,
261
- onClick: ()=>requestCopy(slot),
262
- children: editorCopy.copy
263
- })
264
- }) : null
265
- ]
266
- })
267
- ]
268
- }),
269
- /*#__PURE__*/ _jsxs("div", {
270
- className: slotEmpty ? 'xp-slot-panel xp-slot-panel-empty' : 'xp-slot-panel',
271
- role: "tabpanel",
272
- id: `${tabId}-panel`,
273
- "aria-labelledby": `${tabId}-${slot}`,
274
- children: [
275
- /*#__PURE__*/ _jsx(Slot, {
276
- className: "xp-slot-drop-target"
277
- }),
278
- slotEmpty ? /*#__PURE__*/ _jsx("span", {
279
- className: "xp-slot-drop-label",
280
- children: editorCopy.dropZone(slot)
281
378
  }) : null
282
379
  ]
380
+ }),
381
+ /*#__PURE__*/ _jsx(Tabs, {
382
+ label: editorCopy.tabs,
383
+ value: slot,
384
+ tabListRef: registerControls,
385
+ onChange: (value)=>{
386
+ setOpenMenu(null);
387
+ setActive({
388
+ id: experimentId,
389
+ slot: value
390
+ });
391
+ },
392
+ items: tabs.map((tab)=>({
393
+ id: tab.slot,
394
+ label: tab.label,
395
+ actions: experiment ? /*#__PURE__*/ _jsx(VersionActions, {
396
+ arm: experiment.arms.find((arm)=>arm.key === tab.slot),
397
+ writes: writes,
398
+ editable: editable,
399
+ open: openMenu === tab.slot,
400
+ onOpenChange: (open)=>setOpenMenu(open ? tab.slot : null),
401
+ canCopy: selected && !originalEmpty,
402
+ onCopy: requestCopy,
403
+ onRequestRemove: ()=>setRemoving({
404
+ key: bindingKey,
405
+ arm: experiment.arms.find((arm)=>arm.key === tab.slot)
406
+ })
407
+ }, `${experimentId}-${tab.slot}`) : undefined,
408
+ content: tab.slot === slot ? /*#__PURE__*/ _jsxs("div", {
409
+ className: slotEmpty ? 'xp-slot-panel xp-slot-panel-empty' : 'xp-slot-panel',
410
+ children: [
411
+ /*#__PURE__*/ _jsx(Slot, {
412
+ className: "xp-slot-drop-target"
413
+ }),
414
+ slotEmpty ? /*#__PURE__*/ _jsx("span", {
415
+ className: "xp-slot-drop-label",
416
+ children: editorCopy.dropZone(slot)
417
+ }) : null
418
+ ]
419
+ }) : null
420
+ }))
283
421
  })
284
422
  ]
285
423
  });
286
424
  }
287
- export const ExperimentSlotEditor = {
288
- label: experimentSlotLabel,
289
- fields: experimentSlotFields,
290
- defaultProps: experimentSlotDefaultProps,
291
- render: EditorRender
292
- };
425
+ export function createExperimentSlotEditor({ routes: inputRoutes, accent } = {}) {
426
+ validateAccent(accent);
427
+ const sets = accent ? deriveAccent(accent) : undefined;
428
+ const routes = resolvePuckRoutes(inputRoutes);
429
+ return {
430
+ label: experimentSlotLabel,
431
+ fields: {
432
+ ...experimentSlotFields,
433
+ experiment: createExperimentSlotField({
434
+ routes
435
+ })
436
+ },
437
+ defaultProps: experimentSlotDefaultProps,
438
+ render: (props)=>/*#__PURE__*/ _jsx(PuckHostProvider, {
439
+ routes: routes,
440
+ children: /*#__PURE__*/ _jsx(EditorRender, {
441
+ ...props,
442
+ accent: sets
443
+ })
444
+ })
445
+ };
446
+ }
447
+ export const ExperimentSlotEditor = createExperimentSlotEditor();
@@ -0,0 +1,6 @@
1
+ import type { EditorExperiment } from './editor.model.js';
2
+ export declare function ResultsReadout({ id, api, experiment }: {
3
+ id: string | number;
4
+ api: string;
5
+ experiment: EditorExperiment;
6
+ }): import("react").JSX.Element;
@@ -0,0 +1,99 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { Button } from '../../ui/Button.js';
5
+ import { editorCopy as C } from './copy.js';
6
+ // Matches admin/results/format.ts formatCount; importing it would pull admin copy into this bundle.
7
+ const formatCount = (value)=>typeof value === 'number' && Number.isFinite(value) ? new Intl.NumberFormat('en-US', {
8
+ maximumFractionDigits: 0
9
+ }).format(value) : '—';
10
+ export function ResultsReadout({ id, api, experiment }) {
11
+ const [data, setData] = useState(null);
12
+ const [refresh, setRefresh] = useState(0);
13
+ const pending = useRef(false);
14
+ const [busy, setBusy] = useState(false);
15
+ useEffect(()=>{
16
+ const controller = new AbortController();
17
+ pending.current = true;
18
+ setBusy(true);
19
+ setData(null);
20
+ async function load() {
21
+ try {
22
+ const [summary, results] = await Promise.all([
23
+ fetch(`${api}/xp/experiments/summary?ids=${encodeURIComponent(id)}`, {
24
+ credentials: 'include',
25
+ cache: 'no-store',
26
+ signal: controller.signal
27
+ }),
28
+ fetch(`${api}/xp/experiments/${encodeURIComponent(id)}/results`, {
29
+ credentials: 'include',
30
+ cache: 'no-store',
31
+ signal: controller.signal
32
+ })
33
+ ]);
34
+ if (!summary.ok || !results.ok) throw new Error('unavailable');
35
+ const [summaries, result] = await Promise.all([
36
+ summary.json(),
37
+ results.json()
38
+ ]);
39
+ const entry = summaries[id];
40
+ if (!entry || !Object.hasOwn(entry, 'verdict') || !Object.hasOwn(result, 'snapshot')) throw new Error('unavailable');
41
+ if (!controller.signal.aborted) setData({
42
+ headline: entry.verdict?.headline ?? C.collecting,
43
+ people: result.snapshot?.exposures.perArm ?? null
44
+ });
45
+ } catch {
46
+ if (!controller.signal.aborted) setData('unavailable');
47
+ } finally{
48
+ if (!controller.signal.aborted) {
49
+ pending.current = false;
50
+ setBusy(false);
51
+ }
52
+ }
53
+ }
54
+ void load();
55
+ return ()=>controller.abort();
56
+ }, [
57
+ id,
58
+ api,
59
+ refresh
60
+ ]);
61
+ return /*#__PURE__*/ _jsxs("div", {
62
+ className: "xp-slot-readout",
63
+ children: [
64
+ data === 'unavailable' ? /*#__PURE__*/ _jsx("p", {
65
+ className: "xp-ui-help",
66
+ children: C.resultsUnavailable
67
+ }) : data ? /*#__PURE__*/ _jsxs(_Fragment, {
68
+ children: [
69
+ /*#__PURE__*/ _jsx("strong", {
70
+ children: data.headline
71
+ }),
72
+ data.people ? /*#__PURE__*/ _jsx("div", {
73
+ className: "xp-slot-people",
74
+ children: [
75
+ ...experiment.arms.filter((arm)=>arm.kind !== 'holdout'),
76
+ ...experiment.arms.filter((arm)=>arm.kind === 'holdout')
77
+ ].map((arm)=>/*#__PURE__*/ _jsx("span", {
78
+ children: (arm.kind === 'holdout' ? C.holdoutPeople : C.people)(arm.label, formatCount(data.people[arm.key] ?? 0))
79
+ }, arm.key))
80
+ }) : null
81
+ ]
82
+ }) : /*#__PURE__*/ _jsx("p", {
83
+ className: "xp-ui-help",
84
+ children: C.loading
85
+ }),
86
+ /*#__PURE__*/ _jsx(Button, {
87
+ variant: "quiet",
88
+ size: "sm",
89
+ disabled: busy,
90
+ onClick: ()=>{
91
+ if (pending.current) return;
92
+ pending.current = true;
93
+ setRefresh((value)=>value + 1);
94
+ },
95
+ children: C.refresh
96
+ })
97
+ ]
98
+ });
99
+ }