@delmaredigital/payload-experiments 0.1.0-rc.7 → 0.2.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.
Files changed (207) hide show
  1. package/README.md +28 -0
  2. package/dist/admin/LayerUsage.d.ts +1 -1
  3. package/dist/admin/LayerUsage.js +23 -11
  4. package/dist/admin/MetricSourceSelect.js +2 -1
  5. package/dist/admin/TestsNavLink.d.ts +1 -0
  6. package/dist/admin/TestsNavLink.js +30 -0
  7. package/dist/admin/allocation.model.js +1 -1
  8. package/dist/admin/copy/actions.d.ts +0 -2
  9. package/dist/admin/copy/actions.js +0 -2
  10. package/dist/admin/copy/advanced.d.ts +55 -0
  11. package/dist/admin/copy/advanced.js +55 -0
  12. package/dist/admin/copy/armSelect.js +1 -1
  13. package/dist/admin/copy/arms.d.ts +0 -13
  14. package/dist/admin/copy/arms.js +0 -13
  15. package/dist/admin/copy/{who.d.ts → audience.d.ts} +1 -9
  16. package/dist/admin/copy/{who.js → audience.js} +1 -9
  17. package/dist/admin/copy/history.d.ts +0 -4
  18. package/dist/admin/copy/history.js +3 -7
  19. package/dist/admin/copy/metrics.d.ts +2 -2
  20. package/dist/admin/copy/metrics.js +2 -2
  21. package/dist/admin/copy/newTest.d.ts +32 -0
  22. package/dist/admin/copy/newTest.js +32 -0
  23. package/dist/admin/copy/readiness.d.ts +45 -0
  24. package/dist/admin/copy/readiness.js +50 -0
  25. package/dist/admin/copy/recommendation.d.ts +26 -0
  26. package/dist/admin/copy/recommendation.js +25 -0
  27. package/dist/admin/copy/results.d.ts +0 -1
  28. package/dist/admin/copy/results.js +0 -1
  29. package/dist/admin/copy/review.d.ts +0 -17
  30. package/dist/admin/copy/review.js +1 -112
  31. package/dist/admin/copy/setting.d.ts +10 -0
  32. package/dist/admin/copy/setting.js +10 -0
  33. package/dist/admin/copy/surface.d.ts +36 -0
  34. package/dist/admin/copy/surface.js +47 -0
  35. package/dist/admin/copy/testPage.d.ts +88 -0
  36. package/dist/admin/copy/testPage.js +88 -0
  37. package/dist/admin/paths.d.ts +5 -13
  38. package/dist/admin/paths.js +5 -13
  39. package/dist/admin/review/review.model.d.ts +3 -26
  40. package/dist/admin/review/review.model.js +3 -30
  41. package/dist/admin/surface/ResultsRoute.d.ts +7 -0
  42. package/dist/admin/surface/ResultsRoute.js +58 -0
  43. package/dist/admin/surface/SurfaceShell.d.ts +6 -0
  44. package/dist/admin/surface/SurfaceShell.js +35 -0
  45. package/dist/admin/surface/TestsHome.d.ts +4 -0
  46. package/dist/admin/surface/TestsHome.js +212 -0
  47. package/dist/admin/surface/TestsSurface.d.ts +2 -0
  48. package/dist/admin/surface/TestsSurface.js +36 -0
  49. package/dist/admin/surface/contracts.d.ts +48 -0
  50. package/dist/admin/surface/contracts.js +8 -0
  51. package/dist/admin/surface/home.model.d.ts +41 -0
  52. package/dist/admin/surface/home.model.js +59 -0
  53. package/dist/admin/surface/newTest/NewTest.d.ts +7 -0
  54. package/dist/admin/surface/newTest/NewTest.js +355 -0
  55. package/dist/admin/surface/newTest/newTest.css +9 -0
  56. package/dist/admin/surface/pageBlock/pageBlock.model.d.ts +6 -0
  57. package/dist/admin/surface/pageBlock/pageBlock.model.js +8 -0
  58. package/dist/admin/surface/pageBlock/parts.d.ts +2 -0
  59. package/dist/admin/surface/pageBlock/parts.js +64 -0
  60. package/dist/admin/surface/readiness/ActionDialogs.d.ts +15 -0
  61. package/dist/admin/surface/readiness/ActionDialogs.js +274 -0
  62. package/dist/admin/surface/readiness/Banner.d.ts +2 -0
  63. package/dist/admin/surface/readiness/Banner.js +138 -0
  64. package/dist/admin/surface/readiness/SaveStatus.d.ts +4 -0
  65. package/dist/admin/surface/readiness/SaveStatus.js +50 -0
  66. package/dist/admin/surface/readiness/parts.d.ts +2 -0
  67. package/dist/admin/surface/readiness/parts.js +9 -0
  68. package/dist/admin/surface/readiness/readiness.model.d.ts +22 -0
  69. package/dist/admin/surface/readiness/readiness.model.js +107 -0
  70. package/dist/admin/surface/readiness/useTransition.d.ts +10 -0
  71. package/dist/admin/surface/readiness/useTransition.js +34 -0
  72. package/dist/admin/surface/results/ResultsBody.d.ts +7 -0
  73. package/dist/admin/surface/results/ResultsBody.js +262 -0
  74. package/dist/admin/surface/results/recommendation.model.d.ts +12 -0
  75. package/dist/admin/surface/results/recommendation.model.js +59 -0
  76. package/dist/admin/surface/results/useResults.d.ts +56 -0
  77. package/dist/admin/surface/results/useResults.js +183 -0
  78. package/dist/admin/surface/setting/parts.d.ts +3 -0
  79. package/dist/admin/surface/setting/parts.js +263 -0
  80. package/dist/admin/surface/setting/setting.css +29 -0
  81. package/dist/admin/surface/setting/setting.model.d.ts +8 -0
  82. package/dist/admin/surface/setting/setting.model.js +25 -0
  83. package/dist/admin/surface/surface.css +50 -0
  84. package/dist/admin/surface/test/AdvancedSection.d.ts +2 -0
  85. package/dist/admin/surface/test/AdvancedSection.js +153 -0
  86. package/dist/admin/surface/test/GoalSection.d.ts +2 -0
  87. package/dist/admin/surface/test/GoalSection.js +198 -0
  88. package/dist/admin/surface/test/LengthSection.d.ts +2 -0
  89. package/dist/admin/surface/test/LengthSection.js +87 -0
  90. package/dist/admin/surface/test/Section.d.ts +7 -0
  91. package/dist/admin/surface/test/Section.js +27 -0
  92. package/dist/admin/surface/test/TargetingSection.d.ts +2 -0
  93. package/dist/admin/surface/test/TargetingSection.js +164 -0
  94. package/dist/admin/surface/test/TestHeader.d.ts +4 -0
  95. package/dist/admin/surface/test/TestHeader.js +69 -0
  96. package/dist/admin/surface/test/TestPage.d.ts +7 -0
  97. package/dist/admin/surface/test/TestPage.js +84 -0
  98. package/dist/admin/surface/test/VersionsSection.d.ts +4 -0
  99. package/dist/admin/surface/test/VersionsSection.js +208 -0
  100. package/dist/admin/surface/test/fields.d.ts +55 -0
  101. package/dist/admin/surface/test/fields.js +185 -0
  102. package/dist/admin/surface/test/pageParts.d.ts +2 -0
  103. package/dist/admin/surface/test/pageParts.js +39 -0
  104. package/dist/admin/surface/test/testPage.css +23 -0
  105. package/dist/admin/surface/test/testPage.model.d.ts +17 -0
  106. package/dist/admin/surface/test/testPage.model.js +53 -0
  107. package/dist/admin/surface/testDocument.model.d.ts +39 -0
  108. package/dist/admin/surface/testDocument.model.js +61 -0
  109. package/dist/admin/surface/useTestDocument.d.ts +22 -0
  110. package/dist/admin/surface/useTestDocument.js +345 -0
  111. package/dist/admin/targeting/targeting.model.js +2 -2
  112. package/dist/admin/useLayerUsage.d.ts +0 -5
  113. package/dist/admin/useLayerUsage.js +1 -39
  114. package/dist/admin/views/CollectionEditRedirect.d.ts +4 -0
  115. package/dist/admin/views/CollectionEditRedirect.js +14 -0
  116. package/dist/admin/views/CollectionListRedirect.d.ts +2 -0
  117. package/dist/admin/views/CollectionListRedirect.js +8 -0
  118. package/dist/admin/views/ExperimentsRootView.d.ts +2 -0
  119. package/dist/admin/views/ExperimentsRootView.js +175 -0
  120. package/dist/admin/views/access.model.d.ts +7 -0
  121. package/dist/admin/views/access.model.js +5 -0
  122. package/dist/collections/experiments/config.js +18 -125
  123. package/dist/collections/experiments/createDefaults.js +6 -0
  124. package/dist/collections/layers.js +43 -12
  125. package/dist/collections/metrics.js +70 -14
  126. package/dist/collections/serialize.js +4 -1
  127. package/dist/db/snapshots.d.ts +2 -1
  128. package/dist/db/snapshots.js +2 -2
  129. package/dist/endpoints/results.js +7 -0
  130. package/dist/endpoints/summary.d.ts +1 -1
  131. package/dist/endpoints/summary.js +37 -1
  132. package/dist/exports/adminClient.d.ts +2 -13
  133. package/dist/exports/adminClient.js +2 -13
  134. package/dist/exports/adminRsc.d.ts +3 -0
  135. package/dist/exports/adminRsc.js +3 -0
  136. package/dist/exports/index.d.ts +1 -1
  137. package/dist/options.js +29 -0
  138. package/dist/plugin.js +25 -0
  139. package/dist/puck/editor/CreateExperimentDrawer.d.ts +2 -1
  140. package/dist/puck/editor/CreateExperimentDrawer.js +17 -57
  141. package/dist/puck/editor/ExperimentSlotEditor.js +6 -0
  142. package/dist/puck/editor/copy.d.ts +10 -9
  143. package/dist/puck/editor/copy.js +11 -10
  144. package/dist/puck/editor/createTest.d.ts +7 -0
  145. package/dist/puck/editor/createTest.js +50 -0
  146. package/dist/puck/editor/editor.model.d.ts +28 -1
  147. package/dist/puck/editor/editor.model.js +61 -0
  148. package/dist/results/analyse.js +4 -1
  149. package/dist/results/verdict.d.ts +1 -0
  150. package/dist/results/verdict.js +7 -4
  151. package/dist/transitions/checkStart.d.ts +1 -0
  152. package/dist/transitions/placements.d.ts +5 -1
  153. package/dist/transitions/placements.js +14 -7
  154. package/dist/transitions/preflightData.d.ts +7 -1
  155. package/dist/transitions/preflightData.js +199 -148
  156. package/dist/transitions/start.js +2 -2
  157. package/dist/types/admin.d.ts +1 -0
  158. package/dist/types/contracts.d.ts +17 -0
  159. package/dist/types/endpoints.d.ts +11 -1
  160. package/dist/types/snapshot.d.ts +1 -0
  161. package/dist/types/surface.d.ts +29 -0
  162. package/dist/types/surface.js +1 -0
  163. package/package.json +7 -1
  164. package/dist/access.d.ts +0 -1
  165. package/dist/access.js +0 -2
  166. package/dist/admin/AllocationField.d.ts +0 -2
  167. package/dist/admin/AllocationField.js +0 -80
  168. package/dist/admin/FlagPointSelect.d.ts +0 -2
  169. package/dist/admin/FlagPointSelect.js +0 -54
  170. package/dist/admin/LockedFieldNotice.d.ts +0 -2
  171. package/dist/admin/LockedFieldNotice.js +0 -26
  172. package/dist/admin/PhaseHistory.d.ts +0 -2
  173. package/dist/admin/PhaseHistory.js +0 -76
  174. package/dist/admin/StatusCell.d.ts +0 -3
  175. package/dist/admin/StatusCell.js +0 -19
  176. package/dist/admin/SummaryCell.d.ts +0 -2
  177. package/dist/admin/SummaryCell.js +0 -32
  178. package/dist/admin/actions/AddMetricDialog.d.ts +0 -6
  179. package/dist/admin/actions/AddMetricDialog.js +0 -138
  180. package/dist/admin/actions/AllocateDialog.d.ts +0 -6
  181. package/dist/admin/actions/AllocateDialog.js +0 -81
  182. package/dist/admin/actions/ExperimentActions.d.ts +0 -3
  183. package/dist/admin/actions/ExperimentActions.js +0 -110
  184. package/dist/admin/arms/ArmsField.d.ts +0 -2
  185. package/dist/admin/arms/ArmsField.js +0 -310
  186. package/dist/admin/arms/ParamsEditor.d.ts +0 -16
  187. package/dist/admin/arms/ParamsEditor.js +0 -79
  188. package/dist/admin/copy/tabs.d.ts +0 -7
  189. package/dist/admin/copy/tabs.js +0 -7
  190. package/dist/admin/permissions.model.d.ts +0 -10
  191. package/dist/admin/permissions.model.js +0 -4
  192. package/dist/admin/phaseHistory.model.d.ts +0 -19
  193. package/dist/admin/phaseHistory.model.js +0 -19
  194. package/dist/admin/results/ResultsView.d.ts +0 -3
  195. package/dist/admin/results/ResultsView.js +0 -312
  196. package/dist/admin/review/ReviewPanel.d.ts +0 -2
  197. package/dist/admin/review/ReviewPanel.js +0 -268
  198. package/dist/admin/summaryBatcher.d.ts +0 -8
  199. package/dist/admin/summaryBatcher.js +0 -33
  200. package/dist/admin/targeting/TargetingAttributeField.d.ts +0 -2
  201. package/dist/admin/targeting/TargetingAttributeField.js +0 -31
  202. package/dist/admin/targeting/TargetingOpField.d.ts +0 -2
  203. package/dist/admin/targeting/TargetingOpField.js +0 -45
  204. package/dist/admin/targeting/TargetingValueField.d.ts +0 -2
  205. package/dist/admin/targeting/TargetingValueField.js +0 -142
  206. package/dist/index.d.ts +0 -1
  207. 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, WHO_COPY } from '../copy/who.js';
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 ? WHO_COPY.emptyListNote : undefined;
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, ' ');
@@ -8,8 +8,3 @@ export declare function useLayerExperiments(layerId: DocId | null): {
8
8
  slices: number;
9
9
  }[];
10
10
  } | null;
11
- export declare function useLayerUsage(layerId: DocId | null): {
12
- name: string;
13
- used: number;
14
- free: number;
15
- } | null;
@@ -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
- // Shared by the layer document and the allocation field; all requests use the editor's access.
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 { ListViewServerProps } from 'payload';
2
+ export declare function CollectionListRedirect({ payload }: Pick<ListViewServerProps, 'payload'>): void;
@@ -0,0 +1,8 @@
1
+ import { formatAdminURL } from 'payload/shared';
2
+ import { redirect } from 'next/navigation.js';
3
+ export function CollectionListRedirect({ payload }) {
4
+ redirect(formatAdminURL({
5
+ adminRoute: payload.config.routes.admin,
6
+ path: '/experiments'
7
+ }));
8
+ }
@@ -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>;