@delmaredigital/payload-experiments 0.1.0-rc.7 → 0.2.0-rc.1
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 +28 -0
- package/dist/admin/LayerUsage.d.ts +1 -1
- package/dist/admin/LayerUsage.js +23 -11
- package/dist/admin/MetricSourceSelect.js +2 -1
- package/dist/admin/TestsNavLink.d.ts +1 -0
- package/dist/admin/TestsNavLink.js +30 -0
- package/dist/admin/allocation.model.js +1 -1
- package/dist/admin/copy/actions.d.ts +0 -2
- package/dist/admin/copy/actions.js +0 -2
- package/dist/admin/copy/advanced.d.ts +55 -0
- package/dist/admin/copy/advanced.js +55 -0
- package/dist/admin/copy/armSelect.js +1 -1
- package/dist/admin/copy/arms.d.ts +0 -13
- package/dist/admin/copy/arms.js +0 -13
- package/dist/admin/copy/{who.d.ts → audience.d.ts} +1 -9
- package/dist/admin/copy/{who.js → audience.js} +1 -9
- package/dist/admin/copy/history.d.ts +0 -4
- package/dist/admin/copy/history.js +3 -7
- package/dist/admin/copy/metrics.d.ts +2 -2
- package/dist/admin/copy/metrics.js +2 -2
- package/dist/admin/copy/newTest.d.ts +32 -0
- package/dist/admin/copy/newTest.js +32 -0
- package/dist/admin/copy/readiness.d.ts +45 -0
- package/dist/admin/copy/readiness.js +50 -0
- package/dist/admin/copy/recommendation.d.ts +26 -0
- package/dist/admin/copy/recommendation.js +25 -0
- package/dist/admin/copy/results.d.ts +0 -1
- package/dist/admin/copy/results.js +0 -1
- package/dist/admin/copy/review.d.ts +0 -17
- package/dist/admin/copy/review.js +1 -112
- package/dist/admin/copy/setting.d.ts +10 -0
- package/dist/admin/copy/setting.js +10 -0
- package/dist/admin/copy/surface.d.ts +36 -0
- package/dist/admin/copy/surface.js +47 -0
- package/dist/admin/copy/testPage.d.ts +88 -0
- package/dist/admin/copy/testPage.js +88 -0
- package/dist/admin/paths.d.ts +5 -13
- package/dist/admin/paths.js +5 -13
- package/dist/admin/review/review.model.d.ts +3 -26
- package/dist/admin/review/review.model.js +3 -30
- package/dist/admin/surface/ResultsRoute.d.ts +7 -0
- package/dist/admin/surface/ResultsRoute.js +58 -0
- package/dist/admin/surface/SurfaceShell.d.ts +6 -0
- package/dist/admin/surface/SurfaceShell.js +35 -0
- package/dist/admin/surface/TestsHome.d.ts +4 -0
- package/dist/admin/surface/TestsHome.js +212 -0
- package/dist/admin/surface/TestsSurface.d.ts +2 -0
- package/dist/admin/surface/TestsSurface.js +36 -0
- package/dist/admin/surface/contracts.d.ts +48 -0
- package/dist/admin/surface/contracts.js +8 -0
- package/dist/admin/surface/home.model.d.ts +41 -0
- package/dist/admin/surface/home.model.js +59 -0
- package/dist/admin/surface/newTest/NewTest.d.ts +7 -0
- package/dist/admin/surface/newTest/NewTest.js +355 -0
- package/dist/admin/surface/newTest/newTest.css +9 -0
- package/dist/admin/surface/pageBlock/pageBlock.model.d.ts +6 -0
- package/dist/admin/surface/pageBlock/pageBlock.model.js +8 -0
- package/dist/admin/surface/pageBlock/parts.d.ts +2 -0
- package/dist/admin/surface/pageBlock/parts.js +64 -0
- package/dist/admin/surface/readiness/ActionDialogs.d.ts +15 -0
- package/dist/admin/surface/readiness/ActionDialogs.js +274 -0
- package/dist/admin/surface/readiness/Banner.d.ts +2 -0
- package/dist/admin/surface/readiness/Banner.js +138 -0
- package/dist/admin/surface/readiness/SaveStatus.d.ts +4 -0
- package/dist/admin/surface/readiness/SaveStatus.js +50 -0
- package/dist/admin/surface/readiness/parts.d.ts +2 -0
- package/dist/admin/surface/readiness/parts.js +9 -0
- package/dist/admin/surface/readiness/readiness.model.d.ts +22 -0
- package/dist/admin/surface/readiness/readiness.model.js +107 -0
- package/dist/admin/surface/readiness/useTransition.d.ts +10 -0
- package/dist/admin/surface/readiness/useTransition.js +34 -0
- package/dist/admin/surface/results/ResultsBody.d.ts +7 -0
- package/dist/admin/surface/results/ResultsBody.js +262 -0
- package/dist/admin/surface/results/recommendation.model.d.ts +12 -0
- package/dist/admin/surface/results/recommendation.model.js +59 -0
- package/dist/admin/surface/results/useResults.d.ts +56 -0
- package/dist/admin/surface/results/useResults.js +183 -0
- package/dist/admin/surface/setting/parts.d.ts +3 -0
- package/dist/admin/surface/setting/parts.js +263 -0
- package/dist/admin/surface/setting/setting.css +29 -0
- package/dist/admin/surface/setting/setting.model.d.ts +8 -0
- package/dist/admin/surface/setting/setting.model.js +25 -0
- package/dist/admin/surface/surface.css +50 -0
- package/dist/admin/surface/test/AdvancedSection.d.ts +2 -0
- package/dist/admin/surface/test/AdvancedSection.js +153 -0
- package/dist/admin/surface/test/GoalSection.d.ts +2 -0
- package/dist/admin/surface/test/GoalSection.js +198 -0
- package/dist/admin/surface/test/LengthSection.d.ts +2 -0
- package/dist/admin/surface/test/LengthSection.js +87 -0
- package/dist/admin/surface/test/Section.d.ts +7 -0
- package/dist/admin/surface/test/Section.js +27 -0
- package/dist/admin/surface/test/TargetingSection.d.ts +2 -0
- package/dist/admin/surface/test/TargetingSection.js +164 -0
- package/dist/admin/surface/test/TestHeader.d.ts +4 -0
- package/dist/admin/surface/test/TestHeader.js +69 -0
- package/dist/admin/surface/test/TestPage.d.ts +7 -0
- package/dist/admin/surface/test/TestPage.js +84 -0
- package/dist/admin/surface/test/VersionsSection.d.ts +4 -0
- package/dist/admin/surface/test/VersionsSection.js +208 -0
- package/dist/admin/surface/test/fields.d.ts +55 -0
- package/dist/admin/surface/test/fields.js +185 -0
- package/dist/admin/surface/test/pageParts.d.ts +2 -0
- package/dist/admin/surface/test/pageParts.js +39 -0
- package/dist/admin/surface/test/testPage.css +23 -0
- package/dist/admin/surface/test/testPage.model.d.ts +17 -0
- package/dist/admin/surface/test/testPage.model.js +53 -0
- package/dist/admin/surface/testDocument.model.d.ts +39 -0
- package/dist/admin/surface/testDocument.model.js +61 -0
- package/dist/admin/surface/useTestDocument.d.ts +22 -0
- package/dist/admin/surface/useTestDocument.js +345 -0
- package/dist/admin/targeting/targeting.model.js +2 -2
- package/dist/admin/useLayerUsage.d.ts +0 -5
- package/dist/admin/useLayerUsage.js +1 -39
- package/dist/admin/views/CollectionEditRedirect.d.ts +4 -0
- package/dist/admin/views/CollectionEditRedirect.js +14 -0
- package/dist/admin/views/CollectionListRedirect.d.ts +2 -0
- package/dist/admin/views/CollectionListRedirect.js +8 -0
- package/dist/admin/views/ExperimentsRootView.d.ts +2 -0
- package/dist/admin/views/ExperimentsRootView.js +175 -0
- package/dist/admin/views/access.model.d.ts +7 -0
- package/dist/admin/views/access.model.js +5 -0
- package/dist/collections/experiments/config.js +18 -125
- package/dist/collections/experiments/createDefaults.js +6 -0
- package/dist/collections/layers.js +43 -12
- package/dist/collections/metrics.js +70 -14
- package/dist/collections/serialize.js +4 -1
- package/dist/db/snapshots.d.ts +2 -1
- package/dist/db/snapshots.js +2 -2
- package/dist/endpoints/results.js +7 -0
- package/dist/endpoints/summary.d.ts +1 -1
- package/dist/endpoints/summary.js +37 -1
- package/dist/exports/adminClient.d.ts +2 -13
- package/dist/exports/adminClient.js +2 -13
- package/dist/exports/index.d.ts +1 -1
- package/dist/exports/rsc.d.ts +3 -0
- package/dist/exports/rsc.js +3 -0
- package/dist/options.js +29 -0
- package/dist/plugin.js +25 -0
- package/dist/puck/editor/CreateExperimentDrawer.d.ts +2 -1
- package/dist/puck/editor/CreateExperimentDrawer.js +17 -57
- package/dist/puck/editor/ExperimentSlotEditor.js +6 -0
- package/dist/puck/editor/copy.d.ts +10 -9
- package/dist/puck/editor/copy.js +11 -10
- package/dist/puck/editor/createTest.d.ts +7 -0
- package/dist/puck/editor/createTest.js +50 -0
- package/dist/puck/editor/editor.model.d.ts +28 -1
- package/dist/puck/editor/editor.model.js +61 -0
- package/dist/results/analyse.js +4 -1
- package/dist/results/verdict.d.ts +1 -0
- package/dist/results/verdict.js +7 -4
- package/dist/transitions/checkStart.d.ts +1 -0
- package/dist/transitions/placements.d.ts +5 -1
- package/dist/transitions/placements.js +14 -7
- package/dist/transitions/preflightData.d.ts +7 -1
- package/dist/transitions/preflightData.js +199 -148
- package/dist/transitions/start.js +2 -2
- package/dist/types/admin.d.ts +1 -0
- package/dist/types/contracts.d.ts +17 -0
- package/dist/types/endpoints.d.ts +11 -1
- package/dist/types/snapshot.d.ts +1 -0
- package/dist/types/surface.d.ts +29 -0
- package/dist/types/surface.js +1 -0
- package/package.json +1 -1
- package/dist/access.d.ts +0 -1
- package/dist/access.js +0 -2
- package/dist/admin/AllocationField.d.ts +0 -2
- package/dist/admin/AllocationField.js +0 -80
- package/dist/admin/FlagPointSelect.d.ts +0 -2
- package/dist/admin/FlagPointSelect.js +0 -54
- package/dist/admin/LockedFieldNotice.d.ts +0 -2
- package/dist/admin/LockedFieldNotice.js +0 -26
- package/dist/admin/PhaseHistory.d.ts +0 -2
- package/dist/admin/PhaseHistory.js +0 -76
- package/dist/admin/StatusCell.d.ts +0 -3
- package/dist/admin/StatusCell.js +0 -19
- package/dist/admin/SummaryCell.d.ts +0 -2
- package/dist/admin/SummaryCell.js +0 -32
- package/dist/admin/actions/AddMetricDialog.d.ts +0 -6
- package/dist/admin/actions/AddMetricDialog.js +0 -138
- package/dist/admin/actions/AllocateDialog.d.ts +0 -6
- package/dist/admin/actions/AllocateDialog.js +0 -81
- package/dist/admin/actions/ExperimentActions.d.ts +0 -3
- package/dist/admin/actions/ExperimentActions.js +0 -110
- package/dist/admin/arms/ArmsField.d.ts +0 -2
- package/dist/admin/arms/ArmsField.js +0 -310
- package/dist/admin/arms/ParamsEditor.d.ts +0 -16
- package/dist/admin/arms/ParamsEditor.js +0 -79
- package/dist/admin/copy/tabs.d.ts +0 -7
- package/dist/admin/copy/tabs.js +0 -7
- package/dist/admin/permissions.model.d.ts +0 -10
- package/dist/admin/permissions.model.js +0 -4
- package/dist/admin/phaseHistory.model.d.ts +0 -19
- package/dist/admin/phaseHistory.model.js +0 -19
- package/dist/admin/results/ResultsView.d.ts +0 -3
- package/dist/admin/results/ResultsView.js +0 -312
- package/dist/admin/review/ReviewPanel.d.ts +0 -2
- package/dist/admin/review/ReviewPanel.js +0 -268
- package/dist/admin/summaryBatcher.d.ts +0 -8
- package/dist/admin/summaryBatcher.js +0 -33
- package/dist/admin/targeting/TargetingAttributeField.d.ts +0 -2
- package/dist/admin/targeting/TargetingAttributeField.js +0 -31
- package/dist/admin/targeting/TargetingOpField.d.ts +0 -2
- package/dist/admin/targeting/TargetingOpField.js +0 -45
- package/dist/admin/targeting/TargetingValueField.d.ts +0 -2
- package/dist/admin/targeting/TargetingValueField.js +0 -142
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -4
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { EDITABLE_BY_STATUS, isEditable } from '../../collections/experiments/editability.js';
|
|
2
|
+
import { lockedReason } from '../lockedNotice.model.js';
|
|
3
|
+
const EDITABLE_FIELDS = [
|
|
4
|
+
'name',
|
|
5
|
+
'hypothesis',
|
|
6
|
+
'arms',
|
|
7
|
+
'targeting',
|
|
8
|
+
'allocation',
|
|
9
|
+
'flagPoint',
|
|
10
|
+
'metrics',
|
|
11
|
+
'analysis',
|
|
12
|
+
'exposurePolicy'
|
|
13
|
+
];
|
|
14
|
+
/** Drop unknown and locked keys even when the caller is untyped. Groups replace whole groups. */ export function editablePatch(status, partial) {
|
|
15
|
+
return Object.fromEntries(EDITABLE_FIELDS.flatMap((field)=>Object.hasOwn(partial, field) && partial[field] !== undefined && isEditable(status, field) ? [
|
|
16
|
+
[
|
|
17
|
+
field,
|
|
18
|
+
partial[field]
|
|
19
|
+
]
|
|
20
|
+
] : []));
|
|
21
|
+
}
|
|
22
|
+
function canonical(value) {
|
|
23
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
24
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).filter(([, nested])=>nested !== undefined).sort(([a], [b])=>a.localeCompare(b)).map(([key, nested])=>[
|
|
25
|
+
key,
|
|
26
|
+
canonical(nested)
|
|
27
|
+
]));
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
/** Only changed top-level keys go on the wire; arrays and groups remain intact. */ export function changedPatch(saved, staged) {
|
|
31
|
+
return Object.fromEntries(Object.entries(editablePatch(saved.status, staged)).filter(([key, value])=>JSON.stringify(canonical(saved[key])) !== JSON.stringify(canonical(value))));
|
|
32
|
+
}
|
|
33
|
+
const object = (value)=>value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
34
|
+
/** Payload validation details may be nested under errors[0].data or top-level data. */ export function payloadFieldErrors(envelope, section = '_section', fallback = "Couldn't save these changes. Try again.") {
|
|
35
|
+
const root = object(envelope);
|
|
36
|
+
const errors = Array.isArray(root?.errors) ? root.errors : [];
|
|
37
|
+
const first = object(errors[0]);
|
|
38
|
+
const result = {};
|
|
39
|
+
const addDetails = (data)=>{
|
|
40
|
+
const details = object(data)?.errors;
|
|
41
|
+
if (!Array.isArray(details)) return;
|
|
42
|
+
for (const detail of details){
|
|
43
|
+
const error = object(detail);
|
|
44
|
+
if (typeof error?.message !== 'string') continue;
|
|
45
|
+
const path = typeof error.path === 'string' && error.path ? error.path : section;
|
|
46
|
+
result[path] = error.message;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
addDetails(root?.data);
|
|
50
|
+
for (const error of errors)addDetails(object(error)?.data);
|
|
51
|
+
if (Object.keys(result).length === 0) {
|
|
52
|
+
const message = typeof first?.message === 'string' ? first.message : fallback;
|
|
53
|
+
const locked = /^(\S+) is locked in (\w+)$/.exec(message);
|
|
54
|
+
const field = locked?.[1];
|
|
55
|
+
const status = locked?.[2];
|
|
56
|
+
const reason = field && status && Object.hasOwn(EDITABLE_BY_STATUS, status) ? lockedReason(status, field, EDITABLE_BY_STATUS) : null;
|
|
57
|
+
if (field && reason) result[field] = reason;
|
|
58
|
+
else result[section] = message;
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { DocId } from '../../types/contracts.js';
|
|
2
|
+
import type { PreflightResult } from '../../types/endpoints.js';
|
|
3
|
+
import type { TestDocument, TestDocumentPatch } from './testDocument.model.js';
|
|
4
|
+
/** Section data only: staging never saves. Call commit on blur or a section action. */
|
|
5
|
+
export declare function useTestDocument(id: DocId, { apiRoute }: {
|
|
6
|
+
apiRoute: string;
|
|
7
|
+
}): {
|
|
8
|
+
stage: (partial: TestDocumentPatch) => void;
|
|
9
|
+
commit: () => Promise<void>;
|
|
10
|
+
reload: () => Promise<void>;
|
|
11
|
+
refreshPreflight: () => Promise<void>;
|
|
12
|
+
doc: TestDocument | null;
|
|
13
|
+
preflight: PreflightResult | null;
|
|
14
|
+
preflightError: string | null;
|
|
15
|
+
preflightPending: boolean;
|
|
16
|
+
/** Includes staged edits and all queued or in-flight PATCHes. */
|
|
17
|
+
hasUnsaved: boolean;
|
|
18
|
+
loadState: "loading" | "ready" | "forbidden" | "missing" | "error";
|
|
19
|
+
saving: "idle" | "saving" | "saved" | "error";
|
|
20
|
+
/** Path-specific validation errors; _section is the fallback for a section-wide refusal. */
|
|
21
|
+
errors: Record<string, string>;
|
|
22
|
+
};
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { API_MESSAGES, refusalMessage } from '../copy/common.js';
|
|
4
|
+
import { changedPatch, editablePatch, payloadFieldErrors } from './testDocument.model.js';
|
|
5
|
+
// Saves outlive their mounted reader. Reopening the same document must wait for its queue.
|
|
6
|
+
const drainingSaves = new Map();
|
|
7
|
+
const initialView = ()=>({
|
|
8
|
+
doc: null,
|
|
9
|
+
preflight: null,
|
|
10
|
+
preflightError: null,
|
|
11
|
+
preflightPending: false,
|
|
12
|
+
hasUnsaved: false,
|
|
13
|
+
loadState: 'loading',
|
|
14
|
+
saving: 'idle',
|
|
15
|
+
errors: {}
|
|
16
|
+
});
|
|
17
|
+
async function waitForDrain(draining, signal) {
|
|
18
|
+
if (!draining) return;
|
|
19
|
+
let timer;
|
|
20
|
+
let release;
|
|
21
|
+
const bounded = new Promise((resolve)=>{
|
|
22
|
+
release = resolve;
|
|
23
|
+
timer = setTimeout(resolve, 15_000);
|
|
24
|
+
});
|
|
25
|
+
signal.addEventListener('abort', release, {
|
|
26
|
+
once: true
|
|
27
|
+
});
|
|
28
|
+
try {
|
|
29
|
+
await Promise.race([
|
|
30
|
+
draining,
|
|
31
|
+
bounded
|
|
32
|
+
]);
|
|
33
|
+
} finally{
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
signal.removeEventListener('abort', release);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function optimistic(session) {
|
|
39
|
+
if (!session.saved) return null;
|
|
40
|
+
return [
|
|
41
|
+
...session.queue,
|
|
42
|
+
session.pending
|
|
43
|
+
].reduce((doc, patch)=>({
|
|
44
|
+
...doc,
|
|
45
|
+
...editablePatch(doc.status, patch)
|
|
46
|
+
}), session.saved);
|
|
47
|
+
}
|
|
48
|
+
function clearErrors(errors, patch) {
|
|
49
|
+
return Object.fromEntries(Object.entries(errors).filter(([path])=>!Object.hasOwn(patch, path.split('.')[0])));
|
|
50
|
+
}
|
|
51
|
+
function stage(session, partial) {
|
|
52
|
+
if (!session.saved || session.reloading || session.view.loadState !== 'ready' || session.controller.signal.aborted) return;
|
|
53
|
+
const patch = editablePatch(session.saved.status, partial);
|
|
54
|
+
if (!Object.keys(patch).length) return;
|
|
55
|
+
session.pending = {
|
|
56
|
+
...session.pending,
|
|
57
|
+
...patch
|
|
58
|
+
};
|
|
59
|
+
session.view.errors = clearErrors(session.view.errors, patch);
|
|
60
|
+
if (!session.queue.length) session.view.saving = 'idle';
|
|
61
|
+
session.publish();
|
|
62
|
+
}
|
|
63
|
+
/** Section data only: staging never saves. Call commit on blur or a section action. */ export function useTestDocument(id, { apiRoute }) {
|
|
64
|
+
const base = apiRoute.replace(/\/$/, '');
|
|
65
|
+
const documentId = String(id);
|
|
66
|
+
const key = JSON.stringify([
|
|
67
|
+
base,
|
|
68
|
+
documentId
|
|
69
|
+
]);
|
|
70
|
+
const current = useRef(null);
|
|
71
|
+
const commitRef = useRef(null);
|
|
72
|
+
const defaultAttempts = useRef(new Set());
|
|
73
|
+
const [state, setState] = useState(()=>({
|
|
74
|
+
key,
|
|
75
|
+
view: initialView()
|
|
76
|
+
}));
|
|
77
|
+
const [reloadCount, setReloadCount] = useState(0);
|
|
78
|
+
useEffect(()=>{
|
|
79
|
+
const controller = new AbortController();
|
|
80
|
+
const session = {
|
|
81
|
+
key,
|
|
82
|
+
saved: null,
|
|
83
|
+
pending: {},
|
|
84
|
+
queue: [],
|
|
85
|
+
tail: Promise.resolve(),
|
|
86
|
+
view: initialView(),
|
|
87
|
+
controller,
|
|
88
|
+
reloading: false,
|
|
89
|
+
preflightReads: 0,
|
|
90
|
+
publish () {
|
|
91
|
+
if (controller.signal.aborted) return;
|
|
92
|
+
session.view.doc = session.view.loadState === 'ready' ? optimistic(session) : null;
|
|
93
|
+
session.view.hasUnsaved = session.queue.length > 0 || Object.keys(session.pending).length > 0;
|
|
94
|
+
session.view.preflightPending = session.preflightReads > 0;
|
|
95
|
+
setState({
|
|
96
|
+
key,
|
|
97
|
+
view: {
|
|
98
|
+
...session.view
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
current.current = session;
|
|
104
|
+
const url = `${base}/xp-experiments/${encodeURIComponent(documentId)}?depth=0`;
|
|
105
|
+
let preflightRevision = 0;
|
|
106
|
+
async function refreshPreflight(initial = false) {
|
|
107
|
+
if (controller.signal.aborted) return;
|
|
108
|
+
const revision = ++preflightRevision;
|
|
109
|
+
session.preflightReads++;
|
|
110
|
+
session.publish();
|
|
111
|
+
// createApi has no signal option; this read must abort without cancelling submitted PATCHes.
|
|
112
|
+
let data;
|
|
113
|
+
let status = 0;
|
|
114
|
+
let message = API_MESSAGES.network;
|
|
115
|
+
try {
|
|
116
|
+
const response = await fetch(`${base}/xp/experiments/${encodeURIComponent(documentId)}/preflight`, {
|
|
117
|
+
method: 'GET',
|
|
118
|
+
credentials: 'include',
|
|
119
|
+
signal: controller.signal
|
|
120
|
+
});
|
|
121
|
+
status = response.status;
|
|
122
|
+
const body = await response.json().catch(()=>undefined);
|
|
123
|
+
if (response.ok) {
|
|
124
|
+
data = body;
|
|
125
|
+
message = API_MESSAGES.unreadable;
|
|
126
|
+
} else {
|
|
127
|
+
const envelope = body;
|
|
128
|
+
const error = Array.isArray(envelope?.errors) ? envelope.errors[0] : undefined;
|
|
129
|
+
message = refusalMessage(status === 501 ? 'not-implemented' : error?.code, status, typeof error?.message === 'string' ? error.message : undefined);
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
message = API_MESSAGES.network;
|
|
133
|
+
}
|
|
134
|
+
session.preflightReads--;
|
|
135
|
+
if (controller.signal.aborted) return;
|
|
136
|
+
if (revision !== preflightRevision) {
|
|
137
|
+
session.publish();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (data) {
|
|
141
|
+
session.view.preflight = data;
|
|
142
|
+
session.view.preflightError = null;
|
|
143
|
+
if (initial) session.view.loadState = 'ready';
|
|
144
|
+
} else {
|
|
145
|
+
if (initial) {
|
|
146
|
+
session.view.loadState = status === 403 ? 'forbidden' : status === 404 ? 'missing' : 'error';
|
|
147
|
+
session.view.errors = {
|
|
148
|
+
...session.view.errors,
|
|
149
|
+
_section: message
|
|
150
|
+
};
|
|
151
|
+
} else session.view.preflightError = message;
|
|
152
|
+
}
|
|
153
|
+
session.publish();
|
|
154
|
+
}
|
|
155
|
+
async function readDocument() {
|
|
156
|
+
const response = await fetch(url, {
|
|
157
|
+
method: 'GET',
|
|
158
|
+
credentials: 'include',
|
|
159
|
+
signal: controller.signal
|
|
160
|
+
});
|
|
161
|
+
if (!response.ok) throw new Error(API_MESSAGES.unreadable);
|
|
162
|
+
const doc = await response.json();
|
|
163
|
+
if (!doc || doc.id == null || !doc.status) throw new Error(API_MESSAGES.unreadable);
|
|
164
|
+
return doc;
|
|
165
|
+
}
|
|
166
|
+
// Capture each commit now, then diff against the latest server document when its turn arrives.
|
|
167
|
+
function commit() {
|
|
168
|
+
if (!session.saved || session.reloading || controller.signal.aborted || !Object.keys(session.pending).length) return session.tail;
|
|
169
|
+
const queued = session.pending;
|
|
170
|
+
session.pending = {};
|
|
171
|
+
session.queue.push(queued);
|
|
172
|
+
session.view.saving = 'saving';
|
|
173
|
+
session.publish();
|
|
174
|
+
const step = async ()=>{
|
|
175
|
+
let saved = false;
|
|
176
|
+
let changed = false;
|
|
177
|
+
let failed = false;
|
|
178
|
+
try {
|
|
179
|
+
if (!session.saved) return;
|
|
180
|
+
const patch = changedPatch(session.saved, queued);
|
|
181
|
+
changed = Object.keys(patch).length > 0;
|
|
182
|
+
if (changed) {
|
|
183
|
+
const response = await fetch(url, {
|
|
184
|
+
// Submitted saves belong to the queue, not the mounted reader's lifetime.
|
|
185
|
+
method: 'PATCH',
|
|
186
|
+
credentials: 'include',
|
|
187
|
+
headers: {
|
|
188
|
+
'Content-Type': 'application/json'
|
|
189
|
+
},
|
|
190
|
+
body: JSON.stringify(patch)
|
|
191
|
+
});
|
|
192
|
+
const body = await response.json().catch(()=>undefined);
|
|
193
|
+
if (!response.ok) session.view.errors = {
|
|
194
|
+
...session.view.errors,
|
|
195
|
+
...payloadFieldErrors(body)
|
|
196
|
+
};
|
|
197
|
+
else {
|
|
198
|
+
// Payload collection PATCH returns { doc, message }; GET returns the doc itself.
|
|
199
|
+
const doc = body?.doc;
|
|
200
|
+
const readable = doc && doc.id != null && doc.status;
|
|
201
|
+
// A 2xx has applied even if its response body was lost. Preserve it while re-reading.
|
|
202
|
+
session.saved = readable ? doc : {
|
|
203
|
+
...session.saved,
|
|
204
|
+
...patch
|
|
205
|
+
};
|
|
206
|
+
saved = true;
|
|
207
|
+
// Keep readiness pending even while recovering an unreadable successful PATCH body.
|
|
208
|
+
session.preflightReads++;
|
|
209
|
+
session.publish();
|
|
210
|
+
session.view.errors = clearErrors(session.view.errors, patch);
|
|
211
|
+
delete session.view.errors._section;
|
|
212
|
+
if (!readable && !controller.signal.aborted) session.saved = await readDocument();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} catch (error) {
|
|
216
|
+
failed = true;
|
|
217
|
+
session.view.errors = {
|
|
218
|
+
...session.view.errors,
|
|
219
|
+
_section: error instanceof Error && error.message === API_MESSAGES.unreadable ? API_MESSAGES.unreadable : API_MESSAGES.network
|
|
220
|
+
};
|
|
221
|
+
} finally{
|
|
222
|
+
session.queue.shift();
|
|
223
|
+
// Roll back only a refused commit. Later queued or uncommitted edits remain optimistic.
|
|
224
|
+
session.view.saving = session.queue.length ? 'saving' : saved ? 'saved' : changed || failed ? 'error' : 'idle';
|
|
225
|
+
session.publish();
|
|
226
|
+
}
|
|
227
|
+
if (saved) {
|
|
228
|
+
try {
|
|
229
|
+
await refreshPreflight();
|
|
230
|
+
} finally{
|
|
231
|
+
session.preflightReads--;
|
|
232
|
+
session.publish();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
// Recover both the preceding tail and this step: unexpected throws must not strand later saves.
|
|
237
|
+
session.tail = session.tail.then(step, step).catch(()=>{
|
|
238
|
+
session.view.errors = {
|
|
239
|
+
...session.view.errors,
|
|
240
|
+
_section: API_MESSAGES.network
|
|
241
|
+
};
|
|
242
|
+
session.view.saving = session.queue.length ? 'saving' : 'error';
|
|
243
|
+
session.publish();
|
|
244
|
+
});
|
|
245
|
+
return session.tail;
|
|
246
|
+
}
|
|
247
|
+
// Keep callbacks attached to the session, including during automatic default persistence.
|
|
248
|
+
commitRef.current = {
|
|
249
|
+
session,
|
|
250
|
+
commit,
|
|
251
|
+
refreshPreflight: ()=>refreshPreflight()
|
|
252
|
+
};
|
|
253
|
+
session.publish();
|
|
254
|
+
void (async ()=>{
|
|
255
|
+
try {
|
|
256
|
+
await waitForDrain(drainingSaves.get(key), controller.signal);
|
|
257
|
+
if (controller.signal.aborted) return;
|
|
258
|
+
const response = await fetch(url, {
|
|
259
|
+
method: 'GET',
|
|
260
|
+
credentials: 'include',
|
|
261
|
+
signal: controller.signal
|
|
262
|
+
});
|
|
263
|
+
if (controller.signal.aborted) return;
|
|
264
|
+
if (!response.ok) {
|
|
265
|
+
session.view.loadState = response.status === 403 ? 'forbidden' : response.status === 404 ? 'missing' : 'error';
|
|
266
|
+
session.publish();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const doc = await response.json();
|
|
270
|
+
if (controller.signal.aborted) return;
|
|
271
|
+
if (doc.id == null || !doc.status) throw new Error(API_MESSAGES.unreadable);
|
|
272
|
+
session.saved = doc;
|
|
273
|
+
await refreshPreflight(true);
|
|
274
|
+
if (controller.signal.aborted) return;
|
|
275
|
+
session.publish();
|
|
276
|
+
if (!defaultAttempts.current.has(key) && session.view.loadState === 'ready' && (doc.status === 'draft' || doc.status === 'stopped') && doc.analysis?.plannedDurationDays == null) {
|
|
277
|
+
defaultAttempts.current.add(key);
|
|
278
|
+
stage(session, {
|
|
279
|
+
analysis: {
|
|
280
|
+
...doc.analysis,
|
|
281
|
+
plannedDurationDays: 28
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
await commit();
|
|
285
|
+
}
|
|
286
|
+
} catch {
|
|
287
|
+
if (controller.signal.aborted) return;
|
|
288
|
+
session.view.loadState = 'error';
|
|
289
|
+
session.view.errors = {
|
|
290
|
+
_section: API_MESSAGES.unreadable
|
|
291
|
+
};
|
|
292
|
+
session.publish();
|
|
293
|
+
}
|
|
294
|
+
})();
|
|
295
|
+
return ()=>{
|
|
296
|
+
controller.abort();
|
|
297
|
+
if (!session.queue.length) return;
|
|
298
|
+
const draining = session.tail;
|
|
299
|
+
drainingSaves.set(key, draining);
|
|
300
|
+
void draining.finally(()=>{
|
|
301
|
+
if (drainingSaves.get(key) === draining) drainingSaves.delete(key);
|
|
302
|
+
});
|
|
303
|
+
};
|
|
304
|
+
}, [
|
|
305
|
+
key,
|
|
306
|
+
base,
|
|
307
|
+
documentId,
|
|
308
|
+
reloadCount
|
|
309
|
+
]);
|
|
310
|
+
const stagePartial = useCallback((partial)=>{
|
|
311
|
+
const session = current.current;
|
|
312
|
+
if (session?.key === key) stage(session, partial);
|
|
313
|
+
}, [
|
|
314
|
+
key
|
|
315
|
+
]);
|
|
316
|
+
const commit = useCallback(()=>{
|
|
317
|
+
const entry = commitRef.current;
|
|
318
|
+
return entry?.session.key === key ? entry.commit() : Promise.resolve();
|
|
319
|
+
}, [
|
|
320
|
+
key
|
|
321
|
+
]);
|
|
322
|
+
const reload = useCallback(async ()=>{
|
|
323
|
+
const session = current.current;
|
|
324
|
+
if (session?.key !== key || session.reloading || session.controller.signal.aborted) return;
|
|
325
|
+
// Finish submitted saves before reading again; even an unexpected tail failure cannot reject reload.
|
|
326
|
+
session.reloading = true;
|
|
327
|
+
await session.tail.catch(()=>undefined);
|
|
328
|
+
if (current.current === session && !session.controller.signal.aborted) setReloadCount((count)=>count + 1);
|
|
329
|
+
}, [
|
|
330
|
+
key
|
|
331
|
+
]);
|
|
332
|
+
const refreshPreflight = useCallback(()=>{
|
|
333
|
+
const entry = commitRef.current;
|
|
334
|
+
return entry?.session.key === key && entry.session.view.loadState === 'ready' && !entry.session.reloading ? entry.refreshPreflight() : Promise.resolve();
|
|
335
|
+
}, [
|
|
336
|
+
key
|
|
337
|
+
]);
|
|
338
|
+
return {
|
|
339
|
+
...state.key === key ? state.view : initialView(),
|
|
340
|
+
stage: stagePartial,
|
|
341
|
+
commit,
|
|
342
|
+
reload,
|
|
343
|
+
refreshPreflight
|
|
344
|
+
};
|
|
345
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { TARGETING_OPS, isOpValid } from '../../targeting/ops.js';
|
|
2
|
-
import { NUMBER_OP_LABELS, STRING_OP_LABELS,
|
|
2
|
+
import { NUMBER_OP_LABELS, STRING_OP_LABELS, AUDIENCE_COPY } from '../copy/audience.js';
|
|
3
3
|
export function opsFor(type) {
|
|
4
4
|
return type ? TARGETING_OPS[type] : [];
|
|
5
5
|
}
|
|
@@ -40,7 +40,7 @@ export function rowPath(path) {
|
|
|
40
40
|
export function emptyListHint(op, value) {
|
|
41
41
|
if (op !== 'in' && op !== 'nin') return undefined;
|
|
42
42
|
const list = coerceValue('string', op, value);
|
|
43
|
-
return Array.isArray(list) && list.length === 0 ?
|
|
43
|
+
return Array.isArray(list) && list.length === 0 ? AUDIENCE_COPY.emptyListNote : undefined;
|
|
44
44
|
}
|
|
45
45
|
export function humaniseAttribute(key) {
|
|
46
46
|
const words = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ');
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { useConfig } from '@payloadcms/ui';
|
|
3
3
|
import { useEffect, useState } from 'react';
|
|
4
4
|
import { layerUsage } from './layerUsage.model.js';
|
|
5
|
-
//
|
|
5
|
+
// Used by the layer document; all requests use the editor's access.
|
|
6
6
|
export function useLayerExperiments(layerId) {
|
|
7
7
|
const { config } = useConfig();
|
|
8
8
|
const api = config.routes.api;
|
|
@@ -52,41 +52,3 @@ export function useLayerExperiments(layerId) {
|
|
|
52
52
|
]);
|
|
53
53
|
return id != null && state?.api === api && state.id === id ? state.usage : null;
|
|
54
54
|
}
|
|
55
|
-
export function useLayerUsage(layerId) {
|
|
56
|
-
const { config } = useConfig();
|
|
57
|
-
const api = config.routes.api;
|
|
58
|
-
const id = layerId == null ? null : String(layerId);
|
|
59
|
-
const usage = useLayerExperiments(layerId);
|
|
60
|
-
const [state, setState] = useState(null);
|
|
61
|
-
useEffect(()=>{
|
|
62
|
-
setState(null);
|
|
63
|
-
if (id == null) return;
|
|
64
|
-
const controller = new AbortController();
|
|
65
|
-
void fetch(`${api}/xp-layers/${encodeURIComponent(id)}?depth=0`, {
|
|
66
|
-
credentials: 'include',
|
|
67
|
-
signal: controller.signal
|
|
68
|
-
}).then(async (response)=>{
|
|
69
|
-
if (!response.ok) {
|
|
70
|
-
if (!controller.signal.aborted) setState(null);
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
const doc = await response.json();
|
|
74
|
-
if (!controller.signal.aborted) setState({
|
|
75
|
-
api,
|
|
76
|
-
id,
|
|
77
|
-
name: doc.name
|
|
78
|
-
});
|
|
79
|
-
}).catch(()=>{
|
|
80
|
-
if (!controller.signal.aborted) setState(null);
|
|
81
|
-
});
|
|
82
|
-
return ()=>controller.abort();
|
|
83
|
-
}, [
|
|
84
|
-
api,
|
|
85
|
-
id
|
|
86
|
-
]);
|
|
87
|
-
return usage && id != null && state?.api === api && state.id === id ? {
|
|
88
|
-
name: state.name,
|
|
89
|
-
used: usage.used,
|
|
90
|
-
free: usage.free
|
|
91
|
-
} : null;
|
|
92
|
-
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { DocumentViewServerProps } from 'payload';
|
|
2
|
+
type Props = Pick<DocumentViewServerProps, 'initPageResult' | 'params' | 'routeSegments'>;
|
|
3
|
+
export declare function CollectionEditRedirect({ initPageResult, params, routeSegments }: Props): Promise<never>;
|
|
4
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { formatAdminURL } from 'payload/shared';
|
|
2
|
+
import { redirect } from 'next/navigation.js';
|
|
3
|
+
export async function CollectionEditRedirect({ initPageResult, params, routeSegments }) {
|
|
4
|
+
const resolved = await params;
|
|
5
|
+
const segments = routeSegments ?? (Array.isArray(resolved?.segments) ? resolved.segments : []);
|
|
6
|
+
const collectionIndex = segments.indexOf('xp-experiments');
|
|
7
|
+
const tail = collectionIndex >= 0 ? segments.slice(collectionIndex + 1) : segments;
|
|
8
|
+
const id = tail[0] ?? resolved?.id ?? initPageResult.docID;
|
|
9
|
+
const path = id === undefined || id === 'create' ? '/experiments/new' : `/experiments/${encodeURIComponent(String(id))}${tail[1] === 'results' ? '/results' : ''}`;
|
|
10
|
+
return redirect(formatAdminURL({
|
|
11
|
+
adminRoute: initPageResult.req.payload.config.routes.admin,
|
|
12
|
+
path
|
|
13
|
+
}));
|
|
14
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import type { AdminViewServerProps } from 'payload';
|
|
2
|
+
export declare function ExperimentsRootView({ initPageResult, params, searchParams }: Pick<AdminViewServerProps, 'initPageResult' | 'params' | 'searchParams'>): Promise<string | number | bigint | boolean | Iterable<import("react").ReactNode> | import("react").JSX.Element | null | undefined>;
|