@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.
- package/README.md +283 -775
- package/dist/admin/copy/goals.d.ts +1 -0
- package/dist/admin/copy/goals.js +1 -0
- package/dist/admin/surface/goals/GoalForm.js +14 -8
- package/dist/admin/surface/test/Section.js +9 -8
- package/dist/goals/picker/copy.d.ts +2 -0
- package/dist/goals/picker/copy.js +7 -0
- package/dist/goals/validate.js +3 -2
- package/dist/hooks/puckGoals.d.ts +2 -0
- package/dist/hooks/puckGoals.js +34 -10
- package/package.json +1 -1
|
@@ -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";
|
package/dist/admin/copy/goals.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/goals/validate.js
CHANGED
|
@@ -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
|
|
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) ||
|
|
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;
|
package/dist/hooks/puckGoals.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 (
|
|
33
|
-
|
|
34
|
-
|
|
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.
|
|
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",
|