@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.
- package/README.md +74 -16
- package/dist/admin/PreviewLinks.js +45 -24
- package/dist/admin/TestsNavLink.d.ts +3 -1
- package/dist/admin/TestsNavLink.js +2 -2
- package/dist/admin/armSelect/ArmSelectField.js +14 -22
- package/dist/admin/arms/arms.model.d.ts +1 -21
- package/dist/admin/arms/arms.model.js +1 -153
- package/dist/admin/copy/arms.d.ts +1 -6
- package/dist/admin/copy/arms.js +1 -16
- package/dist/admin/copy/readiness.d.ts +4 -1
- package/dist/admin/copy/readiness.js +3 -1
- package/dist/admin/copy/results.d.ts +2 -0
- package/dist/admin/copy/results.js +3 -1
- package/dist/admin/copy/review.d.ts +1 -1
- package/dist/admin/copy/review.js +1 -1
- package/dist/admin/copy/surface.d.ts +0 -1
- package/dist/admin/copy/surface.js +0 -1
- package/dist/admin/paths.d.ts +2 -1
- package/dist/admin/paths.js +2 -1
- package/dist/admin/previewLinks.model.d.ts +2 -0
- package/dist/admin/previewLinks.model.js +3 -0
- package/dist/admin/results/legacy.d.ts +16 -0
- package/dist/admin/results/legacy.js +27 -0
- package/dist/admin/results/results.model.d.ts +6 -6
- package/dist/admin/results/results.model.js +14 -5
- package/dist/admin/results/snapshot.d.ts +14 -0
- package/dist/admin/results/snapshot.js +3 -0
- package/dist/admin/surface/TestsHome.js +6 -9
- package/dist/admin/surface/goals/data.js +16 -16
- package/dist/admin/surface/home.model.d.ts +1 -0
- package/dist/admin/surface/newTest/NewTest.js +4 -4
- package/dist/admin/surface/readiness/ActionDialogs.js +4 -5
- package/dist/admin/surface/readiness/readiness.model.js +8 -1
- package/dist/admin/surface/results/ResultsBody.js +2 -1
- package/dist/admin/surface/results/recommendation.model.d.ts +3 -2
- package/dist/admin/surface/results/recommendation.model.js +10 -3
- package/dist/admin/surface/surface.css +0 -1
- package/dist/admin/surface/test/GoalSection.js +7 -15
- package/dist/admin/surface/test/testPage.model.d.ts +2 -3
- package/dist/admin/surface/test/testPage.model.js +1 -21
- package/dist/admin/surface/useTestDocument.js +2 -1
- package/dist/admin/useLayerUsage.js +14 -23
- package/dist/admin/views/TestsNavLinkServer.d.ts +2 -0
- package/dist/admin/views/TestsNavLinkServer.js +43 -0
- package/dist/arms/copy.d.ts +6 -0
- package/dist/arms/copy.js +16 -0
- package/dist/arms/model.d.ts +23 -0
- package/dist/arms/model.js +173 -0
- package/dist/db/appends.d.ts +6 -2
- package/dist/db/appends.js +10 -4
- package/dist/db/locks.d.ts +6 -0
- package/dist/db/locks.js +13 -1
- package/dist/db/pageOccupancy.d.ts +12 -0
- package/dist/db/pageOccupancy.js +45 -0
- package/dist/db/snapshots.js +4 -3
- package/dist/exports/adminRsc.d.ts +1 -0
- package/dist/exports/adminRsc.js +1 -0
- package/dist/exports/index.d.ts +1 -1
- package/dist/exports/puck.d.ts +2 -1
- package/dist/exports/puck.js +1 -1
- package/dist/exports/puckEditor.d.ts +3 -1
- package/dist/exports/puckEditor.js +2 -1
- package/dist/exports/rsc.d.ts +1 -0
- package/dist/hooks/pageGuard.js +21 -3
- package/dist/lib/fetchAllDocs.d.ts +16 -0
- package/dist/lib/fetchAllDocs.js +56 -0
- package/dist/options.js +2 -9
- package/dist/plugin.js +4 -0
- package/dist/puck/ExperimentPicker.js +47 -16
- package/dist/puck/ExperimentSlot.d.ts +51 -1
- package/dist/puck/ExperimentSlot.js +23 -12
- package/dist/puck/copy.d.ts +5 -4
- package/dist/puck/copy.js +5 -4
- package/dist/puck/editor/CreateExperimentDrawer.js +46 -56
- package/dist/puck/editor/ExperimentSlotEditor.d.ts +52 -1
- package/dist/puck/editor/ExperimentSlotEditor.js +292 -137
- package/dist/puck/editor/ResultsReadout.d.ts +6 -0
- package/dist/puck/editor/ResultsReadout.js +99 -0
- package/dist/puck/editor/VersionBuilder.d.ts +34 -0
- package/dist/puck/editor/VersionBuilder.js +317 -0
- package/dist/puck/editor/armWrites.d.ts +7 -0
- package/dist/puck/editor/armWrites.js +71 -0
- package/dist/puck/editor/armsCache.d.ts +14 -0
- package/dist/puck/editor/armsCache.js +78 -0
- package/dist/puck/editor/copy.d.ts +33 -1
- package/dist/puck/editor/copy.js +45 -8
- package/dist/puck/editor/createTest.d.ts +1 -1
- package/dist/puck/editor/createTest.js +25 -14
- package/dist/puck/editor/editor.chrome.css +47 -161
- package/dist/puck/editor/editor.css +47 -161
- package/dist/puck/editor/editor.model.d.ts +4 -1
- package/dist/puck/editor/editor.model.js +10 -7
- package/dist/puck/editor/experimentOutlinePlugin.d.ts +3 -0
- package/dist/puck/editor/experimentOutlinePlugin.js +89 -0
- package/dist/puck/experimentSlotField.d.ts +2 -0
- package/dist/puck/experimentSlotField.js +11 -3
- package/dist/puck/hostContext.d.ts +10 -0
- package/dist/puck/hostContext.js +12 -0
- package/dist/puck/routes.d.ts +12 -0
- package/dist/puck/routes.js +11 -0
- package/dist/puck/slotConfig.d.ts +1 -1
- package/dist/puck/slotConfig.js +2 -2
- package/dist/sanitizeReadinessDetail.js +10 -1
- package/dist/server/metadata.js +9 -2
- package/dist/server/normaliseContentRelationships.d.ts +7 -0
- package/dist/server/normaliseContentRelationships.js +62 -0
- package/dist/server/recordContentChange.d.ts +1 -1
- package/dist/server/recordContentChange.js +17 -8
- package/dist/server/runResults.js +36 -4
- package/dist/transitions/checkStart.d.ts +5 -1
- package/dist/transitions/checkStart.js +10 -0
- package/dist/transitions/decide.js +16 -1
- package/dist/transitions/placements.d.ts +2 -6
- package/dist/transitions/placements.js +8 -1
- package/dist/transitions/preflightData.d.ts +8 -0
- package/dist/transitions/preflightData.js +116 -36
- package/dist/transitions/start.js +33 -3
- package/dist/types/contracts.d.ts +19 -13
- package/dist/types/endpoints.d.ts +1 -1
- package/dist/ui/Tabs.d.ts +4 -2
- package/dist/ui/Tabs.js +33 -26
- package/dist/ui/accent.d.ts +22 -3
- package/dist/ui/accent.js +74 -16
- 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.
|
|
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.
|
|
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
|
|
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: ({
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
|
624
|
-
|
|
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:
|
|
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 {
|
|
9
|
-
import {
|
|
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
|
-
|
|
38
|
+
win.close();
|
|
39
|
+
setError(result.status === 0 ? API_MESSAGES.network : refusalMessage(result.code, result.status));
|
|
24
40
|
return;
|
|
25
41
|
}
|
|
26
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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__*/
|
|
70
|
+
/*#__PURE__*/ _jsxs("div", {
|
|
53
71
|
className: "xp-ui-button-group",
|
|
54
|
-
children:
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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)),
|
|
@@ -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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
58
|
+
message: knownCopy(reason)
|
|
67
59
|
});
|
|
68
60
|
});
|
|
69
61
|
return ()=>controller.abort();
|
|
@@ -1,21 +1 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
package/dist/admin/copy/arms.js
CHANGED
|
@@ -1,16 +1,5 @@
|
|
|
1
1
|
export { ARM_LABELS as GROUP_LABELS } from '../../labels/arms.js';
|
|
2
|
-
export
|
|
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
|
-
|
|
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.";
|