@delmaredigital/payload-experiments 0.3.0-rc.4 → 0.4.0-rc.2
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 +122 -27
- package/dist/admin/MetricSourceSelect.js +3 -3
- package/dist/admin/PlacementStatus.d.ts +3 -0
- package/dist/admin/PlacementStatus.js +54 -0
- package/dist/admin/PreviewLinks.d.ts +2 -1
- package/dist/admin/PreviewLinks.js +2 -2
- package/dist/admin/TestsDashboardCards.d.ts +1 -0
- package/dist/admin/TestsDashboardCards.js +4 -8
- package/dist/admin/TestsNavLink.d.ts +1 -0
- package/dist/admin/TestsNavLink.js +4 -10
- package/dist/admin/actions/ConfirmDialog.d.ts +2 -1
- package/dist/admin/actions/ConfirmDialog.js +2 -2
- package/dist/admin/actions/DecideDialog.d.ts +2 -1
- package/dist/admin/actions/DecideDialog.js +2 -2
- package/dist/admin/api.d.ts +18 -2
- package/dist/admin/api.js +47 -2
- package/dist/admin/blocks/BlockPanel.d.ts +4 -0
- package/dist/admin/blocks/BlockPanel.js +199 -0
- package/dist/admin/blocks/TestBlockField.d.ts +5 -0
- package/dist/admin/blocks/TestBlockField.js +163 -0
- package/dist/admin/blocks/formApi.d.ts +46 -0
- package/dist/admin/blocks/formApi.js +159 -0
- package/dist/admin/blocks/inject.d.ts +7 -0
- package/dist/admin/blocks/inject.js +41 -0
- package/dist/admin/blocks/model.d.ts +17 -0
- package/dist/admin/blocks/model.js +45 -0
- package/dist/admin/blocks/statusCache.d.ts +9 -0
- package/dist/admin/blocks/statusCache.js +92 -0
- package/dist/admin/blocks/useBlockForm.d.ts +17 -0
- package/dist/admin/blocks/useBlockForm.js +46 -0
- package/dist/admin/copy/actions.d.ts +2 -0
- package/dist/admin/copy/actions.js +9 -0
- package/dist/admin/copy/blocks.d.ts +43 -0
- package/dist/admin/copy/blocks.js +64 -0
- package/dist/admin/copy/common.d.ts +1 -0
- package/dist/admin/copy/common.js +1 -1
- package/dist/admin/copy/goals.d.ts +2 -0
- package/dist/admin/copy/goals.js +4 -1
- package/dist/admin/copy/newTest.d.ts +10 -0
- package/dist/admin/copy/newTest.js +10 -0
- package/dist/admin/copy/pages.d.ts +24 -0
- package/dist/admin/copy/pages.js +24 -0
- package/dist/admin/copy/readiness.d.ts +23 -3
- package/dist/admin/copy/readiness.js +24 -3
- package/dist/admin/copy/results.d.ts +1 -0
- package/dist/admin/copy/results.js +1 -0
- package/dist/admin/copy/testPage.d.ts +4 -0
- package/dist/admin/copy/testPage.js +4 -0
- package/dist/admin/goals/XpGoalField.d.ts +4 -0
- package/dist/admin/goals/XpGoalField.js +168 -0
- package/dist/admin/goals/xpGoalField.css +48 -0
- package/dist/admin/metricSource.model.d.ts +2 -0
- package/dist/admin/metricSource.model.js +4 -0
- package/dist/admin/pageCollections.d.ts +10 -0
- package/dist/admin/pageCollections.js +20 -0
- package/dist/admin/paths.d.ts +9 -0
- package/dist/admin/paths.js +9 -0
- package/dist/admin/placementStatus.model.d.ts +2 -0
- package/dist/admin/placementStatus.model.js +19 -0
- package/dist/admin/placements/placementLabels.d.ts +11 -0
- package/dist/admin/placements/placementLabels.js +21 -0
- package/dist/admin/placements/placementLabels.model.d.ts +13 -0
- package/dist/admin/placements/placementLabels.model.js +12 -0
- package/dist/admin/placements/usePlacementLabels.d.ts +12 -0
- package/dist/admin/placements/usePlacementLabels.js +102 -0
- package/dist/admin/results/DataQualityPanel.js +4 -1
- package/dist/admin/results/results.model.d.ts +3 -0
- package/dist/admin/results/results.model.js +8 -4
- package/dist/admin/surface/TestsHome.js +39 -10
- package/dist/admin/surface/TestsSurface.js +7 -6
- package/dist/admin/surface/contracts.d.ts +6 -5
- package/dist/admin/surface/goals/GoalForm.js +71 -44
- package/dist/admin/surface/goals/GoalsList.js +5 -4
- package/dist/admin/surface/goals/data.js +4 -1
- package/dist/admin/surface/goals/goals.model.d.ts +1 -2
- package/dist/admin/surface/goals/goals.model.js +1 -3
- package/dist/admin/surface/home.model.d.ts +3 -3
- package/dist/admin/surface/home.model.js +8 -4
- package/dist/admin/surface/newTest/NewTest.js +56 -17
- package/dist/admin/surface/newTest/PageWizard.d.ts +7 -0
- package/dist/admin/surface/newTest/PageWizard.js +214 -0
- package/dist/admin/surface/pagePicker/CopyRoute.d.ts +6 -0
- package/dist/admin/surface/pagePicker/CopyRoute.js +53 -0
- package/dist/admin/surface/pagePicker/PageCombobox.d.ts +10 -0
- package/dist/admin/surface/pagePicker/PageCombobox.js +395 -0
- package/dist/admin/surface/pagePicker/pagePicker.css +10 -0
- package/dist/admin/surface/pagePicker/pagePicker.model.d.ts +27 -0
- package/dist/admin/surface/pagePicker/pagePicker.model.js +25 -0
- package/dist/admin/surface/readiness/ActionDialogs.js +99 -64
- package/dist/admin/surface/readiness/Banner.js +30 -6
- package/dist/admin/surface/readiness/readiness.model.d.ts +8 -1
- package/dist/admin/surface/readiness/readiness.model.js +92 -9
- package/dist/admin/surface/results/ReleasePages.d.ts +3 -1
- package/dist/admin/surface/results/ReleasePages.js +3 -7
- package/dist/admin/surface/results/ResultsBody.js +21 -2
- package/dist/admin/surface/results/ResultsHeader.js +5 -13
- package/dist/admin/surface/results/useResults.d.ts +20 -1
- package/dist/admin/surface/test/AdvancedSection.js +111 -104
- package/dist/admin/surface/test/GoalSection.js +94 -14
- package/dist/admin/surface/test/LengthSection.js +1 -1
- package/dist/admin/surface/test/PageHoldout.d.ts +3 -0
- package/dist/admin/surface/test/PageHoldout.js +67 -0
- package/dist/admin/surface/test/TestPage.js +5 -9
- package/dist/admin/surface/test/VersionsSection.js +12 -5
- package/dist/admin/surface/test/fields.d.ts +13 -4
- package/dist/admin/surface/test/fields.js +46 -14
- package/dist/admin/surface/test/jumpToSection.d.ts +1 -1
- package/dist/admin/surface/test/jumpToSection.js +4 -3
- package/dist/admin/surface/test/pageParts.d.ts +4 -1
- package/dist/admin/surface/test/pageParts.js +49 -1
- package/dist/admin/surface/test/testPage.css +3 -1
- package/dist/admin/surface/testDocument.model.d.ts +1 -0
- package/dist/admin/surface/testDocument.model.js +2 -1
- package/dist/admin/surface/useTestDocument.d.ts +15 -2
- package/dist/admin/surface/useTestDocument.js +53 -15
- package/dist/admin/surfaceUrls.d.ts +7 -0
- package/dist/admin/surfaceUrls.js +19 -0
- package/dist/admin/theme.css +2 -0
- package/dist/admin/useApi.d.ts +21 -2
- package/dist/admin/useApi.js +3 -5
- package/dist/admin/views/CollectionListRedirect.d.ts +1 -0
- package/dist/admin/views/CollectionListRedirect.js +1 -1
- package/dist/admin/views/ExperimentsRootView.js +28 -133
- package/dist/admin/views/GoalEditRedirect.d.ts +4 -0
- package/dist/admin/views/GoalEditRedirect.js +14 -0
- package/dist/admin/views/GoalsCollectionListView.d.ts +2 -0
- package/dist/admin/views/GoalsCollectionListView.js +4 -0
- package/dist/admin/views/TestsCollectionListView.d.ts +2 -0
- package/dist/admin/views/TestsCollectionListView.js +4 -0
- package/dist/admin/views/TestsNavLinkServer.d.ts +1 -0
- package/dist/admin/views/TestsNavLinkServer.js +1 -1
- package/dist/admin/views/buildSurfaceProps.d.ts +8 -0
- package/dist/admin/views/buildSurfaceProps.js +143 -0
- package/dist/admin/views/renderCollectionList.d.ts +2 -0
- package/dist/admin/views/renderCollectionList.js +34 -0
- package/dist/admin/views/renderTestsSurface.d.ts +3 -0
- package/dist/admin/views/renderTestsSurface.js +34 -0
- package/dist/blocks/bindings.d.ts +3 -0
- package/dist/blocks/bindings.js +34 -0
- package/dist/blocks/createExperimentBlock.d.ts +6 -0
- package/dist/blocks/createExperimentBlock.js +77 -0
- package/dist/blocks/formState.d.ts +31 -0
- package/dist/blocks/formState.js +146 -0
- package/dist/blocks/model.d.ts +41 -0
- package/dist/blocks/model.js +34 -0
- package/dist/client/ExperimentGroup.d.ts +6 -0
- package/dist/client/ExperimentGroup.js +9 -0
- package/dist/client/ExperimentsProvider.js +20 -4
- package/dist/client/clickGoals.d.ts +4 -0
- package/dist/client/clickGoals.js +43 -0
- package/dist/collections/experiments/config.js +21 -7
- package/dist/collections/experiments/deleteGuard.d.ts +2 -1
- package/dist/collections/experiments/deleteGuard.js +12 -1
- package/dist/collections/experiments/editability.js +4 -2
- package/dist/collections/experiments/pickSlug.d.ts +2 -0
- package/dist/collections/experiments/pickSlug.js +8 -0
- package/dist/collections/labels.d.ts +3 -0
- package/dist/collections/labels.js +20 -0
- package/dist/collections/metrics.js +28 -6
- package/dist/collections/phases.js +1 -0
- package/dist/collections/serialize.js +7 -0
- package/dist/db/clickGoals.d.ts +9 -0
- package/dist/db/clickGoals.js +22 -0
- package/dist/db/delete.d.ts +1 -0
- package/dist/db/delete.js +1 -0
- package/dist/db/locks.d.ts +8 -0
- package/dist/db/locks.js +27 -4
- package/dist/db/names.d.ts +4 -0
- package/dist/db/names.js +4 -0
- package/dist/db/pageBindings.d.ts +4 -0
- package/dist/db/pageBindings.js +17 -0
- package/dist/db/pageBusy.d.ts +2 -0
- package/dist/db/pageBusy.js +10 -0
- package/dist/db/pageContent.d.ts +10 -0
- package/dist/db/pageContent.js +79 -0
- package/dist/db/placements.d.ts +33 -0
- package/dist/db/placements.js +54 -0
- package/dist/db/slugs.d.ts +8 -0
- package/dist/db/slugs.js +40 -0
- package/dist/db/snapshots.d.ts +6 -0
- package/dist/db/snapshots.js +8 -5
- package/dist/endpoints/clickKeys.d.ts +5 -0
- package/dist/endpoints/clickKeys.js +17 -0
- package/dist/endpoints/events.js +4 -1
- package/dist/endpoints/index.js +28 -0
- package/dist/endpoints/pages.d.ts +5 -0
- package/dist/endpoints/pages.js +120 -0
- package/dist/endpoints/placementStatus.d.ts +6 -0
- package/dist/endpoints/placementStatus.js +238 -0
- package/dist/endpoints/readBody.d.ts +2 -0
- package/dist/endpoints/readBody.js +24 -0
- package/dist/endpoints/results.js +5 -1
- package/dist/endpoints/summary.js +11 -4
- package/dist/endpoints/wrapBlock.d.ts +6 -0
- package/dist/endpoints/wrapBlock.js +76 -0
- package/dist/errors.d.ts +3 -3
- package/dist/errors.js +4 -4
- package/dist/events/rows.d.ts +1 -1
- package/dist/events/rows.js +5 -3
- package/dist/exports/adminClient.d.ts +5 -0
- package/dist/exports/adminClient.js +5 -0
- package/dist/exports/adminRsc.d.ts +3 -0
- package/dist/exports/adminRsc.js +3 -0
- package/dist/exports/blocks.d.ts +2 -0
- package/dist/exports/blocks.js +1 -0
- package/dist/exports/client.d.ts +1 -0
- package/dist/exports/client.js +1 -0
- package/dist/exports/index.d.ts +6 -1
- package/dist/exports/index.js +3 -0
- package/dist/exports/puck.d.ts +3 -1
- package/dist/exports/puck.js +2 -0
- package/dist/exports/puckEditor.d.ts +3 -0
- package/dist/exports/puckEditor.js +2 -0
- package/dist/exports/rsc.d.ts +3 -0
- package/dist/exports/rsc.js +2 -0
- package/dist/fields/xpGoal.d.ts +3 -0
- package/dist/fields/xpGoal.js +16 -0
- package/dist/globals/state.js +10 -0
- package/dist/goals/cache.d.ts +3 -0
- package/dist/goals/cache.js +35 -0
- package/dist/goals/fairness.d.ts +40 -0
- package/dist/goals/fairness.js +200 -0
- package/dist/goals/key.d.ts +4 -0
- package/dist/goals/key.js +3 -0
- package/dist/goals/picker/clickGoalPicker.model.d.ts +29 -0
- package/dist/goals/picker/clickGoalPicker.model.js +45 -0
- package/dist/goals/picker/copy.d.ts +16 -0
- package/dist/goals/picker/copy.js +15 -0
- package/dist/goals/picker/useClickGoalPicker.d.ts +30 -0
- package/dist/goals/picker/useClickGoalPicker.js +220 -0
- package/dist/goals/validate.d.ts +3 -0
- package/dist/goals/validate.js +14 -0
- package/dist/hooks/pageGuard.d.ts +5 -1
- package/dist/hooks/pageGuard.js +157 -95
- package/dist/hooks/puckGoals.d.ts +3 -0
- package/dist/hooks/puckGoals.js +38 -0
- package/dist/onInit.js +29 -0
- package/dist/options.d.ts +2 -0
- package/dist/options.js +41 -1
- package/dist/plugin.js +50 -43
- package/dist/puck/copy.d.ts +1 -0
- package/dist/puck/copy.js +1 -0
- package/dist/puck/editor/editor.chrome.css +14 -0
- package/dist/puck/editor/editor.css +106 -0
- package/dist/puck/editor/editor.model.d.ts +4 -2
- package/dist/puck/editor/editor.model.js +6 -3
- package/dist/puck/editor/integration.d.ts +10 -0
- package/dist/puck/editor/integration.js +30 -0
- package/dist/puck/editor/xpGoalPuckEditorField.d.ts +7 -0
- package/dist/puck/editor/xpGoalPuckEditorField.js +142 -0
- package/dist/puck/integration.d.ts +6 -0
- package/dist/puck/integration.js +21 -0
- package/dist/puck/xpGoalField.d.ts +3 -0
- package/dist/puck/xpGoalField.js +6 -0
- package/dist/results/health.d.ts +4 -0
- package/dist/results/health.js +27 -0
- package/dist/results/snapshot.js +15 -2
- package/dist/sanitizeReadinessDetail.js +43 -0
- package/dist/server/pageKindSeoContract.d.ts +27 -0
- package/dist/server/pageKindSeoContract.js +80 -0
- package/dist/server/recordContentChange.js +58 -8
- package/dist/server/resolveExperimentBlocks.d.ts +5 -0
- package/dist/server/resolveExperimentBlocks.js +54 -0
- package/dist/server/runResults.js +56 -15
- package/dist/server/toClientAssignments.js +2 -1
- package/dist/transitions/addMetric.d.ts +2 -1
- package/dist/transitions/addMetric.js +28 -1
- package/dist/transitions/checkStart.d.ts +14 -1
- package/dist/transitions/checkStart.js +92 -8
- package/dist/transitions/decide.js +25 -0
- package/dist/transitions/deleteExperiment.d.ts +3 -1
- package/dist/transitions/deleteExperiment.js +27 -1
- package/dist/transitions/kernel.js +4 -0
- package/dist/transitions/makeContent.d.ts +5 -0
- package/dist/transitions/makeContent.js +39 -11
- package/dist/transitions/pageCollection.d.ts +10 -0
- package/dist/transitions/pageCollection.js +20 -0
- package/dist/transitions/pageVersions.d.ts +10 -0
- package/dist/transitions/pageVersions.js +44 -0
- package/dist/transitions/phaseRecord.d.ts +1 -0
- package/dist/transitions/placementInteractions.d.ts +40 -0
- package/dist/transitions/placementInteractions.js +94 -0
- package/dist/transitions/placements.d.ts +8 -0
- package/dist/transitions/placements.js +148 -1
- package/dist/transitions/preflightData.d.ts +2 -2
- package/dist/transitions/preflightData.js +137 -5
- package/dist/transitions/resolveCaps.d.ts +1 -0
- package/dist/transitions/resolveCaps.js +6 -2
- package/dist/transitions/start.js +46 -1
- package/dist/types/admin.d.ts +1 -1
- package/dist/types/assignments.d.ts +1 -0
- package/dist/types/contracts.d.ts +15 -0
- package/dist/types/endpoints.d.ts +92 -5
- package/dist/types/index.d.ts +3 -0
- package/dist/types/puck.d.ts +7 -1
- package/dist/types/snapshot.d.ts +17 -4
- package/dist/types/surface.d.ts +13 -0
- package/dist/ui/ui.css +4 -0
- package/package.json +8 -1
|
@@ -2,7 +2,11 @@ import type { PayloadRequest } from 'payload';
|
|
|
2
2
|
import type { Arm, DocId, FlagPoint, PreviewPlacement, ResolvedOptions } from '../types/contracts.js';
|
|
3
3
|
import type { ArmSlot } from '../types/puck.js';
|
|
4
4
|
export interface BlockPlacement extends PreviewPlacement {
|
|
5
|
+
editor?: 'puck' | 'blocks' | 'page';
|
|
6
|
+
doc?: Record<string, unknown>;
|
|
5
7
|
slots: Record<ArmSlot, number>;
|
|
8
|
+
duplicateRows?: boolean;
|
|
9
|
+
identicalSlots?: string[];
|
|
6
10
|
locked?: boolean;
|
|
7
11
|
localized?: boolean;
|
|
8
12
|
}
|
|
@@ -13,10 +17,14 @@ export type FlagPlacement = Awaited<ReturnType<NonNullable<FlagPoint['placements
|
|
|
13
17
|
export declare function scanBlockPlacements(req: PayloadRequest, args: {
|
|
14
18
|
slug: string;
|
|
15
19
|
puckCollections: NonNullable<ResolvedOptions['puck']>['collections'];
|
|
20
|
+
blocksCollections: NonNullable<ResolvedOptions['blocks']>['collections'];
|
|
21
|
+
compareVersions?: boolean;
|
|
16
22
|
}): Promise<BlockPlacement[]>;
|
|
17
23
|
export declare function scanBlockPlacements(req: PayloadRequest, args: {
|
|
18
24
|
slugs: ReadonlySet<string>;
|
|
19
25
|
puckCollections: NonNullable<ResolvedOptions['puck']>['collections'];
|
|
26
|
+
blocksCollections: NonNullable<ResolvedOptions['blocks']>['collections'];
|
|
27
|
+
compareVersions?: boolean;
|
|
20
28
|
}): Promise<Map<string, BlockPlacement[]>>;
|
|
21
29
|
export declare function flagPlacements<TUser extends {
|
|
22
30
|
id: DocId;
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import { BLOCK_SLOTS, EXPERIMENT_BLOCK_SLUG, hostBlocks, slotBlocks } from '../blocks/model.js';
|
|
2
|
+
import { safeError } from '../log.js';
|
|
3
|
+
import { pageVersionContent } from '../db/pageContent.js';
|
|
4
|
+
import { comparableContent } from './pageVersions.js';
|
|
5
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
1
6
|
import { hasLocalizedFields } from './contentFields.js';
|
|
2
|
-
import { puckCollections as normalisePuckCollections } from '../options.js';
|
|
7
|
+
import { documentEditor, puckCollections as normalisePuckCollections } from '../options.js';
|
|
3
8
|
const slots = [
|
|
4
9
|
'control',
|
|
5
10
|
'variantA',
|
|
@@ -23,6 +28,7 @@ export async function scanBlockPlacements(req, args) {
|
|
|
23
28
|
collections: puckCollections
|
|
24
29
|
}
|
|
25
30
|
})){
|
|
31
|
+
const editors = new Map();
|
|
26
32
|
const publishedIds = new Set();
|
|
27
33
|
const publishedSlugs = new Map();
|
|
28
34
|
const draftsEnabled = req.payload.config.collections?.some((config)=>config.slug === collection && config.versions && config.versions.drafts);
|
|
@@ -53,9 +59,28 @@ export async function scanBlockPlacements(req, args) {
|
|
|
53
59
|
if (!draft && doc._status === 'draft') continue;
|
|
54
60
|
if (draft && doc._status !== 'draft' && publishedIds.has(String(doc.id))) continue;
|
|
55
61
|
if (!draft) {
|
|
62
|
+
editors.set(String(doc.id), documentEditor({
|
|
63
|
+
puck: {
|
|
64
|
+
enabled: true,
|
|
65
|
+
collections: puckCollections
|
|
66
|
+
},
|
|
67
|
+
blocks: {
|
|
68
|
+
collections: args.blocksCollections
|
|
69
|
+
}
|
|
70
|
+
}, collection, doc));
|
|
56
71
|
publishedIds.add(String(doc.id));
|
|
57
72
|
if (typeof doc.slug === 'string') publishedSlugs.set(String(doc.id), doc.slug);
|
|
58
73
|
}
|
|
74
|
+
const editor = editors.get(String(doc.id)) ?? documentEditor({
|
|
75
|
+
puck: {
|
|
76
|
+
enabled: true,
|
|
77
|
+
collections: puckCollections
|
|
78
|
+
},
|
|
79
|
+
blocks: {
|
|
80
|
+
collections: args.blocksCollections
|
|
81
|
+
}
|
|
82
|
+
}, collection, doc);
|
|
83
|
+
if (editor !== 'puck') continue;
|
|
59
84
|
function walk(value) {
|
|
60
85
|
if (!value || typeof value !== 'object') return;
|
|
61
86
|
if (Array.isArray(value)) {
|
|
@@ -70,6 +95,8 @@ export async function scanBlockPlacements(req, args) {
|
|
|
70
95
|
placements.get(experiment).push({
|
|
71
96
|
collection,
|
|
72
97
|
id: doc.id,
|
|
98
|
+
editor: 'puck',
|
|
99
|
+
doc,
|
|
73
100
|
title: String(doc.title ?? doc.name ?? doc.id),
|
|
74
101
|
published: !draft || doc._status === 'published',
|
|
75
102
|
...publishedSlugs.has(String(doc.id)) ? {
|
|
@@ -87,7 +114,115 @@ export async function scanBlockPlacements(req, args) {
|
|
|
87
114
|
}
|
|
88
115
|
Object.values(node).forEach(walk);
|
|
89
116
|
}
|
|
117
|
+
const offsets = new Map([
|
|
118
|
+
...placements
|
|
119
|
+
].map(([slug, list])=>[
|
|
120
|
+
slug,
|
|
121
|
+
list.length
|
|
122
|
+
]));
|
|
90
123
|
walk(doc[field]);
|
|
124
|
+
if (args.compareVersions && !draft && [
|
|
125
|
+
...placements
|
|
126
|
+
].some(([slug, list])=>list.length > offsets.get(slug))) {
|
|
127
|
+
const raw = await comparisonContent(req, collection, doc.id);
|
|
128
|
+
const rawSlots = new Map();
|
|
129
|
+
const collect = (value)=>{
|
|
130
|
+
if (Array.isArray(value)) {
|
|
131
|
+
value.forEach(collect);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (!value || typeof value !== 'object') return;
|
|
135
|
+
const node = value, props = node.props;
|
|
136
|
+
const ref = props?.experiment;
|
|
137
|
+
const slug = typeof ref === 'string' ? ref : ref && typeof ref === 'object' && 'slug' in ref ? ref.slug : undefined;
|
|
138
|
+
if (node.type === 'ExperimentSlot' && props && typeof slug === 'string') {
|
|
139
|
+
const list = rawSlots.get(slug) ?? [];
|
|
140
|
+
list.push(props);
|
|
141
|
+
rawSlots.set(slug, list);
|
|
142
|
+
}
|
|
143
|
+
Object.values(node).forEach(collect);
|
|
144
|
+
};
|
|
145
|
+
if (raw && raw._status !== 'draft') collect(raw[field]);
|
|
146
|
+
for (const [slug, list] of placements)list.slice(offsets.get(slug)).forEach((placement, index)=>{
|
|
147
|
+
const props = rawSlots.get(slug)?.[index];
|
|
148
|
+
if (props) placement.identicalSlots = slots.filter((slot)=>slot !== 'control' && Array.isArray(props[slot]) && props[slot].length > 0 && isDeepStrictEqual(comparableContent(props.control ?? []), comparableContent(props[slot])));
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (!result.hasNextPage) break;
|
|
153
|
+
page++;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const registration of args.blocksCollections){
|
|
158
|
+
const { slug: collection, field } = registration;
|
|
159
|
+
const published = new Map();
|
|
160
|
+
const draftsEnabled = req.payload.config.collections?.some((c)=>c.slug === collection && c.versions && c.versions.drafts);
|
|
161
|
+
for (const draft of [
|
|
162
|
+
false,
|
|
163
|
+
true
|
|
164
|
+
]){
|
|
165
|
+
let page = 1;
|
|
166
|
+
while(true){
|
|
167
|
+
const result = await req.payload.find({
|
|
168
|
+
collection,
|
|
169
|
+
req,
|
|
170
|
+
overrideAccess: false,
|
|
171
|
+
depth: 0,
|
|
172
|
+
limit: 100,
|
|
173
|
+
page,
|
|
174
|
+
draft,
|
|
175
|
+
includeLockStatus: true,
|
|
176
|
+
...!draft && draftsEnabled ? {
|
|
177
|
+
where: {
|
|
178
|
+
_status: {
|
|
179
|
+
equals: 'published'
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} : {}
|
|
183
|
+
});
|
|
184
|
+
for (const doc of result.docs){
|
|
185
|
+
if (!draft && doc._status === 'draft') continue;
|
|
186
|
+
if (!draft) published.set(String(doc.id), doc);
|
|
187
|
+
else if (doc._status !== 'draft' && published.has(String(doc.id))) continue;
|
|
188
|
+
const baseline = published.get(String(doc.id)) ?? doc;
|
|
189
|
+
if (documentEditor({
|
|
190
|
+
blocks: {
|
|
191
|
+
collections: args.blocksCollections
|
|
192
|
+
},
|
|
193
|
+
puck: {
|
|
194
|
+
enabled: true,
|
|
195
|
+
collections: puckCollections
|
|
196
|
+
}
|
|
197
|
+
}, collection, baseline) !== 'blocks') continue;
|
|
198
|
+
const raw = args.compareVersions && !draft && hostBlocks(doc[field]).some((block)=>block.blockType === (registration.blockSlug ?? EXPERIMENT_BLOCK_SLUG) && typeof block.experiment === 'string' && slugs.has(block.experiment)) ? await comparisonContent(req, collection, doc.id) : undefined;
|
|
199
|
+
for (const [index, block] of hostBlocks(doc[field]).entries()){
|
|
200
|
+
if (block.blockType !== (registration.blockSlug ?? EXPERIMENT_BLOCK_SLUG) || typeof block.experiment !== 'string' || !slugs.has(block.experiment)) continue;
|
|
201
|
+
const stored = raw ? hostBlocks(raw[field]).find((row)=>row.id === block.id) ?? hostBlocks(raw[field])[index] ?? block : block;
|
|
202
|
+
const rows = Array.isArray(stored.slots) ? stored.slots : [];
|
|
203
|
+
const keys = rows.map((row)=>row?.slot);
|
|
204
|
+
placements.get(block.experiment).push({
|
|
205
|
+
collection,
|
|
206
|
+
id: doc.id,
|
|
207
|
+
editor: 'blocks',
|
|
208
|
+
doc,
|
|
209
|
+
title: String(doc.title ?? doc.name ?? doc.id),
|
|
210
|
+
published: !draft || doc._status === 'published',
|
|
211
|
+
...typeof baseline.slug === 'string' ? {
|
|
212
|
+
slug: baseline.slug
|
|
213
|
+
} : {},
|
|
214
|
+
slots: Object.fromEntries(BLOCK_SLOTS.map((slot)=>[
|
|
215
|
+
slot,
|
|
216
|
+
slotBlocks(block, slot).length
|
|
217
|
+
])),
|
|
218
|
+
duplicateRows: new Set(keys).size !== keys.length,
|
|
219
|
+
identicalSlots: raw ? BLOCK_SLOTS.filter((slot)=>slot !== 'control' && slotBlocks(block, slot).length > 0 && isDeepStrictEqual(comparableContent(slotBlocks(stored, 'control')), comparableContent(slotBlocks(stored, slot)))) : [],
|
|
220
|
+
locked: doc._isLocked === true || doc.locked === true,
|
|
221
|
+
...hasLocalizedFields(req.payload.config.collections?.find((c)=>c.slug === collection)?.fields ?? [], req.payload.config) ? {
|
|
222
|
+
localized: true
|
|
223
|
+
} : {}
|
|
224
|
+
});
|
|
225
|
+
}
|
|
91
226
|
}
|
|
92
227
|
if (!result.hasNextPage) break;
|
|
93
228
|
page++;
|
|
@@ -105,3 +240,15 @@ export async function flagPlacements(options, experiment, req) {
|
|
|
105
240
|
req
|
|
106
241
|
});
|
|
107
242
|
}
|
|
243
|
+
/** A warning-only comparison must never turn an adapter failure into a Start refusal. */ async function comparisonContent(req, collection, id) {
|
|
244
|
+
try {
|
|
245
|
+
return await pageVersionContent(req, collection, id);
|
|
246
|
+
} catch (cause) {
|
|
247
|
+
req.payload.logger.error({
|
|
248
|
+
err: safeError(cause),
|
|
249
|
+
collection,
|
|
250
|
+
id
|
|
251
|
+
}, 'Experiment version comparison failed');
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -17,10 +17,10 @@ export declare function loadStartInputs<TUser extends {
|
|
|
17
17
|
* Start's locked recheck. Raw SQL on the request's transaction when there is one. */
|
|
18
18
|
export declare function loadPageTestsInUse(req: PayloadRequest): Promise<NonNullable<CheckStartInput['pageTestsInUse']>>;
|
|
19
19
|
/** Page targets no registered collection reports as published. A lookup that throws counts as
|
|
20
|
-
* unpublished (logged). Start calls it again under its page-path locks on a detached request. */
|
|
20
|
+
* unpublished (logged) unless propagateErrors is requested by decide. Start calls it again under its page-path locks on a detached request. */
|
|
21
21
|
export declare function unpublishedPageTargets<TUser extends {
|
|
22
22
|
id: DocId;
|
|
23
|
-
}>(req: PayloadRequest, options: ResolvedOptions<TUser>, experiment: Pick<StartExperiment, 'id' | 'arms'
|
|
23
|
+
}>(req: PayloadRequest, options: ResolvedOptions<TUser>, experiment: Pick<StartExperiment, 'id' | 'arms' | 'pageCollection'>, propagateErrors?: boolean): Promise<string[]>;
|
|
24
24
|
/** Shared, request-scoped reads for summary; a failed row is null in tolerant mode.
|
|
25
25
|
* Preflight/Start use the same builder and retain their throwing read behaviour.
|
|
26
26
|
*/
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
import { goalContent, OUTSIDE_GOAL } from '../goals/fairness.js';
|
|
2
|
+
import { placementInteractions, placementPaths, uniquePlacementDocs } from './placementInteractions.js';
|
|
1
3
|
import { Forbidden, NotFound } from 'payload';
|
|
4
|
+
import { normalisePath } from '../runtime/normalisePath.js';
|
|
5
|
+
import { resolvePageCollection } from './pageCollection.js';
|
|
6
|
+
import { identicalPageVersions } from './pageVersions.js';
|
|
2
7
|
import { safeError } from '../log.js';
|
|
3
8
|
import { powerMetricWeeks } from '@delmaredigital/experiments-stats';
|
|
4
9
|
import { checkStart, relationshipId } from './checkStart.js';
|
|
@@ -35,13 +40,18 @@ const failureDetail = (cause)=>{
|
|
|
35
40
|
return routedPageTests(await req.transactionID != null ? txExecutor(req) : pooledExecutor(req.payload));
|
|
36
41
|
}
|
|
37
42
|
/** Page targets no registered collection reports as published. A lookup that throws counts as
|
|
38
|
-
* unpublished (logged). Start calls it again under its page-path locks on a detached request. */ export async function unpublishedPageTargets(req, options, experiment) {
|
|
43
|
+
* unpublished (logged) unless propagateErrors is requested by decide. Start calls it again under its page-path locks on a detached request. */ export async function unpublishedPageTargets(req, options, experiment, propagateErrors = false) {
|
|
44
|
+
const resolved = await resolvePageCollection(req, options, experiment, propagateErrors);
|
|
45
|
+
const collections = resolved ? [
|
|
46
|
+
resolved
|
|
47
|
+
] : [];
|
|
39
48
|
const targets = await Promise.all(experiment.arms.filter((arm)=>arm.kind !== 'holdout' && arm.pageTarget?.trim()).map(async (arm)=>{
|
|
40
|
-
for (const collection of
|
|
49
|
+
for (const collection of collections){
|
|
41
50
|
try {
|
|
42
51
|
const page = await collection.findByPath(arm.pageTarget, req);
|
|
43
52
|
if (page?.published === true) return null;
|
|
44
53
|
} catch (cause) {
|
|
54
|
+
if (propagateErrors) throw cause;
|
|
45
55
|
req.payload.logger.error({
|
|
46
56
|
err: safeError(cause),
|
|
47
57
|
experimentId: experiment.id,
|
|
@@ -114,13 +124,19 @@ const metricIds = (experiment)=>[
|
|
|
114
124
|
pageTestsInUse ??= loadPageTestsInUse(req);
|
|
115
125
|
input.pageTestsInUse = await pageTestsInUse;
|
|
116
126
|
input.pageTargetsUnchecked = options.pages === undefined;
|
|
117
|
-
|
|
127
|
+
input.unknownPageCollection = Boolean(experiment.pageCollection && !options.pages?.collections.some((entry)=>entry.slug === experiment.pageCollection));
|
|
128
|
+
if (options.pages) {
|
|
129
|
+
input.unpublishedPageTargets = await unpublishedPageTargets(req, options, experiment);
|
|
130
|
+
if (options.pages.collections.some((entry)=>entry.contentOf)) input.identicalVersions = await identicalPageVersions(req, await resolvePageCollection(req, options, experiment), experiment.arms);
|
|
131
|
+
}
|
|
118
132
|
}
|
|
119
133
|
if (experiment.kind === 'block') {
|
|
120
134
|
try {
|
|
121
135
|
blocks ??= scanBlockPlacements(req, {
|
|
122
136
|
slugs: blockSlugs,
|
|
123
|
-
|
|
137
|
+
compareVersions: true,
|
|
138
|
+
puckCollections: options.puck?.collections ?? [],
|
|
139
|
+
blocksCollections: options.blocks?.collections ?? []
|
|
124
140
|
});
|
|
125
141
|
input.placements = (await blocks).get(experiment.slug) ?? [];
|
|
126
142
|
} catch (cause) {
|
|
@@ -250,6 +266,13 @@ const metricIds = (experiment)=>[
|
|
|
250
266
|
})());
|
|
251
267
|
input.layer = await layers.get(key);
|
|
252
268
|
}
|
|
269
|
+
if ((experiment.kind === 'page' || experiment.kind === 'block') && options.pages?.collections.length) {
|
|
270
|
+
const paths = experiment.kind === 'page' ? experiment.arms.flatMap((a)=>typeof a.pageTarget === 'string' ? [
|
|
271
|
+
a.pageTarget
|
|
272
|
+
] : []) : await placementPaths(req, options, uniquePlacementDocs(input.placements ?? []));
|
|
273
|
+
input.interactions = await placementInteractions(req, await req.transactionID != null ? txExecutor(req) : pooledExecutor(req.payload), options, experiment, paths.map(normalisePath));
|
|
274
|
+
}
|
|
275
|
+
if (input.metrics.some((metric)=>metric.source.startsWith('click:'))) input.goalContent = await goalContent(req, options, experiment, input.placements ? uniquePlacementDocs(input.placements.filter((placement)=>placement.published)) : undefined);
|
|
253
276
|
inputs.set(String(experiment.id), input);
|
|
254
277
|
} catch (cause) {
|
|
255
278
|
if (!tolerateErrors) throw cause;
|
|
@@ -279,7 +302,9 @@ async function placementUrls(options, placement, pendingPreviews, onFailure, pre
|
|
|
279
302
|
const url = options.admin?.editorUrl?.({
|
|
280
303
|
collection,
|
|
281
304
|
id,
|
|
282
|
-
draft: !published
|
|
305
|
+
draft: !published,
|
|
306
|
+
editor: placement.editor ?? 'puck',
|
|
307
|
+
doc: placement.doc
|
|
283
308
|
});
|
|
284
309
|
if (typeof url === 'string') result.editorUrl = url;
|
|
285
310
|
} catch {}
|
|
@@ -476,9 +501,116 @@ export async function preflightData(req, { experimentId, options }, { now = new
|
|
|
476
501
|
detail: placement.detail
|
|
477
502
|
} : {}
|
|
478
503
|
});
|
|
504
|
+
const pagePlacements = [];
|
|
505
|
+
if (e.kind === 'page') {
|
|
506
|
+
const collection = await resolvePageCollection(req, options, e);
|
|
507
|
+
for (const arm of e.arms.filter((arm)=>arm.kind !== 'holdout')){
|
|
508
|
+
const path = arm.pageTarget ?? '';
|
|
509
|
+
let page = null;
|
|
510
|
+
try {
|
|
511
|
+
page = path && collection ? await collection.findByPath(path, req) : null;
|
|
512
|
+
} catch (cause) {
|
|
513
|
+
onPreviewFailure(cause, {
|
|
514
|
+
collection: collection.slug,
|
|
515
|
+
id: path,
|
|
516
|
+
title: path,
|
|
517
|
+
published: false,
|
|
518
|
+
slots: {
|
|
519
|
+
control: 0,
|
|
520
|
+
variantA: 0,
|
|
521
|
+
variantB: 0,
|
|
522
|
+
variantC: 0
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
if (!page || !collection) {
|
|
527
|
+
pagePlacements.push({
|
|
528
|
+
arm: arm.key,
|
|
529
|
+
path,
|
|
530
|
+
published: false,
|
|
531
|
+
id: null,
|
|
532
|
+
...collection ? {
|
|
533
|
+
collection: collection.slug
|
|
534
|
+
} : {}
|
|
535
|
+
});
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
let doc;
|
|
539
|
+
try {
|
|
540
|
+
doc = await req.payload.findByID({
|
|
541
|
+
collection: collection.slug,
|
|
542
|
+
id: page.id,
|
|
543
|
+
req,
|
|
544
|
+
overrideAccess: false,
|
|
545
|
+
depth: 0,
|
|
546
|
+
draft: !page.published
|
|
547
|
+
});
|
|
548
|
+
} catch (cause) {
|
|
549
|
+
onPreviewFailure(cause, {
|
|
550
|
+
collection: collection.slug,
|
|
551
|
+
id: page.id,
|
|
552
|
+
title: path,
|
|
553
|
+
published: false,
|
|
554
|
+
slots: {
|
|
555
|
+
control: 0,
|
|
556
|
+
variantA: 0,
|
|
557
|
+
variantB: 0,
|
|
558
|
+
variantC: 0
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
pagePlacements.push({
|
|
562
|
+
arm: arm.key,
|
|
563
|
+
path,
|
|
564
|
+
collection: collection.slug,
|
|
565
|
+
published: false,
|
|
566
|
+
id: null
|
|
567
|
+
});
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
const titleField = req.payload.config.collections?.find((entry)=>entry.slug === collection.slug)?.admin.useAsTitle ?? 'title';
|
|
571
|
+
const placement = {
|
|
572
|
+
collection: collection.slug,
|
|
573
|
+
id: page.id,
|
|
574
|
+
title: String(doc[titleField] ?? doc.id),
|
|
575
|
+
published: page.published,
|
|
576
|
+
...page.published && typeof doc.slug === 'string' ? {
|
|
577
|
+
slug: doc.slug
|
|
578
|
+
} : {},
|
|
579
|
+
slots: {
|
|
580
|
+
control: 0,
|
|
581
|
+
variantA: 0,
|
|
582
|
+
variantB: 0,
|
|
583
|
+
variantC: 0
|
|
584
|
+
},
|
|
585
|
+
editor: 'page',
|
|
586
|
+
doc
|
|
587
|
+
};
|
|
588
|
+
pagePlacements.push({
|
|
589
|
+
arm: arm.key,
|
|
590
|
+
path,
|
|
591
|
+
collection: collection.slug,
|
|
592
|
+
id: page.id,
|
|
593
|
+
title: placement.title,
|
|
594
|
+
published: placement.published,
|
|
595
|
+
...placement.slug ? {
|
|
596
|
+
slug: placement.slug
|
|
597
|
+
} : {},
|
|
598
|
+
...await placementUrls(options, placement, pendingPreviews, onPreviewFailure, previews)
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
}
|
|
479
602
|
return {
|
|
480
603
|
checks: checkStart(input),
|
|
604
|
+
...input.goalContent ? {
|
|
605
|
+
goalNotes: input.metrics.filter((m)=>m.source.startsWith('click:') && input.goalContent && !input.goalContent.skipped && !input.goalContent.unchecked.length && !input.goalContent.keys.some((k)=>`click:${k.key}` === m.source)).map((m)=>({
|
|
606
|
+
metricId: m.id,
|
|
607
|
+
message: OUTSIDE_GOAL
|
|
608
|
+
}))
|
|
609
|
+
} : {},
|
|
481
610
|
placements,
|
|
611
|
+
...e.kind === 'page' ? {
|
|
612
|
+
pagePlacements
|
|
613
|
+
} : {},
|
|
482
614
|
baseline,
|
|
483
615
|
trafficPerDay,
|
|
484
616
|
estimate: power
|
|
@@ -2,6 +2,7 @@ import type { DocId, MetricSource } from '../types/contracts.js';
|
|
|
2
2
|
export interface MetricDefinition extends Record<string, unknown> {
|
|
3
3
|
id: DocId;
|
|
4
4
|
source: string;
|
|
5
|
+
direction?: 'increase' | 'decrease';
|
|
5
6
|
attributionWindowHours?: number | null;
|
|
6
7
|
minSampleSize?: number | null;
|
|
7
8
|
guardrailThreshold?: number | null;
|
|
@@ -8,7 +8,7 @@ import { perUnitHistory } from '../results/history.js';
|
|
|
8
8
|
const from = new Date(start.getTime() - 90 * 86400000);
|
|
9
9
|
for (const metric of metrics){
|
|
10
10
|
const source = sources.find((source)=>source.id === metric.source);
|
|
11
|
-
const kind = metric.source.startsWith('events:') ? 'binomial' : source?.kind;
|
|
11
|
+
const kind = metric.source.startsWith('events:') || metric.source.startsWith('click:') ? 'binomial' : source?.kind;
|
|
12
12
|
const definition = {
|
|
13
13
|
...structuredClone(metric),
|
|
14
14
|
...source ? {
|
|
@@ -19,11 +19,15 @@ import { perUnitHistory } from '../results/history.js';
|
|
|
19
19
|
historyUnits: undefined,
|
|
20
20
|
capNotice: undefined
|
|
21
21
|
};
|
|
22
|
+
if (metric.source.startsWith('click:')) {
|
|
23
|
+
definition.direction = 'increase';
|
|
24
|
+
definition.capping = null;
|
|
25
|
+
}
|
|
22
26
|
const cap = metric.capping?.type ? metric.capping : kind === 'revenue' ? {
|
|
23
27
|
type: 'percentile',
|
|
24
28
|
value: 0.99
|
|
25
29
|
} : null;
|
|
26
|
-
if (kind === 'revenue' || !source && cap && !metric.source.startsWith('events:')) {
|
|
30
|
+
if (kind === 'revenue' || !source && cap && !(metric.source.startsWith('events:') || metric.source.startsWith('click:'))) {
|
|
27
31
|
definition.capping = cap;
|
|
28
32
|
if (cap?.type === 'absolute' && typeof cap.value === 'number' && Number.isFinite(cap.value) && cap.value >= 0) {
|
|
29
33
|
definition.resolvedCap = cap.value;
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import { goalContent } from '../goals/fairness.js';
|
|
2
|
+
import { detached } from './placementInteractions.js';
|
|
3
|
+
import { lockAndRescanPlacements, placementInteractions } from './placementInteractions.js';
|
|
1
4
|
import { lockPagePaths } from '../db/locks.js';
|
|
2
5
|
import { lockMetricsForFreeze } from '../db/metricUsage.js';
|
|
3
6
|
import { DEFAULT_SEQUENTIAL_TUNING_PARAMETER } from '../constants.js';
|
|
4
7
|
import { claimSlices } from '../db/slices.js';
|
|
5
|
-
import { pageInUseCopy, unpublishedPageCopy } from '../admin/copy/readiness.js';
|
|
8
|
+
import { READINESS_COPY, asymmetricGoalsCopy, sameAddressCopy, interactingTestsCopy, pageInUseCopy, unpublishedPageCopy } from '../admin/copy/readiness.js';
|
|
9
|
+
import { GROUP_LABELS } from '../admin/copy/arms.js';
|
|
6
10
|
import { XpRefusal } from '../errors.js';
|
|
7
11
|
import { isOpValid } from '../targeting/ops.js';
|
|
8
12
|
import { newSeed } from '../runtime/seed.js';
|
|
@@ -31,7 +35,20 @@ export async function readExperiment(req, id) {
|
|
|
31
35
|
}
|
|
32
36
|
export function refuse(code, detail) {
|
|
33
37
|
const messages = {
|
|
38
|
+
'goal-in-some-versions': asymmetricGoalsCopy(detail),
|
|
39
|
+
'placements-changed': "This test's pages changed while it was starting. Try again.",
|
|
40
|
+
'page-busy': "One of this test's pages is being saved right now. Try again in a moment.",
|
|
41
|
+
'interacting-tests': (()=>{
|
|
42
|
+
const d = detail?.[0];
|
|
43
|
+
return d ? interactingTestsCopy(d.path, d.status, d.name, d.version) : 'Finish one of the tests first.';
|
|
44
|
+
})(),
|
|
45
|
+
'unknown-collection': "That page type isn't set up for tests.",
|
|
46
|
+
'placement-removed': READINESS_COPY.placementRemoved,
|
|
34
47
|
'page-in-use': pageInUseCopy(detail?.paths?.[0] ?? 'this page'),
|
|
48
|
+
'version-same-address': (()=>{
|
|
49
|
+
const d = detail;
|
|
50
|
+
return sameAddressCopy(GROUP_LABELS[d?.arm ?? 'variantA'], GROUP_LABELS[d?.otherArm ?? 'control'], d?.path ?? 'this address');
|
|
51
|
+
})(),
|
|
35
52
|
'page-target-unpublished': unpublishedPageCopy(typeof detail === 'string' ? detail : 'This page'),
|
|
36
53
|
'invalid-status': 'This action is not available in the current status.',
|
|
37
54
|
'stale-draft': 'The experiment changed. Review it again before starting.',
|
|
@@ -46,6 +63,9 @@ export function refuse(code, detail) {
|
|
|
46
63
|
'entry-not-dismissed': 'That page is not dismissed.'
|
|
47
64
|
};
|
|
48
65
|
const conflict = [
|
|
66
|
+
'page-busy',
|
|
67
|
+
'placements-changed',
|
|
68
|
+
'interacting-tests',
|
|
49
69
|
'invalid-status',
|
|
50
70
|
'stale-draft',
|
|
51
71
|
'page-in-use',
|
|
@@ -110,6 +130,17 @@ export async function start(req, { experimentId, options }) {
|
|
|
110
130
|
const current = await readExperiment(req, experimentId);
|
|
111
131
|
if (current.updatedAt !== updatedAt) refuse('stale-draft');
|
|
112
132
|
if (current.status !== 'draft' && current.status !== 'stopped') refuse('invalid-status');
|
|
133
|
+
let placementDocs;
|
|
134
|
+
if (current.kind === 'block') {
|
|
135
|
+
const rescan = await lockAndRescanPlacements(req, exec, options, current, input.placements ?? []);
|
|
136
|
+
placementDocs = rescan.docs;
|
|
137
|
+
const failure = checkStart({
|
|
138
|
+
...input,
|
|
139
|
+
experiment: current,
|
|
140
|
+
placements: rescan.placements
|
|
141
|
+
}).find((check)=>!check.ok);
|
|
142
|
+
if (failure && failure.code !== 'page-targets-unchecked' && failure.code !== 'make-content-unsupported') refuse(failure.code, failure.detail);
|
|
143
|
+
}
|
|
113
144
|
if (current.kind === 'page') {
|
|
114
145
|
// Independent experiment/layer locks do not serialize overlapping pages.
|
|
115
146
|
// Re-read occupancy after acquiring sorted path locks; hold through commit.
|
|
@@ -121,6 +152,8 @@ export async function start(req, { experimentId, options }) {
|
|
|
121
152
|
pageTestsInUse: await loadPageTestsInUse(req)
|
|
122
153
|
}).find((check)=>check.code === 'page-in-use' && !check.ok);
|
|
123
154
|
if (recheck) refuse('page-in-use', recheck.detail);
|
|
155
|
+
const conflicts = await placementInteractions(req, exec, options, current, pageTargetPaths(current.arms));
|
|
156
|
+
if (conflicts.length) refuse('interacting-tests', conflicts);
|
|
124
157
|
if (options.pages) {
|
|
125
158
|
// The page guard takes the same path locks, so a delete, unpublish or rename that committed
|
|
126
159
|
// while Start waited is visible now. Read it on a detached request (committed state, outside
|
|
@@ -181,6 +214,15 @@ export async function start(req, { experimentId, options }) {
|
|
|
181
214
|
}).find((check)=>!check.ok);
|
|
182
215
|
if (recheck && recheck.code !== 'page-targets-unchecked' && recheck.code !== 'make-content-unsupported') refuse(recheck.code, recheck.detail);
|
|
183
216
|
}
|
|
217
|
+
if (input.metrics.some((metric)=>metric.source.startsWith('click:'))) {
|
|
218
|
+
const content = await detached(req, ()=>goalContent(req, options, current, placementDocs));
|
|
219
|
+
const failure = checkStart({
|
|
220
|
+
...input,
|
|
221
|
+
experiment: current,
|
|
222
|
+
goalContent: content
|
|
223
|
+
}).find((check)=>check.code === 'goal-in-some-versions' && !check.ok);
|
|
224
|
+
if (failure) refuse('goal-in-some-versions', failure.detail);
|
|
225
|
+
}
|
|
184
226
|
const lockedMetrics = await lockMetricsForFreeze(exec, caps.definitions.map((metric)=>metric.id));
|
|
185
227
|
if (!lockedMetrics) {
|
|
186
228
|
throw new XpRefusal('stale-draft', 'A goal in this test was deleted. Review the goals before starting.', undefined, 409);
|
|
@@ -233,6 +275,9 @@ export async function start(req, { experimentId, options }) {
|
|
|
233
275
|
allocationChanges: [],
|
|
234
276
|
targeting,
|
|
235
277
|
metricDefinitions: definitions,
|
|
278
|
+
...placementDocs ? {
|
|
279
|
+
placementDocs
|
|
280
|
+
} : {},
|
|
236
281
|
analysis: {
|
|
237
282
|
...current.analysis,
|
|
238
283
|
method: current.analysis?.method ?? 'sequential',
|
package/dist/types/admin.d.ts
CHANGED
|
@@ -18,4 +18,4 @@ export type SerializedAttributeKeys = Record<string, {
|
|
|
18
18
|
type: 'string' | 'number' | 'boolean';
|
|
19
19
|
values?: string[];
|
|
20
20
|
}>;
|
|
21
|
-
export type EditableField = 'name' | 'hypothesis' | 'arms' | 'targeting' | 'allocation' | 'flagPoint' | 'metrics' | 'analysis' | 'exposurePolicy';
|
|
21
|
+
export type EditableField = 'name' | 'hypothesis' | 'arms' | 'targeting' | 'allocation' | 'flagPoint' | 'metrics' | 'analysis' | 'exposurePolicy' | 'pageCollection';
|
|
@@ -103,6 +103,10 @@ export type ExperimentsAccess = (args: {
|
|
|
103
103
|
}) => boolean | Promise<boolean>;
|
|
104
104
|
export interface ExperimentsPageCollection {
|
|
105
105
|
slug: string;
|
|
106
|
+
contentOf?: (doc: Record<string, unknown>) => unknown;
|
|
107
|
+
copyHint?: string;
|
|
108
|
+
copyUrl?: string;
|
|
109
|
+
copyLabel?: string;
|
|
106
110
|
pathOf: (doc: Record<string, unknown>) => string | null;
|
|
107
111
|
/** Lookup with the supplied req and overrideAccess: false. Under an anonymous req (results checks) only published pages are visible, so a hit is { published: true } or null. Under a req that can read drafts (Start), a draft-only page may be reported as { published: false }. */
|
|
108
112
|
findByPath: (path: string, req: PayloadRequest) => Promise<{
|
|
@@ -130,9 +134,12 @@ export interface ExperimentsPluginOptions<TUser extends {
|
|
|
130
134
|
collection: string;
|
|
131
135
|
id: DocId;
|
|
132
136
|
draft?: boolean;
|
|
137
|
+
editor: 'puck' | 'blocks' | 'page';
|
|
138
|
+
doc?: Record<string, unknown>;
|
|
133
139
|
}) => string;
|
|
134
140
|
previewUrl?: (placement: PreviewPlacement) => string | null | undefined | Promise<string | null | undefined>;
|
|
135
141
|
descriptions?: {
|
|
142
|
+
page?: string;
|
|
136
143
|
block?: string;
|
|
137
144
|
document?: string;
|
|
138
145
|
setting?: string;
|
|
@@ -151,6 +158,14 @@ export interface ExperimentsPluginOptions<TUser extends {
|
|
|
151
158
|
pages?: {
|
|
152
159
|
collections: ExperimentsPageCollection[];
|
|
153
160
|
};
|
|
161
|
+
blocks?: {
|
|
162
|
+
collections: Array<{
|
|
163
|
+
slug: string;
|
|
164
|
+
field: string;
|
|
165
|
+
blockSlug?: string;
|
|
166
|
+
appliesTo?: (doc: Record<string, unknown>) => boolean;
|
|
167
|
+
}>;
|
|
168
|
+
};
|
|
154
169
|
puck?: {
|
|
155
170
|
enabled: boolean;
|
|
156
171
|
collections: Array<string | {
|