@delmaredigital/payload-experiments 0.4.0-rc.1 → 0.4.0-rc.3
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 +283 -773
- 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/api.d.ts +7 -0
- package/dist/admin/api.js +27 -0
- package/dist/admin/blocks/BlockPanel.js +65 -19
- package/dist/admin/blocks/formApi.d.ts +3 -0
- package/dist/admin/blocks/formApi.js +9 -3
- package/dist/admin/blocks/statusCache.d.ts +2 -0
- package/dist/admin/blocks/statusCache.js +11 -2
- package/dist/admin/copy/blocks.d.ts +16 -0
- package/dist/admin/copy/blocks.js +27 -1
- package/dist/admin/copy/common.d.ts +1 -0
- package/dist/admin/copy/common.js +1 -1
- package/dist/admin/copy/goals.d.ts +1 -0
- package/dist/admin/copy/goals.js +1 -0
- package/dist/admin/copy/pages.d.ts +2 -4
- package/dist/admin/copy/pages.js +3 -5
- package/dist/admin/copy/readiness.d.ts +1 -0
- package/dist/admin/copy/readiness.js +1 -0
- package/dist/admin/goals/XpGoalField.d.ts +2 -0
- package/dist/admin/goals/XpGoalField.js +51 -35
- package/dist/admin/goals/xpGoalField.css +48 -0
- package/dist/admin/paths.d.ts +3 -0
- package/dist/admin/paths.js +3 -0
- package/dist/admin/surface/TestsHome.js +2 -4
- package/dist/admin/surface/TestsSurface.js +2 -5
- package/dist/admin/surface/goals/GoalForm.js +18 -18
- package/dist/admin/surface/goals/GoalsList.js +2 -1
- package/dist/admin/surface/home.model.d.ts +2 -2
- package/dist/admin/surface/newTest/NewTest.js +2 -4
- package/dist/admin/surface/newTest/PageWizard.js +7 -2
- package/dist/admin/surface/pagePicker/CopyRoute.d.ts +1 -1
- package/dist/admin/surface/pagePicker/{PagePicker.d.ts → PageCombobox.d.ts} +3 -3
- 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 +2 -0
- package/dist/admin/surface/readiness/readiness.model.js +1 -0
- package/dist/admin/surface/results/ResultsHeader.js +3 -8
- package/dist/admin/surface/results/useResults.d.ts +10 -0
- package/dist/admin/surface/test/GoalSection.js +4 -7
- package/dist/admin/surface/test/Section.js +9 -8
- package/dist/admin/surface/test/TestPage.js +3 -8
- package/dist/admin/surface/test/pageParts.js +10 -39
- package/dist/admin/surface/test/testPage.css +0 -5
- package/dist/admin/surface/useTestDocument.d.ts +4 -0
- 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 +10 -0
- package/dist/admin/views/CollectionListRedirect.d.ts +1 -0
- package/dist/admin/views/CollectionListRedirect.js +1 -1
- package/dist/admin/views/ExperimentsRootView.js +18 -165
- 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/collections/experiments/config.js +9 -5
- package/dist/collections/metrics.js +16 -4
- package/dist/exports/adminRsc.d.ts +3 -0
- package/dist/exports/adminRsc.js +3 -0
- package/dist/fields/xpGoal.js +2 -14
- package/dist/goals/picker/copy.d.ts +2 -0
- package/dist/goals/picker/copy.js +7 -0
- package/dist/goals/validate.d.ts +3 -0
- package/dist/goals/validate.js +15 -0
- package/dist/hooks/puckGoals.d.ts +5 -0
- package/dist/hooks/puckGoals.js +62 -0
- package/dist/plugin.js +12 -18
- package/dist/puck/editor/editor.chrome.css +13 -0
- package/dist/puck/editor/editor.css +105 -0
- package/dist/puck/editor/xpGoalPuckEditorField.js +28 -20
- package/dist/sanitizeReadinessDetail.js +2 -0
- package/dist/transitions/checkStart.d.ts +2 -1
- package/dist/transitions/checkStart.js +3 -1
- package/dist/transitions/decide.js +1 -0
- package/dist/transitions/start.js +2 -1
- package/dist/types/endpoints.d.ts +1 -1
- package/dist/ui/ui.css +4 -0
- package/package.json +1 -1
- package/dist/admin/surface/pagePicker/PagePicker.js +0 -172
|
@@ -1,20 +1,16 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { surfaceUrls } from './surfaceUrls.js';
|
|
3
4
|
import './theme.css';
|
|
4
5
|
import './dashboard.css';
|
|
5
6
|
import { Card, useAuth, useConfig } from '@payloadcms/ui';
|
|
6
|
-
import { formatAdminURL } from 'payload/shared';
|
|
7
7
|
import { SURFACE_COPY } from './copy/surface.js';
|
|
8
|
-
export function TestsDashboardCards() {
|
|
8
|
+
/** @deprecated Retained for existing import maps until 0.5.0. */ export function TestsDashboardCards() {
|
|
9
9
|
const { config } = useConfig();
|
|
10
10
|
const { permissions } = useAuth();
|
|
11
11
|
const testPermissions = permissions?.collections?.['xp-experiments'];
|
|
12
12
|
const canManage = testPermissions?.update === true;
|
|
13
13
|
if (!canManage) return null;
|
|
14
|
-
const href = (path)=>formatAdminURL({
|
|
15
|
-
adminRoute: config.routes.admin,
|
|
16
|
-
path
|
|
17
|
-
});
|
|
18
14
|
return /*#__PURE__*/ _jsxs("section", {
|
|
19
15
|
className: "xp-dash-group",
|
|
20
16
|
"aria-labelledby": "xp-dash-title",
|
|
@@ -32,7 +28,7 @@ export function TestsDashboardCards() {
|
|
|
32
28
|
title: SURFACE_COPY.navAllTests,
|
|
33
29
|
titleAs: "h3",
|
|
34
30
|
buttonAriaLabel: SURFACE_COPY.dashShowTests,
|
|
35
|
-
href:
|
|
31
|
+
href: surfaceUrls.home(config.routes.admin),
|
|
36
32
|
id: "card-xp-tests"
|
|
37
33
|
})
|
|
38
34
|
}),
|
|
@@ -41,7 +37,7 @@ export function TestsDashboardCards() {
|
|
|
41
37
|
title: SURFACE_COPY.navGoals,
|
|
42
38
|
titleAs: "h3",
|
|
43
39
|
buttonAriaLabel: SURFACE_COPY.dashShowGoals,
|
|
44
|
-
href:
|
|
40
|
+
href: surfaceUrls.goals(config.routes.admin),
|
|
45
41
|
id: "card-xp-goals"
|
|
46
42
|
})
|
|
47
43
|
})
|
|
@@ -1,22 +1,16 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { surfaceUrls } from './surfaceUrls.js';
|
|
3
4
|
import './theme.css';
|
|
4
5
|
import { Link, NavGroup, useAuth, useConfig } from '@payloadcms/ui';
|
|
5
|
-
import { formatAdminURL } from 'payload/shared';
|
|
6
6
|
import { usePathname } from 'next/navigation.js';
|
|
7
7
|
import { SURFACE_COPY } from './copy/surface.js';
|
|
8
|
-
function TestsNav({ isOpen = true }) {
|
|
8
|
+
/** @deprecated Retained for existing import maps until 0.5.0. */ function TestsNav({ isOpen = true }) {
|
|
9
9
|
const { config } = useConfig();
|
|
10
10
|
const { permissions } = useAuth();
|
|
11
11
|
const pathname = usePathname();
|
|
12
|
-
const href =
|
|
13
|
-
|
|
14
|
-
path: '/experiments'
|
|
15
|
-
});
|
|
16
|
-
const goalsHref = formatAdminURL({
|
|
17
|
-
adminRoute: config.routes.admin,
|
|
18
|
-
path: '/experiments/goals'
|
|
19
|
-
});
|
|
12
|
+
const href = surfaceUrls.home(config.routes.admin);
|
|
13
|
+
const goalsHref = surfaceUrls.goals(config.routes.admin);
|
|
20
14
|
const base = href.replace(/\/$/, '');
|
|
21
15
|
const goalsBase = goalsHref.replace(/\/$/, '');
|
|
22
16
|
const goalsActive = pathname === goalsBase || Boolean(pathname?.startsWith(`${goalsBase}/`));
|
package/dist/admin/api.d.ts
CHANGED
|
@@ -13,6 +13,13 @@ export type ApiResult<T> = {
|
|
|
13
13
|
};
|
|
14
14
|
type RequestOptions = NonNullable<Parameters<typeof refusalMessage>[3]>;
|
|
15
15
|
export declare function createApi(apiBase: string): {
|
|
16
|
+
findTestIdBySlug: (slug: string) => Promise<Extract<ApiResult<DocId>, {
|
|
17
|
+
ok: false;
|
|
18
|
+
}> | {
|
|
19
|
+
ok: true;
|
|
20
|
+
data: DocId;
|
|
21
|
+
}>;
|
|
22
|
+
deleteTest: (id: DocId) => Promise<ApiResult<unknown>>;
|
|
16
23
|
wrapBlock: (body: WrapBlockBody) => Promise<ApiResult<Record<string, unknown>>>;
|
|
17
24
|
pages: (query: {
|
|
18
25
|
collection: string;
|
package/dist/admin/api.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { API_MESSAGES, refusalMessage } from './copy/common.js';
|
|
2
|
+
import { BLOCK_COPY } from './copy/blocks.js';
|
|
2
3
|
import { CONTENT_ENTRY_REFUSALS } from './copy/actions.js';
|
|
3
4
|
export function createApi(apiBase) {
|
|
5
|
+
const collectionBase = `${apiBase.replace(/\/$/, '')}/xp-experiments`;
|
|
4
6
|
const base = `${apiBase.replace(/\/$/, '')}/xp/experiments`;
|
|
5
7
|
const route = (id, action)=>`${base}/${encodeURIComponent(id)}/${action}`;
|
|
6
8
|
async function request(path, method, body, options, signal) {
|
|
@@ -64,6 +66,31 @@ export function createApi(apiBase) {
|
|
|
64
66
|
}
|
|
65
67
|
const post = (id, action, body, options)=>request(()=>route(id, action), 'POST', body, options);
|
|
66
68
|
return {
|
|
69
|
+
findTestIdBySlug: async (slug)=>{
|
|
70
|
+
const result = await request(()=>`${collectionBase}?where[slug][equals]=${encodeURIComponent(slug)}&depth=0&limit=1`, 'GET');
|
|
71
|
+
if (!result.ok) return result;
|
|
72
|
+
const id = result.data?.docs?.[0]?.id;
|
|
73
|
+
if (id === undefined) return {
|
|
74
|
+
ok: false,
|
|
75
|
+
status: 404,
|
|
76
|
+
code: 'not-found',
|
|
77
|
+
message: refusalMessage('not-found', 404, undefined, {
|
|
78
|
+
notFound: BLOCK_COPY.alreadyDeleted
|
|
79
|
+
}),
|
|
80
|
+
retryable: false
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
ok: true,
|
|
84
|
+
data: id
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
deleteTest: (id)=>request(()=>`${collectionBase}/${encodeURIComponent(id)}`, 'DELETE', undefined, {
|
|
88
|
+
notFound: BLOCK_COPY.noLongerDraft,
|
|
89
|
+
forbidden: BLOCK_COPY.noLongerDraft,
|
|
90
|
+
codes: {
|
|
91
|
+
'invalid-status': BLOCK_COPY.noLongerDraft
|
|
92
|
+
}
|
|
93
|
+
}),
|
|
67
94
|
wrapBlock: (body)=>request(()=>`${apiBase.replace(/\/$/, '')}/xp/blocks/wrap`, 'POST', body),
|
|
68
95
|
pages: (query, signal)=>request(()=>`${apiBase.replace(/\/$/, '')}/xp/pages?${new URLSearchParams({
|
|
69
96
|
collection: query.collection,
|
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { Banner, Button, ConfirmationModal, useModal, useFormInitializing, useRowLabel } from '@payloadcms/ui';
|
|
3
|
+
import { Banner, Button, ConfirmationModal, toast, useModal, useFormInitializing, useRowLabel } from '@payloadcms/ui';
|
|
4
|
+
import { formatAdminURL } from 'payload/shared';
|
|
4
5
|
import { useEffect, useId, useRef, useState } from 'react';
|
|
5
6
|
import { copyOriginalIntoVersion, syncExperimentSlots, unwrapExperimentBlock } from '../../blocks/formState.js';
|
|
6
|
-
import { BLOCK_COPY, blockSyncNotice } from '../copy/blocks.js';
|
|
7
|
+
import { BLOCK_COPY, blockSyncNotice, blockUnwrapNotice, blockDeleteConfirmation, blockUnwrapConfirmation, blockRunningUnwrap, blockDecidedUnwrap, blockFinishedCopy } from '../copy/blocks.js';
|
|
7
8
|
import { API_MESSAGES } from '../copy/common.js';
|
|
8
9
|
import { isOrphan, liveStatus, slotLabel, statusSentence } from './model.js';
|
|
9
|
-
import {
|
|
10
|
+
import { createApi } from '../api.js';
|
|
11
|
+
import { useBlockStatus, setBlockStatus } from './statusCache.js';
|
|
10
12
|
import { useBlockForm } from './useBlockForm.js';
|
|
13
|
+
import { BlockChangedError, RefusalError } from './formApi.js';
|
|
14
|
+
function errorMessage(error) {
|
|
15
|
+
return error instanceof BlockChangedError || error instanceof RefusalError ? error.message : API_MESSAGES.failed;
|
|
16
|
+
}
|
|
11
17
|
export function BlockStatusField({ path }) {
|
|
12
18
|
const context = useBlockForm(path), { block, field, manager, config, collection, replace } = context;
|
|
13
19
|
const initializing = useFormInitializing();
|
|
14
20
|
const status = useBlockStatus(config.routes.api, block?.experiment, true);
|
|
15
21
|
const synced = useRef(false), syncing = useRef(false);
|
|
16
22
|
const [notice, setNotice] = useState(false), [error, setError] = useState(), [busy, setBusy] = useState(false);
|
|
17
|
-
const [
|
|
23
|
+
const [syncError, setSyncError] = useState();
|
|
24
|
+
const { openModal } = useModal();
|
|
25
|
+
const modalSlug = `xp-unwrap-${useId()}`;
|
|
18
26
|
const canSync = !initializing && manager && block && field && liveStatus(status) && [
|
|
19
27
|
'draft',
|
|
20
28
|
'stopped'
|
|
@@ -23,7 +31,7 @@ export function BlockStatusField({ path }) {
|
|
|
23
31
|
if (!canSync || syncing.current) return;
|
|
24
32
|
syncing.current = true;
|
|
25
33
|
setBusy(true);
|
|
26
|
-
|
|
34
|
+
setSyncError(undefined);
|
|
27
35
|
try {
|
|
28
36
|
const next = syncExperimentSlots(block, status.arms, field);
|
|
29
37
|
if (JSON.stringify(next.slots) !== JSON.stringify(block.slots)) {
|
|
@@ -32,8 +40,8 @@ export function BlockStatusField({ path }) {
|
|
|
32
40
|
]);
|
|
33
41
|
setNotice(true);
|
|
34
42
|
}
|
|
35
|
-
} catch
|
|
36
|
-
|
|
43
|
+
} catch (error) {
|
|
44
|
+
setSyncError(errorMessage(error));
|
|
37
45
|
} finally{
|
|
38
46
|
syncing.current = false;
|
|
39
47
|
setBusy(false);
|
|
@@ -44,45 +52,83 @@ export function BlockStatusField({ path }) {
|
|
|
44
52
|
synced.current = true;
|
|
45
53
|
void sync();
|
|
46
54
|
});
|
|
47
|
-
if (!status) return null;
|
|
55
|
+
if (!status || !manager) return null;
|
|
56
|
+
const draft = liveStatus(status) && status.status === 'draft';
|
|
57
|
+
const stopped = liveStatus(status) && status.status === 'stopped';
|
|
48
58
|
async function unwrap() {
|
|
49
59
|
if (!block || !field || busy) return;
|
|
50
60
|
setBusy(true);
|
|
51
61
|
setError(undefined);
|
|
62
|
+
let deleted = false;
|
|
52
63
|
try {
|
|
64
|
+
if (draft) {
|
|
65
|
+
const api = createApi(config.routes.api);
|
|
66
|
+
const found = await api.findTestIdBySlug(block.experiment);
|
|
67
|
+
if (!found.ok) throw new RefusalError(found.message);
|
|
68
|
+
const removed = await api.deleteTest(found.data);
|
|
69
|
+
if (!removed.ok) throw new RefusalError(removed.message);
|
|
70
|
+
deleted = true;
|
|
71
|
+
setBlockStatus(config.routes.api, block.experiment, {
|
|
72
|
+
deleted: true
|
|
73
|
+
});
|
|
74
|
+
}
|
|
53
75
|
await replace(unwrapExperimentBlock(block, field));
|
|
54
|
-
|
|
55
|
-
|
|
76
|
+
toast.success(blockUnwrapNotice(Boolean(collection?.versions?.drafts), deleted));
|
|
77
|
+
} catch (error) {
|
|
78
|
+
setError(deleted ? BLOCK_COPY.finishUnwrap : errorMessage(error));
|
|
56
79
|
} finally{
|
|
57
80
|
setBusy(false);
|
|
58
81
|
}
|
|
59
82
|
}
|
|
60
83
|
const sentence = statusSentence(status);
|
|
84
|
+
const linkedCopy = liveStatus(status) ? status.status === 'running' ? blockRunningUnwrap : status.status === 'decided' ? blockDecidedUnwrap : status.status === 'archived' && status.winnerArm ? blockFinishedCopy(slotLabel(status.winnerArm, status)) : undefined : undefined;
|
|
85
|
+
const href = formatAdminURL({
|
|
86
|
+
adminRoute: config.routes.admin,
|
|
87
|
+
path: `/experiments/by-slug/${encodeURIComponent(block?.experiment ?? '')}`
|
|
88
|
+
});
|
|
61
89
|
return /*#__PURE__*/ _jsxs("div", {
|
|
62
90
|
children: [
|
|
63
91
|
liveStatus(status) && status.status === 'running' ? /*#__PURE__*/ _jsx(Banner, {
|
|
64
92
|
children: sentence
|
|
65
|
-
}) : /*#__PURE__*/ _jsx("p", {
|
|
93
|
+
}) : linkedCopy && liveStatus(status) && status.status === 'archived' ? null : /*#__PURE__*/ _jsx("p", {
|
|
66
94
|
children: sentence
|
|
67
95
|
}),
|
|
96
|
+
linkedCopy ? /*#__PURE__*/ _jsxs("p", {
|
|
97
|
+
children: [
|
|
98
|
+
linkedCopy.before,
|
|
99
|
+
/*#__PURE__*/ _jsx("a", {
|
|
100
|
+
href: href,
|
|
101
|
+
children: linkedCopy.link
|
|
102
|
+
}),
|
|
103
|
+
linkedCopy.after
|
|
104
|
+
]
|
|
105
|
+
}) : null,
|
|
68
106
|
notice ? /*#__PURE__*/ _jsx("p", {
|
|
69
107
|
role: "status",
|
|
70
108
|
children: blockSyncNotice(Boolean(collection?.versions?.drafts))
|
|
71
109
|
}) : null,
|
|
72
|
-
|
|
110
|
+
isOrphan(status) || draft || stopped ? /*#__PURE__*/ _jsx(Button, {
|
|
73
111
|
disabled: busy,
|
|
74
|
-
onClick: ()=>void unwrap(),
|
|
75
|
-
children: BLOCK_COPY.unwrap
|
|
112
|
+
onClick: ()=>draft || stopped ? openModal(modalSlug) : void unwrap(),
|
|
113
|
+
children: draft ? BLOCK_COPY.removeAndUnwrap : BLOCK_COPY.unwrap
|
|
114
|
+
}) : null,
|
|
115
|
+
draft || stopped ? /*#__PURE__*/ _jsx(ConfirmationModal, {
|
|
116
|
+
modalSlug: modalSlug,
|
|
117
|
+
heading: draft ? BLOCK_COPY.removeAndUnwrap : BLOCK_COPY.unwrap,
|
|
118
|
+
body: draft ? blockDeleteConfirmation(status.name) : blockUnwrapConfirmation(status.name),
|
|
119
|
+
confirmLabel: draft ? BLOCK_COPY.deleteAndUnwrap : BLOCK_COPY.unwrap,
|
|
120
|
+
cancelLabel: BLOCK_COPY.cancel,
|
|
121
|
+
onConfirm: unwrap
|
|
76
122
|
}) : null,
|
|
77
123
|
error ? /*#__PURE__*/ _jsx("p", {
|
|
78
124
|
role: "alert",
|
|
79
125
|
children: error
|
|
80
126
|
}) : null,
|
|
81
|
-
|
|
127
|
+
syncError ? /*#__PURE__*/ _jsx("p", {
|
|
82
128
|
role: "alert",
|
|
83
|
-
children:
|
|
129
|
+
children: syncError
|
|
84
130
|
}) : null,
|
|
85
|
-
|
|
131
|
+
syncError && canSync ? /*#__PURE__*/ _jsx(Button, {
|
|
86
132
|
disabled: busy,
|
|
87
133
|
onClick: ()=>void sync(),
|
|
88
134
|
children: BLOCK_COPY.retry
|
|
@@ -119,8 +165,8 @@ export function CopyOriginalField({ path }) {
|
|
|
119
165
|
await replace([
|
|
120
166
|
copyOriginalIntoVersion(block, slot, field)
|
|
121
167
|
]);
|
|
122
|
-
} catch
|
|
123
|
-
setError(
|
|
168
|
+
} catch (error) {
|
|
169
|
+
setError(errorMessage(error));
|
|
124
170
|
} finally{
|
|
125
171
|
setBusy(false);
|
|
126
172
|
}
|
|
@@ -5,6 +5,9 @@ import { type HostBlock } from '../../blocks/model.js';
|
|
|
5
5
|
export declare class BlockChangedError extends Error {
|
|
6
6
|
constructor();
|
|
7
7
|
}
|
|
8
|
+
/** An API refusal already translated into editor-facing copy. */
|
|
9
|
+
export declare class RefusalError extends Error {
|
|
10
|
+
}
|
|
8
11
|
export type RowsField = Extract<Field, {
|
|
9
12
|
type: 'blocks';
|
|
10
13
|
}>;
|
|
@@ -5,6 +5,8 @@ import { BLOCK_COPY } from '../copy/blocks.js';
|
|
|
5
5
|
super(BLOCK_COPY.blockChanged);
|
|
6
6
|
}
|
|
7
7
|
}
|
|
8
|
+
/** An API refusal already translated into editor-facing copy. */ export class RefusalError extends Error {
|
|
9
|
+
}
|
|
8
10
|
export const wrapMode = 'form-api';
|
|
9
11
|
/** Client twin: unnamed layout containers do not contribute to a data path. */ export function findClientBlocksField(fields, name) {
|
|
10
12
|
for (const field of fields){
|
|
@@ -71,14 +73,18 @@ export function rowLocation(path) {
|
|
|
71
73
|
export function dataAt(data, path) {
|
|
72
74
|
return path.split('.').reduce((value, key)=>value && typeof value === 'object' ? value[key] : undefined, data);
|
|
73
75
|
}
|
|
76
|
+
/** Stable object keys; array order and scalar values remain significant. */ function stringify(value) {
|
|
77
|
+
return JSON.stringify(value, (_, item)=>item && typeof item === 'object' && !Array.isArray(item) ? Object.fromEntries(Object.entries(item).sort(([a], [b])=>a < b ? -1 : a > b ? 1 : 0)) : item);
|
|
78
|
+
}
|
|
74
79
|
/** Payload 3.90.2 and 4 beta.6 use the same row APIs. Sync replaces the whole
|
|
75
80
|
* wrapper so add/drop/reorder and the server-rendered component slots stay together.
|
|
76
81
|
* N=0 needs UPDATE: removeFieldRow alone serializes the empty field as number 0.
|
|
77
82
|
*/ export async function replaceBlockRow(args) {
|
|
78
83
|
const { form, getFormState, collectionSlug, id, field, index, rows } = args;
|
|
79
84
|
const data = form.getData(), before = hostBlocks(data[field]);
|
|
80
|
-
if (args.expectedBlock &&
|
|
81
|
-
|
|
85
|
+
if (args.expectedBlock && stringify(form.getDataByPath(`${field}.${index}`)) !== stringify(args.expectedBlock)) throw new BlockChangedError();
|
|
86
|
+
// Compare the same reader on both sides: getData() keeps empty row counts as 0.
|
|
87
|
+
const original = stringify(form.getDataByPath(field));
|
|
82
88
|
const next = [
|
|
83
89
|
...before.slice(0, index),
|
|
84
90
|
...rows,
|
|
@@ -100,7 +106,7 @@ export function dataAt(data, path) {
|
|
|
100
106
|
renderAllFields: true,
|
|
101
107
|
skipValidation: true
|
|
102
108
|
});
|
|
103
|
-
if (
|
|
109
|
+
if (stringify(form.getDataByPath(field)) !== original) throw new BlockChangedError();
|
|
104
110
|
if (!result.state) throw new Error('Missing server form state');
|
|
105
111
|
const state = result.state;
|
|
106
112
|
function subFieldState(rowIndex) {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { BlockStatus } from './model.js';
|
|
2
2
|
export declare const validBlockSlug: (slug: unknown) => slug is string;
|
|
3
|
+
/** Update a mounted owner's snapshot and invalidate an older pending read. */
|
|
4
|
+
export declare function setBlockStatus(api: string, slug: string, value: BlockStatus): void;
|
|
3
5
|
/** Only status fields own reads. StrictMode replays the same mount token; a
|
|
4
6
|
* different owner after the last cleanup starts fresh, even in the same commit.
|
|
5
7
|
* Deferred eviction removes every entry with no live owner after that commit.
|
|
@@ -9,6 +9,13 @@ const keyOf = (api, slug)=>JSON.stringify([
|
|
|
9
9
|
]);
|
|
10
10
|
const publish = (key)=>listeners.get(key)?.callbacks.forEach((listener)=>listener());
|
|
11
11
|
export const validBlockSlug = (slug)=>typeof slug === 'string' && slug.length > 0 && slug.length <= 64;
|
|
12
|
+
/** Update a mounted owner's snapshot and invalidate an older pending read. */ export function setBlockStatus(api, slug, value) {
|
|
13
|
+
const key = keyOf(api, slug), current = cache.get(key);
|
|
14
|
+
if (!current) return;
|
|
15
|
+
current.revision++;
|
|
16
|
+
current.value = value;
|
|
17
|
+
publish(key);
|
|
18
|
+
}
|
|
12
19
|
/** Only status fields own reads. StrictMode replays the same mount token; a
|
|
13
20
|
* different owner after the last cleanup starts fresh, even in the same commit.
|
|
14
21
|
* Deferred eviction removes every entry with no live owner after that commit.
|
|
@@ -47,7 +54,8 @@ export const validBlockSlug = (slug)=>typeof slug === 'string' && slug.length >
|
|
|
47
54
|
current = {
|
|
48
55
|
value: null,
|
|
49
56
|
owners: 0,
|
|
50
|
-
mounts: new WeakSet()
|
|
57
|
+
mounts: new WeakSet(),
|
|
58
|
+
revision: 0
|
|
51
59
|
};
|
|
52
60
|
cache.set(key, current);
|
|
53
61
|
}
|
|
@@ -55,12 +63,13 @@ export const validBlockSlug = (slug)=>typeof slug === 'string' && slug.length >
|
|
|
55
63
|
owned.owners++;
|
|
56
64
|
owned.mounts.add(token.current);
|
|
57
65
|
publish(key);
|
|
66
|
+
const revision = owned.revision;
|
|
58
67
|
if (!owned.pending) owned.pending = createApi(api).placementStatus({
|
|
59
68
|
slugs: [
|
|
60
69
|
slug
|
|
61
70
|
]
|
|
62
71
|
}).then((result)=>{
|
|
63
|
-
if (cache.get(key) !== owned) return;
|
|
72
|
+
if (cache.get(key) !== owned || owned.revision !== revision) return;
|
|
64
73
|
owned.value = result.ok ? result.data?.slugs[slug] ?? null : null;
|
|
65
74
|
publish(key);
|
|
66
75
|
});
|
|
@@ -11,6 +11,11 @@ export declare const BLOCK_COPY: {
|
|
|
11
11
|
copy: string;
|
|
12
12
|
confirmCopy: string;
|
|
13
13
|
unwrap: string;
|
|
14
|
+
removeAndUnwrap: string;
|
|
15
|
+
deleteAndUnwrap: string;
|
|
16
|
+
alreadyDeleted: string;
|
|
17
|
+
noLongerDraft: string;
|
|
18
|
+
finishUnwrap: string;
|
|
14
19
|
deleted: string;
|
|
15
20
|
discarded: string;
|
|
16
21
|
running: string;
|
|
@@ -22,6 +27,17 @@ export declare const blockPosition: (label: string, position: number) => string;
|
|
|
22
27
|
export declare const blockTestName: (title: string, label: string) => string;
|
|
23
28
|
export declare const blockDraftStatus: (name: string) => string;
|
|
24
29
|
export declare const blockDecidedStatus: (winner: string, rollout: boolean) => string;
|
|
30
|
+
export type BlockLinkedCopy = {
|
|
31
|
+
before: string;
|
|
32
|
+
link: string;
|
|
33
|
+
after: string;
|
|
34
|
+
};
|
|
35
|
+
export declare const blockRunningUnwrap: BlockLinkedCopy;
|
|
36
|
+
export declare const blockDecidedUnwrap: BlockLinkedCopy;
|
|
37
|
+
export declare const blockFinishedCopy: (winner: string) => BlockLinkedCopy;
|
|
25
38
|
export declare const blockFinishedStatus: (winner: string) => string;
|
|
39
|
+
export declare const blockDeleteConfirmation: (name: string) => string;
|
|
40
|
+
export declare const blockUnwrapConfirmation: (name: string) => string;
|
|
41
|
+
export declare const blockUnwrapNotice: (drafts: boolean, deleted: boolean) => string;
|
|
26
42
|
export declare const blockSaveNotice: (drafts: boolean) => "Save a draft of this page to add the test." | "Save this page to add the test.";
|
|
27
43
|
export declare const blockSyncNotice: (drafts: boolean) => "Versions were updated to match the test. Save draft to keep them." | "Versions were updated to match the test. Save to keep them.";
|
|
@@ -11,6 +11,11 @@ export const BLOCK_COPY = {
|
|
|
11
11
|
copy: 'Copy the Original into this version',
|
|
12
12
|
confirmCopy: 'Replace this version’s content with a copy of the Original? Edits you made to this version will be lost.',
|
|
13
13
|
unwrap: 'Unwrap',
|
|
14
|
+
removeAndUnwrap: 'Remove the test and unwrap',
|
|
15
|
+
deleteAndUnwrap: 'Delete and unwrap',
|
|
16
|
+
alreadyDeleted: 'This test was already deleted. Reload the page.',
|
|
17
|
+
noLongerDraft: 'This test is no longer a draft. Reload the page.',
|
|
18
|
+
finishUnwrap: 'The test was removed; use Unwrap to finish.',
|
|
14
19
|
deleted: "This block's test was deleted; the page shows the Original.",
|
|
15
20
|
discarded: 'This test has ended; the page shows the Original.',
|
|
16
21
|
running: "Running: visitors see different versions. Edit a version's tab to change it; the change is noted on the results.",
|
|
@@ -33,6 +38,27 @@ export const blockPosition = (label, position)=>`${label} (block ${position})`;
|
|
|
33
38
|
export const blockTestName = (title, label)=>`${title}: ${label}`;
|
|
34
39
|
export const blockDraftStatus = (name)=>`This block is being tested in the draft test ${name}. Visitors see the Original until it starts.`;
|
|
35
40
|
export const blockDecidedStatus = (winner, rollout)=>`Decided: visitors see ${winner}${rollout ? '; a holdout still sees the Original' : ''}.`;
|
|
36
|
-
export const
|
|
41
|
+
export const blockRunningUnwrap = {
|
|
42
|
+
before: 'Stop ',
|
|
43
|
+
link: 'the test',
|
|
44
|
+
after: ' to unwrap.'
|
|
45
|
+
};
|
|
46
|
+
export const blockDecidedUnwrap = {
|
|
47
|
+
before: 'To put the winner into this page, use "Make it the content" on ',
|
|
48
|
+
link: 'the test',
|
|
49
|
+
after: '.'
|
|
50
|
+
};
|
|
51
|
+
export const blockFinishedCopy = (winner)=>({
|
|
52
|
+
before: `Finished: visitors see ${winner}. Use "Make it the content" on `,
|
|
53
|
+
link: 'the test',
|
|
54
|
+
after: ' to replace this block.'
|
|
55
|
+
});
|
|
56
|
+
export const blockFinishedStatus = (winner)=>{
|
|
57
|
+
const copy = blockFinishedCopy(winner);
|
|
58
|
+
return copy.before + copy.link + copy.after;
|
|
59
|
+
};
|
|
60
|
+
export const blockDeleteConfirmation = (name)=>`Delete the draft test ${name} and put the Original back in its place? Deleting the test can't be undone.`;
|
|
61
|
+
export const blockUnwrapConfirmation = (name)=>`Put the Original back in this block's place? The test ${name} and its results are kept; it can't be started again.`;
|
|
62
|
+
export const blockUnwrapNotice = (drafts, deleted)=>`${deleted ? 'The draft test was deleted and the block unwrapped.' : 'The block was unwrapped.'} ${drafts ? 'Save draft' : 'Save'} to keep it.`;
|
|
37
63
|
export const blockSaveNotice = (drafts)=>drafts ? 'Save a draft of this page to add the test.' : 'Save this page to add the test.';
|
|
38
64
|
export const blockSyncNotice = (drafts)=>drafts ? 'Versions were updated to match the test. Save draft to keep them.' : 'Versions were updated to match the test. Save to keep them.';
|
|
@@ -22,6 +22,7 @@ export declare const LOCKED_MESSAGES: {
|
|
|
22
22
|
};
|
|
23
23
|
export declare function refusalMessage(code: string | undefined, status: number, message?: string, options?: {
|
|
24
24
|
notFound?: string;
|
|
25
|
+
forbidden?: string;
|
|
25
26
|
codes?: Readonly<Record<string, string>>;
|
|
26
27
|
fallback?: string;
|
|
27
28
|
}): string;
|
|
@@ -28,6 +28,6 @@ export function refusalMessage(code, status, message, options) {
|
|
|
28
28
|
if (code === 'not-found' || status === 404) return options?.notFound ?? API_MESSAGES.notFound;
|
|
29
29
|
if (code === 'commit-unverified') return API_MESSAGES.commitUnverified;
|
|
30
30
|
if (code === 'not-implemented' || status === 501) return API_MESSAGES.notImplemented;
|
|
31
|
-
if (code === 'forbidden' || status === 403) return API_MESSAGES.forbidden;
|
|
31
|
+
if (code === 'forbidden' || status === 403) return options?.forbidden ?? API_MESSAGES.forbidden;
|
|
32
32
|
return options?.fallback ?? (message || API_MESSAGES.failed);
|
|
33
33
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export declare const GOALS_COPY: {
|
|
2
2
|
readonly slugRule: "Use 1–64 lowercase letters, numbers, underscores or hyphens, starting with a letter or number.";
|
|
3
3
|
readonly duplicateSlug: "Another goal already uses this short name. Change it under Advanced.";
|
|
4
|
+
readonly duplicateSlugMain: "Another goal already uses this short name. Choose another.";
|
|
4
5
|
readonly title: "Goals";
|
|
5
6
|
readonly goal: "Goal";
|
|
6
7
|
readonly newGoal: "New goal";
|
package/dist/admin/copy/goals.js
CHANGED
|
@@ -2,6 +2,7 @@ import { CLICK_GOAL_COPY } from '../../goals/picker/copy.js';
|
|
|
2
2
|
export const GOALS_COPY = {
|
|
3
3
|
slugRule: CLICK_GOAL_COPY.slugRule,
|
|
4
4
|
duplicateSlug: CLICK_GOAL_COPY.duplicateSlug,
|
|
5
|
+
duplicateSlugMain: 'Another goal already uses this short name. Choose another.',
|
|
5
6
|
title: 'Goals',
|
|
6
7
|
goal: 'Goal',
|
|
7
8
|
newGoal: 'New goal',
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
export declare const PAGE_COPY: {
|
|
2
|
-
readonly search: "Search pages by title";
|
|
3
2
|
readonly more: "More";
|
|
4
3
|
readonly loading: "Loading pages…";
|
|
5
4
|
readonly empty: "There are no pages here yet.";
|
|
@@ -8,8 +7,6 @@ export declare const PAGE_COPY: {
|
|
|
8
7
|
readonly draft: "Draft";
|
|
9
8
|
readonly noPath: "This page has no address yet.";
|
|
10
9
|
readonly choose: "Choose a page";
|
|
11
|
-
readonly change: "Change";
|
|
12
|
-
readonly close: "Close";
|
|
13
10
|
readonly where: "Where it shows";
|
|
14
11
|
readonly open: "Open";
|
|
15
12
|
readonly preview: "Preview";
|
|
@@ -17,10 +14,11 @@ export declare const PAGE_COPY: {
|
|
|
17
14
|
readonly duplicateStep: "Choose Duplicate from the document's actions. Keep the Original's title on the copy, unless the title is what you're testing.";
|
|
18
15
|
readonly ownAddress: "Give the copy its own address and save it as a draft. Publish it when it's ready; the test can't start until it is.";
|
|
19
16
|
readonly thenChoose: "Then choose the page you made.";
|
|
20
|
-
readonly noPage: "No page chosen yet.";
|
|
21
17
|
};
|
|
22
18
|
export declare const usedPageCopy: (label: string) => string;
|
|
23
19
|
export declare const usedAddressCopy: (label: string, path: string, collection: string) => string;
|
|
24
20
|
export declare const openOriginalCopy: (collection: string) => string;
|
|
25
21
|
export declare const openCollectionCopy: (collection: string) => string;
|
|
26
22
|
export declare const pageStepCopy: (label: string) => string;
|
|
23
|
+
export declare const pageNotFoundCopy: (path: string, collection: string) => string;
|
|
24
|
+
export declare const pageFieldCopy: (label: string) => string;
|
package/dist/admin/copy/pages.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
export const PAGE_COPY = {
|
|
2
|
-
search: 'Search pages by title',
|
|
3
2
|
more: 'More',
|
|
4
3
|
loading: 'Loading pages…',
|
|
5
4
|
empty: 'There are no pages here yet.',
|
|
@@ -8,19 +7,18 @@ export const PAGE_COPY = {
|
|
|
8
7
|
draft: 'Draft',
|
|
9
8
|
noPath: 'This page has no address yet.',
|
|
10
9
|
choose: 'Choose a page',
|
|
11
|
-
change: 'Change',
|
|
12
|
-
close: 'Close',
|
|
13
10
|
where: 'Where it shows',
|
|
14
11
|
open: 'Open',
|
|
15
12
|
preview: 'Preview',
|
|
16
13
|
contentChecksOff: 'Content checks are off for this page type.',
|
|
17
14
|
duplicateStep: "Choose Duplicate from the document's actions. Keep the Original's title on the copy, unless the title is what you're testing.",
|
|
18
15
|
ownAddress: "Give the copy its own address and save it as a draft. Publish it when it's ready; the test can't start until it is.",
|
|
19
|
-
thenChoose: 'Then choose the page you made.'
|
|
20
|
-
noPage: 'No page chosen yet.'
|
|
16
|
+
thenChoose: 'Then choose the page you made.'
|
|
21
17
|
};
|
|
22
18
|
export const usedPageCopy = (label)=>`This is ${label}.`;
|
|
23
19
|
export const usedAddressCopy = (label, path, collection)=>`Same address as ${label} (${path}). Change this page's address in ${collection}, publish it, then choose it.`;
|
|
24
20
|
export const openOriginalCopy = (collection)=>`Open the Original in ${collection}.`;
|
|
25
21
|
export const openCollectionCopy = (collection)=>`Open ${collection}`;
|
|
26
22
|
export const pageStepCopy = (label)=>`A page for ${label}`;
|
|
23
|
+
export const pageNotFoundCopy = (path, collection)=>`${path} isn't in ${collection}. Choose a page.`;
|
|
24
|
+
export const pageFieldCopy = (label)=>`Page for ${label}`;
|
|
@@ -25,6 +25,7 @@ export declare const READINESS_COPY: {
|
|
|
25
25
|
readonly everyone: "Everyone already takes part.";
|
|
26
26
|
readonly participation: "How many people take part (%)";
|
|
27
27
|
readonly noPlacements: "Not on a page yet. Open a page, add an Experiment block and choose this test.";
|
|
28
|
+
readonly placementRemoved: "The block for this test was removed from its page, so it can't start again. Its results are kept. To test the block again, start a new test from the page.";
|
|
28
29
|
readonly noPoint: "Choose the setting to test.";
|
|
29
30
|
readonly busy: "Another test is already running on this setting. Stop it first.";
|
|
30
31
|
readonly noGoal: "Choose a goal.";
|
|
@@ -25,6 +25,7 @@ export const READINESS_COPY = {
|
|
|
25
25
|
everyone: 'Everyone already takes part.',
|
|
26
26
|
participation: 'How many people take part (%)',
|
|
27
27
|
noPlacements: 'Not on a page yet. Open a page, add an Experiment block and choose this test.',
|
|
28
|
+
placementRemoved: "The block for this test was removed from its page, so it can't start again. Its results are kept. To test the block again, start a new test from the page.",
|
|
28
29
|
noPoint: 'Choose the setting to test.',
|
|
29
30
|
busy: 'Another test is already running on this setting. Stop it first.',
|
|
30
31
|
noGoal: 'Choose a goal.',
|