@delmaredigital/payload-experiments 0.2.0-rc.4 → 0.2.0-rc.5

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 CHANGED
@@ -466,6 +466,13 @@ for these patterns.
466
466
 
467
467
  ## Admin and client
468
468
 
469
+ The sidebar includes a **Tests** group with **All tests** and **Goals**. The
470
+ dashboard also includes **Tests** and **Goals** cards above its widgets. Goals
471
+ appears only for users who can manage tests. Both additions follow the host's
472
+ admin route and keep host-provided navigation and dashboard entries first.
473
+ After upgrading, run `payload generate:importmap` to register the dashboard card
474
+ component.
475
+
469
476
  ### Client assignments and exposure
470
477
 
471
478
  Read assignments on the server and pass them to `ExperimentsProvider` from
@@ -6,6 +6,7 @@ import { armLabel } from './labels.js';
6
6
  import { useApi } from './useApi.js';
7
7
  import { REVIEW_COPY as C } from './copy/review.js';
8
8
  import { API_MESSAGES } from './copy/common.js';
9
+ import { previewPages } from './previewLinks.model.js';
9
10
  export function PreviewLinks({ experimentId, placements, arms }) {
10
11
  const api = useApi();
11
12
  const inFlight = useRef(false);
@@ -39,10 +40,13 @@ export function PreviewLinks({ experimentId, placements, arms }) {
39
40
  return /*#__PURE__*/ _jsxs("div", {
40
41
  className: "xp-preview-links",
41
42
  children: [
42
- placements.map((placement)=>/*#__PURE__*/ _jsxs("div", {
43
+ previewPages(placements).map((placement)=>/*#__PURE__*/ _jsxs("div", {
43
44
  children: [
44
- /*#__PURE__*/ _jsx("p", {
45
- children: placement.title
45
+ /*#__PURE__*/ _jsxs("p", {
46
+ children: [
47
+ placement.title,
48
+ placement.slotCount > 1 ? ` ${C.placedTimes(placement.slotCount)}` : null
49
+ ]
46
50
  }),
47
51
  placement.url ? arms.filter((arm)=>arm.kind !== 'holdout' && arm.key !== 'holdout').map((arm)=>/*#__PURE__*/ _jsxs(Button, {
48
52
  disabled: busy,
@@ -59,7 +63,7 @@ export function PreviewLinks({ experimentId, placements, arms }) {
59
63
  children: C.noPreview
60
64
  })
61
65
  ]
62
- }, `${placement.collection}/${placement.id}`)),
66
+ }, placement.key)),
63
67
  error ? /*#__PURE__*/ _jsx("p", {
64
68
  role: "alert",
65
69
  children: error
@@ -0,0 +1,3 @@
1
+ import './theme.css';
2
+ import './dashboard.css';
3
+ export declare function TestsDashboardCards(): import("react").JSX.Element | null;
@@ -0,0 +1,52 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import './theme.css';
4
+ import './dashboard.css';
5
+ import { Card, useAuth, useConfig } from '@payloadcms/ui';
6
+ import { formatAdminURL } from 'payload/shared';
7
+ import { SURFACE_COPY } from './copy/surface.js';
8
+ export function TestsDashboardCards() {
9
+ const { config } = useConfig();
10
+ const { permissions } = useAuth();
11
+ const testPermissions = permissions?.collections?.['xp-experiments'];
12
+ const canManage = testPermissions?.update === true;
13
+ if (!canManage) return null;
14
+ const href = (path)=>formatAdminURL({
15
+ adminRoute: config.routes.admin,
16
+ path
17
+ });
18
+ return /*#__PURE__*/ _jsxs("section", {
19
+ className: "xp-dash-group",
20
+ "aria-labelledby": "xp-dash-title",
21
+ children: [
22
+ /*#__PURE__*/ _jsx("h2", {
23
+ className: "xp-dash-label",
24
+ id: "xp-dash-title",
25
+ children: SURFACE_COPY.title
26
+ }),
27
+ /*#__PURE__*/ _jsxs("ul", {
28
+ className: "xp-dash-card-list",
29
+ children: [
30
+ /*#__PURE__*/ _jsx("li", {
31
+ children: /*#__PURE__*/ _jsx(Card, {
32
+ title: SURFACE_COPY.navAllTests,
33
+ titleAs: "h3",
34
+ buttonAriaLabel: SURFACE_COPY.dashShowTests,
35
+ href: href('/experiments'),
36
+ id: "card-xp-tests"
37
+ })
38
+ }),
39
+ /*#__PURE__*/ _jsx("li", {
40
+ children: /*#__PURE__*/ _jsx(Card, {
41
+ title: SURFACE_COPY.navGoals,
42
+ titleAs: "h3",
43
+ buttonAriaLabel: SURFACE_COPY.dashShowGoals,
44
+ href: href('/experiments/goals'),
45
+ id: "card-xp-goals"
46
+ })
47
+ })
48
+ ]
49
+ })
50
+ ]
51
+ });
52
+ }
@@ -1,2 +1,3 @@
1
1
  import './theme.css';
2
- export declare function TestsNavLink(): import("react").JSX.Element;
2
+ declare function TestsNav(): import("react").JSX.Element | null;
3
+ export { TestsNav as TestsNavLink };
@@ -1,31 +1,74 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import './theme.css';
4
- import { Link, useConfig } from '@payloadcms/ui';
4
+ 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
- export function TestsNavLink() {
8
+ function TestsNav() {
9
9
  const { config } = useConfig();
10
- const path = usePathname();
10
+ const { permissions } = useAuth();
11
+ const pathname = usePathname();
11
12
  const href = formatAdminURL({
12
13
  adminRoute: config.routes.admin,
13
14
  path: '/experiments'
14
15
  });
16
+ const goalsHref = formatAdminURL({
17
+ adminRoute: config.routes.admin,
18
+ path: '/experiments/goals'
19
+ });
15
20
  const base = href.replace(/\/$/, '');
16
- const active = path === base || path?.startsWith(`${base}/`);
17
- return /*#__PURE__*/ _jsxs(Link, {
18
- href: href,
19
- className: "nav__link",
20
- "aria-current": active ? 'page' : undefined,
21
- children: [
22
- active ? /*#__PURE__*/ _jsx("div", {
23
- className: "nav__link-indicator"
24
- }) : null,
25
- /*#__PURE__*/ _jsx("span", {
26
- className: "nav__link-label",
27
- children: SURFACE_COPY.title
28
- })
29
- ]
21
+ const goalsBase = goalsHref.replace(/\/$/, '');
22
+ const goalsActive = pathname === goalsBase || Boolean(pathname?.startsWith(`${goalsBase}/`));
23
+ const testsActive = !goalsActive && (pathname === base || Boolean(pathname?.startsWith(`${base}/`)));
24
+ const testPermissions = permissions?.collections?.['xp-experiments'];
25
+ const canManage = testPermissions?.update === true;
26
+ if (!canManage) return null;
27
+ const entries = [
28
+ {
29
+ id: 'nav-xp-tests',
30
+ href,
31
+ label: SURFACE_COPY.navAllTests,
32
+ active: testsActive
33
+ },
34
+ {
35
+ id: 'nav-xp-goals',
36
+ href: goalsHref,
37
+ label: SURFACE_COPY.navGoals,
38
+ active: goalsActive
39
+ }
40
+ ];
41
+ // getPreference is async and can fetch; let NavGroup default to open.
42
+ return /*#__PURE__*/ _jsx(NavGroup, {
43
+ label: SURFACE_COPY.title,
44
+ children: entries.map((entry)=>{
45
+ const props = {
46
+ className: 'nav__link',
47
+ id: entry.id,
48
+ 'aria-current': entry.active ? 'page' : undefined
49
+ };
50
+ const content = /*#__PURE__*/ _jsxs(_Fragment, {
51
+ children: [
52
+ entry.active ? /*#__PURE__*/ _jsx("div", {
53
+ className: "nav__link-indicator"
54
+ }) : null,
55
+ /*#__PURE__*/ _jsx("span", {
56
+ className: "nav__link-label",
57
+ children: entry.label
58
+ })
59
+ ]
60
+ });
61
+ return pathname === entry.href ? /*#__PURE__*/ _jsx("div", {
62
+ ...props,
63
+ children: content
64
+ }, entry.id) : /*#__PURE__*/ _jsx(Link, {
65
+ ...props,
66
+ href: entry.href,
67
+ prefetch: false,
68
+ children: content
69
+ }, entry.id);
70
+ })
30
71
  });
31
72
  }
73
+ // Preserve the existing import-map key.
74
+ export { TestsNav as TestsNavLink };
@@ -1,6 +1,7 @@
1
1
  export declare const REVIEW_COPY: {
2
2
  readonly noBaseline: "No data yet for this metric.";
3
3
  readonly preview: "Preview";
4
+ readonly placedTimes: (n: number) => string;
4
5
  readonly noPreview: "Preview isn't set up for this page type";
5
6
  readonly raise: "Raise allocation";
6
7
  readonly addMetric: "Add a metric";
@@ -1,6 +1,7 @@
1
1
  export const REVIEW_COPY = {
2
2
  noBaseline: 'No data yet for this metric.',
3
3
  preview: 'Preview',
4
+ placedTimes: (n)=>`(placed ${n} times on this page)`,
4
5
  noPreview: "Preview isn't set up for this page type",
5
6
  raise: 'Raise allocation',
6
7
  addMetric: 'Add a metric',
@@ -3,6 +3,10 @@ export declare const SURFACE_COPY: {
3
3
  readonly newTest: "New test";
4
4
  readonly goals: "Goals";
5
5
  readonly search: "Search tests";
6
+ readonly navAllTests: "All tests";
7
+ readonly navGoals: "Goals";
8
+ readonly dashShowTests: "Show all tests";
9
+ readonly dashShowGoals: "Show all goals";
6
10
  readonly backToAdmin: "Back to admin";
7
11
  readonly limitedList: "Showing the 200 most recent tests.";
8
12
  readonly notInstalled: "The Tests screen isn't installed yet. Run `payload generate:importmap` and reload.";
@@ -3,6 +3,10 @@ export const SURFACE_COPY = {
3
3
  newTest: 'New test',
4
4
  goals: 'Goals',
5
5
  search: 'Search tests',
6
+ navAllTests: 'All tests',
7
+ navGoals: 'Goals',
8
+ dashShowTests: 'Show all tests',
9
+ dashShowGoals: 'Show all goals',
6
10
  backToAdmin: 'Back to admin',
7
11
  limitedList: 'Showing the 200 most recent tests.',
8
12
  notInstalled: "The Tests screen isn't installed yet. Run `payload generate:importmap` and reload.",
@@ -0,0 +1,35 @@
1
+ .xp-dash-group {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: var(--xp-space-4);
5
+ margin-bottom: var(--xp-space-4);
6
+ }
7
+
8
+ .xp-dash-label { margin: 0; }
9
+
10
+ .xp-dash-card-list {
11
+ padding: 0;
12
+ margin: 0;
13
+ list-style: none;
14
+ display: grid;
15
+ grid-template-columns: repeat(5, 1fr);
16
+ gap: var(--xp-dash-card-gap);
17
+ }
18
+
19
+ .xp-dash-card-list .card { height: 100%; }
20
+
21
+ @media (max-width: 1440px) {
22
+ .xp-dash-card-list { grid-template-columns: repeat(4, 1fr); }
23
+ }
24
+
25
+ @media (max-width: 1024px) {
26
+ .xp-dash-card-list { grid-template-columns: repeat(2, 1fr); }
27
+ }
28
+
29
+ @media (max-width: 768px) {
30
+ .xp-dash-card-list { gap: var(--xp-dash-card-gap-small); }
31
+ }
32
+
33
+ @media (max-width: 400px) {
34
+ .xp-dash-card-list { grid-template-columns: repeat(1, 1fr); }
35
+ }
@@ -6,3 +6,4 @@ export declare const XP_TESTS_LIST_REDIRECT = "@delmaredigital/payload-experimen
6
6
  export declare const XP_TEST_EDIT_REDIRECT = "@delmaredigital/payload-experiments/admin/rsc#CollectionEditRedirect";
7
7
  export declare const XP_TESTS_NAV_LINK = "@delmaredigital/payload-experiments/admin/client#TestsNavLink";
8
8
  export declare const XP_TESTS_SURFACE = "@delmaredigital/payload-experiments/admin/client#TestsSurface";
9
+ export declare const XP_TESTS_DASHBOARD_CARDS = "@delmaredigital/payload-experiments/admin/client#TestsDashboardCards";
@@ -6,3 +6,4 @@ export const XP_TESTS_LIST_REDIRECT = '@delmaredigital/payload-experiments/admin
6
6
  export const XP_TEST_EDIT_REDIRECT = '@delmaredigital/payload-experiments/admin/rsc#CollectionEditRedirect';
7
7
  export const XP_TESTS_NAV_LINK = '@delmaredigital/payload-experiments/admin/client#TestsNavLink';
8
8
  export const XP_TESTS_SURFACE = '@delmaredigital/payload-experiments/admin/client#TestsSurface';
9
+ export const XP_TESTS_DASHBOARD_CARDS = '@delmaredigital/payload-experiments/admin/client#TestsDashboardCards';
@@ -0,0 +1,9 @@
1
+ import type { PreflightResult } from '../types/endpoints.js';
2
+ type Placement = PreflightResult['placements'][number];
3
+ type PreviewPage = Pick<Placement, 'collection' | 'id' | 'title' | 'url'> & {
4
+ key: string;
5
+ slotCount: number;
6
+ };
7
+ /** Preview URLs identify pages; counts use the most placements in any one version. */
8
+ export declare function previewPages(placements: readonly Placement[]): PreviewPage[];
9
+ export {};
@@ -0,0 +1,34 @@
1
+ /** Preview URLs identify pages; counts use the most placements in any one version. */ export function previewPages(placements) {
2
+ const pages = new Map();
3
+ for (const placement of placements){
4
+ const key = `${placement.collection}\u0000${String(placement.id)}`;
5
+ const existing = pages.get(key);
6
+ if (existing) {
7
+ const { page, versions } = existing;
8
+ const count = (versions.get(placement.detail) ?? 0) + 1;
9
+ versions.set(placement.detail, count);
10
+ page.slotCount = Math.max(page.slotCount, count);
11
+ if (!page.url && placement.url) page.url = placement.url;
12
+ } else {
13
+ pages.set(key, {
14
+ page: {
15
+ key,
16
+ collection: placement.collection,
17
+ id: placement.id,
18
+ title: placement.title,
19
+ url: placement.url,
20
+ slotCount: 1
21
+ },
22
+ versions: new Map([
23
+ [
24
+ placement.detail,
25
+ 1
26
+ ]
27
+ ])
28
+ });
29
+ }
30
+ }
31
+ return [
32
+ ...pages.values()
33
+ ].map(({ page })=>page);
34
+ }
@@ -36,6 +36,8 @@
36
36
  --xp-space-4: calc(var(--base) * 1);
37
37
  --xp-space-5: calc(var(--base) * 1.5);
38
38
  --xp-space-6: calc(var(--base) * 2);
39
+ --xp-dash-card-gap: 12px;
40
+ --xp-dash-card-gap-small: calc(var(--base) * 0.4);
39
41
  --xp-font-body: var(--font-body);
40
42
  --xp-font-size-small: calc(var(--base) * 0.65);
41
43
  --xp-font-size-body: calc(var(--base) * 0.75);
@@ -4,6 +4,7 @@ export function eventsCollection(options) {
4
4
  slug: 'xp-events',
5
5
  graphQL: false,
6
6
  disableDuplicate: true,
7
+ versions: false,
7
8
  admin: {
8
9
  hidden: true
9
10
  },
@@ -4,6 +4,7 @@ export function identitiesCollection(options) {
4
4
  slug: 'xp-identities',
5
5
  graphQL: false,
6
6
  disableDuplicate: true,
7
+ versions: false,
7
8
  admin: {
8
9
  hidden: true
9
10
  },
@@ -12,6 +12,7 @@ export function layersCollection(options) {
12
12
  slug: 'xp-layers',
13
13
  graphQL: false,
14
14
  disableDuplicate: true,
15
+ versions: false,
15
16
  // group:false hides navigation while keeping collection routes reachable (hidden causes 404s).
16
17
  labels: {
17
18
  singular: 'Test group',
@@ -8,6 +8,7 @@ export function metricsCollection(options) {
8
8
  slug: 'xp-metrics',
9
9
  graphQL: false,
10
10
  disableDuplicate: true,
11
+ versions: false,
11
12
  // group:false hides navigation while keeping collection routes reachable (hidden causes 404s).
12
13
  labels: {
13
14
  singular: 'Goal',
@@ -4,6 +4,7 @@ export function phasesCollection(options) {
4
4
  slug: 'xp-phases',
5
5
  graphQL: false,
6
6
  disableDuplicate: true,
7
+ versions: false,
7
8
  admin: {
8
9
  hidden: true
9
10
  },
@@ -4,6 +4,7 @@ export function snapshotsCollection(options) {
4
4
  slug: 'xp-results-snapshots',
5
5
  graphQL: false,
6
6
  disableDuplicate: true,
7
+ versions: false,
7
8
  admin: {
8
9
  hidden: true
9
10
  },
@@ -3,3 +3,4 @@ export { MetricSourceSelect } from '../admin/MetricSourceSelect.js';
3
3
  export { ArmSelectField } from '../admin/armSelect/ArmSelectField.js';
4
4
  export { TestsNavLink } from '../admin/TestsNavLink.js';
5
5
  export { TestsSurface } from '../admin/surface/TestsSurface.js';
6
+ export { TestsDashboardCards } from '../admin/TestsDashboardCards.js';
@@ -4,3 +4,4 @@ export { MetricSourceSelect } from '../admin/MetricSourceSelect.js';
4
4
  export { ArmSelectField } from '../admin/armSelect/ArmSelectField.js';
5
5
  export { TestsNavLink } from '../admin/TestsNavLink.js';
6
6
  export { TestsSurface } from '../admin/surface/TestsSurface.js';
7
+ export { TestsDashboardCards } from '../admin/TestsDashboardCards.js';
@@ -4,6 +4,7 @@ export function stateGlobal(options) {
4
4
  slug: 'xp-state',
5
5
  dbName: 'xp_state',
6
6
  graphQL: false,
7
+ versions: false,
7
8
  admin: {
8
9
  hidden: true
9
10
  },
package/dist/plugin.js CHANGED
@@ -15,6 +15,8 @@ export function createExperimentsPlugin(input) {
15
15
  // Erase the user subtype only at this non-generic factory boundary.
16
16
  const { collections, globals } = buildCollections(options);
17
17
  const onInit = createOnInit(options);
18
+ const beforeDashboard = config.admin?.components?.beforeDashboard ?? [];
19
+ const beforeNavLinks = config.admin?.components?.beforeNavLinks ?? [];
18
20
  return {
19
21
  ...config,
20
22
  admin: {
@@ -35,8 +37,12 @@ export function createExperimentsPlugin(input) {
35
37
  path: '/experiments/:segments*'
36
38
  }
37
39
  },
38
- beforeNavLinks: [
39
- ...config.admin?.components?.beforeNavLinks ?? [],
40
+ beforeDashboard: beforeDashboard.includes(paths.XP_TESTS_DASHBOARD_CARDS) ? beforeDashboard : [
41
+ ...beforeDashboard,
42
+ paths.XP_TESTS_DASHBOARD_CARDS
43
+ ],
44
+ beforeNavLinks: beforeNavLinks.includes(paths.XP_TESTS_NAV_LINK) ? beforeNavLinks : [
45
+ ...beforeNavLinks,
40
46
  paths.XP_TESTS_NAV_LINK
41
47
  ]
42
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delmaredigital/payload-experiments",
3
- "version": "0.2.0-rc.4",
3
+ "version": "0.2.0-rc.5",
4
4
  "description": "No-code A/B testing for Payload CMS and Next.js: block, page and flag experiments with holdouts, layers, exposure logging and honest statistics inside the admin — no vendor",
5
5
  "type": "module",
6
6
  "license": "MIT",