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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  export declare const GOALS_COPY: {
2
2
  readonly slugRule: "Use 1–64 lowercase letters, numbers, underscores or hyphens, starting with a letter or number.";
3
3
  readonly duplicateSlug: "Another goal already uses this short name. Change it under Advanced.";
4
+ readonly duplicateSlugMain: "Another goal already uses this short name. Choose another.";
4
5
  readonly title: "Goals";
5
6
  readonly goal: "Goal";
6
7
  readonly newGoal: "New goal";
@@ -2,6 +2,7 @@ import { CLICK_GOAL_COPY } from '../../goals/picker/copy.js';
2
2
  export const GOALS_COPY = {
3
3
  slugRule: CLICK_GOAL_COPY.slugRule,
4
4
  duplicateSlug: CLICK_GOAL_COPY.duplicateSlug,
5
+ duplicateSlugMain: 'Another goal already uses this short name. Choose another.',
5
6
  title: 'Goals',
6
7
  goal: 'Goal',
7
8
  newGoal: 'New goal',
@@ -51,17 +51,13 @@ export function GoalForm({ surface, id, returnToTest }) {
51
51
  const [loadError, setLoadError] = useState('');
52
52
  const [error, setError] = useState('');
53
53
  const [fieldErrors, setFieldErrors] = useState({});
54
+ const [seenFieldErrors, setSeenFieldErrors] = useState(fieldErrors);
55
+ const [advancedOpen, setAdvancedOpen] = useState(false);
54
56
  const [pending, setPending] = useState(null);
55
57
  const [slugEdited, setSlugEdited] = useState(false);
56
58
  const [attempt, setAttempt] = useState(0);
57
59
  const inFlight = useRef(false);
58
- const advanced = useRef(null);
59
60
  const [confirmDelete, setConfirmDelete] = useState(false);
60
- useEffect(()=>{
61
- if (advanced.current && (fieldErrors.slug || fieldErrors.capType || fieldErrors.capValue || fieldErrors.warningPercent)) advanced.current.open = true;
62
- }, [
63
- fieldErrors
64
- ]);
65
61
  const listUrl = surfaceUrls.goals(surface.adminRoute, returnToTest ? {
66
62
  return: returnToTest
67
63
  } : undefined);
@@ -108,6 +104,11 @@ export function GoalForm({ surface, id, returnToTest }) {
108
104
  ]);
109
105
  const source = sourceFor(draft.source, surface.metricSources);
110
106
  const click = draft.source.startsWith('click:');
107
+ // Keep the disclosure and its new field errors in the same commit.
108
+ if (fieldErrors !== seenFieldErrors) {
109
+ setSeenFieldErrors(fieldErrors);
110
+ if (click && fieldErrors.slug || fieldErrors.capType || fieldErrors.capValue || fieldErrors.warningPercent) setAdvancedOpen(true);
111
+ }
111
112
  const hint = metricSourceHint(source);
112
113
  const options = metricSourceOptions(surface.metricSources);
113
114
  const set = (key, value)=>setDraft((current)=>({
@@ -177,7 +178,7 @@ export function GoalForm({ surface, id, returnToTest }) {
177
178
  if (cause instanceof GoalResponseError) setFieldErrors(Object.fromEntries(Object.entries(cause.fields).flatMap(([path, message])=>fieldNames[path] ? [
178
179
  [
179
180
  fieldNames[path],
180
- path === 'slug' ? C.duplicateSlug : message
181
+ path === 'slug' ? click ? C.duplicateSlug : C.duplicateSlugMain : message
181
182
  ]
182
183
  ] : [])));
183
184
  inFlight.current = false;
@@ -320,6 +321,10 @@ export function GoalForm({ surface, id, returnToTest }) {
320
321
  ...current,
321
322
  slug: clickGoalSlug(draft.name) ? undefined : C.slugRule
322
323
  }));
324
+ else if (sourceId === 'click:' !== click) setFieldErrors((current)=>({
325
+ ...current,
326
+ slug: undefined
327
+ }));
323
328
  },
324
329
  children: [
325
330
  /*#__PURE__*/ _jsx("option", {
@@ -375,7 +380,8 @@ export function GoalForm({ surface, id, returnToTest }) {
375
380
  }),
376
381
  /*#__PURE__*/ _jsxs("details", {
377
382
  className: "xp-goal-advanced",
378
- ref: advanced,
383
+ open: advancedOpen,
384
+ onToggle: (event)=>setAdvancedOpen(event.currentTarget.open),
379
385
  children: [
380
386
  /*#__PURE__*/ _jsx("summary", {
381
387
  children: C.advanced
@@ -1,24 +1,25 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useEffect, useRef } from 'react';
3
+ import { useState } from 'react';
4
4
  import { readinessParts } from '../readiness/parts.js';
5
5
  import { SectionCard } from '../../../ui/SectionCard.js';
6
6
  import { SECTION_ORDER } from '../contracts.js';
7
7
  import { TEST_PAGE as C } from '../../copy/testPage.js';
8
8
  export function Section({ test, sectionId, children }) {
9
- const disclosure = useRef(null);
10
9
  const hasAdvancedError = Object.keys(test.errors).some((path)=>path.startsWith('analysis.') && ![
11
10
  'analysis.reachPerDay',
12
11
  'analysis.plannedDurationDays'
13
12
  ].includes(path) || path === 'exposurePolicy');
14
- useEffect(()=>{
15
- if (hasAdvancedError && disclosure.current) disclosure.current.open = true;
16
- }, [
17
- hasAdvancedError
18
- ]);
13
+ const [open, setOpen] = useState(hasAdvancedError);
14
+ const [prevHasAdvancedError, setPrevHasAdvancedError] = useState(hasAdvancedError);
15
+ if (hasAdvancedError !== prevHasAdvancedError) {
16
+ setPrevHasAdvancedError(hasAdvancedError);
17
+ if (hasAdvancedError) setOpen(true);
18
+ }
19
19
  const done = readinessParts.sectionDone(test)[sectionId];
20
20
  if (sectionId === 'advanced') return /*#__PURE__*/ _jsxs("details", {
21
- ref: disclosure,
21
+ open: open,
22
+ onToggle: (event)=>setOpen(event.currentTarget.open),
22
23
  className: "xp-test-advanced",
23
24
  id: "section-advanced",
24
25
  children: [
@@ -10,7 +10,9 @@ export declare const CLICK_GOAL_COPY: {
10
10
  readonly createGoal: "Create goal";
11
11
  readonly cancel: "Cancel";
12
12
  readonly undefinedKey: "No click goal has this name yet. Create it with \"+ New click goal\".";
13
+ readonly invalidKey: "Use a click goal short name.";
13
14
  readonly slugRule: "Use 1–64 lowercase letters, numbers, underscores or hyphens, starting with a letter or number.";
14
15
  readonly duplicateSlug: "Another goal already uses this short name. Change it under Advanced.";
15
16
  readonly saveError: "Couldn't save the goal. Try again.";
16
17
  };
18
+ export declare function puckGoalRefusal(rawKey: string, block: string, malformed: boolean, more?: number): string;
@@ -9,7 +9,14 @@
9
9
  createGoal: 'Create goal',
10
10
  cancel: 'Cancel',
11
11
  undefinedKey: 'No click goal has this name yet. Create it with "+ New click goal".',
12
+ invalidKey: 'Use a click goal short name.',
12
13
  slugRule: 'Use 1–64 lowercase letters, numbers, underscores or hyphens, starting with a letter or number.',
13
14
  duplicateSlug: 'Another goal already uses this short name. Change it under Advanced.',
14
15
  saveError: "Couldn't save the goal. Try again."
15
16
  };
17
+ export function puckGoalRefusal(rawKey, block, malformed, more = 0) {
18
+ // A malformed key can be any length; keep the dialog readable.
19
+ const key = rawKey.length > 64 ? `${rawKey.slice(0, 64)}…` : rawKey;
20
+ const sentence = malformed ? `Click goal "${key}" on the ${block} block isn't a valid short name. Use lowercase letters, numbers, - and _, then publish.` : `Click goal "${key}" doesn't exist yet. Create it with "+ New click goal" on the ${block} block, then publish.`;
21
+ return sentence + (more > 0 ? ` (and ${more} more)` : '');
22
+ }
@@ -2,13 +2,14 @@ import { definedClickKeys } from '../db/clickGoals.js';
2
2
  import { pooledExecutor } from '../db/executor.js';
3
3
  import { clickKeys } from './cache.js';
4
4
  import { CLICK_KEY } from './key.js';
5
+ import { CLICK_GOAL_COPY } from './picker/copy.js';
5
6
  /** Shared publish validation for Payload fields and Puck JSON props. */ export async function validateClickGoal(value, req) {
6
7
  if (!value) return true;
7
- if (!CLICK_KEY.test(value)) return 'Use a click goal short name.';
8
+ if (!CLICK_KEY.test(value)) return CLICK_GOAL_COPY.invalidKey;
8
9
  const options = req.payload.config.custom?.xp;
9
10
  if (!options || await options.access.manage({
10
11
  req
11
12
  }) !== true) return true;
12
13
  const keys = await clickKeys(req.payload);
13
- return !keys || keys.has(value) || (await definedClickKeys(pooledExecutor(req.payload))).has(value) || 'No click goal has this name yet. Create it with "+ New click goal".';
14
+ return !keys || keys.has(value) || (await definedClickKeys(pooledExecutor(req.payload))).has(value) || CLICK_GOAL_COPY.undefinedKey;
14
15
  }
@@ -1,3 +1,5 @@
1
1
  import { type CollectionBeforeChangeHook } from 'payload';
2
+ /** Host component labels are not passed to the plugin; display their type as words. */
3
+ export declare function puckBlockName(rawType: string): string;
2
4
  /** Validate only the Puck JSON included in a publishing write. */
3
5
  export declare function puckGoalsBeforeChange(field: string): CollectionBeforeChangeHook;
@@ -1,5 +1,13 @@
1
1
  import { ValidationError } from 'payload';
2
2
  import { validateClickGoal } from '../goals/validate.js';
3
+ import { CLICK_KEY } from '../goals/key.js';
4
+ import { puckGoalRefusal } from '../goals/picker/copy.js';
5
+ /** Host component labels are not passed to the plugin; display their type as words. */ export function puckBlockName(rawType) {
6
+ // Cap before any regex: the type comes from saved page JSON and the first pattern backtracks on long capital runs.
7
+ const type = rawType.slice(0, 64);
8
+ const words = type.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2').replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/([A-Za-z])([0-9])/g, '$1 $2').replace(/[\s_-]+/g, ' ').trim();
9
+ return words ? words[0].toUpperCase() + words.slice(1) : 'Page';
10
+ }
3
11
  /** Validate only the Puck JSON included in a publishing write. */ export function puckGoalsBeforeChange(field) {
4
12
  return async function validatePuckGoals({ collection, data, originalDoc, req }) {
5
13
  const drafts = Boolean(collection.versions && collection.versions.drafts);
@@ -15,24 +23,40 @@ import { validateClickGoal } from '../goals/validate.js';
15
23
  }
16
24
  }
17
25
  const errors = [];
18
- async function walk(value, path) {
26
+ let first;
27
+ // Holds the raw component type; it is humanised once, for the first refusal only.
28
+ async function walk(value, path, block = '') {
19
29
  if (!value || typeof value !== 'object') return;
30
+ const item = value;
31
+ if (typeof item.type === 'string' && item.props && typeof item.props === 'object' && !Array.isArray(item.props)) {
32
+ block = item.type;
33
+ }
20
34
  for (const [key, prop] of Object.entries(value)){
21
35
  const propPath = `${path}.${key}`;
22
36
  if (key === 'xpGoal' && typeof prop === 'string') {
23
37
  const result = await validateClickGoal(prop, req);
24
- if (result !== true) errors.push({
25
- path: propPath,
26
- message: result
27
- });
28
- } else await walk(prop, propPath);
38
+ if (result !== true) {
39
+ first ??= {
40
+ key: prop,
41
+ block
42
+ };
43
+ errors.push({
44
+ path: propPath,
45
+ message: result
46
+ });
47
+ }
48
+ } else await walk(prop, propPath, block);
29
49
  }
30
50
  }
31
51
  await walk(value, field);
32
- if (errors.length) throw new ValidationError({
33
- collection: collection.slug,
34
- errors
35
- });
52
+ if (first) {
53
+ const error = new ValidationError({
54
+ collection: collection.slug,
55
+ errors
56
+ });
57
+ error.message = puckGoalRefusal(first.key, first.block ? puckBlockName(first.block) : 'Page', !CLICK_KEY.test(first.key), errors.length - 1);
58
+ throw error;
59
+ }
36
60
  return data;
37
61
  };
38
62
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delmaredigital/payload-experiments",
3
- "version": "0.4.0-rc.2",
3
+ "version": "0.4.0-rc.3",
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",