@delmaredigital/payload-experiments 0.4.0-rc.1 → 0.4.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +10 -8
  2. package/dist/admin/TestsDashboardCards.d.ts +1 -0
  3. package/dist/admin/TestsDashboardCards.js +4 -8
  4. package/dist/admin/TestsNavLink.d.ts +1 -0
  5. package/dist/admin/TestsNavLink.js +4 -10
  6. package/dist/admin/api.d.ts +7 -0
  7. package/dist/admin/api.js +27 -0
  8. package/dist/admin/blocks/BlockPanel.js +65 -19
  9. package/dist/admin/blocks/formApi.d.ts +3 -0
  10. package/dist/admin/blocks/formApi.js +9 -3
  11. package/dist/admin/blocks/statusCache.d.ts +2 -0
  12. package/dist/admin/blocks/statusCache.js +11 -2
  13. package/dist/admin/copy/blocks.d.ts +16 -0
  14. package/dist/admin/copy/blocks.js +27 -1
  15. package/dist/admin/copy/common.d.ts +1 -0
  16. package/dist/admin/copy/common.js +1 -1
  17. package/dist/admin/copy/pages.d.ts +2 -4
  18. package/dist/admin/copy/pages.js +3 -5
  19. package/dist/admin/copy/readiness.d.ts +1 -0
  20. package/dist/admin/copy/readiness.js +1 -0
  21. package/dist/admin/goals/XpGoalField.d.ts +2 -0
  22. package/dist/admin/goals/XpGoalField.js +51 -35
  23. package/dist/admin/goals/xpGoalField.css +48 -0
  24. package/dist/admin/paths.d.ts +3 -0
  25. package/dist/admin/paths.js +3 -0
  26. package/dist/admin/surface/TestsHome.js +2 -4
  27. package/dist/admin/surface/TestsSurface.js +2 -5
  28. package/dist/admin/surface/goals/GoalForm.js +4 -10
  29. package/dist/admin/surface/goals/GoalsList.js +2 -1
  30. package/dist/admin/surface/home.model.d.ts +2 -2
  31. package/dist/admin/surface/newTest/NewTest.js +2 -4
  32. package/dist/admin/surface/newTest/PageWizard.js +7 -2
  33. package/dist/admin/surface/pagePicker/CopyRoute.d.ts +1 -1
  34. package/dist/admin/surface/pagePicker/{PagePicker.d.ts → PageCombobox.d.ts} +3 -3
  35. package/dist/admin/surface/pagePicker/PageCombobox.js +395 -0
  36. package/dist/admin/surface/pagePicker/pagePicker.css +10 -0
  37. package/dist/admin/surface/pagePicker/pagePicker.model.d.ts +2 -0
  38. package/dist/admin/surface/readiness/readiness.model.js +1 -0
  39. package/dist/admin/surface/results/ResultsHeader.js +3 -8
  40. package/dist/admin/surface/results/useResults.d.ts +10 -0
  41. package/dist/admin/surface/test/GoalSection.js +4 -7
  42. package/dist/admin/surface/test/TestPage.js +3 -8
  43. package/dist/admin/surface/test/pageParts.js +10 -39
  44. package/dist/admin/surface/test/testPage.css +0 -5
  45. package/dist/admin/surface/useTestDocument.d.ts +4 -0
  46. package/dist/admin/surfaceUrls.d.ts +7 -0
  47. package/dist/admin/surfaceUrls.js +19 -0
  48. package/dist/admin/theme.css +2 -0
  49. package/dist/admin/useApi.d.ts +10 -0
  50. package/dist/admin/views/CollectionListRedirect.d.ts +1 -0
  51. package/dist/admin/views/CollectionListRedirect.js +1 -1
  52. package/dist/admin/views/ExperimentsRootView.js +18 -165
  53. package/dist/admin/views/GoalEditRedirect.d.ts +4 -0
  54. package/dist/admin/views/GoalEditRedirect.js +14 -0
  55. package/dist/admin/views/GoalsCollectionListView.d.ts +2 -0
  56. package/dist/admin/views/GoalsCollectionListView.js +4 -0
  57. package/dist/admin/views/TestsCollectionListView.d.ts +2 -0
  58. package/dist/admin/views/TestsCollectionListView.js +4 -0
  59. package/dist/admin/views/TestsNavLinkServer.d.ts +1 -0
  60. package/dist/admin/views/TestsNavLinkServer.js +1 -1
  61. package/dist/admin/views/buildSurfaceProps.d.ts +8 -0
  62. package/dist/admin/views/buildSurfaceProps.js +143 -0
  63. package/dist/admin/views/renderCollectionList.d.ts +2 -0
  64. package/dist/admin/views/renderCollectionList.js +34 -0
  65. package/dist/admin/views/renderTestsSurface.d.ts +3 -0
  66. package/dist/admin/views/renderTestsSurface.js +34 -0
  67. package/dist/collections/experiments/config.js +9 -5
  68. package/dist/collections/metrics.js +16 -4
  69. package/dist/exports/adminRsc.d.ts +3 -0
  70. package/dist/exports/adminRsc.js +3 -0
  71. package/dist/fields/xpGoal.js +2 -14
  72. package/dist/goals/validate.d.ts +3 -0
  73. package/dist/goals/validate.js +14 -0
  74. package/dist/hooks/puckGoals.d.ts +3 -0
  75. package/dist/hooks/puckGoals.js +38 -0
  76. package/dist/plugin.js +12 -18
  77. package/dist/puck/editor/editor.chrome.css +13 -0
  78. package/dist/puck/editor/editor.css +105 -0
  79. package/dist/puck/editor/xpGoalPuckEditorField.js +28 -20
  80. package/dist/sanitizeReadinessDetail.js +2 -0
  81. package/dist/transitions/checkStart.d.ts +2 -1
  82. package/dist/transitions/checkStart.js +3 -1
  83. package/dist/transitions/decide.js +1 -0
  84. package/dist/transitions/start.js +2 -1
  85. package/dist/types/endpoints.d.ts +1 -1
  86. package/dist/ui/ui.css +4 -0
  87. package/package.json +1 -1
  88. package/dist/admin/surface/pagePicker/PagePicker.js +0 -172
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @delmaredigital/payload-experiments
2
2
 
3
- > ⚠️ Upgrading to 0.4? Run `payload migrate:create` for four nullable plugin columns (`xp_experiments.page_collection` and its `_xp_experiments_v` counterpart, `xp_phases.placement_docs`, `xp_state.retired_slugs`), plus the Experiment block and `xpGoal` columns if you adopt them, then `payload generate:importmap`. Saves on pages, blocks and Puck collections can now be refused while a test runs on them (`page-busy`, `interacting-tests`); Puck tests whose slot is only on a draft can't start until the page is published; host `metricSources` ids may not start with `click:`. See the CHANGELOG's BREAKING section.
3
+ > ⚠️ Upgrading to 0.4? Run `payload migrate:create` for four nullable plugin columns (`xp_experiments.page_collection` and its `_xp_experiments_v` counterpart, `xp_phases.placement_docs`, `xp_state.retired_slugs`), plus the Experiment block and `xpGoal` columns if you adopt them, then `payload generate:importmap`. Saves on pages, blocks and Puck collections can now be refused while a test runs on them (`page-busy`, `interacting-tests`); Puck tests whose slot is only on a draft can't start until the page is published; host `metricSources` ids may not start with `click:`; `admin.editorUrl` now receives a required `editor` argument. See the CHANGELOG's BREAKING section.
4
4
 
5
5
  > ⚠️ Upgrading to 0.3? For 0.3.0-rc.1, every `linkIdentity` call must supply `verified`; it now returns an outcome. Run `payload migrate:create` for the nullable `xp_identities.verified_at` column and `user_id` index, plus the release-pages columns listed in the CHANGELOG (`decision_pages_released_at` / `decision_pages_released_by` on `xp_experiments` and their `version_` counterparts on `_xp_experiments_v`). Existing identity links become unverified; see the CHANGELOG for optional backfill.
6
6
 
@@ -620,13 +620,15 @@ for these patterns.
620
620
 
621
621
  ## Admin and client
622
622
 
623
- The sidebar includes a **Tests** group with **All tests** and **Goals**. The
624
- dashboard also includes **Tests** and **Goals** cards above its widgets. Goals
625
- appears only for users who can manage tests. Both additions follow the host's
626
- admin route and keep host-provided navigation and dashboard entries first.
627
- After upgrading, run `payload generate:importmap` to register the dashboard card
628
- component and the server nav wrapper.
629
- A host nav group also labelled "Tests" shares Payload's NavGroup DOM id and open-state preference with the plugin's group, so pick another label.
623
+ **Tests** is a regular navigation group for managers only, with **All tests**
624
+ and **Goals**. It is the first named group, after Payload's default
625
+ Collections/Globals groups when the host has ungrouped entities. Payload's own
626
+ dashboard shows the Tests cards. A host collection with `group: 'Tests'` joins
627
+ the plugin's group.
628
+
629
+ After upgrading, run `payload generate:importmap` to register the two list views
630
+ and the Goals edit redirect. Without regeneration, both list URLs and the Goal
631
+ create/edit URLs show Payload's raw views.
630
632
 
631
633
  ### Client assignments and exposure
632
634
 
@@ -1,3 +1,4 @@
1
1
  import './theme.css';
2
2
  import './dashboard.css';
3
+ /** @deprecated Retained for existing import maps until 0.5.0. */
3
4
  export declare function TestsDashboardCards(): import("react").JSX.Element | null;
@@ -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: href('/experiments'),
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: href('/experiments/goals'),
40
+ href: surfaceUrls.goals(config.routes.admin),
45
41
  id: "card-xp-goals"
46
42
  })
47
43
  })
@@ -1,4 +1,5 @@
1
1
  import './theme.css';
2
+ /** @deprecated Retained for existing import maps until 0.5.0. */
2
3
  declare function TestsNav({ isOpen }: {
3
4
  isOpen?: boolean;
4
5
  }): import("react").JSX.Element | null;
@@ -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 = formatAdminURL({
13
- adminRoute: config.routes.admin,
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}/`));
@@ -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 { useBlockStatus } from './statusCache.js';
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 [syncFailed, setSyncFailed] = useState(false);
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
- setSyncFailed(false);
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
- setSyncFailed(true);
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
- } catch {
55
- setError(API_MESSAGES.failed);
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
- manager && isOrphan(status) ? /*#__PURE__*/ _jsx(Button, {
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
- syncFailed ? /*#__PURE__*/ _jsx("p", {
127
+ syncError ? /*#__PURE__*/ _jsx("p", {
82
128
  role: "alert",
83
- children: API_MESSAGES.failed
129
+ children: syncError
84
130
  }) : null,
85
- syncFailed && canSync ? /*#__PURE__*/ _jsx(Button, {
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(API_MESSAGES.failed);
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 && JSON.stringify(before[index]) !== JSON.stringify(args.expectedBlock)) throw new BlockChangedError();
81
- const original = JSON.stringify(data[field]);
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 (JSON.stringify(form.getDataByPath(field)) !== original) throw new BlockChangedError();
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 blockFinishedStatus = (winner)=>`Finished: visitors see ${winner}. Use "Make it the content" on the test to replace this block.`;
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,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;
@@ -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.',
@@ -1,2 +1,4 @@
1
1
  import type { TextFieldClientProps } from 'payload';
2
+ import '../theme.css';
3
+ import './xpGoalField.css';
2
4
  export declare function XpGoalField({ path, field, readOnly }: TextFieldClientProps): import("react").JSX.Element;