@qoretechnologies/reqraft 0.10.57-pr.127.ge03fd3a → 0.10.57
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/.claude/CLAUDE.md +1 -1
- package/README.md +1 -1
- package/design/TRACKING.md +82 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/tracking/ui/ExperimentCard.d.ts +55 -0
- package/dist/tracking/ui/ExperimentCard.d.ts.map +1 -0
- package/dist/tracking/ui/ExperimentCard.js +396 -0
- package/dist/tracking/ui/ExperimentCard.js.map +1 -0
- package/dist/tracking/ui/ExperimentsOverlay.d.ts +59 -0
- package/dist/tracking/ui/ExperimentsOverlay.d.ts.map +1 -0
- package/dist/tracking/ui/ExperimentsOverlay.js +359 -0
- package/dist/tracking/ui/ExperimentsOverlay.js.map +1 -0
- package/dist/tracking/ui/anchor.d.ts +107 -0
- package/dist/tracking/ui/anchor.d.ts.map +1 -0
- package/dist/tracking/ui/anchor.js +108 -0
- package/dist/tracking/ui/anchor.js.map +1 -0
- package/dist/tracking/ui/card.d.ts +121 -0
- package/dist/tracking/ui/card.d.ts.map +1 -0
- package/dist/tracking/ui/card.js +417 -0
- package/dist/tracking/ui/card.js.map +1 -0
- package/dist/tracking/ui/client.d.ts +42 -0
- package/dist/tracking/ui/client.d.ts.map +1 -0
- package/dist/tracking/ui/client.js +123 -0
- package/dist/tracking/ui/client.js.map +1 -0
- package/dist/tracking/ui/index.d.ts +20 -0
- package/dist/tracking/ui/index.d.ts.map +1 -0
- package/dist/tracking/ui/index.js +60 -0
- package/dist/tracking/ui/index.js.map +1 -0
- package/dist/tracking/ui/types.d.ts +120 -0
- package/dist/tracking/ui/types.d.ts.map +1 -0
- package/dist/tracking/ui/types.js +8 -0
- package/dist/tracking/ui/types.js.map +1 -0
- package/dist/tracking/ui/useExperimentsAdmin.d.ts +31 -0
- package/dist/tracking/ui/useExperimentsAdmin.d.ts.map +1 -0
- package/dist/tracking/ui/useExperimentsAdmin.js +136 -0
- package/dist/tracking/ui/useExperimentsAdmin.js.map +1 -0
- package/package.json +3 -3
- package/src/index.tsx +3 -1
- package/src/stories/tracking.tsx +206 -0
- package/src/tracking/ui/ExperimentCard.stories.tsx +296 -0
- package/src/tracking/ui/ExperimentCard.tsx +667 -0
- package/src/tracking/ui/ExperimentsOverlay.stories.tsx +155 -0
- package/src/tracking/ui/ExperimentsOverlay.tsx +437 -0
- package/src/tracking/ui/anchor.ts +180 -0
- package/src/tracking/ui/card.ts +464 -0
- package/src/tracking/ui/client.ts +117 -0
- package/src/tracking/ui/index.ts +77 -0
- package/src/tracking/ui/types.ts +110 -0
- package/src/tracking/ui/useExperimentsAdmin.ts +103 -0
- package/src/tracking/react/Experiment.stories.tsx +0 -174
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small client for the qorus-api experiment routes the A/B test card acts on: the list with
|
|
3
|
+
* results, accept, remove-variant, start, pause, stop, and creating a draft (§9.3, §9.5 of the
|
|
4
|
+
* contract). Optional: the card and the overlay only take data and callbacks, so a product can
|
|
5
|
+
* reach its server any other way.
|
|
6
|
+
*/
|
|
7
|
+
import type {
|
|
8
|
+
IAdminExperiment,
|
|
9
|
+
IExperimentEntry,
|
|
10
|
+
INewExperiment,
|
|
11
|
+
TExperimentActionResult,
|
|
12
|
+
TLifecycleAction,
|
|
13
|
+
} from './types';
|
|
14
|
+
|
|
15
|
+
export class ExperimentsAdminError extends Error {
|
|
16
|
+
constructor(
|
|
17
|
+
message: string,
|
|
18
|
+
readonly status: number,
|
|
19
|
+
readonly code?: string
|
|
20
|
+
) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = 'ExperimentsAdminError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type TExperimentsFetch = (input: string, init?: RequestInit) => Promise<Response>;
|
|
27
|
+
|
|
28
|
+
export interface IExperimentsAdminOptions {
|
|
29
|
+
/**
|
|
30
|
+
* The list route, with any query it needs: `/qorus-cloud/editor/experiments?property=landing`
|
|
31
|
+
* (the editor route) or `/qorus-saas-admin/analytics/experiments?property=ide` (the admin API).
|
|
32
|
+
*/
|
|
33
|
+
listUrl: string;
|
|
34
|
+
/**
|
|
35
|
+
* The route of one action. Default: the list route's path, then `/<key>/<action>`
|
|
36
|
+
* (`…/experiments/signup-cta/accept`), without the list's query.
|
|
37
|
+
*/
|
|
38
|
+
actionUrl?: (key: string, action: string) => string;
|
|
39
|
+
/** Where a new draft is posted. Default: the list route's path. */
|
|
40
|
+
createUrl?: string;
|
|
41
|
+
/** Sent with every request (e.g. an `Authorization` header). */
|
|
42
|
+
headers?: Record<string, string>;
|
|
43
|
+
/** `same-origin` by default. */
|
|
44
|
+
credentials?: RequestCredentials;
|
|
45
|
+
/** Default: the global `fetch`. */
|
|
46
|
+
fetch?: TExperimentsFetch;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface IExperimentsAdminClient {
|
|
50
|
+
list(): Promise<IExperimentEntry[]>;
|
|
51
|
+
accept(key: string, variant: string): Promise<TExperimentActionResult>;
|
|
52
|
+
remove(key: string, variant: string): Promise<TExperimentActionResult>;
|
|
53
|
+
lifecycle(key: string, action: TLifecycleAction): Promise<IAdminExperiment>;
|
|
54
|
+
create(def: INewExperiment): Promise<IAdminExperiment>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const pathOf = (url: string) => url.split('?')[0].replace(/\/+$/, '');
|
|
58
|
+
|
|
59
|
+
export const createExperimentsAdminClient = (
|
|
60
|
+
options: IExperimentsAdminOptions
|
|
61
|
+
): IExperimentsAdminClient => {
|
|
62
|
+
const base = pathOf(options.listUrl);
|
|
63
|
+
const actionUrl =
|
|
64
|
+
options.actionUrl ??
|
|
65
|
+
((key: string, action: string) => `${base}/${encodeURIComponent(key)}/${action}`);
|
|
66
|
+
const doFetch: TExperimentsFetch = options.fetch ?? ((input, init) => fetch(input, init));
|
|
67
|
+
|
|
68
|
+
const call = async <T>(url: string, body?: unknown): Promise<T> => {
|
|
69
|
+
const res = await doFetch(url, {
|
|
70
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
71
|
+
credentials: options.credentials ?? 'same-origin',
|
|
72
|
+
headers: {
|
|
73
|
+
Accept: 'application/json',
|
|
74
|
+
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
75
|
+
...options.headers,
|
|
76
|
+
},
|
|
77
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
78
|
+
});
|
|
79
|
+
const parsed = (await res.json().catch(() => null)) as {
|
|
80
|
+
err?: string;
|
|
81
|
+
error?: string;
|
|
82
|
+
desc?: string;
|
|
83
|
+
message?: string;
|
|
84
|
+
} | null;
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
const code = parsed?.err ?? parsed?.error;
|
|
87
|
+
if (res.status === 401 || res.status === 403) {
|
|
88
|
+
throw new ExperimentsAdminError(
|
|
89
|
+
'Not allowed to manage A/B tests on this server (the API key is missing or wrong).',
|
|
90
|
+
res.status,
|
|
91
|
+
code
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
throw new ExperimentsAdminError(
|
|
95
|
+
parsed?.desc ?? parsed?.message ?? code ?? `HTTP ${res.status}`,
|
|
96
|
+
res.status,
|
|
97
|
+
code
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return parsed as T;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
list: () =>
|
|
105
|
+
call<IExperimentEntry[] | { experiments?: IExperimentEntry[] }>(options.listUrl).then(
|
|
106
|
+
(body) =>
|
|
107
|
+
Array.isArray(body) ? body
|
|
108
|
+
: Array.isArray(body?.experiments) ? body.experiments
|
|
109
|
+
: []
|
|
110
|
+
),
|
|
111
|
+
accept: (key, variant) => call<TExperimentActionResult>(actionUrl(key, 'accept'), { variant }),
|
|
112
|
+
remove: (key, variant) =>
|
|
113
|
+
call<TExperimentActionResult>(actionUrl(key, 'remove-variant'), { variant }),
|
|
114
|
+
lifecycle: (key, action) => call<IAdminExperiment>(actionUrl(key, action), {}),
|
|
115
|
+
create: (def) => call<IAdminExperiment>(options.createUrl ?? base, def),
|
|
116
|
+
};
|
|
117
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The A/B test UI: `<ExperimentCard>` (one test, driven by props), `<ExperimentsOverlay>` (every
|
|
3
|
+
* `<Experiment>` on the page outlined, with its card anchored to it), and the optional
|
|
4
|
+
* `useExperimentsAdmin` / `createExperimentsAdminClient` for the qorus-api routes. Built on Reqore;
|
|
5
|
+
* the engine (`../index`) does not import this layer. How to embed it: design/TRACKING.md, §8.
|
|
6
|
+
*/
|
|
7
|
+
export { ExperimentCard } from './ExperimentCard';
|
|
8
|
+
export type { IExperimentCardProps } from './ExperimentCard';
|
|
9
|
+
export { boxOf, ExperimentsOverlay, OVERLAY_ATTRIBUTE } from './ExperimentsOverlay';
|
|
10
|
+
export type { IExperimentsOverlayProps } from './ExperimentsOverlay';
|
|
11
|
+
export { useExperimentsAdmin } from './useExperimentsAdmin';
|
|
12
|
+
export type { IUseExperimentsAdmin, IUseExperimentsAdminOptions } from './useExperimentsAdmin';
|
|
13
|
+
export { createExperimentsAdminClient, ExperimentsAdminError } from './client';
|
|
14
|
+
export type {
|
|
15
|
+
IExperimentsAdminClient,
|
|
16
|
+
IExperimentsAdminOptions,
|
|
17
|
+
TExperimentsFetch,
|
|
18
|
+
} from './client';
|
|
19
|
+
export {
|
|
20
|
+
activatesRow,
|
|
21
|
+
CARD_MAX_WIDTH,
|
|
22
|
+
CARD_MIN_HEIGHT,
|
|
23
|
+
cardReducer,
|
|
24
|
+
CLOSED,
|
|
25
|
+
openCard,
|
|
26
|
+
OUTSIDE_IGNORE,
|
|
27
|
+
placeCard,
|
|
28
|
+
} from './anchor';
|
|
29
|
+
export type { IBox, ICardState, IPlaceInput, IPlacement, TCardEvent } from './anchor';
|
|
30
|
+
export {
|
|
31
|
+
actionOutcome,
|
|
32
|
+
barValues,
|
|
33
|
+
canAccept,
|
|
34
|
+
canPause,
|
|
35
|
+
canRemove,
|
|
36
|
+
canStart,
|
|
37
|
+
canStop,
|
|
38
|
+
confirmCopy,
|
|
39
|
+
count,
|
|
40
|
+
DEFAULT_MIN_SAMPLE,
|
|
41
|
+
helpSections,
|
|
42
|
+
leadingVersion,
|
|
43
|
+
NOT_SET_UP_TEXT,
|
|
44
|
+
sortVersionRows,
|
|
45
|
+
UNREACHABLE_TEXT,
|
|
46
|
+
lifecycleCopy,
|
|
47
|
+
numbersLine,
|
|
48
|
+
percent,
|
|
49
|
+
phaseNote,
|
|
50
|
+
primaryMetric,
|
|
51
|
+
STATUS_LABEL,
|
|
52
|
+
statusTag,
|
|
53
|
+
verdictIntent,
|
|
54
|
+
verdictLine,
|
|
55
|
+
versionName,
|
|
56
|
+
versionRows,
|
|
57
|
+
versionsMaxHeight,
|
|
58
|
+
VISIBLE_VERSIONS,
|
|
59
|
+
winnerNote,
|
|
60
|
+
} from './card';
|
|
61
|
+
export type { IConfirmCopy, IHelpSection, IVersionRow, TStatusIntent } from './card';
|
|
62
|
+
export type {
|
|
63
|
+
IAdminExperiment,
|
|
64
|
+
IAdminExperimentEvent,
|
|
65
|
+
IAdminExperimentPhase,
|
|
66
|
+
IAdminVariant,
|
|
67
|
+
IExperimentEntry,
|
|
68
|
+
IExperimentResults,
|
|
69
|
+
INewExperiment,
|
|
70
|
+
IResultsMetric,
|
|
71
|
+
IResultsVariant,
|
|
72
|
+
TExperimentAction,
|
|
73
|
+
TExperimentActionResult,
|
|
74
|
+
TExperimentStatus,
|
|
75
|
+
TLifecycleAction,
|
|
76
|
+
TVersionAction,
|
|
77
|
+
} from './types';
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The experiment and results objects of the qorus-api admin and editor routes (§5, §7, §9.5 of
|
|
3
|
+
* `design/analytics-and-experiments-design.md`), with the fields the A/B test UI reads.
|
|
4
|
+
* Change the contract there first, then here.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type TExperimentStatus =
|
|
8
|
+
'draft' | 'running' | 'paused' | 'stopped' | 'concluded' | 'archived';
|
|
9
|
+
|
|
10
|
+
export interface IAdminVariant {
|
|
11
|
+
key: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
weight: number;
|
|
14
|
+
control?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface IAdminExperimentPhase {
|
|
18
|
+
phase: number;
|
|
19
|
+
started_at: string;
|
|
20
|
+
ended_at: string | null;
|
|
21
|
+
variants: IAdminVariant[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface IAdminExperimentEvent {
|
|
25
|
+
at: string;
|
|
26
|
+
action: string;
|
|
27
|
+
variant?: string | null;
|
|
28
|
+
/** Who did it (`admin`, or the product's editor, e.g. `landing-editor`). */
|
|
29
|
+
actor: string;
|
|
30
|
+
user?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The admin experiment object. */
|
|
34
|
+
export interface IAdminExperiment {
|
|
35
|
+
key: string;
|
|
36
|
+
name?: string;
|
|
37
|
+
property?: string;
|
|
38
|
+
status: TExperimentStatus;
|
|
39
|
+
variants: IAdminVariant[];
|
|
40
|
+
winner?: string | null;
|
|
41
|
+
/** The current phase: a removed version starts a new one, and the numbers count from zero. */
|
|
42
|
+
phase?: number;
|
|
43
|
+
/** Earlier phases and the current one (last, `ended_at: null` while it runs). */
|
|
44
|
+
phases?: IAdminExperimentPhase[];
|
|
45
|
+
/** Every state change, oldest first. */
|
|
46
|
+
history?: IAdminExperimentEvent[];
|
|
47
|
+
metrics?: { primary?: string; guardrails?: string[]; secondary?: string[] };
|
|
48
|
+
/** Share of visitors in the test, 0..1. */
|
|
49
|
+
traffic?: number;
|
|
50
|
+
/** The too-early gate: every version needs `per_variant` visitors and `days` must pass (default 200, 7). */
|
|
51
|
+
min_sample?: { per_variant: number; days: number };
|
|
52
|
+
started_at?: string | null;
|
|
53
|
+
ended_at?: string | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface IResultsVariant {
|
|
57
|
+
variant: string;
|
|
58
|
+
exposed: number;
|
|
59
|
+
converters: number;
|
|
60
|
+
rate: number | null;
|
|
61
|
+
uplift?: number | null;
|
|
62
|
+
chance_to_beat: number | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface IResultsMetric {
|
|
66
|
+
key: string;
|
|
67
|
+
name?: string;
|
|
68
|
+
role: 'primary' | 'guardrail' | 'secondary';
|
|
69
|
+
verdict?: string;
|
|
70
|
+
verdict_text?: string;
|
|
71
|
+
variants: IResultsVariant[];
|
|
72
|
+
unavailable?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The admin results object. */
|
|
76
|
+
export interface IExperimentResults {
|
|
77
|
+
verdict?: string;
|
|
78
|
+
/** The verdict in plain words, written by the server. */
|
|
79
|
+
verdict_text?: string;
|
|
80
|
+
winner_variant?: string | null;
|
|
81
|
+
phase?: number;
|
|
82
|
+
metrics: IResultsMetric[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One entry of the editor list: the experiment and its results (null for a draft). */
|
|
86
|
+
export interface IExperimentEntry {
|
|
87
|
+
experiment: IAdminExperiment;
|
|
88
|
+
results: IExperimentResults | null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The experiment an action returns; `auto_accepted` when removing left one version. */
|
|
92
|
+
export type TExperimentActionResult = IAdminExperiment & { auto_accepted?: boolean };
|
|
93
|
+
|
|
94
|
+
export type TLifecycleAction = 'start' | 'pause' | 'stop';
|
|
95
|
+
export type TVersionAction = 'accept' | 'remove';
|
|
96
|
+
export type TExperimentAction = TVersionAction | TLifecycleAction;
|
|
97
|
+
|
|
98
|
+
/** A new experiment definition (admin shape); the server always creates it as a draft. */
|
|
99
|
+
export interface INewExperiment {
|
|
100
|
+
key: string;
|
|
101
|
+
name: string;
|
|
102
|
+
hypothesis?: string;
|
|
103
|
+
property?: string;
|
|
104
|
+
variants: IAdminVariant[];
|
|
105
|
+
traffic?: number;
|
|
106
|
+
targeting?: { paths?: string[] | null; device?: string | null; lang?: string | null };
|
|
107
|
+
trigger?: { section?: string | null };
|
|
108
|
+
metrics: { primary: string; guardrails?: string[]; secondary?: string[] };
|
|
109
|
+
min_sample?: { per_variant: number; days: number };
|
|
110
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
createExperimentsAdminClient,
|
|
4
|
+
type IExperimentsAdminClient,
|
|
5
|
+
type IExperimentsAdminOptions,
|
|
6
|
+
} from './client';
|
|
7
|
+
import type { IExperimentEntry, TExperimentActionResult, TLifecycleAction } from './types';
|
|
8
|
+
|
|
9
|
+
export interface IUseExperimentsAdminOptions extends IExperimentsAdminOptions {
|
|
10
|
+
/** Reload the list this often (someone else may accept a test meanwhile). Default 60 s; 0 turns it off. */
|
|
11
|
+
refreshMs?: number;
|
|
12
|
+
/** Load nothing while false (e.g. while the overlay is hidden). Default true. */
|
|
13
|
+
enabled?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface IUseExperimentsAdmin {
|
|
17
|
+
/** The list, or null before the first answer. */
|
|
18
|
+
tests: IExperimentEntry[] | null;
|
|
19
|
+
/** The last load's error message, or null. */
|
|
20
|
+
error: string | null;
|
|
21
|
+
reload: () => Promise<void>;
|
|
22
|
+
/** Each action reloads the list when it resolves, and rejects with the server's words. */
|
|
23
|
+
accept: (key: string, variant: string) => Promise<TExperimentActionResult>;
|
|
24
|
+
remove: (key: string, variant: string) => Promise<TExperimentActionResult>;
|
|
25
|
+
start: (key: string) => Promise<unknown>;
|
|
26
|
+
pause: (key: string) => Promise<unknown>;
|
|
27
|
+
stop: (key: string) => Promise<unknown>;
|
|
28
|
+
client: IExperimentsAdminClient;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The A/B test list and its actions from the qorus-api routes, for `<ExperimentsOverlay>` or
|
|
33
|
+
* `<ExperimentCard>`. Optional and replaceable: both components take plain data and callbacks.
|
|
34
|
+
*
|
|
35
|
+
* const admin = useExperimentsAdmin({ listUrl: '/qorus-cloud/editor/experiments?property=landing' });
|
|
36
|
+
* <ExperimentsOverlay tests={admin.tests} loadError={admin.error} onAccept={admin.accept} … />
|
|
37
|
+
*/
|
|
38
|
+
export const useExperimentsAdmin = (options: IUseExperimentsAdminOptions): IUseExperimentsAdmin => {
|
|
39
|
+
const { refreshMs = 60_000, enabled = true } = options;
|
|
40
|
+
// The options object is usually a fresh literal: the client follows its values, not its identity.
|
|
41
|
+
const latest = useRef(options);
|
|
42
|
+
latest.current = options;
|
|
43
|
+
const headersKey = JSON.stringify(options.headers ?? {});
|
|
44
|
+
const client = useMemo(
|
|
45
|
+
() =>
|
|
46
|
+
createExperimentsAdminClient({
|
|
47
|
+
...latest.current,
|
|
48
|
+
fetch: (input, init) => (latest.current.fetch ?? fetch)(input, init),
|
|
49
|
+
}),
|
|
50
|
+
[options.listUrl, options.createUrl, options.credentials, headersKey]
|
|
51
|
+
);
|
|
52
|
+
const [tests, setTests] = useState<IExperimentEntry[] | null>(null);
|
|
53
|
+
const [error, setError] = useState<string | null>(null);
|
|
54
|
+
const alive = useRef(true);
|
|
55
|
+
|
|
56
|
+
const reload = useCallback(async () => {
|
|
57
|
+
try {
|
|
58
|
+
const list = await client.list();
|
|
59
|
+
if (!alive.current) return;
|
|
60
|
+
setTests(list);
|
|
61
|
+
setError(null);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (alive.current) setError((err as Error).message);
|
|
64
|
+
}
|
|
65
|
+
}, [client]);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
alive.current = true;
|
|
69
|
+
if (!enabled) return () => undefined;
|
|
70
|
+
void reload();
|
|
71
|
+
const id = refreshMs > 0 ? setInterval(() => void reload(), refreshMs) : null;
|
|
72
|
+
return () => {
|
|
73
|
+
alive.current = false;
|
|
74
|
+
if (id) clearInterval(id);
|
|
75
|
+
};
|
|
76
|
+
}, [reload, enabled, refreshMs]);
|
|
77
|
+
|
|
78
|
+
const then = useCallback(
|
|
79
|
+
async <T>(promise: Promise<T>): Promise<T> => {
|
|
80
|
+
const result = await promise;
|
|
81
|
+
await reload();
|
|
82
|
+
return result;
|
|
83
|
+
},
|
|
84
|
+
[reload]
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const lifecycle = useCallback(
|
|
88
|
+
(key: string, action: TLifecycleAction) => then(client.lifecycle(key, action)),
|
|
89
|
+
[client, then]
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
tests,
|
|
94
|
+
error,
|
|
95
|
+
reload,
|
|
96
|
+
accept: useCallback((key, variant) => then(client.accept(key, variant)), [client, then]),
|
|
97
|
+
remove: useCallback((key, variant) => then(client.remove(key, variant)), [client, then]),
|
|
98
|
+
start: useCallback((key) => lifecycle(key, 'start'), [lifecycle]),
|
|
99
|
+
pause: useCallback((key) => lifecycle(key, 'pause'), [lifecycle]),
|
|
100
|
+
stop: useCallback((key) => lifecycle(key, 'stop'), [lifecycle]),
|
|
101
|
+
client,
|
|
102
|
+
};
|
|
103
|
+
};
|
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
import { ReqoreControlGroup, ReqoreP, ReqorePanel, ReqoreTag, ReqoreTagGroup } from '@qoretechnologies/reqore';
|
|
2
|
-
import { StoryObj } from '@storybook/react-vite';
|
|
3
|
-
import { useState } from 'react';
|
|
4
|
-
import { expect, within } from 'storybook/test';
|
|
5
|
-
import { StoryMeta } from '../../types';
|
|
6
|
-
import { KEYS, type IExperimentDefinition } from '../core/types';
|
|
7
|
-
import { createMemoryTracker, type IMemoryTrackerOptions } from '../testing';
|
|
8
|
-
import { Experiment } from './Experiment';
|
|
9
|
-
import { useExperiment } from './hooks';
|
|
10
|
-
import { TrackingProvider } from './TrackingProvider';
|
|
11
|
-
|
|
12
|
-
const KEY = 'signup-cta';
|
|
13
|
-
|
|
14
|
-
const RUNNING: IExperimentDefinition = {
|
|
15
|
-
key: KEY,
|
|
16
|
-
status: 'running',
|
|
17
|
-
variants: [
|
|
18
|
-
{ key: 'a', weight: 0.5, control: true },
|
|
19
|
-
{ key: 'b', weight: 0.5 },
|
|
20
|
-
],
|
|
21
|
-
traffic: 1,
|
|
22
|
-
trigger: null,
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
/** `createMemoryTracker`'s clock starts here; the cached definitions are fresh for it. */
|
|
26
|
-
const CLOCK = Date.UTC(2026, 8, 29, 10, 0, 0);
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* The definitions as the tracker caches them after a fetch, so the first render already
|
|
30
|
-
* has them (a real app gets the same from its cache on every visit after the first).
|
|
31
|
-
*/
|
|
32
|
-
const cached = (experiments: IExperimentDefinition[]) => ({
|
|
33
|
-
[KEYS.experimentsCache]: JSON.stringify({ at: CLOCK, property: 'test', experiments }),
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
/** A visitor id whose GrowthBook hash for `signup-cta` lands in version B at 50/50. */
|
|
37
|
-
const VISITOR_IN_B = 'visitor-3';
|
|
38
|
-
|
|
39
|
-
const Version = ({ letter, label }: { letter: string; label: string }) => (
|
|
40
|
-
<ReqorePanel label={`Version ${letter}`} flat>
|
|
41
|
-
<ReqoreP>{label}</ReqoreP>
|
|
42
|
-
</ReqorePanel>
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
const VARIANTS = {
|
|
46
|
-
a: <Version letter='A' label='The control: what every visitor sees without the experiment.' />,
|
|
47
|
-
b: <Version letter='B' label='The challenger: shown only to visitors assigned to it.' />,
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
/** Shows why this visitor sees what they see (the same assignment `<Experiment>` uses). */
|
|
51
|
-
const Reason = () => {
|
|
52
|
-
const { variant, assignment } = useExperiment(KEY, { variants: ['a', 'b'] });
|
|
53
|
-
return (
|
|
54
|
-
<ReqoreTagGroup>
|
|
55
|
-
<ReqoreTag labelKey='Shown' label={variant.toUpperCase()} />
|
|
56
|
-
<ReqoreTag labelKey='Reason' label={assignment.reason} />
|
|
57
|
-
<ReqoreTag labelKey='Counts for the test' label={assignment.enrolled ? 'yes' : 'no'} />
|
|
58
|
-
</ReqoreTagGroup>
|
|
59
|
-
);
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
const Demo = ({ tracking }: { tracking?: IMemoryTrackerOptions }) => {
|
|
63
|
-
const [memory] = useState(() => (tracking ? createMemoryTracker(tracking) : null));
|
|
64
|
-
return (
|
|
65
|
-
<TrackingProvider tracker={memory?.tracker ?? null} location='/'>
|
|
66
|
-
<ReqoreControlGroup vertical fluid>
|
|
67
|
-
<Reason />
|
|
68
|
-
<Experiment id={KEY} variants={VARIANTS} />
|
|
69
|
-
</ReqoreControlGroup>
|
|
70
|
-
</TrackingProvider>
|
|
71
|
-
);
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
const meta = {
|
|
75
|
-
title: 'Tracking/Experiment',
|
|
76
|
-
component: Demo,
|
|
77
|
-
} as StoryMeta<typeof Demo>;
|
|
78
|
-
|
|
79
|
-
export default meta;
|
|
80
|
-
export type Story = StoryObj<typeof meta>;
|
|
81
|
-
|
|
82
|
-
const expectShown = async (canvasElement: HTMLElement, variant: string, reason: string) => {
|
|
83
|
-
const canvas = within(canvasElement);
|
|
84
|
-
await expect(await canvas.findByText(`Version ${variant.toUpperCase()}`)).toBeInTheDocument();
|
|
85
|
-
const wrapper = canvasElement.querySelector(`[data-experiment="${KEY}"]`) as HTMLElement;
|
|
86
|
-
await expect(wrapper.dataset.variant).toBe(variant);
|
|
87
|
-
await expect(wrapper.dataset.experimentReason).toBe(reason);
|
|
88
|
-
await expect(canvas.getByText(reason)).toBeInTheDocument();
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
export const ControlWithoutTracker: Story = {
|
|
92
|
-
parameters: {
|
|
93
|
-
docs: {
|
|
94
|
-
description: {
|
|
95
|
-
story:
|
|
96
|
-
'Renders an experiment with no tracker behind it (as before the visitor answered the consent prompt). Shows version A, the control, with the reason `no-consent`.',
|
|
97
|
-
},
|
|
98
|
-
},
|
|
99
|
-
},
|
|
100
|
-
play: async ({ canvasElement }) => {
|
|
101
|
-
await expectShown(canvasElement, 'a', 'no-consent');
|
|
102
|
-
},
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
export const AssignedVariant: Story = {
|
|
106
|
-
args: {
|
|
107
|
-
tracking: { consent: 'granted', vid: VISITOR_IN_B, seed: cached([RUNNING]) },
|
|
108
|
-
},
|
|
109
|
-
parameters: {
|
|
110
|
-
docs: {
|
|
111
|
-
description: {
|
|
112
|
-
story:
|
|
113
|
-
'Renders a running 50/50 experiment for a visitor who allowed tracking and whose id hashes into version B. Shows version B, reason `assigned`, counted for the test.',
|
|
114
|
-
},
|
|
115
|
-
},
|
|
116
|
-
},
|
|
117
|
-
play: async ({ canvasElement }) => {
|
|
118
|
-
await expectShown(canvasElement, 'b', 'assigned');
|
|
119
|
-
await expect(within(canvasElement).getByText('yes')).toBeInTheDocument();
|
|
120
|
-
},
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
export const NotEnrolled: Story = {
|
|
124
|
-
args: {
|
|
125
|
-
tracking: { consent: 'granted', vid: VISITOR_IN_B, seed: cached([{ ...RUNNING, traffic: 0 }]) },
|
|
126
|
-
},
|
|
127
|
-
parameters: {
|
|
128
|
-
docs: {
|
|
129
|
-
description: {
|
|
130
|
-
story:
|
|
131
|
-
'Renders a running experiment with a traffic share of 0 for a visitor who allowed tracking. Shows version A, reason `not-enrolled`, not counted for the test.',
|
|
132
|
-
},
|
|
133
|
-
},
|
|
134
|
-
},
|
|
135
|
-
play: async ({ canvasElement }) => {
|
|
136
|
-
await expectShown(canvasElement, 'a', 'not-enrolled');
|
|
137
|
-
},
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
export const PreviewLink: Story = {
|
|
141
|
-
args: {
|
|
142
|
-
tracking: { page: { search: `?qa_variant=${KEY}:b` }, seed: cached([RUNNING]) },
|
|
143
|
-
},
|
|
144
|
-
parameters: {
|
|
145
|
-
docs: {
|
|
146
|
-
description: {
|
|
147
|
-
story:
|
|
148
|
-
'Renders the experiment opened through a `?qa_variant=signup-cta:b` preview link, without consent. Shows version B with the reason `preview`, never counted.',
|
|
149
|
-
},
|
|
150
|
-
},
|
|
151
|
-
},
|
|
152
|
-
play: async ({ canvasElement }) => {
|
|
153
|
-
await expectShown(canvasElement, 'b', 'preview');
|
|
154
|
-
},
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
export const ConcludedWinner: Story = {
|
|
158
|
-
args: {
|
|
159
|
-
tracking: {
|
|
160
|
-
seed: cached([{ key: KEY, status: 'concluded', winner: 'b' } as IExperimentDefinition]),
|
|
161
|
-
},
|
|
162
|
-
},
|
|
163
|
-
parameters: {
|
|
164
|
-
docs: {
|
|
165
|
-
description: {
|
|
166
|
-
story:
|
|
167
|
-
'Renders an experiment that ended with version B as the winner, for a visitor who never answered the consent prompt. Shows version B to everybody, reason `concluded`.',
|
|
168
|
-
},
|
|
169
|
-
},
|
|
170
|
-
},
|
|
171
|
-
play: async ({ canvasElement }) => {
|
|
172
|
-
await expectShown(canvasElement, 'b', 'concluded');
|
|
173
|
-
},
|
|
174
|
-
};
|