@delmaredigital/payload-experiments 0.3.0-rc.1 → 0.3.0-rc.4

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 (124) hide show
  1. package/README.md +74 -16
  2. package/dist/admin/PreviewLinks.js +45 -24
  3. package/dist/admin/TestsNavLink.d.ts +3 -1
  4. package/dist/admin/TestsNavLink.js +2 -2
  5. package/dist/admin/armSelect/ArmSelectField.js +14 -22
  6. package/dist/admin/arms/arms.model.d.ts +1 -21
  7. package/dist/admin/arms/arms.model.js +1 -153
  8. package/dist/admin/copy/arms.d.ts +1 -6
  9. package/dist/admin/copy/arms.js +1 -16
  10. package/dist/admin/copy/readiness.d.ts +4 -1
  11. package/dist/admin/copy/readiness.js +3 -1
  12. package/dist/admin/copy/results.d.ts +2 -0
  13. package/dist/admin/copy/results.js +3 -1
  14. package/dist/admin/copy/review.d.ts +1 -1
  15. package/dist/admin/copy/review.js +1 -1
  16. package/dist/admin/copy/surface.d.ts +0 -1
  17. package/dist/admin/copy/surface.js +0 -1
  18. package/dist/admin/paths.d.ts +2 -1
  19. package/dist/admin/paths.js +2 -1
  20. package/dist/admin/previewLinks.model.d.ts +2 -0
  21. package/dist/admin/previewLinks.model.js +3 -0
  22. package/dist/admin/results/legacy.d.ts +16 -0
  23. package/dist/admin/results/legacy.js +27 -0
  24. package/dist/admin/results/results.model.d.ts +6 -6
  25. package/dist/admin/results/results.model.js +14 -5
  26. package/dist/admin/results/snapshot.d.ts +14 -0
  27. package/dist/admin/results/snapshot.js +3 -0
  28. package/dist/admin/surface/TestsHome.js +6 -9
  29. package/dist/admin/surface/goals/data.js +16 -16
  30. package/dist/admin/surface/home.model.d.ts +1 -0
  31. package/dist/admin/surface/newTest/NewTest.js +4 -4
  32. package/dist/admin/surface/readiness/ActionDialogs.js +4 -5
  33. package/dist/admin/surface/readiness/readiness.model.js +8 -1
  34. package/dist/admin/surface/results/ResultsBody.js +2 -1
  35. package/dist/admin/surface/results/recommendation.model.d.ts +3 -2
  36. package/dist/admin/surface/results/recommendation.model.js +10 -3
  37. package/dist/admin/surface/surface.css +0 -1
  38. package/dist/admin/surface/test/GoalSection.js +7 -15
  39. package/dist/admin/surface/test/testPage.model.d.ts +2 -3
  40. package/dist/admin/surface/test/testPage.model.js +1 -21
  41. package/dist/admin/surface/useTestDocument.js +2 -1
  42. package/dist/admin/useLayerUsage.js +14 -23
  43. package/dist/admin/views/TestsNavLinkServer.d.ts +2 -0
  44. package/dist/admin/views/TestsNavLinkServer.js +43 -0
  45. package/dist/arms/copy.d.ts +6 -0
  46. package/dist/arms/copy.js +16 -0
  47. package/dist/arms/model.d.ts +23 -0
  48. package/dist/arms/model.js +173 -0
  49. package/dist/db/appends.d.ts +6 -2
  50. package/dist/db/appends.js +10 -4
  51. package/dist/db/locks.d.ts +6 -0
  52. package/dist/db/locks.js +13 -1
  53. package/dist/db/pageOccupancy.d.ts +12 -0
  54. package/dist/db/pageOccupancy.js +45 -0
  55. package/dist/db/snapshots.js +4 -3
  56. package/dist/exports/adminRsc.d.ts +1 -0
  57. package/dist/exports/adminRsc.js +1 -0
  58. package/dist/exports/index.d.ts +1 -1
  59. package/dist/exports/puck.d.ts +2 -1
  60. package/dist/exports/puck.js +1 -1
  61. package/dist/exports/puckEditor.d.ts +3 -1
  62. package/dist/exports/puckEditor.js +2 -1
  63. package/dist/exports/rsc.d.ts +1 -0
  64. package/dist/hooks/pageGuard.js +21 -3
  65. package/dist/lib/fetchAllDocs.d.ts +16 -0
  66. package/dist/lib/fetchAllDocs.js +56 -0
  67. package/dist/options.js +2 -9
  68. package/dist/plugin.js +4 -0
  69. package/dist/puck/ExperimentPicker.js +47 -16
  70. package/dist/puck/ExperimentSlot.d.ts +51 -1
  71. package/dist/puck/ExperimentSlot.js +23 -12
  72. package/dist/puck/copy.d.ts +5 -4
  73. package/dist/puck/copy.js +5 -4
  74. package/dist/puck/editor/CreateExperimentDrawer.js +46 -56
  75. package/dist/puck/editor/ExperimentSlotEditor.d.ts +52 -1
  76. package/dist/puck/editor/ExperimentSlotEditor.js +292 -137
  77. package/dist/puck/editor/ResultsReadout.d.ts +6 -0
  78. package/dist/puck/editor/ResultsReadout.js +99 -0
  79. package/dist/puck/editor/VersionBuilder.d.ts +34 -0
  80. package/dist/puck/editor/VersionBuilder.js +317 -0
  81. package/dist/puck/editor/armWrites.d.ts +7 -0
  82. package/dist/puck/editor/armWrites.js +71 -0
  83. package/dist/puck/editor/armsCache.d.ts +14 -0
  84. package/dist/puck/editor/armsCache.js +78 -0
  85. package/dist/puck/editor/copy.d.ts +33 -1
  86. package/dist/puck/editor/copy.js +45 -8
  87. package/dist/puck/editor/createTest.d.ts +1 -1
  88. package/dist/puck/editor/createTest.js +25 -14
  89. package/dist/puck/editor/editor.chrome.css +47 -161
  90. package/dist/puck/editor/editor.css +47 -161
  91. package/dist/puck/editor/editor.model.d.ts +4 -1
  92. package/dist/puck/editor/editor.model.js +10 -7
  93. package/dist/puck/editor/experimentOutlinePlugin.d.ts +3 -0
  94. package/dist/puck/editor/experimentOutlinePlugin.js +89 -0
  95. package/dist/puck/experimentSlotField.d.ts +2 -0
  96. package/dist/puck/experimentSlotField.js +11 -3
  97. package/dist/puck/hostContext.d.ts +10 -0
  98. package/dist/puck/hostContext.js +12 -0
  99. package/dist/puck/routes.d.ts +12 -0
  100. package/dist/puck/routes.js +11 -0
  101. package/dist/puck/slotConfig.d.ts +1 -1
  102. package/dist/puck/slotConfig.js +2 -2
  103. package/dist/sanitizeReadinessDetail.js +10 -1
  104. package/dist/server/metadata.js +9 -2
  105. package/dist/server/normaliseContentRelationships.d.ts +7 -0
  106. package/dist/server/normaliseContentRelationships.js +62 -0
  107. package/dist/server/recordContentChange.d.ts +1 -1
  108. package/dist/server/recordContentChange.js +17 -8
  109. package/dist/server/runResults.js +36 -4
  110. package/dist/transitions/checkStart.d.ts +5 -1
  111. package/dist/transitions/checkStart.js +10 -0
  112. package/dist/transitions/decide.js +16 -1
  113. package/dist/transitions/placements.d.ts +2 -6
  114. package/dist/transitions/placements.js +8 -1
  115. package/dist/transitions/preflightData.d.ts +8 -0
  116. package/dist/transitions/preflightData.js +116 -36
  117. package/dist/transitions/start.js +33 -3
  118. package/dist/types/contracts.d.ts +19 -13
  119. package/dist/types/endpoints.d.ts +1 -1
  120. package/dist/ui/Tabs.d.ts +4 -2
  121. package/dist/ui/Tabs.js +33 -26
  122. package/dist/ui/accent.d.ts +22 -3
  123. package/dist/ui/accent.js +74 -16
  124. package/package.json +5 -2
package/README.md CHANGED
@@ -271,7 +271,10 @@ Other server integrations:
271
271
  - `recordContentChange({ doc, previousDoc, req, collection })` can be called
272
272
  from a host collection's `afterChange` for content tagged with the arm-select
273
273
  field; for a collection with drafts, also add `captureContentBaseline` to its
274
- `beforeChange` hooks so the previously published content can be compared. Supply that hook's transaction request. Puck collections get the hook
274
+ `beforeChange` hooks so the previously published content can be compared.
275
+ Collections without drafts can also add `captureContentBaseline` for an exact
276
+ raw comparison; without it, the helper normalises relationships to ids. No host
277
+ change is required for collections without drafts. Supply that hook's transaction request. Puck collections get the hook
275
278
  automatically when enabled. Markers compare published content; drafts and
276
279
  autosaves do not add markers (collections without drafts record on save).
277
280
  Neither the helper nor the installed hooks ever throw into your save: a
@@ -281,7 +284,10 @@ Other server integrations:
281
284
  canonical; archived losing paths point canonically to the winner. It reads
282
285
  the signed request context via `headers()`, so it decides from the URL the
283
286
  visitor requested, not the rewritten route, and the Original's URL is never
284
- noindex. Call it only from a dynamic `generateMetadata`, never inside
287
+ noindex. While a test is live the Original's URL also gets a canonical to
288
+ itself, even when it is served with a variant's route: merge this result after
289
+ your own metadata so its `alternates.canonical` wins over one derived from the
290
+ rendered route. Call it only from a dynamic `generateMetadata`, never inside
285
291
  `'use cache'`; `path` is the fallback when the proxy did not run.
286
292
  `experimentSitemapFilter(path, manifest)` is a synchronous build-time filter:
287
293
  pass a privately server-loaded manifest and keep paths for which it returns
@@ -488,13 +494,23 @@ No migration is needed.
488
494
  ### `admin` options
489
495
 
490
496
  Host links and kind descriptions are optional. For a host using payload-puck's
491
- editor route and a `puck-templates` collection, pass:
497
+ editor route, public pages and a `puck-templates` collection:
498
+
499
+ | Option | Signature and behavior |
500
+ | --- | --- |
501
+ | `admin.previewUrl` | `(placement: PreviewPlacement) => string \| null \| undefined \| Promise<string \| null \| undefined>`. Receives `collection`, `id`, `title`, `published`, and optional `slug`. May be async; return `null` to hide preview for that placement. Each call has a 2 s limit, with at most 8 lookups awaited concurrently. |
502
+
503
+ For example, pass:
492
504
 
493
505
  ```ts
494
506
  admin: {
495
507
  editorUrl: ({ collection, id, draft }) =>
496
508
  `/admin/puck-editor/${encodeURIComponent(collection)}/${encodeURIComponent(String(id))}`,
497
- previewUrl: ({ id }) => `/preview/template/${encodeURIComponent(String(id))}`,
509
+ previewUrl: async ({ collection, slug }) => {
510
+ if (collection !== 'pages' || slug === undefined) return null
511
+ // Match the site's public page routing, including its homepage convention.
512
+ return slug === 'home' ? '/' : `/${slug}`
513
+ },
498
514
  descriptions: {
499
515
  block: 'Try different content on a landing page.',
500
516
  document: 'Try different versions of a donation ask.',
@@ -503,11 +519,21 @@ admin: {
503
519
  }
504
520
  ```
505
521
 
506
- Use your host's preview route for `previewUrl`. Preflight resolves both URL
507
- callbacks on the server for block placements; it passes only URL strings to the
508
- client. Missing or failed callbacks leave the URL unset, and the client uses the
509
- admin collection route when no editor URL is supplied. Kind descriptions are
510
- available to the server views through `config.custom.xp`.
522
+ `PreviewPlacement` is exported from the package root and `/rsc`. Its `slug` is
523
+ the published main row's string slug, including for a placement found in a newer
524
+ draft with a different slug. It is absent for unpublished documents. `published`
525
+ describes the placement's version; the callback receives no other document fields.
526
+ An async callback can also look up the public path by `collection` and `id` using
527
+ the host's own data access. The old `({ collection, id }) => string` form still works.
528
+
529
+ Preflight resolves the URL callbacks on the server for block placements and sends
530
+ only URL strings to the client. A null/undefined result, a non-string, an exception,
531
+ a rejected promise or a timeout leaves preview hidden for that placement. When an
532
+ editor URL exists, the admin offers "Edit on page"; when neither URL exists, it
533
+ omits the row. Timed-out host work cannot be cancelled and keeps its concurrency
534
+ slot until it settles; if all 8 slots remain occupied, further preview lookups
535
+ are skipped for that preflight. Kind descriptions are available to the server views through
536
+ `config.custom.xp`.
511
537
 
512
538
  A flag point can also declare `description?: string` to explain the setting below its label.
513
539
 
@@ -567,7 +593,8 @@ dashboard also includes **Tests** and **Goals** cards above its widgets. Goals
567
593
  appears only for users who can manage tests. Both additions follow the host's
568
594
  admin route and keep host-provided navigation and dashboard entries first.
569
595
  After upgrading, run `payload generate:importmap` to register the dashboard card
570
- component.
596
+ component and the server nav wrapper.
597
+ 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.
571
598
 
572
599
  ### Client assignments and exposure
573
600
 
@@ -620,19 +647,41 @@ configured to allow client writes; record revenue on the server.
620
647
 
621
648
  ### Puck render and editor configs
622
649
 
623
- Register `ExperimentSlot` from `@delmaredigital/payload-experiments/puck` in
624
- your render config and `ExperimentSlotEditor` from
625
- `@delmaredigital/payload-experiments/puck/editor` in your editor config, using
626
- the same `ExperimentSlot` component name:
650
+ Register the factories from `/puck` and `/puck/editor`, using the same
651
+ `ExperimentSlot` component name in both configs. Pass the host's Payload routes:
627
652
 
628
653
  ```ts
654
+ import { createExperimentSlot } from '@delmaredigital/payload-experiments/puck'
655
+ import { createExperimentSlotEditor, experimentOutlinePlugin } from '@delmaredigital/payload-experiments/puck/editor'
656
+
657
+ const routes = { admin: '/manage', api: '/content-api' } // Same as Payload config.routes
658
+ const accent = '#1f3a68' // Same as the plugin's theme.accent
659
+
629
660
  // Render config
630
- components: { ExperimentSlot }
661
+ components: { ExperimentSlot: createExperimentSlot({ routes }) }
631
662
 
632
663
  // Editor config
633
- components: { ExperimentSlot: ExperimentSlotEditor }
664
+ components: { ExperimentSlot: createExperimentSlotEditor({ routes, accent }) }
665
+ ```
666
+
667
+ Add `experimentOutlinePlugin()` to Puck's `plugins` alongside this block config
668
+ (with payload-puck, use its `plugins` option). It composes with the host's outline
669
+ override and hides empty version slots that the block's test doesn't use. Content
670
+ in an unused slot stays visible in the outline and gets a warning in the block.
671
+
672
+ ```ts
673
+ plugins: [experimentOutlinePlugin()]
634
674
  ```
635
675
 
676
+ Routes default to Payload's `/admin` and `/api` when omitted. The existing
677
+ `ExperimentSlot` and `ExperimentSlotEditor` exports remain available with these
678
+ defaults. Hosts on non-default routes switch to the factories. The standalone
679
+ `createExperimentSlotField({ routes })` also accepts the host's routes.
680
+
681
+ Pass the same accent as the plugin's `theme.accent`: the editor cannot read
682
+ Payload config. Both use the same validator and report the same error for an
683
+ invalid colour. Without an accent the editor uses the neutral palette.
684
+
636
685
  Load the editor stylesheet inside the Puck iframe. With Tailwind v4, add the
637
686
  package import **after** Tailwind in the CSS that builds your editor iframe
638
687
  stylesheet (the stylesheet listed in payload-puck's `editorStylesheets`):
@@ -648,6 +697,15 @@ The editor CSS includes its own light and dark palette, selected by
648
697
  `data-theme="dark"` or the `dark` class on the iframe's `<html>`. The client component's CSS import alone
649
698
  does not reach an iframe configured with `syncHostStyles: false`.
650
699
 
700
+ If Puck runs outside Payload's admin, the page needs Payload's admin styles (the plugin's tokens map onto them) and the plugin's admin styles, plus `data-theme="light"` or `data-theme="dark"` on `<html>`. Inside the admin all of this is already in place:
701
+
702
+ ```css
703
+ @import "@payloadcms/next/css";
704
+ @import "@payloadcms/ui/styles.css";
705
+ @import "@delmaredigital/payload-experiments/admin/theme.css";
706
+ @import "@delmaredigital/payload-experiments/admin/ui.css";
707
+ ```
708
+
651
709
  Resolve the Puck data on the server with `resolveExperimentData` before
652
710
  rendering it. The render component shows only the selected group's slot. The
653
711
  editor lets you select a block experiment, edit each group's content, copy the
@@ -5,8 +5,9 @@ import { Button } from '../ui/Button.js';
5
5
  import { armLabel } from './labels.js';
6
6
  import { useApi } from './useApi.js';
7
7
  import { REVIEW_COPY as C } from './copy/review.js';
8
- import { API_MESSAGES } from './copy/common.js';
9
- import { previewPages } from './previewLinks.model.js';
8
+ import { TEST_PAGE } from './copy/testPage.js';
9
+ import { API_MESSAGES, refusalMessage } from './copy/common.js';
10
+ import { actionablePreviewPages } from './previewLinks.model.js';
10
11
  export function PreviewLinks({ experimentId, placements, arms, flagPoint }) {
11
12
  const api = useApi();
12
13
  const inFlight = useRef(false);
@@ -17,20 +18,37 @@ export function PreviewLinks({ experimentId, placements, arms, flagPoint }) {
17
18
  inFlight.current = true;
18
19
  setBusy(true);
19
20
  setError(null);
21
+ let win = null;
20
22
  try {
23
+ win = window.open('', '_blank');
24
+ if (!win) {
25
+ setError(C.previewBlocked);
26
+ return;
27
+ }
28
+ // Clearing opener immediately gives the same opener protection as noopener: the
29
+ // previewed page cannot reach window.opener. The admin URL may be sent as
30
+ // referrer to the host's own site (resolved against location.origin); accepted.
31
+ try {
32
+ win.opener = null;
33
+ } catch {}
21
34
  const result = await api.previewToken(experimentId, arm);
35
+ // The person closed the blank tab while the token loaded: nothing to show or report.
36
+ if (win.closed) return;
22
37
  if (!result.ok) {
23
- setError(result.message);
38
+ win.close();
39
+ setError(result.status === 0 ? API_MESSAGES.network : refusalMessage(result.code, result.status));
24
40
  return;
25
41
  }
26
- if (!result.data?.token) {
42
+ if (typeof result.data?.token !== 'string' || !result.data.token) {
43
+ win.close();
27
44
  setError(API_MESSAGES.unreadable);
28
45
  return;
29
46
  }
30
47
  const target = new URL(url, window.location.origin);
31
48
  target.searchParams.set('xp_preview', result.data.token);
32
- window.open(target.href, '_blank', 'noopener,noreferrer');
49
+ win.location.replace(target.href);
33
50
  } catch {
51
+ win?.close();
34
52
  setError(API_MESSAGES.failed);
35
53
  } finally{
36
54
  inFlight.current = false;
@@ -40,7 +58,7 @@ export function PreviewLinks({ experimentId, placements, arms, flagPoint }) {
40
58
  return /*#__PURE__*/ _jsxs("div", {
41
59
  className: "xp-preview-links",
42
60
  children: [
43
- previewPages(placements, flagPoint).map((placement)=>/*#__PURE__*/ _jsxs("div", {
61
+ actionablePreviewPages(placements, flagPoint).map((placement)=>/*#__PURE__*/ _jsxs("div", {
44
62
  className: "xp-preview-row",
45
63
  children: [
46
64
  /*#__PURE__*/ _jsxs("p", {
@@ -49,26 +67,29 @@ export function PreviewLinks({ experimentId, placements, arms, flagPoint }) {
49
67
  placement.slotCount > 1 ? ` ${C.placedTimes(placement.slotCount)}` : null
50
68
  ]
51
69
  }),
52
- /*#__PURE__*/ _jsx("div", {
70
+ /*#__PURE__*/ _jsxs("div", {
53
71
  className: "xp-ui-button-group",
54
- children: placement.url ? arms.filter((arm)=>arm.kind !== 'holdout' && arm.key !== 'holdout').map((arm)=>/*#__PURE__*/ _jsxs(Button, {
55
- variant: "secondary",
72
+ children: [
73
+ placement.url ? arms.filter((arm)=>arm.kind !== 'holdout' && arm.key !== 'holdout').map((arm)=>/*#__PURE__*/ _jsxs(Button, {
74
+ variant: "secondary",
75
+ size: "sm",
76
+ disabled: busy,
77
+ onClick: ()=>{
78
+ void preview(placement.url, arm.key);
79
+ },
80
+ children: [
81
+ C.preview,
82
+ " ",
83
+ arm.label?.trim() || armLabel(arm.key)
84
+ ]
85
+ }, arm.key)) : null,
86
+ placement.editorUrl ? /*#__PURE__*/ _jsx(Button, {
87
+ variant: "link",
56
88
  size: "sm",
57
- disabled: busy,
58
- onClick: ()=>{
59
- void preview(placement.url, arm.key);
60
- },
61
- children: [
62
- C.preview,
63
- " ",
64
- arm.label?.trim() || armLabel(arm.key)
65
- ]
66
- }, arm.key)) : /*#__PURE__*/ _jsx(Button, {
67
- variant: "secondary",
68
- size: "sm",
69
- disabled: true,
70
- children: C.noPreview
71
- })
89
+ href: placement.editorUrl,
90
+ children: TEST_PAGE.editOnPage
91
+ }) : null
92
+ ]
72
93
  })
73
94
  ]
74
95
  }, placement.key)),
@@ -1,3 +1,5 @@
1
1
  import './theme.css';
2
- declare function TestsNav(): import("react").JSX.Element | null;
2
+ declare function TestsNav({ isOpen }: {
3
+ isOpen?: boolean;
4
+ }): import("react").JSX.Element | null;
3
5
  export { TestsNav as TestsNavLink };
@@ -5,7 +5,7 @@ import { Link, NavGroup, useAuth, useConfig } from '@payloadcms/ui';
5
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() {
8
+ function TestsNav({ isOpen = true }) {
9
9
  const { config } = useConfig();
10
10
  const { permissions } = useAuth();
11
11
  const pathname = usePathname();
@@ -38,9 +38,9 @@ function TestsNav() {
38
38
  active: goalsActive
39
39
  }
40
40
  ];
41
- // getPreference is async and can fetch; let NavGroup default to open.
42
41
  return /*#__PURE__*/ _jsx(NavGroup, {
43
42
  label: SURFACE_COPY.title,
43
+ isOpen: isOpen,
44
44
  children: entries.map((entry)=>{
45
45
  const props = {
46
46
  className: 'nav__link',
@@ -1,9 +1,11 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { fetchAllDocs } from '../../lib/fetchAllDocs.js';
3
4
  import { Banner, Button, SelectInput, useConfig, useField } from '@payloadcms/ui';
4
5
  import { useEffect, useState } from 'react';
5
6
  import { bindArmSelect, experimentId } from './armSelect.model.js';
6
7
  import { ARM_SELECT_COPY } from '../copy/armSelect.js';
8
+ /** Only authored copy reaches the field; transport or parse errors read as unavailable. */ const knownCopy = (reason)=>reason instanceof Error && Object.values(ARM_SELECT_COPY).includes(reason.message) ? reason.message : ARM_SELECT_COPY.unavailable;
7
9
  export function ArmSelectField({ path, field, readOnly }) {
8
10
  const { config } = useConfig();
9
11
  const api = config.routes.api;
@@ -26,27 +28,17 @@ export function ArmSelectField({ path, field, readOnly }) {
26
28
  if (!point) return;
27
29
  const controller = new AbortController();
28
30
  async function list(statuses) {
29
- const docs = [];
30
- let page = 1;
31
- while(!controller.signal.aborted){
32
- const query = new URLSearchParams({
33
- depth: '0',
34
- limit: '100',
35
- page: String(page),
36
- 'where[flagPoint][equals]': point,
37
- 'where[status][in]': statuses
38
- });
39
- const response = await fetch(`${api}/xp-experiments?${query}`, {
40
- credentials: 'include',
41
- signal: controller.signal
42
- });
43
- if (!response.ok) throw new Error(response.status === 403 ? ARM_SELECT_COPY.forbidden : ARM_SELECT_COPY.unavailable);
44
- const data = await response.json();
45
- docs.push(...data.docs);
46
- if (!data.hasNextPage) break;
47
- page++;
48
- }
49
- return docs;
31
+ const query = new URLSearchParams({
32
+ depth: '0',
33
+ 'where[flagPoint][equals]': point,
34
+ 'where[status][in]': statuses
35
+ });
36
+ const response = await fetchAllDocs(`${api}/xp-experiments?${query}`, {
37
+ credentials: 'include',
38
+ signal: controller.signal
39
+ });
40
+ if (!response.ok) throw new Error(response.status === 403 ? ARM_SELECT_COPY.forbidden : ARM_SELECT_COPY.unavailable);
41
+ return response.docs;
50
42
  }
51
43
  void Promise.all([
52
44
  list('running,decided'),
@@ -63,7 +55,7 @@ export function ArmSelectField({ path, field, readOnly }) {
63
55
  }).catch((reason)=>{
64
56
  if (!controller.signal.aborted) setError({
65
57
  key,
66
- message: reason instanceof Error ? reason.message : ARM_SELECT_COPY.unavailable
58
+ message: knownCopy(reason)
67
59
  });
68
60
  });
69
61
  return ()=>controller.abort();
@@ -1,21 +1 @@
1
- import type { ArmIssue } from '../../collections/experiments/armInvariants.js';
2
- import type { Arm, ArmKey, ExperimentKind } from '../../types/contracts.js';
3
- export declare function defaultArms(kind?: ExperimentKind): Arm[];
4
- /** Keep content slots in step with the experiment kind, including an unset kind. */
5
- export declare function withSlotsFor(arms: Arm[], kind?: ExperimentKind): Arm[];
6
- export declare function totalWeight(arms: Arm[]): number;
7
- export declare function addVariant(arms: Arm[], kind?: ExperimentKind): Arm[];
8
- export declare function removeArm(arms: Arm[], key: ArmKey): Arm[];
9
- export declare function addHoldoutArm(arms: Arm[]): Arm[];
10
- export declare function removeHoldout(arms: Arm[]): Arm[];
11
- export declare function splitEvenly(arms: Arm[]): Arm[];
12
- export declare function setWeight(arms: Arm[], key: ArmKey, weight: number): Arm[];
13
- export declare function issuesByRow(arms: Arm[], kind?: ExperimentKind): {
14
- rows: ArmIssue[][];
15
- form: ArmIssue[];
16
- };
17
- export declare const ARM_FIELDS: readonly ["key", "label", "weight", "kind", "slot", "params", "pageTarget"];
18
- /** Payload arrays store each row's values at separate field paths. */
19
- export declare function readArms(fields: Record<string, {
20
- value?: unknown;
21
- }>, path?: string): Arm[];
1
+ export * from '../../arms/model.js';
@@ -1,153 +1 @@
1
- import { addHoldout, checkArms } from '../../collections/experiments/armInvariants.js';
2
- import { GROUP_LABELS } from '../copy/arms.js';
3
- const VARIANTS = [
4
- 'variantA',
5
- 'variantB',
6
- 'variantC'
7
- ];
8
- export function defaultArms(kind) {
9
- return withSlotsFor([
10
- {
11
- key: 'control',
12
- kind: 'control',
13
- label: GROUP_LABELS.control,
14
- weight: 50
15
- },
16
- {
17
- key: 'variantA',
18
- kind: 'variant',
19
- label: GROUP_LABELS.variantA,
20
- weight: 50
21
- }
22
- ], kind);
23
- }
24
- /** Keep content slots in step with the experiment kind, including an unset kind. */ export function withSlotsFor(arms, kind) {
25
- const next = arms.map((arm)=>{
26
- if (kind === 'block' && arm.kind !== 'holdout') return arm.slot === arm.key ? arm : {
27
- ...arm,
28
- slot: arm.key
29
- };
30
- // Postgres returns null for unset columns; null counts as "no slot" so a loaded doc is not rewritten.
31
- if (arm.slot == null) return arm;
32
- const { slot: _slot, ...rest } = arm;
33
- return rest;
34
- });
35
- return next.every((arm, index)=>arm === arms[index]) ? arms : next;
36
- }
37
- const finiteWeight = (weight)=>Number.isFinite(weight) ? weight : 0;
38
- export function totalWeight(arms) {
39
- return arms.reduce((sum, arm)=>sum + finiteWeight(arm.weight), 0);
40
- }
41
- export function addVariant(arms, kind) {
42
- if (!arms.some((arm)=>arm.kind === 'control')) return arms;
43
- const key = VARIANTS.find((candidate)=>!arms.some((arm)=>arm.key === candidate));
44
- if (!key || arms.filter((arm)=>arm.kind === 'variant').length >= 3) return arms;
45
- const block = kind === 'block' || kind === undefined && arms.some((arm)=>arm.slot !== undefined);
46
- const variant = {
47
- key,
48
- kind: 'variant',
49
- label: GROUP_LABELS[key],
50
- weight: 0,
51
- ...block ? {
52
- slot: key
53
- } : {}
54
- };
55
- const index = arms.findIndex((arm)=>arm.kind === 'holdout');
56
- const result = [
57
- ...arms
58
- ];
59
- result.splice(index < 0 ? arms.length : index, 0, variant);
60
- return result;
61
- }
62
- export function removeArm(arms, key) {
63
- if (arms.some((arm)=>arm.key === key && arm.kind === 'control')) return arms;
64
- if (key === 'holdout') return removeHoldout(arms);
65
- return arms.filter((arm)=>arm.key !== key);
66
- }
67
- export function addHoldoutArm(arms) {
68
- if (!arms.some((arm)=>arm.kind === 'control') || arms.some((arm)=>arm.kind === 'holdout' || arm.key === 'holdout')) return arms;
69
- return addHoldout(arms.map((arm)=>Number.isFinite(arm.weight) ? arm : {
70
- ...arm,
71
- weight: 0
72
- }));
73
- }
74
- // Largest remainder, with equal fractional remainders awarded to Original first.
75
- function distribute(arms, total, proportional) {
76
- if (!arms.length) return arms;
77
- const weightTotal = totalWeight(arms);
78
- const useWeights = proportional && weightTotal > 0;
79
- const denominator = useWeights ? weightTotal : arms.length;
80
- const numerators = arms.map((arm)=>(useWeights ? finiteWeight(arm.weight) : 1) * total);
81
- const weights = numerators.map((numerator)=>Math.floor(numerator / denominator));
82
- const order = arms.map((_, i)=>i).sort((a, b)=>numerators[b] % denominator - numerators[a] % denominator || Number(arms[b].kind === 'control') - Number(arms[a].kind === 'control') || a - b);
83
- const remainder = total - weights.reduce((sum, weight)=>sum + weight, 0);
84
- for(let i = 0; i < remainder; i++)weights[order[i % order.length]]++;
85
- return arms.map((arm, i)=>({
86
- ...arm,
87
- weight: weights[i]
88
- }));
89
- }
90
- export function removeHoldout(arms) {
91
- if (!arms.some((arm)=>arm.kind === 'holdout')) return arms;
92
- return distribute(arms.filter((arm)=>arm.kind !== 'holdout'), totalWeight(arms), true);
93
- }
94
- export function splitEvenly(arms) {
95
- const holdout = arms.find((arm)=>arm.kind === 'holdout');
96
- const weight = finiteWeight(holdout?.weight ?? 0);
97
- return [
98
- ...distribute(arms.filter((arm)=>arm.kind !== 'holdout'), 100 - weight, false),
99
- ...holdout ? [
100
- {
101
- ...holdout,
102
- weight
103
- }
104
- ] : []
105
- ];
106
- }
107
- export function setWeight(arms, key, weight) {
108
- return arms.map((arm)=>arm.key === key ? {
109
- ...arm,
110
- weight
111
- } : arm);
112
- }
113
- export function issuesByRow(arms, kind) {
114
- const rows = arms.map(()=>[]);
115
- const form = [];
116
- for (const issue of checkArms(arms, kind)){
117
- const index = issue.armIndex ?? arms.findIndex((arm)=>arm.key === issue.armKey);
118
- if (index >= 0 && index < arms.length) rows[index].push(issue);
119
- else form.push(issue);
120
- }
121
- return {
122
- rows,
123
- form
124
- };
125
- }
126
- export const ARM_FIELDS = [
127
- 'key',
128
- 'label',
129
- 'weight',
130
- 'kind',
131
- 'slot',
132
- 'params',
133
- 'pageTarget'
134
- ];
135
- /** Payload arrays store each row's values at separate field paths. */ export function readArms(fields, path = 'arms') {
136
- const indices = new Set();
137
- for (const name of Object.keys(fields)){
138
- if (!name.startsWith(`${path}.`)) continue;
139
- const [index, field] = name.slice(path.length + 1).split('.');
140
- if (/^\d+$/.test(index) && field === 'key') indices.add(Number(index));
141
- }
142
- return [
143
- ...indices
144
- ].sort((a, b)=>a - b).map((index)=>Object.fromEntries(ARM_FIELDS.flatMap((field)=>{
145
- const value = fields[`${path}.${index}.${field}`]?.value;
146
- return value === undefined ? [] : [
147
- [
148
- field,
149
- value
150
- ]
151
- ];
152
- })));
153
- }
1
+ export * from '../../arms/model.js';
@@ -1,6 +1,5 @@
1
- import type { ArmIssueCode } from '../../types/endpoints.js';
2
1
  export { ARM_LABELS as GROUP_LABELS } from '../../labels/arms.js';
3
- export declare const ISSUE_COPY: Record<ArmIssueCode, string>;
2
+ export { ISSUE_COPY, issueMessage } from '../../arms/copy.js';
4
3
  export declare const ARMS_COPY: {
5
4
  readonly holdout: "The holdout sees the Original and is measured but not compared during the test. After you decide, it keeps seeing the Original so the winner can be compared with it.";
6
5
  readonly pageRequired: "Enter a page path for this group.";
@@ -12,7 +11,3 @@ export declare const ARMS_COPY: {
12
11
  readonly paramOption: "Choose an available option for {label}.";
13
12
  readonly paramCheckbox: "Choose yes or no for {label}.";
14
13
  };
15
- export declare function issueMessage(issue: {
16
- code: string;
17
- message: string;
18
- }, total: number): string;
@@ -1,16 +1,5 @@
1
1
  export { ARM_LABELS as GROUP_LABELS } from '../../labels/arms.js';
2
- export const ISSUE_COPY = {
3
- 'weights-total': 'Weights must add up to 100%. They add up to {total}% now.',
4
- 'control-count': 'Include exactly one Original.',
5
- 'control-first': 'Put the Original first.',
6
- 'holdout-last': 'Put the holdout last.',
7
- 'holdout-count': 'Include only one holdout.',
8
- 'variant-count': 'Include up to three variants.',
9
- 'duplicate-key': 'Each group must have a different standard name.',
10
- 'duplicate-slot': 'Each group must have a different content slot.',
11
- 'slot-required': 'Choose the matching content slot for this group.',
12
- 'invalid-key': 'Use the standard group names: Original, Variant A–C, Holdout.'
13
- };
2
+ export { ISSUE_COPY, issueMessage } from '../../arms/copy.js';
14
3
  export const ARMS_COPY = {
15
4
  holdout: 'The holdout sees the Original and is measured but not compared during the test. After you decide, it keeps seeing the Original so the winner can be compared with it.',
16
5
  pageRequired: 'Enter a page path for this group.',
@@ -22,7 +11,3 @@ export const ARMS_COPY = {
22
11
  paramOption: 'Choose an available option for {label}.',
23
12
  paramCheckbox: 'Choose yes or no for {label}.'
24
13
  };
25
- export function issueMessage(issue, total) {
26
- const copy = Object.hasOwn(ISSUE_COPY, issue.code) ? ISSUE_COPY[issue.code] : issue.message;
27
- return copy.replace('{total}', String(total));
28
- }
@@ -34,6 +34,7 @@ export declare const READINESS_COPY: {
34
34
  readonly full: "This test's group is full. Lower how many people take part, or create the test again without the group.";
35
35
  readonly differentPeople: "This test's group counts different people. Create the test again without the group.";
36
36
  readonly uncheckedPages: "Version pages aren't checked; make sure each one is published.";
37
+ readonly chooseDifferentPage: "Choose a different page";
37
38
  readonly noTargets: "Add a page address for each version.";
38
39
  readonly pageVisitors: "Whole-page tests count visitors. Create the test again counting visitors.";
39
40
  readonly blockPathRule: "Remove the page-path rule in Who takes part; page block tests run on their page.";
@@ -45,6 +46,7 @@ export declare const READINESS_COPY: {
45
46
  readonly attributeFallback: "this attribute";
46
47
  };
47
48
  export declare const unsupportedContentCopy: (page: string) => string;
49
+ export declare const pageInUseCopy: (path: string) => string;
48
50
  export declare const unpublishedPageCopy: (path: string) => string;
49
51
  /** Old responses may include raw host errors; unknown strings use authored copy. */
50
52
  export declare const placementFailureCopy: (reason: unknown) => "Couldn't check where this test is used. Try again." | "You don't have permission to check where this test is used." | "A page used by this test no longer exists. Choose another page.";
@@ -59,6 +61,7 @@ export declare const attributeNext: (attribute: string) => string;
59
61
  export declare const stoppedCopy: (date: string | null) => string;
60
62
  export declare const decidedCopy: (date: string | null, winner: string) => string;
61
63
  export declare const runningCopy: (date: string | null, people: number | null, days: number | null) => string;
62
- export declare const pageGuardCopy: (name: string, status: "running" | "decided" | "archived") => string;
64
+ /** After archive the guard protects the winner and the Original; only the winner page is "the winner". */
65
+ export declare const pageGuardCopy: (name: string, status: "running" | "decided" | "archived", role?: "winner" | "original") => string;
63
66
  export declare const versionPageUnavailableCopy: (path: string, status?: string) => string;
64
67
  export declare const versionPageUncheckedCopy: (path: string, status?: string) => string;
@@ -34,6 +34,7 @@ export const READINESS_COPY = {
34
34
  full: "This test's group is full. Lower how many people take part, or create the test again without the group.",
35
35
  differentPeople: "This test's group counts different people. Create the test again without the group.",
36
36
  uncheckedPages: "Version pages aren't checked; make sure each one is published.",
37
+ chooseDifferentPage: 'Choose a different page',
37
38
  noTargets: 'Add a page address for each version.',
38
39
  pageVisitors: 'Whole-page tests count visitors. Create the test again counting visitors.',
39
40
  blockPathRule: 'Remove the page-path rule in Who takes part; page block tests run on their page.',
@@ -46,6 +47,7 @@ export const READINESS_COPY = {
46
47
  attributeFallback: 'this attribute'
47
48
  };
48
49
  export const unsupportedContentCopy = (page)=>`${page} has more than one language, so Make it the content isn't available for it; you'll copy the winning version by hand.`;
50
+ export const pageInUseCopy = (path)=>`Another running or decided test already uses ${path}. Choose a different page, or finish that test first.`;
49
51
  export const unpublishedPageCopy = (path)=>`${path} isn't a published page. Publish it, or pick another page.`;
50
52
  /** Old responses may include raw host errors; unknown strings use authored copy. */ export const placementFailureCopy = (reason)=>PLACEMENT_FAILURE_COPY[typeof reason === 'string' && Object.hasOwn(PLACEMENT_FAILURE_COPY, reason) ? reason : 'placements-failed'];
51
53
  const withoutPeriod = (value)=>value.trim().replace(/[.!?]+$/, '');
@@ -64,6 +66,6 @@ export const runningCopy = (date, people, days)=>[
64
66
  people === null ? null : `${people.toLocaleString('en-US')} people so far`,
65
67
  days === null ? null : `about ${days} days to go`
66
68
  ].filter(Boolean).join(' · ');
67
- export const pageGuardCopy = (name, status)=>status === 'archived' ? `This page is shown at other addresses because the finished test "${name}" made it the winner. To free it, open the test and choose "Release pages".` : status === 'decided' ? `This page is part of the decided test "${name}". Choose "Make it the content" or "Discard winner" on the test before you delete, rename or unpublish it.` : `This page is part of the running test "${name}". Stop the test before you delete, rename or unpublish it.`;
69
+ /** After archive the guard protects the winner and the Original; only the winner page is "the winner". */ export const pageGuardCopy = (name, status, role = 'winner')=>status === 'archived' ? role === 'original' ? `This page's address now shows the winner of the finished test "${name}". To free it, open the test and choose "Release pages".` : `This page is shown at other addresses because the finished test "${name}" made it the winner. To free it, open the test and choose "Release pages".` : status === 'decided' ? `This page is part of the decided test "${name}". Choose "Make it the content" or "Discard winner" on the test before you delete, rename or unpublish it.` : `This page is part of the running test "${name}". Stop the test before you delete, rename or unpublish it.`;
68
70
  export const versionPageUnavailableCopy = (path, status = 'running')=>`${path} isn't published, so visitors assigned to it see an error. Republish it, or ${status === 'decided' ? 'discard the winner' : 'stop the test'}.`;
69
71
  export const versionPageUncheckedCopy = (path, status = 'running')=>`Couldn't check whether ${path} is published. Check that it is still published, or ${status === 'decided' ? 'discard the winner' : 'stop the test'}.`;
@@ -57,6 +57,8 @@ export declare const RESULTS_COPY: {
57
57
  readonly infeasible: "A clear answer would take more than 90 days at this traffic.";
58
58
  readonly noCap: "Not capped: fewer than 20 people had this outcome in the 90 days before Start, so every value counts in full.";
59
59
  readonly quality: "Data quality";
60
+ readonly missingWinner: "The stored winner is missing from this round’s results.";
61
+ readonly missingWinnerNext: "Recompute; if it persists, contact a developer before trusting these results.";
60
62
  readonly srm: "Traffic isn't splitting as set. Check for caching or a page placed twice.";
61
63
  readonly multiGroup: "People who saw more than one group were excluded.";
62
64
  readonly multiGroupNext: "Check for caching or a page placed twice before trusting the results.";