@delmaredigital/payload-experiments 0.2.0-rc.4 → 0.2.0-rc.6
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 +7 -0
- package/dist/admin/PreviewLinks.js +8 -4
- package/dist/admin/TestsDashboardCards.d.ts +3 -0
- package/dist/admin/TestsDashboardCards.js +52 -0
- package/dist/admin/TestsNavLink.d.ts +2 -1
- package/dist/admin/TestsNavLink.js +61 -18
- package/dist/admin/copy/review.d.ts +1 -0
- package/dist/admin/copy/review.js +1 -0
- package/dist/admin/copy/surface.d.ts +4 -0
- package/dist/admin/copy/surface.js +4 -0
- package/dist/admin/dashboard.css +35 -0
- package/dist/admin/paths.d.ts +1 -0
- package/dist/admin/paths.js +1 -0
- package/dist/admin/previewLinks.model.d.ts +9 -0
- package/dist/admin/previewLinks.model.js +34 -0
- package/dist/admin/theme.css +2 -0
- package/dist/collections/events.js +1 -0
- package/dist/collections/identities.js +1 -0
- package/dist/collections/layers.js +1 -0
- package/dist/collections/metrics.js +1 -0
- package/dist/collections/phases.js +1 -0
- package/dist/collections/snapshots.js +1 -0
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +1 -0
- package/dist/endpoints/recompute.js +6 -1
- package/dist/exports/adminClient.d.ts +1 -0
- package/dist/exports/adminClient.js +1 -0
- package/dist/globals/state.js +1 -0
- package/dist/plugin.js +8 -2
- package/dist/puck/copy.d.ts +6 -0
- package/dist/puck/copy.js +7 -0
- package/dist/puck/editor/ExperimentSlotEditor.js +2 -0
- package/dist/puck/slotConfig.js +14 -9
- package/dist/results/analyse.d.ts +1 -1
- package/dist/results/analyse.js +7 -3
- package/dist/server/runResults.d.ts +1 -0
- package/dist/server/runResults.js +4 -0
- package/dist/types/endpoints.d.ts +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -466,6 +466,13 @@ for these patterns.
|
|
|
466
466
|
|
|
467
467
|
## Admin and client
|
|
468
468
|
|
|
469
|
+
The sidebar includes a **Tests** group with **All tests** and **Goals**. The
|
|
470
|
+
dashboard also includes **Tests** and **Goals** cards above its widgets. Goals
|
|
471
|
+
appears only for users who can manage tests. Both additions follow the host's
|
|
472
|
+
admin route and keep host-provided navigation and dashboard entries first.
|
|
473
|
+
After upgrading, run `payload generate:importmap` to register the dashboard card
|
|
474
|
+
component.
|
|
475
|
+
|
|
469
476
|
### Client assignments and exposure
|
|
470
477
|
|
|
471
478
|
Read assignments on the server and pass them to `ExperimentsProvider` from
|
|
@@ -6,6 +6,7 @@ import { armLabel } from './labels.js';
|
|
|
6
6
|
import { useApi } from './useApi.js';
|
|
7
7
|
import { REVIEW_COPY as C } from './copy/review.js';
|
|
8
8
|
import { API_MESSAGES } from './copy/common.js';
|
|
9
|
+
import { previewPages } from './previewLinks.model.js';
|
|
9
10
|
export function PreviewLinks({ experimentId, placements, arms }) {
|
|
10
11
|
const api = useApi();
|
|
11
12
|
const inFlight = useRef(false);
|
|
@@ -39,10 +40,13 @@ export function PreviewLinks({ experimentId, placements, arms }) {
|
|
|
39
40
|
return /*#__PURE__*/ _jsxs("div", {
|
|
40
41
|
className: "xp-preview-links",
|
|
41
42
|
children: [
|
|
42
|
-
placements.map((placement)=>/*#__PURE__*/ _jsxs("div", {
|
|
43
|
+
previewPages(placements).map((placement)=>/*#__PURE__*/ _jsxs("div", {
|
|
43
44
|
children: [
|
|
44
|
-
/*#__PURE__*/
|
|
45
|
-
children:
|
|
45
|
+
/*#__PURE__*/ _jsxs("p", {
|
|
46
|
+
children: [
|
|
47
|
+
placement.title,
|
|
48
|
+
placement.slotCount > 1 ? ` ${C.placedTimes(placement.slotCount)}` : null
|
|
49
|
+
]
|
|
46
50
|
}),
|
|
47
51
|
placement.url ? arms.filter((arm)=>arm.kind !== 'holdout' && arm.key !== 'holdout').map((arm)=>/*#__PURE__*/ _jsxs(Button, {
|
|
48
52
|
disabled: busy,
|
|
@@ -59,7 +63,7 @@ export function PreviewLinks({ experimentId, placements, arms }) {
|
|
|
59
63
|
children: C.noPreview
|
|
60
64
|
})
|
|
61
65
|
]
|
|
62
|
-
},
|
|
66
|
+
}, placement.key)),
|
|
63
67
|
error ? /*#__PURE__*/ _jsx("p", {
|
|
64
68
|
role: "alert",
|
|
65
69
|
children: error
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import './theme.css';
|
|
4
|
+
import './dashboard.css';
|
|
5
|
+
import { Card, useAuth, useConfig } from '@payloadcms/ui';
|
|
6
|
+
import { formatAdminURL } from 'payload/shared';
|
|
7
|
+
import { SURFACE_COPY } from './copy/surface.js';
|
|
8
|
+
export function TestsDashboardCards() {
|
|
9
|
+
const { config } = useConfig();
|
|
10
|
+
const { permissions } = useAuth();
|
|
11
|
+
const testPermissions = permissions?.collections?.['xp-experiments'];
|
|
12
|
+
const canManage = testPermissions?.update === true;
|
|
13
|
+
if (!canManage) return null;
|
|
14
|
+
const href = (path)=>formatAdminURL({
|
|
15
|
+
adminRoute: config.routes.admin,
|
|
16
|
+
path
|
|
17
|
+
});
|
|
18
|
+
return /*#__PURE__*/ _jsxs("section", {
|
|
19
|
+
className: "xp-dash-group",
|
|
20
|
+
"aria-labelledby": "xp-dash-title",
|
|
21
|
+
children: [
|
|
22
|
+
/*#__PURE__*/ _jsx("h2", {
|
|
23
|
+
className: "xp-dash-label",
|
|
24
|
+
id: "xp-dash-title",
|
|
25
|
+
children: SURFACE_COPY.title
|
|
26
|
+
}),
|
|
27
|
+
/*#__PURE__*/ _jsxs("ul", {
|
|
28
|
+
className: "xp-dash-card-list",
|
|
29
|
+
children: [
|
|
30
|
+
/*#__PURE__*/ _jsx("li", {
|
|
31
|
+
children: /*#__PURE__*/ _jsx(Card, {
|
|
32
|
+
title: SURFACE_COPY.navAllTests,
|
|
33
|
+
titleAs: "h3",
|
|
34
|
+
buttonAriaLabel: SURFACE_COPY.dashShowTests,
|
|
35
|
+
href: href('/experiments'),
|
|
36
|
+
id: "card-xp-tests"
|
|
37
|
+
})
|
|
38
|
+
}),
|
|
39
|
+
/*#__PURE__*/ _jsx("li", {
|
|
40
|
+
children: /*#__PURE__*/ _jsx(Card, {
|
|
41
|
+
title: SURFACE_COPY.navGoals,
|
|
42
|
+
titleAs: "h3",
|
|
43
|
+
buttonAriaLabel: SURFACE_COPY.dashShowGoals,
|
|
44
|
+
href: href('/experiments/goals'),
|
|
45
|
+
id: "card-xp-goals"
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
]
|
|
49
|
+
})
|
|
50
|
+
]
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -1,31 +1,74 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import './theme.css';
|
|
4
|
-
import { Link, useConfig } from '@payloadcms/ui';
|
|
4
|
+
import { Link, NavGroup, useAuth, useConfig } from '@payloadcms/ui';
|
|
5
5
|
import { formatAdminURL } from 'payload/shared';
|
|
6
6
|
import { usePathname } from 'next/navigation.js';
|
|
7
7
|
import { SURFACE_COPY } from './copy/surface.js';
|
|
8
|
-
|
|
8
|
+
function TestsNav() {
|
|
9
9
|
const { config } = useConfig();
|
|
10
|
-
const
|
|
10
|
+
const { permissions } = useAuth();
|
|
11
|
+
const pathname = usePathname();
|
|
11
12
|
const href = formatAdminURL({
|
|
12
13
|
adminRoute: config.routes.admin,
|
|
13
14
|
path: '/experiments'
|
|
14
15
|
});
|
|
16
|
+
const goalsHref = formatAdminURL({
|
|
17
|
+
adminRoute: config.routes.admin,
|
|
18
|
+
path: '/experiments/goals'
|
|
19
|
+
});
|
|
15
20
|
const base = href.replace(/\/$/, '');
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
21
|
+
const goalsBase = goalsHref.replace(/\/$/, '');
|
|
22
|
+
const goalsActive = pathname === goalsBase || Boolean(pathname?.startsWith(`${goalsBase}/`));
|
|
23
|
+
const testsActive = !goalsActive && (pathname === base || Boolean(pathname?.startsWith(`${base}/`)));
|
|
24
|
+
const testPermissions = permissions?.collections?.['xp-experiments'];
|
|
25
|
+
const canManage = testPermissions?.update === true;
|
|
26
|
+
if (!canManage) return null;
|
|
27
|
+
const entries = [
|
|
28
|
+
{
|
|
29
|
+
id: 'nav-xp-tests',
|
|
30
|
+
href,
|
|
31
|
+
label: SURFACE_COPY.navAllTests,
|
|
32
|
+
active: testsActive
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'nav-xp-goals',
|
|
36
|
+
href: goalsHref,
|
|
37
|
+
label: SURFACE_COPY.navGoals,
|
|
38
|
+
active: goalsActive
|
|
39
|
+
}
|
|
40
|
+
];
|
|
41
|
+
// getPreference is async and can fetch; let NavGroup default to open.
|
|
42
|
+
return /*#__PURE__*/ _jsx(NavGroup, {
|
|
43
|
+
label: SURFACE_COPY.title,
|
|
44
|
+
children: entries.map((entry)=>{
|
|
45
|
+
const props = {
|
|
46
|
+
className: 'nav__link',
|
|
47
|
+
id: entry.id,
|
|
48
|
+
'aria-current': entry.active ? 'page' : undefined
|
|
49
|
+
};
|
|
50
|
+
const content = /*#__PURE__*/ _jsxs(_Fragment, {
|
|
51
|
+
children: [
|
|
52
|
+
entry.active ? /*#__PURE__*/ _jsx("div", {
|
|
53
|
+
className: "nav__link-indicator"
|
|
54
|
+
}) : null,
|
|
55
|
+
/*#__PURE__*/ _jsx("span", {
|
|
56
|
+
className: "nav__link-label",
|
|
57
|
+
children: entry.label
|
|
58
|
+
})
|
|
59
|
+
]
|
|
60
|
+
});
|
|
61
|
+
return pathname === entry.href ? /*#__PURE__*/ _jsx("div", {
|
|
62
|
+
...props,
|
|
63
|
+
children: content
|
|
64
|
+
}, entry.id) : /*#__PURE__*/ _jsx(Link, {
|
|
65
|
+
...props,
|
|
66
|
+
href: entry.href,
|
|
67
|
+
prefetch: false,
|
|
68
|
+
children: content
|
|
69
|
+
}, entry.id);
|
|
70
|
+
})
|
|
30
71
|
});
|
|
31
72
|
}
|
|
73
|
+
// Preserve the existing import-map key.
|
|
74
|
+
export { TestsNav as TestsNavLink };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export declare const REVIEW_COPY: {
|
|
2
2
|
readonly noBaseline: "No data yet for this metric.";
|
|
3
3
|
readonly preview: "Preview";
|
|
4
|
+
readonly placedTimes: (n: number) => string;
|
|
4
5
|
readonly noPreview: "Preview isn't set up for this page type";
|
|
5
6
|
readonly raise: "Raise allocation";
|
|
6
7
|
readonly addMetric: "Add a metric";
|
|
@@ -3,6 +3,10 @@ export declare const SURFACE_COPY: {
|
|
|
3
3
|
readonly newTest: "New test";
|
|
4
4
|
readonly goals: "Goals";
|
|
5
5
|
readonly search: "Search tests";
|
|
6
|
+
readonly navAllTests: "All tests";
|
|
7
|
+
readonly navGoals: "Goals";
|
|
8
|
+
readonly dashShowTests: "Show all tests";
|
|
9
|
+
readonly dashShowGoals: "Show all goals";
|
|
6
10
|
readonly backToAdmin: "Back to admin";
|
|
7
11
|
readonly limitedList: "Showing the 200 most recent tests.";
|
|
8
12
|
readonly notInstalled: "The Tests screen isn't installed yet. Run `payload generate:importmap` and reload.";
|
|
@@ -3,6 +3,10 @@ export const SURFACE_COPY = {
|
|
|
3
3
|
newTest: 'New test',
|
|
4
4
|
goals: 'Goals',
|
|
5
5
|
search: 'Search tests',
|
|
6
|
+
navAllTests: 'All tests',
|
|
7
|
+
navGoals: 'Goals',
|
|
8
|
+
dashShowTests: 'Show all tests',
|
|
9
|
+
dashShowGoals: 'Show all goals',
|
|
6
10
|
backToAdmin: 'Back to admin',
|
|
7
11
|
limitedList: 'Showing the 200 most recent tests.',
|
|
8
12
|
notInstalled: "The Tests screen isn't installed yet. Run `payload generate:importmap` and reload.",
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
.xp-dash-group {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
gap: var(--xp-space-4);
|
|
5
|
+
margin-bottom: var(--xp-space-4);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.xp-dash-label { margin: 0; }
|
|
9
|
+
|
|
10
|
+
.xp-dash-card-list {
|
|
11
|
+
padding: 0;
|
|
12
|
+
margin: 0;
|
|
13
|
+
list-style: none;
|
|
14
|
+
display: grid;
|
|
15
|
+
grid-template-columns: repeat(5, 1fr);
|
|
16
|
+
gap: var(--xp-dash-card-gap);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.xp-dash-card-list .card { height: 100%; }
|
|
20
|
+
|
|
21
|
+
@media (max-width: 1440px) {
|
|
22
|
+
.xp-dash-card-list { grid-template-columns: repeat(4, 1fr); }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@media (max-width: 1024px) {
|
|
26
|
+
.xp-dash-card-list { grid-template-columns: repeat(2, 1fr); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@media (max-width: 768px) {
|
|
30
|
+
.xp-dash-card-list { gap: var(--xp-dash-card-gap-small); }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@media (max-width: 400px) {
|
|
34
|
+
.xp-dash-card-list { grid-template-columns: repeat(1, 1fr); }
|
|
35
|
+
}
|
package/dist/admin/paths.d.ts
CHANGED
|
@@ -6,3 +6,4 @@ export declare const XP_TESTS_LIST_REDIRECT = "@delmaredigital/payload-experimen
|
|
|
6
6
|
export declare const XP_TEST_EDIT_REDIRECT = "@delmaredigital/payload-experiments/admin/rsc#CollectionEditRedirect";
|
|
7
7
|
export declare const XP_TESTS_NAV_LINK = "@delmaredigital/payload-experiments/admin/client#TestsNavLink";
|
|
8
8
|
export declare const XP_TESTS_SURFACE = "@delmaredigital/payload-experiments/admin/client#TestsSurface";
|
|
9
|
+
export declare const XP_TESTS_DASHBOARD_CARDS = "@delmaredigital/payload-experiments/admin/client#TestsDashboardCards";
|
package/dist/admin/paths.js
CHANGED
|
@@ -6,3 +6,4 @@ export const XP_TESTS_LIST_REDIRECT = '@delmaredigital/payload-experiments/admin
|
|
|
6
6
|
export const XP_TEST_EDIT_REDIRECT = '@delmaredigital/payload-experiments/admin/rsc#CollectionEditRedirect';
|
|
7
7
|
export const XP_TESTS_NAV_LINK = '@delmaredigital/payload-experiments/admin/client#TestsNavLink';
|
|
8
8
|
export const XP_TESTS_SURFACE = '@delmaredigital/payload-experiments/admin/client#TestsSurface';
|
|
9
|
+
export const XP_TESTS_DASHBOARD_CARDS = '@delmaredigital/payload-experiments/admin/client#TestsDashboardCards';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PreflightResult } from '../types/endpoints.js';
|
|
2
|
+
type Placement = PreflightResult['placements'][number];
|
|
3
|
+
type PreviewPage = Pick<Placement, 'collection' | 'id' | 'title' | 'url'> & {
|
|
4
|
+
key: string;
|
|
5
|
+
slotCount: number;
|
|
6
|
+
};
|
|
7
|
+
/** Preview URLs identify pages; counts use the most placements in any one version. */
|
|
8
|
+
export declare function previewPages(placements: readonly Placement[]): PreviewPage[];
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Preview URLs identify pages; counts use the most placements in any one version. */ export function previewPages(placements) {
|
|
2
|
+
const pages = new Map();
|
|
3
|
+
for (const placement of placements){
|
|
4
|
+
const key = `${placement.collection}\u0000${String(placement.id)}`;
|
|
5
|
+
const existing = pages.get(key);
|
|
6
|
+
if (existing) {
|
|
7
|
+
const { page, versions } = existing;
|
|
8
|
+
const count = (versions.get(placement.detail) ?? 0) + 1;
|
|
9
|
+
versions.set(placement.detail, count);
|
|
10
|
+
page.slotCount = Math.max(page.slotCount, count);
|
|
11
|
+
if (!page.url && placement.url) page.url = placement.url;
|
|
12
|
+
} else {
|
|
13
|
+
pages.set(key, {
|
|
14
|
+
page: {
|
|
15
|
+
key,
|
|
16
|
+
collection: placement.collection,
|
|
17
|
+
id: placement.id,
|
|
18
|
+
title: placement.title,
|
|
19
|
+
url: placement.url,
|
|
20
|
+
slotCount: 1
|
|
21
|
+
},
|
|
22
|
+
versions: new Map([
|
|
23
|
+
[
|
|
24
|
+
placement.detail,
|
|
25
|
+
1
|
|
26
|
+
]
|
|
27
|
+
])
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return [
|
|
32
|
+
...pages.values()
|
|
33
|
+
].map(({ page })=>page);
|
|
34
|
+
}
|
package/dist/admin/theme.css
CHANGED
|
@@ -36,6 +36,8 @@
|
|
|
36
36
|
--xp-space-4: calc(var(--base) * 1);
|
|
37
37
|
--xp-space-5: calc(var(--base) * 1.5);
|
|
38
38
|
--xp-space-6: calc(var(--base) * 2);
|
|
39
|
+
--xp-dash-card-gap: 12px;
|
|
40
|
+
--xp-dash-card-gap-small: calc(var(--base) * 0.4);
|
|
39
41
|
--xp-font-body: var(--font-body);
|
|
40
42
|
--xp-font-size-small: calc(var(--base) * 0.65);
|
|
41
43
|
--xp-font-size-body: calc(var(--base) * 0.75);
|
|
@@ -12,6 +12,7 @@ export function layersCollection(options) {
|
|
|
12
12
|
slug: 'xp-layers',
|
|
13
13
|
graphQL: false,
|
|
14
14
|
disableDuplicate: true,
|
|
15
|
+
versions: false,
|
|
15
16
|
// group:false hides navigation while keeping collection routes reachable (hidden causes 404s).
|
|
16
17
|
labels: {
|
|
17
18
|
singular: 'Test group',
|
package/dist/constants.d.ts
CHANGED
package/dist/constants.js
CHANGED
|
@@ -22,6 +22,7 @@ export async function handleRecompute(req, _ctx) {
|
|
|
22
22
|
experimentId: docs[0].id,
|
|
23
23
|
sweep: false
|
|
24
24
|
});
|
|
25
|
+
if (result.failed) return resultsError(500, 'analysis-failed', 'Results could not be computed; this has been logged.');
|
|
25
26
|
if (result.skipped || result.budgetExhausted) return resultsError(503, 'recompute-failed', 'Results could not be recomputed. Try again.');
|
|
26
27
|
const latest = (await readSnapshots(req, [
|
|
27
28
|
docs[0].id
|
|
@@ -36,7 +37,11 @@ export async function handleRecompute(req, _ctx) {
|
|
|
36
37
|
'Cache-Control': 'no-store'
|
|
37
38
|
}
|
|
38
39
|
});
|
|
39
|
-
} catch
|
|
40
|
+
} catch (error) {
|
|
41
|
+
req.payload.logger.error({
|
|
42
|
+
err: error,
|
|
43
|
+
experimentId: docs[0].id
|
|
44
|
+
}, 'Experiment recompute failed');
|
|
40
45
|
return resultsError(503, 'recompute-failed', 'Results could not be recomputed. Try again.');
|
|
41
46
|
}
|
|
42
47
|
}
|
|
@@ -3,3 +3,4 @@ export { MetricSourceSelect } from '../admin/MetricSourceSelect.js';
|
|
|
3
3
|
export { ArmSelectField } from '../admin/armSelect/ArmSelectField.js';
|
|
4
4
|
export { TestsNavLink } from '../admin/TestsNavLink.js';
|
|
5
5
|
export { TestsSurface } from '../admin/surface/TestsSurface.js';
|
|
6
|
+
export { TestsDashboardCards } from '../admin/TestsDashboardCards.js';
|
|
@@ -4,3 +4,4 @@ export { MetricSourceSelect } from '../admin/MetricSourceSelect.js';
|
|
|
4
4
|
export { ArmSelectField } from '../admin/armSelect/ArmSelectField.js';
|
|
5
5
|
export { TestsNavLink } from '../admin/TestsNavLink.js';
|
|
6
6
|
export { TestsSurface } from '../admin/surface/TestsSurface.js';
|
|
7
|
+
export { TestsDashboardCards } from '../admin/TestsDashboardCards.js';
|
package/dist/globals/state.js
CHANGED
package/dist/plugin.js
CHANGED
|
@@ -15,6 +15,8 @@ export function createExperimentsPlugin(input) {
|
|
|
15
15
|
// Erase the user subtype only at this non-generic factory boundary.
|
|
16
16
|
const { collections, globals } = buildCollections(options);
|
|
17
17
|
const onInit = createOnInit(options);
|
|
18
|
+
const beforeDashboard = config.admin?.components?.beforeDashboard ?? [];
|
|
19
|
+
const beforeNavLinks = config.admin?.components?.beforeNavLinks ?? [];
|
|
18
20
|
return {
|
|
19
21
|
...config,
|
|
20
22
|
admin: {
|
|
@@ -35,8 +37,12 @@ export function createExperimentsPlugin(input) {
|
|
|
35
37
|
path: '/experiments/:segments*'
|
|
36
38
|
}
|
|
37
39
|
},
|
|
38
|
-
|
|
39
|
-
...
|
|
40
|
+
beforeDashboard: beforeDashboard.includes(paths.XP_TESTS_DASHBOARD_CARDS) ? beforeDashboard : [
|
|
41
|
+
...beforeDashboard,
|
|
42
|
+
paths.XP_TESTS_DASHBOARD_CARDS
|
|
43
|
+
],
|
|
44
|
+
beforeNavLinks: beforeNavLinks.includes(paths.XP_TESTS_NAV_LINK) ? beforeNavLinks : [
|
|
45
|
+
...beforeNavLinks,
|
|
40
46
|
paths.XP_TESTS_NAV_LINK
|
|
41
47
|
]
|
|
42
48
|
}
|
package/dist/puck/copy.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
export declare const slotLabels: {
|
|
2
|
+
readonly control: "Original";
|
|
3
|
+
readonly variantA: "Variant A";
|
|
4
|
+
readonly variantB: "Variant B";
|
|
5
|
+
readonly variantC: "Variant C";
|
|
6
|
+
};
|
|
1
7
|
export declare const pickerCopy: {
|
|
2
8
|
readonly label: "Experiment";
|
|
3
9
|
readonly none: "No experiment";
|
package/dist/puck/copy.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
// Kept separate from admin copy because the Puck entries cannot import admin modules.
|
|
2
|
+
export const slotLabels = {
|
|
3
|
+
control: 'Original',
|
|
4
|
+
variantA: 'Variant A',
|
|
5
|
+
variantB: 'Variant B',
|
|
6
|
+
variantC: 'Variant C'
|
|
7
|
+
};
|
|
1
8
|
export const pickerCopy = {
|
|
2
9
|
label: 'Experiment',
|
|
3
10
|
none: 'No experiment',
|
|
@@ -156,6 +156,8 @@ function EditorRender(props) {
|
|
|
156
156
|
experiment ? /*#__PURE__*/ _jsx("a", {
|
|
157
157
|
className: "xp-slot-button xp-slot-button-primary",
|
|
158
158
|
href: `/admin/experiments/${encodeURIComponent(String(experimentId))}${experiment.status === 'draft' ? '' : '/results'}`,
|
|
159
|
+
target: "_blank",
|
|
160
|
+
rel: "noopener noreferrer",
|
|
159
161
|
children: experiment.status === 'draft' ? editorCopy.setup : editorCopy.viewResults
|
|
160
162
|
}) : null
|
|
161
163
|
]
|
package/dist/puck/slotConfig.js
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
import { createExperimentSlotField } from './experimentSlotField.js';
|
|
2
|
-
import { pickerCopy } from './copy.js';
|
|
2
|
+
import { pickerCopy, slotLabels } from './copy.js';
|
|
3
3
|
export const experimentSlotLabel = pickerCopy.label;
|
|
4
4
|
export const experimentSlotFields = {
|
|
5
5
|
experiment: createExperimentSlotField(),
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
type: 'slot'
|
|
6
|
+
// Puck 0.24's defaultSlots prepends each slot, reversing declaration order in the outline.
|
|
7
|
+
variantC: {
|
|
8
|
+
type: 'slot',
|
|
9
|
+
label: slotLabels.variantC
|
|
11
10
|
},
|
|
12
11
|
variantB: {
|
|
13
|
-
type: 'slot'
|
|
12
|
+
type: 'slot',
|
|
13
|
+
label: slotLabels.variantB
|
|
14
14
|
},
|
|
15
|
-
|
|
16
|
-
type: 'slot'
|
|
15
|
+
variantA: {
|
|
16
|
+
type: 'slot',
|
|
17
|
+
label: slotLabels.variantA
|
|
18
|
+
},
|
|
19
|
+
control: {
|
|
20
|
+
type: 'slot',
|
|
21
|
+
label: slotLabels.control
|
|
17
22
|
}
|
|
18
23
|
};
|
|
19
24
|
export const experimentSlotDefaultProps = {
|
|
@@ -33,7 +33,7 @@ export interface AnalysisPhase {
|
|
|
33
33
|
analysis: {
|
|
34
34
|
method: 'sequential' | 'bayesian';
|
|
35
35
|
alpha?: number;
|
|
36
|
-
sequentialTuningParameter
|
|
36
|
+
sequentialTuningParameter?: number | null;
|
|
37
37
|
cuped: 'auto' | 'off';
|
|
38
38
|
cupedLookbackDays?: number;
|
|
39
39
|
minExposuresPerArm: number;
|
package/dist/results/analyse.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DEFAULT_GAUSSIAN_PRIOR, bonferroniAlpha, capValues, createThetaAdjustedStatistics, effectBayesianABTest, sequentialOneSidedTreatmentGreaterTTest, sequentialOneSidedTreatmentLesserTTest, sequentialTwoSidedTTest, statisticMean, statisticVariance } from '@delmaredigital/experiments-stats';
|
|
2
|
+
import { DEFAULT_SEQUENTIAL_TUNING_PARAMETER } from '../constants.js';
|
|
2
3
|
import { timestampMs } from './aggregate.js';
|
|
3
4
|
import { outcomeStatistic } from './cuped.js';
|
|
4
5
|
import { guardrailStatus } from './guardrails.js';
|
|
@@ -17,6 +18,9 @@ const severity = {
|
|
|
17
18
|
*/ export function analysePhase(input) {
|
|
18
19
|
const { phase, units } = input;
|
|
19
20
|
const { analysis } = phase;
|
|
21
|
+
// Start freezes this optional collection field as-is. Match the stats engine's
|
|
22
|
+
// default and record the effective value in every snapshot, including n = 0.
|
|
23
|
+
const sequentialTuningParameter = analysis.sequentialTuningParameter ?? DEFAULT_SEQUENTIAL_TUNING_PARAMETER;
|
|
20
24
|
const alpha = analysis.alpha ?? 0.05;
|
|
21
25
|
const prior = analysis.prior ?? DEFAULT_GAUSSIAN_PRIOR;
|
|
22
26
|
const bayesian = analysis.bayesian ?? {
|
|
@@ -73,7 +77,7 @@ const severity = {
|
|
|
73
77
|
capped: {}
|
|
74
78
|
};
|
|
75
79
|
const params = {
|
|
76
|
-
sequentialTuningParameter
|
|
80
|
+
sequentialTuningParameter,
|
|
77
81
|
prior: {
|
|
78
82
|
...prior
|
|
79
83
|
},
|
|
@@ -158,7 +162,7 @@ const severity = {
|
|
|
158
162
|
metric.cupedApplied ||= a.type === 'regressionAdjusted' && b.type === 'regressionAdjusted';
|
|
159
163
|
const config = {
|
|
160
164
|
alpha,
|
|
161
|
-
sequentialTuningParameter
|
|
165
|
+
sequentialTuningParameter
|
|
162
166
|
};
|
|
163
167
|
const inverse = definition.direction === 'decrease';
|
|
164
168
|
let comparison;
|
|
@@ -342,7 +346,7 @@ const severity = {
|
|
|
342
346
|
alpha: alphaAdjusted,
|
|
343
347
|
method: analysis.method,
|
|
344
348
|
prior,
|
|
345
|
-
sequentialTuningParameter
|
|
349
|
+
sequentialTuningParameter
|
|
346
350
|
});
|
|
347
351
|
verdict = computeVerdict({
|
|
348
352
|
...verdictInput,
|
|
@@ -219,6 +219,7 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
|
|
|
219
219
|
const summary = {
|
|
220
220
|
ran: 0,
|
|
221
221
|
skipped: 0,
|
|
222
|
+
failed: 0,
|
|
222
223
|
swept: 0,
|
|
223
224
|
processedIds: [],
|
|
224
225
|
budgetExhausted: false,
|
|
@@ -243,6 +244,7 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
|
|
|
243
244
|
});
|
|
244
245
|
} catch (error) {
|
|
245
246
|
summary.skipped++;
|
|
247
|
+
if (!(error instanceof ResultsBudgetError)) summary.failed++;
|
|
246
248
|
summary.budgetExhausted = error instanceof ResultsBudgetError;
|
|
247
249
|
payload.logger.error({
|
|
248
250
|
err: error,
|
|
@@ -277,6 +279,7 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
|
|
|
277
279
|
if (summary.processedIds.length < 100) summary.processedIds.push(id);
|
|
278
280
|
} catch (error) {
|
|
279
281
|
summary.skipped++;
|
|
282
|
+
if (!(error instanceof ResultsBudgetError)) summary.failed++;
|
|
280
283
|
summary.budgetExhausted ||= error instanceof ResultsBudgetError;
|
|
281
284
|
payload.logger.error({
|
|
282
285
|
err: error,
|
|
@@ -296,6 +299,7 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
|
|
|
296
299
|
logger: payload.logger
|
|
297
300
|
});
|
|
298
301
|
} catch (error) {
|
|
302
|
+
if (!(error instanceof ResultsBudgetError)) summary.failed++;
|
|
299
303
|
summary.budgetExhausted ||= error instanceof ResultsBudgetError;
|
|
300
304
|
payload.logger.error({
|
|
301
305
|
err: error
|
|
@@ -4,7 +4,7 @@ import type { ReadinessCode } from '../transitions/checkStart.js';
|
|
|
4
4
|
export type { ReadinessCode } from '../transitions/checkStart.js';
|
|
5
5
|
import type { Snapshot, VerdictCode } from './snapshot.js';
|
|
6
6
|
export type ArmIssueCode = 'weights-total' | 'control-count' | 'control-first' | 'holdout-last' | 'holdout-count' | 'variant-count' | 'duplicate-key' | 'duplicate-slot' | 'slot-required' | 'invalid-key';
|
|
7
|
-
export type RefusalCode = ArmIssueCode | 'not-found' | 'no-planned-duration' | 'no-primary-metric' | 'no-placements' | 'empty-slot' | 'page-no-targets' | 'page-user-unit' | 'flag-no-point' | 'flag-params-invalid' | 'flag-point-busy' | 'flag-placement-missing' | 'layer-full' | 'layer-unit-mismatch' | 'unknown-metric-source' | 'unit-source-mismatch' | 'no-reach-per-day' | 'invalid-status' | 'stale-draft' | 'commit-unverified' | 'allocation-lower' | 'confirmation-required' | 'invalid-winner' | 'slices-taken' | 'locked-placement' | 'not-implemented' | 'forbidden' | 'unauthenticated' | 'body-too-large' | 'invalid-body' | 'too-many-ids' | 'invalid-phase' | 'invalid-ids' | 'recompute-failed' | 'undeclared-targeting-key' | 'invalid-targeting-rule' | 'duplicate-metric';
|
|
7
|
+
export type RefusalCode = ArmIssueCode | 'not-found' | 'no-planned-duration' | 'no-primary-metric' | 'no-placements' | 'empty-slot' | 'page-no-targets' | 'page-user-unit' | 'flag-no-point' | 'flag-params-invalid' | 'flag-point-busy' | 'flag-placement-missing' | 'layer-full' | 'layer-unit-mismatch' | 'unknown-metric-source' | 'unit-source-mismatch' | 'no-reach-per-day' | 'invalid-status' | 'stale-draft' | 'commit-unverified' | 'allocation-lower' | 'confirmation-required' | 'invalid-winner' | 'slices-taken' | 'locked-placement' | 'not-implemented' | 'forbidden' | 'unauthenticated' | 'body-too-large' | 'invalid-body' | 'too-many-ids' | 'invalid-phase' | 'invalid-ids' | 'recompute-failed' | 'analysis-failed' | 'undeclared-targeting-key' | 'invalid-targeting-rule' | 'duplicate-metric';
|
|
8
8
|
export type ErrorEnvelope = {
|
|
9
9
|
errors: Array<{
|
|
10
10
|
code: RefusalCode;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delmaredigital/payload-experiments",
|
|
3
|
-
"version": "0.2.0-rc.
|
|
3
|
+
"version": "0.2.0-rc.6",
|
|
4
4
|
"description": "No-code A/B testing for Payload CMS and Next.js: block, page and flag experiments with holdouts, layers, exposure logging and honest statistics inside the admin — no vendor",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|