@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.
- package/README.md +15 -0
- package/dist/admin/TestsNavLink.d.ts +1 -0
- package/dist/admin/TestsNavLink.js +1 -0
- package/dist/admin/actions/ConfirmDialog.d.ts +1 -0
- package/dist/admin/actions/ConfirmDialog.js +7 -5
- package/dist/admin/components/ChipsInput.d.ts +12 -0
- package/dist/admin/components/ChipsInput.js +96 -0
- package/dist/admin/components/RootViewMessage.d.ts +5 -0
- package/dist/admin/components/RootViewMessage.js +15 -0
- package/dist/admin/components/chipsInput.css +11 -0
- package/dist/admin/copy/audience.d.ts +4 -0
- package/dist/admin/copy/audience.js +4 -0
- package/dist/admin/copy/goals.d.ts +44 -0
- package/dist/admin/copy/goals.js +46 -0
- package/dist/admin/results/ArmTable.js +2 -1
- package/dist/admin/results/CappedCell.js +2 -1
- package/dist/admin/results/CiChart.js +8 -7
- package/dist/admin/results/DataQualityPanel.js +3 -2
- package/dist/admin/results/VerdictCard.js +3 -2
- package/dist/admin/styles.css +8 -8
- package/dist/admin/styles.d.ts +1 -0
- package/dist/admin/styles.js +1 -0
- package/dist/admin/surface/SurfaceShell.d.ts +1 -0
- package/dist/admin/surface/SurfaceShell.js +1 -0
- package/dist/admin/surface/TestsHome.js +1 -1
- package/dist/admin/surface/TestsSurface.js +10 -1
- package/dist/admin/surface/goals/GoalForm.d.ts +7 -0
- package/dist/admin/surface/goals/GoalForm.js +470 -0
- package/dist/admin/surface/goals/GoalsBreadcrumb.d.ts +5 -0
- package/dist/admin/surface/goals/GoalsBreadcrumb.js +47 -0
- package/dist/admin/surface/goals/GoalsList.d.ts +6 -0
- package/dist/admin/surface/goals/GoalsList.js +135 -0
- package/dist/admin/surface/goals/data.d.ts +3 -0
- package/dist/admin/surface/goals/data.js +24 -0
- package/dist/admin/surface/goals/goals.css +15 -0
- package/dist/admin/surface/goals/goals.model.d.ts +30 -0
- package/dist/admin/surface/goals/goals.model.js +26 -0
- package/dist/admin/surface/newTest/newTest.css +6 -6
- package/dist/admin/surface/readiness/Banner.js +5 -4
- package/dist/admin/surface/results/ResultsBody.js +3 -2
- package/dist/admin/surface/setting/setting.css +11 -11
- package/dist/admin/surface/surface.css +29 -29
- package/dist/admin/surface/test/GoalSection.js +32 -1
- package/dist/admin/surface/test/TargetingSection.js +27 -10
- package/dist/admin/surface/test/VersionsSection.js +6 -5
- package/dist/admin/surface/test/testPage.css +12 -12
- package/dist/admin/targeting/targeting.model.d.ts +6 -1
- package/dist/admin/targeting/targeting.model.js +18 -2
- package/dist/admin/theme.css +42 -0
- package/dist/admin/theme.d.ts +50 -0
- package/dist/admin/theme.js +49 -0
- package/dist/admin/views/ExperimentsRootView.js +18 -18
- package/dist/collections/metricValidation.d.ts +3 -0
- package/dist/collections/metricValidation.js +9 -0
- package/dist/collections/metrics.js +3 -5
- package/dist/endpoints/preflight.js +3 -2
- package/dist/endpoints/previewToken.js +3 -2
- package/dist/endpoints/recompute.js +3 -2
- package/dist/endpoints/results.js +3 -2
- package/dist/endpoints/routeId.d.ts +5 -0
- package/dist/endpoints/routeId.js +13 -0
- package/dist/endpoints/summary.js +32 -21
- package/dist/endpoints/transitions.js +3 -2
- package/dist/plugin.js +2 -2
- package/dist/puck/editor/CreateExperimentDrawer.d.ts +1 -0
- package/dist/puck/editor/CreateExperimentDrawer.js +34 -25
- package/dist/puck/editor/ExperimentSlotEditor.d.ts +1 -0
- package/dist/puck/editor/ExperimentSlotEditor.js +137 -78
- package/dist/puck/editor/copy.d.ts +3 -1
- package/dist/puck/editor/copy.js +3 -1
- package/dist/puck/editor/editor.css +302 -0
- package/dist/types/surface.d.ts +2 -1
- 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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4
|
-
const result =
|
|
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
|
|
18
|
-
if (
|
|
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
|
|
5
|
-
if (
|
|
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
|
|
56
|
-
if (
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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.
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
23
|
-
const snapshot = snapshots.find((row)=>String(typeof row.experiment === 'object' ? row.experiment.id : row.experiment) === id)?.snapshot;
|
|
24
|
-
response[
|
|
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:
|
|
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
|
|
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) ===
|
|
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[
|
|
75
|
+
response[key].ready = !!input && checkStart(input).every((check)=>check.ok);
|
|
65
76
|
} catch {
|
|
66
|
-
response[
|
|
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
|
|
34
|
-
if (
|
|
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
|
-
|
|
39
|
-
...config.admin?.components?.
|
|
38
|
+
beforeNavLinks: [
|
|
39
|
+
...config.admin?.components?.beforeNavLinks ?? [],
|
|
40
40
|
paths.XP_TESTS_NAV_LINK
|
|
41
41
|
]
|
|
42
42
|
}
|
|
@@ -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("
|
|
47
|
-
|
|
48
|
-
display: 'block',
|
|
49
|
-
marginBottom: 8
|
|
50
|
-
},
|
|
44
|
+
/*#__PURE__*/ _jsxs("div", {
|
|
45
|
+
className: "xp-slot-field",
|
|
51
46
|
children: [
|
|
52
|
-
|
|
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__*/
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
});
|