@delmaredigital/payload-experiments 0.2.0-rc.2 → 0.2.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 (73) hide show
  1. package/README.md +15 -0
  2. package/dist/admin/TestsNavLink.d.ts +1 -0
  3. package/dist/admin/TestsNavLink.js +1 -0
  4. package/dist/admin/actions/ConfirmDialog.d.ts +1 -0
  5. package/dist/admin/actions/ConfirmDialog.js +7 -5
  6. package/dist/admin/components/ChipsInput.d.ts +12 -0
  7. package/dist/admin/components/ChipsInput.js +96 -0
  8. package/dist/admin/components/RootViewMessage.d.ts +5 -0
  9. package/dist/admin/components/RootViewMessage.js +15 -0
  10. package/dist/admin/components/chipsInput.css +11 -0
  11. package/dist/admin/copy/audience.d.ts +4 -0
  12. package/dist/admin/copy/audience.js +4 -0
  13. package/dist/admin/copy/goals.d.ts +44 -0
  14. package/dist/admin/copy/goals.js +46 -0
  15. package/dist/admin/results/ArmTable.js +2 -1
  16. package/dist/admin/results/CappedCell.js +2 -1
  17. package/dist/admin/results/CiChart.js +8 -7
  18. package/dist/admin/results/DataQualityPanel.js +3 -2
  19. package/dist/admin/results/VerdictCard.js +3 -2
  20. package/dist/admin/styles.css +8 -8
  21. package/dist/admin/styles.d.ts +1 -0
  22. package/dist/admin/styles.js +1 -0
  23. package/dist/admin/surface/SurfaceShell.d.ts +1 -0
  24. package/dist/admin/surface/SurfaceShell.js +1 -0
  25. package/dist/admin/surface/TestsHome.js +1 -1
  26. package/dist/admin/surface/TestsSurface.js +10 -1
  27. package/dist/admin/surface/goals/GoalForm.d.ts +7 -0
  28. package/dist/admin/surface/goals/GoalForm.js +470 -0
  29. package/dist/admin/surface/goals/GoalsBreadcrumb.d.ts +5 -0
  30. package/dist/admin/surface/goals/GoalsBreadcrumb.js +47 -0
  31. package/dist/admin/surface/goals/GoalsList.d.ts +6 -0
  32. package/dist/admin/surface/goals/GoalsList.js +135 -0
  33. package/dist/admin/surface/goals/data.d.ts +3 -0
  34. package/dist/admin/surface/goals/data.js +24 -0
  35. package/dist/admin/surface/goals/goals.css +15 -0
  36. package/dist/admin/surface/goals/goals.model.d.ts +30 -0
  37. package/dist/admin/surface/goals/goals.model.js +26 -0
  38. package/dist/admin/surface/newTest/newTest.css +6 -6
  39. package/dist/admin/surface/readiness/Banner.js +5 -4
  40. package/dist/admin/surface/results/ResultsBody.js +3 -2
  41. package/dist/admin/surface/setting/setting.css +11 -11
  42. package/dist/admin/surface/surface.css +29 -29
  43. package/dist/admin/surface/test/GoalSection.js +32 -1
  44. package/dist/admin/surface/test/TargetingSection.js +27 -10
  45. package/dist/admin/surface/test/VersionsSection.js +6 -5
  46. package/dist/admin/surface/test/testPage.css +12 -12
  47. package/dist/admin/targeting/targeting.model.d.ts +6 -1
  48. package/dist/admin/targeting/targeting.model.js +18 -2
  49. package/dist/admin/theme.css +42 -0
  50. package/dist/admin/theme.d.ts +50 -0
  51. package/dist/admin/theme.js +49 -0
  52. package/dist/admin/views/ExperimentsRootView.js +18 -18
  53. package/dist/collections/metricValidation.d.ts +3 -0
  54. package/dist/collections/metricValidation.js +9 -0
  55. package/dist/collections/metrics.js +3 -5
  56. package/dist/endpoints/preflight.js +3 -2
  57. package/dist/endpoints/previewToken.js +3 -2
  58. package/dist/endpoints/recompute.js +3 -2
  59. package/dist/endpoints/results.js +3 -2
  60. package/dist/endpoints/routeId.d.ts +5 -0
  61. package/dist/endpoints/routeId.js +13 -0
  62. package/dist/endpoints/summary.js +32 -21
  63. package/dist/endpoints/transitions.js +3 -2
  64. package/dist/plugin.js +2 -2
  65. package/dist/puck/editor/CreateExperimentDrawer.d.ts +1 -0
  66. package/dist/puck/editor/CreateExperimentDrawer.js +34 -25
  67. package/dist/puck/editor/ExperimentSlotEditor.d.ts +1 -0
  68. package/dist/puck/editor/ExperimentSlotEditor.js +137 -78
  69. package/dist/puck/editor/copy.d.ts +3 -1
  70. package/dist/puck/editor/copy.js +3 -1
  71. package/dist/puck/editor/editor.css +302 -0
  72. package/dist/types/surface.d.ts +2 -1
  73. package/package.json +2 -1
@@ -0,0 +1,50 @@
1
+ /** every Payload token reference lives in theme.css; porting to Payload 4 edits that file only */
2
+ export declare const theme: {
3
+ readonly color: {
4
+ readonly bg: "var(--xp-bg)";
5
+ readonly text: "var(--xp-text)";
6
+ readonly surface: "var(--xp-surface)";
7
+ readonly surfaceRaised: "var(--xp-surface-raised)";
8
+ readonly border: "var(--xp-border)";
9
+ readonly borderStrong: "var(--xp-border-strong)";
10
+ readonly borderControl: "var(--xp-border-control)";
11
+ readonly inputBorder: "var(--xp-input-border)";
12
+ readonly borderAccent: "var(--xp-border-accent)";
13
+ readonly accent: "var(--xp-accent)";
14
+ readonly textMuted: "var(--xp-text-muted)";
15
+ readonly textStrong: "var(--xp-text-strong)";
16
+ readonly textEmphasis: "var(--xp-text-emphasis)";
17
+ readonly inputBg: "var(--xp-input-bg)";
18
+ readonly overlay: "var(--xp-overlay)";
19
+ readonly successSurface: "var(--xp-success-surface)";
20
+ readonly successBg: "var(--xp-success-bg)";
21
+ readonly success: "var(--xp-success)";
22
+ readonly successText: "var(--xp-success-text)";
23
+ readonly successTextStrong: "var(--xp-success-text-strong)";
24
+ readonly warningSurface: "var(--xp-warning-surface)";
25
+ readonly warningBg: "var(--xp-warning-bg)";
26
+ readonly warning: "var(--xp-warning)";
27
+ readonly warningText: "var(--xp-warning-text)";
28
+ readonly warningTextStrong: "var(--xp-warning-text-strong)";
29
+ readonly errorSurface: "var(--xp-error-surface)";
30
+ readonly error: "var(--xp-error)";
31
+ readonly errorText: "var(--xp-error-text)";
32
+ };
33
+ readonly radius: {
34
+ readonly small: "var(--xp-radius)";
35
+ readonly large: "var(--xp-radius-large)";
36
+ };
37
+ readonly space: {
38
+ readonly 1: "var(--xp-space-1)";
39
+ readonly 2: "var(--xp-space-2)";
40
+ readonly 3: "var(--xp-space-3)";
41
+ readonly 4: "var(--xp-space-4)";
42
+ readonly 5: "var(--xp-space-5)";
43
+ readonly 6: "var(--xp-space-6)";
44
+ };
45
+ readonly font: {
46
+ readonly body: "var(--xp-font-body)";
47
+ readonly small: "var(--xp-font-size-small)";
48
+ readonly size: "var(--xp-font-size-body)";
49
+ };
50
+ };
@@ -0,0 +1,49 @@
1
+ /** every Payload token reference lives in theme.css; porting to Payload 4 edits that file only */ export const theme = {
2
+ color: {
3
+ bg: 'var(--xp-bg)',
4
+ text: 'var(--xp-text)',
5
+ surface: 'var(--xp-surface)',
6
+ surfaceRaised: 'var(--xp-surface-raised)',
7
+ border: 'var(--xp-border)',
8
+ borderStrong: 'var(--xp-border-strong)',
9
+ borderControl: 'var(--xp-border-control)',
10
+ inputBorder: 'var(--xp-input-border)',
11
+ borderAccent: 'var(--xp-border-accent)',
12
+ accent: 'var(--xp-accent)',
13
+ textMuted: 'var(--xp-text-muted)',
14
+ textStrong: 'var(--xp-text-strong)',
15
+ textEmphasis: 'var(--xp-text-emphasis)',
16
+ inputBg: 'var(--xp-input-bg)',
17
+ overlay: 'var(--xp-overlay)',
18
+ successSurface: 'var(--xp-success-surface)',
19
+ successBg: 'var(--xp-success-bg)',
20
+ success: 'var(--xp-success)',
21
+ successText: 'var(--xp-success-text)',
22
+ successTextStrong: 'var(--xp-success-text-strong)',
23
+ warningSurface: 'var(--xp-warning-surface)',
24
+ warningBg: 'var(--xp-warning-bg)',
25
+ warning: 'var(--xp-warning)',
26
+ warningText: 'var(--xp-warning-text)',
27
+ warningTextStrong: 'var(--xp-warning-text-strong)',
28
+ errorSurface: 'var(--xp-error-surface)',
29
+ error: 'var(--xp-error)',
30
+ errorText: 'var(--xp-error-text)'
31
+ },
32
+ radius: {
33
+ small: 'var(--xp-radius)',
34
+ large: 'var(--xp-radius-large)'
35
+ },
36
+ space: {
37
+ 1: 'var(--xp-space-1)',
38
+ 2: 'var(--xp-space-2)',
39
+ 3: 'var(--xp-space-3)',
40
+ 4: 'var(--xp-space-4)',
41
+ 5: 'var(--xp-space-5)',
42
+ 6: 'var(--xp-space-6)'
43
+ },
44
+ font: {
45
+ body: 'var(--xp-font-body)',
46
+ small: 'var(--xp-font-size-small)',
47
+ size: 'var(--xp-font-size-body)'
48
+ }
49
+ };
@@ -18,7 +18,11 @@ export async function ExperimentsRootView({ initPageResult, params, searchParams
18
18
  let id;
19
19
  if (tail.length === 0) route = 'home';
20
20
  else if (tail.length === 1 && tail[0] === 'new') route = 'new';
21
- else if (tail.length === 1) {
21
+ else if (tail.length === 1 && tail[0] === 'goals') route = 'goals';
22
+ else if (tail.length === 2 && tail[0] === 'goals') {
23
+ route = tail[1] === 'new' ? 'goalNew' : 'goal';
24
+ if (route === 'goal') id = tail[1];
25
+ } else if (tail.length === 1) {
22
26
  route = 'test';
23
27
  id = tail[0];
24
28
  } else if (tail.length === 2 && tail[1] === 'results') {
@@ -41,6 +45,8 @@ export async function ExperimentsRootView({ initPageResult, params, searchParams
41
45
  path: `${login}?redirect=${encodeURIComponent(currentPath)}`
42
46
  }));
43
47
  }
48
+ // Keep the server export importable in plain Node; Next loads the CSS-bearing client boundary when this view renders.
49
+ const { RootViewMessage } = await import('../components/RootViewMessage.js');
44
50
  const options = xpOptionsFromReq(req);
45
51
  async function permits(access) {
46
52
  try {
@@ -60,12 +66,7 @@ export async function ExperimentsRootView({ initPageResult, params, searchParams
60
66
  canManage,
61
67
  canReadResults
62
68
  }) !== 'ok') {
63
- return /*#__PURE__*/ _jsx("div", {
64
- style: {
65
- padding: 'var(--base)',
66
- color: 'var(--theme-text)',
67
- background: 'var(--theme-bg)'
68
- },
69
+ return /*#__PURE__*/ _jsx(RootViewMessage, {
69
70
  children: SURFACE_COPY.noAccess
70
71
  });
71
72
  }
@@ -131,7 +132,11 @@ export async function ExperimentsRootView({ initPageResult, params, searchParams
131
132
  }
132
133
  }))).flat() : undefined;
133
134
  const descriptions = options.admin?.descriptions;
135
+ const returnToTest = (await searchParams)?.return;
134
136
  const clientProps = {
137
+ ...typeof returnToTest === 'string' && /^[\w-]+$/.test(returnToTest) ? {
138
+ returnToTest
139
+ } : {},
135
140
  route,
136
141
  ...id === undefined ? {} : {
137
142
  id
@@ -155,6 +160,12 @@ export async function ExperimentsRootView({ initPageResult, params, searchParams
155
160
  }
156
161
  } : {}
157
162
  };
163
+ function TestsSurfaceFallback() {
164
+ return /*#__PURE__*/ _jsx(RootViewMessage, {
165
+ alert: true,
166
+ children: SURFACE_COPY.notInstalled
167
+ });
168
+ }
158
169
  return RenderServerComponent({
159
170
  Component: XP_TESTS_SURFACE,
160
171
  Fallback: TestsSurfaceFallback,
@@ -162,14 +173,3 @@ export async function ExperimentsRootView({ initPageResult, params, searchParams
162
173
  clientProps
163
174
  });
164
175
  }
165
- function TestsSurfaceFallback() {
166
- return /*#__PURE__*/ _jsx("div", {
167
- role: "alert",
168
- style: {
169
- padding: 'var(--base)',
170
- color: 'var(--theme-text)',
171
- background: 'var(--theme-bg)'
172
- },
173
- children: SURFACE_COPY.notInstalled
174
- });
175
- }
@@ -0,0 +1,3 @@
1
+ /** Shared with the goals form; preserve Payload's field validation messages. */
2
+ export declare function validateCappingType(value: unknown, capValue: unknown, sourceKind?: string): true | string;
3
+ export declare function validateCappingValue(value: unknown, type: unknown): true | string;
@@ -0,0 +1,9 @@
1
+ /** Shared with the goals form; preserve Payload's field validation messages. */ export function validateCappingType(value, capValue, sourceKind) {
2
+ const hasCapping = value != null || capValue != null;
3
+ if (hasCapping && sourceKind && sourceKind !== 'revenue') return 'Count and yes-or-no goals cannot use a limit.';
4
+ return !hasCapping || value === 'percentile' || value === 'absolute' || 'Choose how to set the limit.';
5
+ }
6
+ export function validateCappingValue(value, type) {
7
+ if (value == null && !type) return true;
8
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 && (type === 'absolute' || type === 'percentile' && value > 0 && value <= 1) || 'Enter a valid limit (for past amounts, use a number greater than 0 and at most 1).';
9
+ }
@@ -1,6 +1,7 @@
1
1
  import { XP_METRIC_SOURCE_SELECT } from '../admin/paths.js';
2
2
  import { managedAccess } from './access.js';
3
3
  import { serializeMetricSources } from './serialize.js';
4
+ import { validateCappingType, validateCappingValue } from './metricValidation.js';
4
5
  export function metricsCollection(options) {
5
6
  const sources = serializeMetricSources(options);
6
7
  return {
@@ -125,10 +126,8 @@ export function metricsCollection(options) {
125
126
  description: 'Choose whether the limit comes from past amounts or a fixed amount.'
126
127
  },
127
128
  validate: (value, { data, siblingData })=>{
128
- const hasCapping = value != null || siblingData?.value != null;
129
129
  const source = sources.find((source)=>source.id === data?.source);
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.';
130
+ return validateCappingType(value, siblingData?.value, source?.kind);
132
131
  }
133
132
  },
134
133
  {
@@ -140,8 +139,7 @@ export function metricsCollection(options) {
140
139
  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
140
  },
142
141
  validate: (value, { siblingData })=>{
143
- if (value == null && !siblingData?.type) return true;
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).';
142
+ return validateCappingValue(value, siblingData?.type);
145
143
  }
146
144
  }
147
145
  ]
@@ -1,7 +1,8 @@
1
1
  import { preflightData } from '../transitions/preflightData.js';
2
+ import { routeId } from './routeId.js';
2
3
  export async function handlePreflight(req, ctx, dependencies) {
3
- const id = req.routeParams?.id;
4
- const result = typeof id === 'string' || typeof id === 'number' ? await preflightData(req, {
4
+ const id = routeId(req);
5
+ const result = id !== undefined ? await preflightData(req, {
5
6
  experimentId: id,
6
7
  options: ctx.options
7
8
  }, dependencies) : null;
@@ -1,4 +1,5 @@
1
1
  import { signPreviewToken } from '../runtime/previewToken.js';
2
+ import { routeId } from './routeId.js';
2
3
  export async function handlePreviewToken(req, { options }) {
3
4
  const headers = {
4
5
  'Cache-Control': 'no-store'
@@ -14,8 +15,8 @@ export async function handlePreviewToken(req, { options }) {
14
15
  status,
15
16
  headers
16
17
  });
17
- const id = req.routeParams?.id;
18
- if (typeof id !== 'string' && typeof id !== 'number') return refuse(404, 'not-found', 'Experiment not found');
18
+ const id = routeId(req);
19
+ if (id === undefined) return refuse(404, 'not-found', 'Experiment not found');
19
20
  const result = await req.payload.find({
20
21
  collection: 'xp-experiments',
21
22
  req,
@@ -1,8 +1,9 @@
1
1
  import { runResults } from '../server/runResults.js';
2
2
  import { readSnapshots, resultsError } from './results.js';
3
+ import { routeId } from './routeId.js';
3
4
  export async function handleRecompute(req, _ctx) {
4
- const id = req.routeParams?.id;
5
- if (typeof id !== 'string' && typeof id !== 'number') return resultsError(404, 'not-found', 'Experiment not found');
5
+ const id = routeId(req);
6
+ if (id === undefined) return resultsError(404, 'not-found', 'Experiment not found');
6
7
  const { docs } = await req.payload.find({
7
8
  collection: 'xp-experiments',
8
9
  req,
@@ -1,5 +1,6 @@
1
1
  import { resultsMetadata, newestSnapshotIds } from '../db/snapshots.js';
2
2
  import { pooledExecutor } from '../db/executor.js';
3
+ import { routeId } from './routeId.js';
3
4
  const headers = {
4
5
  'Cache-Control': 'no-store'
5
6
  };
@@ -52,8 +53,8 @@ export async function readSnapshots(req, ids, { per = 'phase', decisionId, serie
52
53
  }));
53
54
  }
54
55
  export async function handleResults(req, _ctx) {
55
- const id = req.routeParams?.id;
56
- if (typeof id !== 'string' && typeof id !== 'number') return resultsError(404, 'not-found', 'Experiment not found');
56
+ const id = routeId(req);
57
+ if (id === undefined) return resultsError(404, 'not-found', 'Experiment not found');
57
58
  const params = new URL(req.url ?? 'http://localhost').searchParams;
58
59
  const selected = params.get('phase');
59
60
  if (selected !== null && (!/^\d+$/.test(selected) || !Number.isSafeInteger(Number(selected)) || Number(selected) < 1)) return resultsError(400, 'invalid-body', 'Choose a valid round');
@@ -0,0 +1,5 @@
1
+ import type { PayloadRequest } from 'payload';
2
+ import type { DocId } from '../types/contracts.js';
3
+ /** Match Payload's relationship ID type before passing an HTTP ID to Local API or SQL. */
4
+ export declare function experimentId(req: PayloadRequest, value: unknown): DocId | undefined;
5
+ export declare function routeId(req: PayloadRequest): DocId | undefined;
@@ -0,0 +1,13 @@
1
+ /** Match Payload's relationship ID type before passing an HTTP ID to Local API or SQL. */ export function experimentId(req, value) {
2
+ if (typeof value !== 'string' && typeof value !== 'number') return undefined;
3
+ const id = String(value);
4
+ const idType = req.payload.collections?.['xp-experiments']?.customIDType ?? req.payload.db.defaultIDType;
5
+ if (idType !== 'number') return id;
6
+ if (!/^\d+$/.test(id)) return undefined;
7
+ const number = Number(id);
8
+ // Payload's Postgres serial ids are int4; larger values would reach SQL and fail with 22003.
9
+ return Number.isSafeInteger(number) && number <= 2_147_483_647 ? number : undefined;
10
+ }
11
+ export function routeId(req) {
12
+ return experimentId(req, req.routeParams?.id);
13
+ }
@@ -1,27 +1,40 @@
1
1
  import { checkStart } from '../transitions/checkStart.js';
2
2
  import { loadStartInputsBatch } from '../transitions/preflightData.js';
3
3
  import { readSnapshots, resultsError } from './results.js';
4
+ import { experimentId } from './routeId.js';
4
5
  export async function handleSummary(req, { options }) {
5
6
  let ids;
6
7
  try {
7
- // Split before decoding: an opaque id can itself contain a comma.
8
- const raw = new URL(req.url ?? 'http://localhost').search.slice(1).split('&').find((part)=>part.split('=')[0] === 'ids')?.slice(4);
9
- if (raw === undefined) return resultsError(400, 'invalid-body', 'Provide experiment ids');
10
- ids = raw.split(',').map((id)=>decodeURIComponent(id.replaceAll('+', ' ')));
8
+ const url = new URL(req.url ?? 'http://localhost');
9
+ // URLSearchParams tolerates malformed escapes; preserve the existing parse refusal.
10
+ decodeURIComponent(url.search.replaceAll('+', ' '));
11
+ const values = url.searchParams.getAll('ids');
12
+ if (values.length === 0) return resultsError(400, 'invalid-body', 'Provide experiment ids');
13
+ ids = values.flatMap((value)=>value.split(',')).map((id)=>id.trim()).filter(Boolean);
11
14
  } catch {
12
15
  return resultsError(400, 'invalid-body', 'Could not parse experiment ids');
13
16
  }
14
17
  if (ids.length > 100) return resultsError(422, 'too-many-ids', 'Request at most 100 experiments');
15
- if (ids.some((id)=>!id.trim())) return resultsError(422, 'invalid-ids', 'Experiment ids must be nonempty');
16
- const snapshots = await readSnapshots(req, [
17
- ...new Set(ids)
18
- ], {
19
- per: 'experiment'
18
+ if (ids.length === 0) return resultsError(422, 'invalid-ids', 'Experiment ids must be nonempty');
19
+ const requested = ids.flatMap((key)=>{
20
+ const id = experimentId(req, key);
21
+ return id === undefined ? [] : [
22
+ {
23
+ key,
24
+ id
25
+ }
26
+ ];
20
27
  });
28
+ const uniqueIds = [
29
+ ...new Set(requested.map(({ id })=>id))
30
+ ];
31
+ const snapshots = uniqueIds.length ? await readSnapshots(req, uniqueIds, {
32
+ per: 'experiment'
33
+ }) : [];
21
34
  const response = Object.create(null);
22
- for (const id of ids){
23
- const snapshot = snapshots.find((row)=>String(typeof row.experiment === 'object' ? row.experiment.id : row.experiment) === id)?.snapshot;
24
- response[id] = snapshot ? {
35
+ for (const { key, id } of requested){
36
+ const snapshot = snapshots.find((row)=>String(typeof row.experiment === 'object' ? row.experiment.id : row.experiment) === String(id))?.snapshot;
37
+ response[key] = snapshot ? {
25
38
  exposures: snapshot.exposures.total,
26
39
  days: Math.max(0, (Date.parse(snapshot.window.to) - Date.parse(snapshot.window.from)) / 86_400_000),
27
40
  verdict: {
@@ -35,7 +48,7 @@ export async function handleSummary(req, { options }) {
35
48
  };
36
49
  }
37
50
  try {
38
- if (await options.access.manage({
51
+ if (uniqueIds.length && await options.access.manage({
39
52
  req
40
53
  }) === true) {
41
54
  const found = await req.payload.find({
@@ -44,26 +57,24 @@ export async function handleSummary(req, { options }) {
44
57
  overrideAccess: false,
45
58
  depth: 0,
46
59
  pagination: false,
47
- limit: ids.length,
60
+ limit: uniqueIds.length,
48
61
  where: {
49
62
  id: {
50
- in: [
51
- ...new Set(ids)
52
- ]
63
+ in: uniqueIds
53
64
  }
54
65
  }
55
66
  });
56
67
  const drafts = found.docs.filter((e)=>e.status === 'draft' || e.status === 'stopped');
57
68
  const inputs = await loadStartInputsBatch(req, options, drafts, true);
58
- for (const id of ids){
69
+ for (const { key, id } of requested){
59
70
  // 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);
71
+ const experiment = drafts.find((e)=>String(e.id) === String(id));
61
72
  if (!experiment) continue;
62
73
  const input = inputs.get(String(experiment.id));
63
74
  try {
64
- response[id].ready = !!input && checkStart(input).every((check)=>check.ok);
75
+ response[key].ready = !!input && checkStart(input).every((check)=>check.ok);
65
76
  } catch {
66
- response[id].ready = false;
77
+ response[key].ready = false;
67
78
  }
68
79
  }
69
80
  }
@@ -7,6 +7,7 @@ import { decide } from '../transitions/decide.js';
7
7
  import { releaseHoldout } from '../transitions/releaseHoldout.js';
8
8
  import { makeContent } from '../transitions/makeContent.js';
9
9
  import { archive } from '../transitions/archive.js';
10
+ import { routeId } from './routeId.js';
10
11
  const invalidBody = ()=>Response.json({
11
12
  errors: [
12
13
  {
@@ -30,8 +31,8 @@ async function body(req) {
30
31
  }
31
32
  const only = (value, keys)=>Object.keys(value).every((key)=>keys.includes(key));
32
33
  async function respond(req, action) {
33
- const id = req.routeParams?.id;
34
- if (typeof id !== 'string' && typeof id !== 'number') return Response.json({
34
+ const id = routeId(req);
35
+ if (id === undefined) return Response.json({
35
36
  errors: [
36
37
  {
37
38
  code: 'not-found',
package/dist/plugin.js CHANGED
@@ -35,8 +35,8 @@ export function createExperimentsPlugin(input) {
35
35
  path: '/experiments/:segments*'
36
36
  }
37
37
  },
38
- afterNavLinks: [
39
- ...config.admin?.components?.afterNavLinks ?? [],
38
+ beforeNavLinks: [
39
+ ...config.admin?.components?.beforeNavLinks ?? [],
40
40
  paths.XP_TESTS_NAV_LINK
41
41
  ]
42
42
  }
@@ -1,4 +1,5 @@
1
1
  import type { ExperimentRef } from '../../types/puck.js';
2
+ import './editor.css';
2
3
  export declare function CreateExperimentDrawer({ onCreate, onClose, initialName }: {
3
4
  initialName?: string;
4
5
  onCreate: (experiment: ExperimentRef) => void;
@@ -3,8 +3,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useId, useRef, useState } from 'react';
4
4
  import { CreateRefused, createTest } from './createTest.js';
5
5
  import { editorCopy } from './copy.js';
6
+ import './editor.css';
6
7
  export function CreateExperimentDrawer({ onCreate, onClose, initialName = '' }) {
7
8
  const titleId = useId();
9
+ const nameId = useId();
8
10
  const [name, setName] = useState(initialName);
9
11
  const inFlight = useRef(false);
10
12
  const [pending, setPending] = useState(false);
@@ -30,27 +32,26 @@ export function CreateExperimentDrawer({ onCreate, onClose, initialName = '' })
30
32
  }
31
33
  }
32
34
  return /*#__PURE__*/ _jsxs("aside", {
35
+ className: "xp-slot-root xp-slot-drawer",
33
36
  role: "dialog",
34
37
  "aria-labelledby": titleId,
35
- style: {
36
- padding: 16,
37
- border: '1px solid #aaa',
38
- background: 'Canvas',
39
- color: 'CanvasText'
40
- },
41
38
  children: [
42
39
  /*#__PURE__*/ _jsx("h3", {
40
+ className: "xp-slot-title",
43
41
  id: titleId,
44
42
  children: editorCopy.createLabel
45
43
  }),
46
- /*#__PURE__*/ _jsxs("label", {
47
- style: {
48
- display: 'block',
49
- marginBottom: 8
50
- },
44
+ /*#__PURE__*/ _jsxs("div", {
45
+ className: "xp-slot-field",
51
46
  children: [
52
- editorCopy.name,
47
+ /*#__PURE__*/ _jsx("label", {
48
+ className: "xp-slot-label",
49
+ htmlFor: nameId,
50
+ children: editorCopy.name
51
+ }),
53
52
  /*#__PURE__*/ _jsx("input", {
53
+ className: "xp-slot-input",
54
+ id: nameId,
54
55
  value: name,
55
56
  disabled: pending,
56
57
  onChange: (event)=>setName(event.currentTarget.value)
@@ -58,22 +59,30 @@ export function CreateExperimentDrawer({ onCreate, onClose, initialName = '' })
58
59
  ]
59
60
  }),
60
61
  error ? /*#__PURE__*/ _jsx("p", {
62
+ className: "xp-slot-error",
61
63
  role: "alert",
62
64
  children: error
63
65
  }) : null,
64
- /*#__PURE__*/ _jsx("button", {
65
- type: "button",
66
- disabled: pending || !name.trim(),
67
- onClick: ()=>{
68
- void submitNew();
69
- },
70
- children: pending ? editorCopy.creating : editorCopy.createLabel
71
- }),
72
- /*#__PURE__*/ _jsx("button", {
73
- type: "button",
74
- disabled: pending,
75
- onClick: onClose,
76
- children: editorCopy.cancel
66
+ /*#__PURE__*/ _jsxs("div", {
67
+ className: "xp-slot-actions",
68
+ children: [
69
+ /*#__PURE__*/ _jsx("button", {
70
+ className: "xp-slot-button xp-slot-button-primary",
71
+ type: "button",
72
+ disabled: pending || !name.trim(),
73
+ onClick: ()=>{
74
+ void submitNew();
75
+ },
76
+ children: pending ? editorCopy.creating : editorCopy.createLabel
77
+ }),
78
+ /*#__PURE__*/ _jsx("button", {
79
+ className: "xp-slot-button",
80
+ type: "button",
81
+ disabled: pending,
82
+ onClick: onClose,
83
+ children: editorCopy.cancel
84
+ })
85
+ ]
77
86
  })
78
87
  ]
79
88
  });
@@ -1,3 +1,4 @@
1
1
  import { type ComponentConfig } from '@puckeditor/core';
2
2
  import type { ExperimentSlotProps } from '../../types/puck.js';
3
+ import './editor.css';
3
4
  export declare const ExperimentSlotEditor: ComponentConfig<ExperimentSlotProps>;