@qoretechnologies/reqraft 0.10.56 → 0.10.57-pr.127.g0a792ea
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 -0
- package/README.md +20 -0
- package/design/TRACKING.md +290 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/tracking/browser/attach.d.ts +8 -0
- package/dist/tracking/browser/attach.d.ts.map +1 -0
- package/dist/tracking/browser/attach.js +182 -0
- package/dist/tracking/browser/attach.js.map +1 -0
- package/dist/tracking/browser/env.d.ts +14 -0
- package/dist/tracking/browser/env.d.ts.map +1 -0
- package/dist/tracking/browser/env.js +150 -0
- package/dist/tracking/browser/env.js.map +1 -0
- package/dist/tracking/core/consent.d.ts +21 -0
- package/dist/tracking/core/consent.d.ts.map +1 -0
- package/dist/tracking/core/consent.js +39 -0
- package/dist/tracking/core/consent.js.map +1 -0
- package/dist/tracking/core/engagement.d.ts +20 -0
- package/dist/tracking/core/engagement.d.ts.map +1 -0
- package/dist/tracking/core/engagement.js +52 -0
- package/dist/tracking/core/engagement.js.map +1 -0
- package/dist/tracking/core/experiments.d.ts +109 -0
- package/dist/tracking/core/experiments.d.ts.map +1 -0
- package/dist/tracking/core/experiments.js +294 -0
- package/dist/tracking/core/experiments.js.map +1 -0
- package/dist/tracking/core/hash.d.ts +16 -0
- package/dist/tracking/core/hash.d.ts.map +1 -0
- package/dist/tracking/core/hash.js +31 -0
- package/dist/tracking/core/hash.js.map +1 -0
- package/dist/tracking/core/ids.d.ts +6 -0
- package/dist/tracking/core/ids.d.ts.map +1 -0
- package/dist/tracking/core/ids.js +31 -0
- package/dist/tracking/core/ids.js.map +1 -0
- package/dist/tracking/core/observable.d.ts +12 -0
- package/dist/tracking/core/observable.d.ts.map +1 -0
- package/dist/tracking/core/observable.js +32 -0
- package/dist/tracking/core/observable.js.map +1 -0
- package/dist/tracking/core/queue.d.ts +47 -0
- package/dist/tracking/core/queue.d.ts.map +1 -0
- package/dist/tracking/core/queue.js +205 -0
- package/dist/tracking/core/queue.js.map +1 -0
- package/dist/tracking/core/sections.d.ts +54 -0
- package/dist/tracking/core/sections.d.ts.map +1 -0
- package/dist/tracking/core/sections.js +156 -0
- package/dist/tracking/core/sections.js.map +1 -0
- package/dist/tracking/core/storage.d.ts +15 -0
- package/dist/tracking/core/storage.d.ts.map +1 -0
- package/dist/tracking/core/storage.js +72 -0
- package/dist/tracking/core/storage.js.map +1 -0
- package/dist/tracking/core/tracker.d.ts +122 -0
- package/dist/tracking/core/tracker.d.ts.map +1 -0
- package/dist/tracking/core/tracker.js +390 -0
- package/dist/tracking/core/tracker.js.map +1 -0
- package/dist/tracking/core/types.d.ts +130 -0
- package/dist/tracking/core/types.d.ts.map +1 -0
- package/dist/tracking/core/types.js +69 -0
- package/dist/tracking/core/types.js.map +1 -0
- package/dist/tracking/core/utm.d.ts +6 -0
- package/dist/tracking/core/utm.d.ts.map +1 -0
- package/dist/tracking/core/utm.js +30 -0
- package/dist/tracking/core/utm.js.map +1 -0
- package/dist/tracking/index.d.ts +46 -0
- package/dist/tracking/index.d.ts.map +1 -0
- package/dist/tracking/index.js +91 -0
- package/dist/tracking/index.js.map +1 -0
- package/dist/tracking/markup.d.ts +12 -0
- package/dist/tracking/markup.d.ts.map +1 -0
- package/dist/tracking/markup.js +19 -0
- package/dist/tracking/markup.js.map +1 -0
- package/dist/tracking/react/Experiment.d.ts +23 -0
- package/dist/tracking/react/Experiment.d.ts.map +1 -0
- package/dist/tracking/react/Experiment.js +28 -0
- package/dist/tracking/react/Experiment.js.map +1 -0
- package/dist/tracking/react/TrackingProvider.d.ts +26 -0
- package/dist/tracking/react/TrackingProvider.d.ts.map +1 -0
- package/dist/tracking/react/TrackingProvider.js +45 -0
- package/dist/tracking/react/TrackingProvider.js.map +1 -0
- package/dist/tracking/react/hooks.d.ts +39 -0
- package/dist/tracking/react/hooks.d.ts.map +1 -0
- package/dist/tracking/react/hooks.js +96 -0
- package/dist/tracking/react/hooks.js.map +1 -0
- package/dist/tracking/react/index.d.ts +7 -0
- package/dist/tracking/react/index.d.ts.map +1 -0
- package/dist/tracking/react/index.js +17 -0
- package/dist/tracking/react/index.js.map +1 -0
- package/dist/tracking/testing.d.ts +40 -0
- package/dist/tracking/testing.d.ts.map +1 -0
- package/dist/tracking/testing.js +140 -0
- package/dist/tracking/testing.js.map +1 -0
- 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 +340 -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 +1 -1
- package/src/index.tsx +5 -0
- package/src/stories/tracking.tsx +206 -0
- package/src/tracking/browser/attach.ts +186 -0
- package/src/tracking/browser/env.ts +78 -0
- package/src/tracking/core/consent.ts +37 -0
- package/src/tracking/core/engagement.ts +47 -0
- package/src/tracking/core/experiments.ts +258 -0
- package/src/tracking/core/hash.ts +26 -0
- package/src/tracking/core/ids.ts +25 -0
- package/src/tracking/core/observable.ts +23 -0
- package/src/tracking/core/queue.ts +131 -0
- package/src/tracking/core/sections.ts +166 -0
- package/src/tracking/core/storage.ts +69 -0
- package/src/tracking/core/tracker.ts +417 -0
- package/src/tracking/core/types.ts +162 -0
- package/src/tracking/core/utm.ts +23 -0
- package/src/tracking/index.ts +72 -0
- package/src/tracking/markup.ts +17 -0
- package/src/tracking/react/Experiment.tsx +44 -0
- package/src/tracking/react/TrackingProvider.tsx +56 -0
- package/src/tracking/react/hooks.ts +106 -0
- package/src/tracking/react/index.ts +13 -0
- package/src/tracking/testing.ts +108 -0
- package/src/tracking/ui/ExperimentCard.stories.tsx +290 -0
- package/src/tracking/ui/ExperimentCard.tsx +581 -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
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ReqoreCallout,
|
|
3
|
+
ReqoreEntityRow,
|
|
4
|
+
ReqoreH4,
|
|
5
|
+
ReqoreModal,
|
|
6
|
+
ReqoreP,
|
|
7
|
+
ReqorePanel,
|
|
8
|
+
ReqoreProgress,
|
|
9
|
+
ReqoreVerticalSpacer,
|
|
10
|
+
useReqoreProperty,
|
|
11
|
+
useReqoreTheme,
|
|
12
|
+
} from '@qoretechnologies/reqore';
|
|
13
|
+
import type { TReqoreBadge } from '@qoretechnologies/reqore/dist/components/Button';
|
|
14
|
+
import type { IReqoreEffect } from '@qoretechnologies/reqore/dist/components/Effect';
|
|
15
|
+
import type { IReqoreEntityRowAction } from '@qoretechnologies/reqore/dist/components/EntityRow';
|
|
16
|
+
import type { IReqorePanelBottomAction } from '@qoretechnologies/reqore/dist/components/Panel';
|
|
17
|
+
import type { IReqoreIconName } from '@qoretechnologies/reqore/dist/types/icons';
|
|
18
|
+
import { getLuminance } from 'polished';
|
|
19
|
+
import {
|
|
20
|
+
useEffect,
|
|
21
|
+
useLayoutEffect,
|
|
22
|
+
useMemo,
|
|
23
|
+
useRef,
|
|
24
|
+
useState,
|
|
25
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
26
|
+
} from 'react';
|
|
27
|
+
import styled from 'styled-components';
|
|
28
|
+
import { activatesRow } from './anchor';
|
|
29
|
+
import {
|
|
30
|
+
actionOutcome,
|
|
31
|
+
barValues,
|
|
32
|
+
canAccept,
|
|
33
|
+
canPause,
|
|
34
|
+
canRemove,
|
|
35
|
+
canStart,
|
|
36
|
+
canStop,
|
|
37
|
+
confirmCopy,
|
|
38
|
+
helpSections,
|
|
39
|
+
leadingVersion,
|
|
40
|
+
lifecycleCopy,
|
|
41
|
+
numbersLine,
|
|
42
|
+
phaseNote,
|
|
43
|
+
primaryMetric,
|
|
44
|
+
sortVersionRows,
|
|
45
|
+
statusTag,
|
|
46
|
+
verdictIntent,
|
|
47
|
+
verdictLine,
|
|
48
|
+
versionRows,
|
|
49
|
+
versionsMaxHeight,
|
|
50
|
+
} from './card';
|
|
51
|
+
import type { IExperimentEntry, TExperimentAction, TExperimentActionResult } from './types';
|
|
52
|
+
|
|
53
|
+
type TMaybeAsync<T = unknown> = T | Promise<T>;
|
|
54
|
+
|
|
55
|
+
export interface IExperimentCardProps {
|
|
56
|
+
/** The experiment key (`data-experiment` of `<Experiment>`). */
|
|
57
|
+
experimentKey: string;
|
|
58
|
+
/** The server's experiment and its results; absent while the test is not on the server. */
|
|
59
|
+
test?: IExperimentEntry | null;
|
|
60
|
+
/** The card's title. Default: the experiment's name, else its key. */
|
|
61
|
+
name?: string;
|
|
62
|
+
/** The version this page shows now: its row takes the info colour (and `aria-current`). */
|
|
63
|
+
shown?: string;
|
|
64
|
+
/** The versions the page's code renders (`data-variants`); a server version outside it cannot be shown. */
|
|
65
|
+
codeVariants?: string[];
|
|
66
|
+
/** The room the card has (px): the versions scroll inside when it is short. */
|
|
67
|
+
maxHeight?: number;
|
|
68
|
+
/** Why the list could not be loaded (the analytics service cannot be reached). */
|
|
69
|
+
loadError?: string | null;
|
|
70
|
+
/** An "Open in …" link at the start of the footer (a new tab). */
|
|
71
|
+
detailsUrl?: string | null;
|
|
72
|
+
/** The link's label. Default `Open in admin portal`. */
|
|
73
|
+
detailsLabel?: string;
|
|
74
|
+
/** One version's analytics (a new tab): a "Show analytics" action on each row; none without it. */
|
|
75
|
+
versionDetailsUrl?: (variant: string) => string | null | undefined;
|
|
76
|
+
/** Where the full analytics live, in the help's words. Default `the admin portal`. */
|
|
77
|
+
analyticsLabel?: string;
|
|
78
|
+
/** Best first (conversion on the primary metric, then visitors). Default true. */
|
|
79
|
+
sort?: boolean;
|
|
80
|
+
/** The words for a test that is in the page's code but not set up in the analytics service yet. */
|
|
81
|
+
notSetUpText?: string;
|
|
82
|
+
/** Who removed a version, in words, by the history's `actor` (`{ 'landing-editor': 'in the editor' }`). */
|
|
83
|
+
actorLabels?: Record<string, string>;
|
|
84
|
+
/** Asks before Accept, Remove, Start, Pause and Stop (Reqore's confirm dialog). Default true. */
|
|
85
|
+
confirm?: boolean;
|
|
86
|
+
/** A notification after each action (what happened, or the server's error). Default true. */
|
|
87
|
+
notify?: boolean;
|
|
88
|
+
/** A click (or Enter / Space) on a version's row: show that version on this page. Rows are not clickable without it. */
|
|
89
|
+
onShow?: (variant: string) => void;
|
|
90
|
+
/** Each action appears only when its callback is given; a returned promise shows the button busy until it settles. */
|
|
91
|
+
onAccept?: (variant: string) => TMaybeAsync<TExperimentActionResult | unknown>;
|
|
92
|
+
onRemove?: (variant: string) => TMaybeAsync<TExperimentActionResult | unknown>;
|
|
93
|
+
onStart?: () => TMaybeAsync;
|
|
94
|
+
onPause?: () => TMaybeAsync;
|
|
95
|
+
onStop?: () => TMaybeAsync;
|
|
96
|
+
/** The ✕ in the header. */
|
|
97
|
+
onClose?: () => void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Solid tags: a badge with only an intent is a minimal tag, unreadable on a light theme.
|
|
101
|
+
const solid = (intent: 'success' | 'warning' | 'danger' | 'muted' | 'info'): IReqoreEffect => ({
|
|
102
|
+
gradient: { colors: { 0: intent, 100: `${intent}:darken:1` } },
|
|
103
|
+
weight: 'bold',
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const ROW_GAP = 6;
|
|
107
|
+
|
|
108
|
+
// Layout only: the versions scroll inside the card past three and a half rows.
|
|
109
|
+
const StyledVersions = styled.div<{ $max: number | null }>`
|
|
110
|
+
display: flex;
|
|
111
|
+
flex-direction: column;
|
|
112
|
+
gap: ${ROW_GAP}px;
|
|
113
|
+
max-height: ${({ $max }) => ($max ? `${$max}px` : 'none')};
|
|
114
|
+
overflow-y: ${({ $max }) => ($max ? 'auto' : 'visible')};
|
|
115
|
+
overflow-x: hidden;
|
|
116
|
+
overscroll-behavior: contain;
|
|
117
|
+
min-width: 0;
|
|
118
|
+
|
|
119
|
+
/* The rows take the card's width, never their content's, padding included: a host page
|
|
120
|
+
without a global border-box reset would otherwise push each row past the card's edge. */
|
|
121
|
+
> * {
|
|
122
|
+
min-width: 0;
|
|
123
|
+
}
|
|
124
|
+
*,
|
|
125
|
+
*::before,
|
|
126
|
+
*::after {
|
|
127
|
+
box-sizing: border-box;
|
|
128
|
+
}
|
|
129
|
+
`;
|
|
130
|
+
|
|
131
|
+
/** Header, verdict and footer take about this much of `maxHeight`; the versions get the rest. */
|
|
132
|
+
const CHROME_HEIGHT = 230;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* One A/B test: its name and status, the verdict in plain words, one row per version (visitors,
|
|
136
|
+
* conversion, chance to beat the original, a bar; click a row to show that version; Accept, and
|
|
137
|
+
* Remove at the far end), and the test's own controls (Start / Resume, Pause, Stop) with an
|
|
138
|
+
* optional link to its details. Driven only by props: no requests of its own.
|
|
139
|
+
*/
|
|
140
|
+
export const ExperimentCard = ({
|
|
141
|
+
experimentKey,
|
|
142
|
+
test,
|
|
143
|
+
name,
|
|
144
|
+
shown,
|
|
145
|
+
codeVariants = [],
|
|
146
|
+
maxHeight,
|
|
147
|
+
loadError,
|
|
148
|
+
detailsUrl,
|
|
149
|
+
detailsLabel = 'Open in admin portal',
|
|
150
|
+
versionDetailsUrl,
|
|
151
|
+
analyticsLabel = 'the admin portal',
|
|
152
|
+
sort = true,
|
|
153
|
+
notSetUpText,
|
|
154
|
+
actorLabels,
|
|
155
|
+
confirm = true,
|
|
156
|
+
notify = true,
|
|
157
|
+
onShow,
|
|
158
|
+
onAccept,
|
|
159
|
+
onRemove,
|
|
160
|
+
onStart,
|
|
161
|
+
onPause,
|
|
162
|
+
onStop,
|
|
163
|
+
onClose,
|
|
164
|
+
}: IExperimentCardProps) => {
|
|
165
|
+
const entry = test ?? undefined;
|
|
166
|
+
const confirmAction = useReqoreProperty('confirmAction');
|
|
167
|
+
const addNotification = useReqoreProperty('addNotification');
|
|
168
|
+
const theme = useReqoreTheme();
|
|
169
|
+
const light = (() => {
|
|
170
|
+
try {
|
|
171
|
+
return getLuminance(theme.main) > 0.5;
|
|
172
|
+
} catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
})();
|
|
176
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
177
|
+
const [helpOpen, setHelpOpen] = useState(false);
|
|
178
|
+
const [rowHeight, setRowHeight] = useState(0);
|
|
179
|
+
const firstRow = useRef<HTMLDivElement>(null);
|
|
180
|
+
const mounted = useRef(true);
|
|
181
|
+
useEffect(
|
|
182
|
+
() => () => {
|
|
183
|
+
mounted.current = false;
|
|
184
|
+
},
|
|
185
|
+
[]
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const unreachable = !entry && !!loadError;
|
|
189
|
+
const codeKey = codeVariants.join(',');
|
|
190
|
+
// The order is decided when the data arrives (a refresh), never while the user works in the card.
|
|
191
|
+
const order = useMemo(() => {
|
|
192
|
+
const all = versionRows(entry, codeVariants);
|
|
193
|
+
return (sort ? sortVersionRows(all) : all).map((r) => r.key);
|
|
194
|
+
// `codeVariants` is followed by its keys, not its identity.
|
|
195
|
+
}, [entry, codeKey, sort]);
|
|
196
|
+
const unsorted = versionRows(entry, codeVariants);
|
|
197
|
+
const rows = order
|
|
198
|
+
.map((key) => unsorted.find((r) => r.key === key))
|
|
199
|
+
.filter(Boolean)
|
|
200
|
+
.concat(unsorted.filter((r) => !order.includes(r.key)));
|
|
201
|
+
const leader = leadingVersion(entry, rows);
|
|
202
|
+
const bars = barValues(rows);
|
|
203
|
+
const metric = primaryMetric(entry?.results);
|
|
204
|
+
const status = entry?.experiment.status;
|
|
205
|
+
const title = name || entry?.experiment.name || experimentKey;
|
|
206
|
+
const tag = statusTag(entry, unreachable);
|
|
207
|
+
const note = phaseNote(entry, actorLabels);
|
|
208
|
+
const busyOn = (action: string, variant = '') => busy === `${action}:${variant}`;
|
|
209
|
+
|
|
210
|
+
useLayoutEffect(() => {
|
|
211
|
+
const h = firstRow.current?.offsetHeight ?? 0;
|
|
212
|
+
if (h && h !== rowHeight) setRowHeight(h);
|
|
213
|
+
});
|
|
214
|
+
// A row's height changes with the card's width (a phone, a resized window): measure it again.
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
const el = firstRow.current;
|
|
217
|
+
if (!el || typeof ResizeObserver === 'undefined') return undefined;
|
|
218
|
+
const ro = new ResizeObserver(() => setRowHeight(el.offsetHeight));
|
|
219
|
+
ro.observe(el);
|
|
220
|
+
return () => ro.disconnect();
|
|
221
|
+
}, []);
|
|
222
|
+
|
|
223
|
+
// Minimal buttons tint their text from the intent, too pale on a light theme: a deeper shade there.
|
|
224
|
+
const quiet = (intent?: 'success' | 'warning' | 'danger' | 'info') => ({
|
|
225
|
+
minimal: true,
|
|
226
|
+
...(light && intent ?
|
|
227
|
+
{
|
|
228
|
+
effect: { color: `${intent}:darken:2` as const, weight: 'thick' as const },
|
|
229
|
+
iconColor: `${intent}:darken:2` as const,
|
|
230
|
+
}
|
|
231
|
+
: {}),
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const act = (action: TExperimentAction, variant?: string) => {
|
|
235
|
+
if (!entry) return;
|
|
236
|
+
const callbacks = {
|
|
237
|
+
accept: onAccept,
|
|
238
|
+
remove: onRemove,
|
|
239
|
+
start: onStart,
|
|
240
|
+
pause: onPause,
|
|
241
|
+
stop: onStop,
|
|
242
|
+
};
|
|
243
|
+
const callback = callbacks[action];
|
|
244
|
+
if (!callback) return;
|
|
245
|
+
const isVersion = action === 'accept' || action === 'remove';
|
|
246
|
+
const copy = isVersion ? confirmCopy(action, entry, variant) : lifecycleCopy(action, entry);
|
|
247
|
+
const titles: Record<TExperimentAction, [string, string]> = {
|
|
248
|
+
accept: ['Winner accepted', 'Could not accept'],
|
|
249
|
+
remove: ['Version removed', 'Could not remove'],
|
|
250
|
+
start: [status === 'paused' ? 'Test resumed' : 'Test started', 'Could not start'],
|
|
251
|
+
pause: ['Test paused', 'Could not pause'],
|
|
252
|
+
stop: ['Test stopped', 'Could not stop'],
|
|
253
|
+
};
|
|
254
|
+
const run = async () => {
|
|
255
|
+
setBusy(`${action}:${variant ?? ''}`);
|
|
256
|
+
try {
|
|
257
|
+
const result = await (callback as (v?: string) => TMaybeAsync)(variant);
|
|
258
|
+
if (notify) {
|
|
259
|
+
const content =
|
|
260
|
+
isVersion ?
|
|
261
|
+
actionOutcome(action, result as TExperimentActionResult, variant)
|
|
262
|
+
: `“${title}” is ${
|
|
263
|
+
action === 'start' ? 'running'
|
|
264
|
+
: action === 'pause' ? 'paused'
|
|
265
|
+
: 'stopped'
|
|
266
|
+
}.`;
|
|
267
|
+
addNotification?.({
|
|
268
|
+
intent: 'success',
|
|
269
|
+
title: titles[action][0],
|
|
270
|
+
content,
|
|
271
|
+
duration: 8000,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
} catch (err) {
|
|
275
|
+
if (notify) {
|
|
276
|
+
addNotification?.({
|
|
277
|
+
intent: 'danger',
|
|
278
|
+
title: titles[action][1],
|
|
279
|
+
content: (err as Error)?.message ?? String(err),
|
|
280
|
+
duration: 10000,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
} finally {
|
|
284
|
+
if (mounted.current) setBusy(null);
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
if (confirm && confirmAction) {
|
|
288
|
+
confirmAction({
|
|
289
|
+
title: copy.title,
|
|
290
|
+
description: copy.description,
|
|
291
|
+
confirmLabel: copy.confirmLabel,
|
|
292
|
+
confirmButtonIntent:
|
|
293
|
+
action === 'accept' || action === 'start' ? 'success'
|
|
294
|
+
: action === 'pause' ? 'warning'
|
|
295
|
+
: 'danger',
|
|
296
|
+
onConfirm: () => void run(),
|
|
297
|
+
});
|
|
298
|
+
} else {
|
|
299
|
+
void run();
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const footer: IReqorePanelBottomAction[] = [];
|
|
304
|
+
if (detailsUrl) {
|
|
305
|
+
footer.push({
|
|
306
|
+
position: 'left',
|
|
307
|
+
icon: 'ExternalLinkLine',
|
|
308
|
+
label: detailsLabel,
|
|
309
|
+
responsive: false,
|
|
310
|
+
...quiet(),
|
|
311
|
+
tooltip: 'This test’s details (a new tab)',
|
|
312
|
+
onClick: () => window.open(detailsUrl, '_blank', 'noopener'),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
if (entry && status) {
|
|
316
|
+
// Every button in the card is minimal, except Start test / Resume: the one primary action.
|
|
317
|
+
if (onStart && canStart(status)) {
|
|
318
|
+
footer.push({
|
|
319
|
+
position: 'right',
|
|
320
|
+
icon: 'PlayLine',
|
|
321
|
+
intent: 'success',
|
|
322
|
+
label: status === 'paused' ? 'Resume' : 'Start test',
|
|
323
|
+
responsive: false,
|
|
324
|
+
disabled: !!busy,
|
|
325
|
+
loading: busyOn('start'),
|
|
326
|
+
tooltip:
|
|
327
|
+
status === 'paused' ?
|
|
328
|
+
'Split visitors between the versions again'
|
|
329
|
+
: 'Start splitting visitors between the versions and counting',
|
|
330
|
+
onClick: () => act('start'),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
if (onPause && canPause(status)) {
|
|
334
|
+
footer.push({
|
|
335
|
+
position: 'right',
|
|
336
|
+
icon: 'PauseLine',
|
|
337
|
+
intent: 'warning',
|
|
338
|
+
label: 'Pause',
|
|
339
|
+
responsive: false,
|
|
340
|
+
...quiet('warning'),
|
|
341
|
+
disabled: !!busy,
|
|
342
|
+
loading: busyOn('pause'),
|
|
343
|
+
tooltip: 'Everybody sees the original until you resume',
|
|
344
|
+
onClick: () => act('pause'),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
if (onStop && canStop(status)) {
|
|
348
|
+
footer.push({
|
|
349
|
+
position: 'right',
|
|
350
|
+
icon: 'StopLine',
|
|
351
|
+
intent: 'danger',
|
|
352
|
+
label: 'Stop',
|
|
353
|
+
responsive: false,
|
|
354
|
+
...quiet('danger'),
|
|
355
|
+
disabled: !!busy,
|
|
356
|
+
loading: busyOn('stop'),
|
|
357
|
+
tooltip: 'End the test without a winner',
|
|
358
|
+
onClick: () => act('stop'),
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const accept = status ? canAccept(status) : false;
|
|
364
|
+
const remove = status ? canRemove(status, rows.length) : false;
|
|
365
|
+
const listMax = versionsMaxHeight({
|
|
366
|
+
rows: rows.length,
|
|
367
|
+
rowHeight,
|
|
368
|
+
gap: ROW_GAP,
|
|
369
|
+
available: maxHeight ? Math.max(rowHeight, maxHeight - CHROME_HEIGHT) : undefined,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
return (
|
|
373
|
+
<ReqorePanel
|
|
374
|
+
className='reqraft-experiment-card'
|
|
375
|
+
label={title}
|
|
376
|
+
icon='FlaskLine'
|
|
377
|
+
badge={{ label: tag.label, icon: tag.icon as IReqoreIconName, effect: solid(tag.intent) }}
|
|
378
|
+
size='small'
|
|
379
|
+
rounded
|
|
380
|
+
flat={false}
|
|
381
|
+
padded
|
|
382
|
+
collapsible={false}
|
|
383
|
+
onClose={onClose}
|
|
384
|
+
closeTooltip='Close (Esc)'
|
|
385
|
+
closeButtonProps={{ 'aria-label': 'Close the test card', minimal: true } as never}
|
|
386
|
+
actions={[
|
|
387
|
+
{
|
|
388
|
+
icon: 'QuestionLine',
|
|
389
|
+
minimal: true,
|
|
390
|
+
flat: true,
|
|
391
|
+
tooltip: 'How A/B tests work',
|
|
392
|
+
'aria-label': 'How A/B tests work',
|
|
393
|
+
onClick: () => setHelpOpen(true),
|
|
394
|
+
} as never,
|
|
395
|
+
]}
|
|
396
|
+
bottomActions={footer.length ? footer : undefined}
|
|
397
|
+
>
|
|
398
|
+
<ReqoreCallout
|
|
399
|
+
size='small'
|
|
400
|
+
flat
|
|
401
|
+
icon='LineChartLine'
|
|
402
|
+
intent={unreachable ? 'warning' : verdictIntent(entry)}
|
|
403
|
+
effect={{ weight: 500 }}
|
|
404
|
+
>
|
|
405
|
+
{verdictLine(entry, { unreachable, notSetUpText })}
|
|
406
|
+
</ReqoreCallout>
|
|
407
|
+
{note && (
|
|
408
|
+
<>
|
|
409
|
+
<ReqoreVerticalSpacer height={6} />
|
|
410
|
+
<ReqoreP size='small' effect={{ opacity: 0.7 }}>
|
|
411
|
+
{note}
|
|
412
|
+
</ReqoreP>
|
|
413
|
+
</>
|
|
414
|
+
)}
|
|
415
|
+
<ReqoreVerticalSpacer height={10} />
|
|
416
|
+
<ReqoreP size='small' effect={{ opacity: 0.75, weight: 500 }}>
|
|
417
|
+
{`${rows.length} versions${metric?.name ? ` · goal: “${metric.name}”` : ''}`}
|
|
418
|
+
</ReqoreP>
|
|
419
|
+
<ReqoreVerticalSpacer height={6} />
|
|
420
|
+
<StyledVersions
|
|
421
|
+
$max={listMax}
|
|
422
|
+
role='list'
|
|
423
|
+
aria-label='Versions'
|
|
424
|
+
className='reqraft-experiment-versions'
|
|
425
|
+
>
|
|
426
|
+
{rows.map((r, i) => {
|
|
427
|
+
const isShown = r.key === shown;
|
|
428
|
+
const badges: TReqoreBadge[] = [];
|
|
429
|
+
if (leader?.key === r.key) {
|
|
430
|
+
badges.push(
|
|
431
|
+
leader.kind === 'winner' ?
|
|
432
|
+
{ label: 'Winner', icon: 'TrophyLine', effect: solid('success') }
|
|
433
|
+
: { label: 'Leading', icon: 'ArrowUpLine', effect: solid('success') }
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
if (r.control) badges.push({ label: 'Original', icon: 'HomeLine' });
|
|
437
|
+
if (!r.inCode)
|
|
438
|
+
badges.push({ label: 'Not in the code', icon: 'AlertLine', effect: solid('warning') });
|
|
439
|
+
// The whole row shows its version; Accept and Remove stop the click (Reqore's row actions do).
|
|
440
|
+
const showable = !!onShow && r.inCode && !isShown;
|
|
441
|
+
const actions: IReqoreEntityRowAction[] = [];
|
|
442
|
+
const analytics = entry ? versionDetailsUrl?.(r.key) : null;
|
|
443
|
+
if (analytics) {
|
|
444
|
+
actions.push({
|
|
445
|
+
size: 'small',
|
|
446
|
+
icon: 'BarChartBoxLine',
|
|
447
|
+
intent: 'info',
|
|
448
|
+
...quiet('info'),
|
|
449
|
+
tooltip: `Show analytics for “${r.name}” (a new tab)`,
|
|
450
|
+
'aria-label': `Show analytics for ${r.name}`,
|
|
451
|
+
onClick: () => window.open(analytics, '_blank', 'noopener'),
|
|
452
|
+
} as IReqoreEntityRowAction);
|
|
453
|
+
}
|
|
454
|
+
if (entry && onAccept) {
|
|
455
|
+
actions.push({
|
|
456
|
+
size: 'small',
|
|
457
|
+
icon: 'TrophyLine',
|
|
458
|
+
intent: 'success',
|
|
459
|
+
label: 'Accept',
|
|
460
|
+
...quiet('success'),
|
|
461
|
+
disabled: !accept || !!busy,
|
|
462
|
+
loading: busyOn('accept', r.key),
|
|
463
|
+
tooltip:
|
|
464
|
+
accept ?
|
|
465
|
+
`End the test: every visitor sees “${r.name}”`
|
|
466
|
+
: 'Only a started test can be accepted',
|
|
467
|
+
onClick: () => act('accept', r.key),
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
if (entry && onRemove) {
|
|
471
|
+
actions.push({
|
|
472
|
+
size: 'small',
|
|
473
|
+
icon: 'DeleteBinLine',
|
|
474
|
+
intent: 'danger',
|
|
475
|
+
...quiet('danger'),
|
|
476
|
+
'aria-label': `Remove version ${r.name}`,
|
|
477
|
+
// At the far end of the row, away from Accept, so it is not clicked by accident.
|
|
478
|
+
style: { marginLeft: 'auto' },
|
|
479
|
+
disabled: !remove || !!busy,
|
|
480
|
+
loading: busyOn('remove', r.key),
|
|
481
|
+
tooltip:
|
|
482
|
+
remove ?
|
|
483
|
+
`Remove version: drop “${r.name}” from the test`
|
|
484
|
+
: 'This version cannot be removed now',
|
|
485
|
+
onClick: () => act('remove', r.key),
|
|
486
|
+
} as IReqoreEntityRowAction);
|
|
487
|
+
}
|
|
488
|
+
return (
|
|
489
|
+
<div
|
|
490
|
+
role='listitem'
|
|
491
|
+
key={r.key}
|
|
492
|
+
ref={i === 0 ? firstRow : undefined}
|
|
493
|
+
data-version={r.key}
|
|
494
|
+
aria-current={isShown || undefined}
|
|
495
|
+
>
|
|
496
|
+
<ReqoreEntityRow
|
|
497
|
+
size='small'
|
|
498
|
+
rounded
|
|
499
|
+
flat={!isShown}
|
|
500
|
+
intent={isShown ? 'info' : undefined}
|
|
501
|
+
label={r.name}
|
|
502
|
+
badge={badges}
|
|
503
|
+
description={numbersLine(r, metric?.name, status)}
|
|
504
|
+
metadata={
|
|
505
|
+
r.visitors ?
|
|
506
|
+
<ReqoreProgress
|
|
507
|
+
value={bars[i]}
|
|
508
|
+
size='tiny'
|
|
509
|
+
rounded
|
|
510
|
+
flat
|
|
511
|
+
style={{ width: '100%', minWidth: 160 }}
|
|
512
|
+
intent={
|
|
513
|
+
isShown ? 'info'
|
|
514
|
+
: r.control ?
|
|
515
|
+
'muted'
|
|
516
|
+
: 'success'
|
|
517
|
+
}
|
|
518
|
+
aria-label={`${r.name}: conversion compared with the best version`}
|
|
519
|
+
/>
|
|
520
|
+
: undefined
|
|
521
|
+
}
|
|
522
|
+
iconHasBackground={false}
|
|
523
|
+
showIcon={false}
|
|
524
|
+
actions={actions.length ? actions : undefined}
|
|
525
|
+
{...(showable ?
|
|
526
|
+
{
|
|
527
|
+
role: 'button',
|
|
528
|
+
tabIndex: 0,
|
|
529
|
+
'aria-label': `Show “${r.name}” on this page`,
|
|
530
|
+
tooltip: `Show “${r.name}” on this page (only here, never counted)`,
|
|
531
|
+
onClick: () => onShow(r.key),
|
|
532
|
+
onKeyDown: (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
533
|
+
if (!activatesRow(e.key, e.target === e.currentTarget)) return;
|
|
534
|
+
e.preventDefault();
|
|
535
|
+
onShow(r.key);
|
|
536
|
+
},
|
|
537
|
+
}
|
|
538
|
+
: {})}
|
|
539
|
+
/>
|
|
540
|
+
</div>
|
|
541
|
+
);
|
|
542
|
+
})}
|
|
543
|
+
</StyledVersions>
|
|
544
|
+
{!entry && (
|
|
545
|
+
<>
|
|
546
|
+
<ReqoreVerticalSpacer height={8} />
|
|
547
|
+
<ReqoreP size='small' effect={{ opacity: 0.7 }}>
|
|
548
|
+
{unreachable ?
|
|
549
|
+
'Start, Accept and Remove come back once the analytics service answers.'
|
|
550
|
+
: 'Start, Accept and Remove appear once the test is set up.'}
|
|
551
|
+
</ReqoreP>
|
|
552
|
+
</>
|
|
553
|
+
)}
|
|
554
|
+
<ReqoreModal
|
|
555
|
+
isOpen={helpOpen}
|
|
556
|
+
onClose={() => setHelpOpen(false)}
|
|
557
|
+
label='How A/B tests work'
|
|
558
|
+
icon='QuestionLine'
|
|
559
|
+
width='640px'
|
|
560
|
+
blur={2}
|
|
561
|
+
className='reqraft-experiment-help'
|
|
562
|
+
>
|
|
563
|
+
{helpSections(entry, analyticsLabel).map((section) => (
|
|
564
|
+
<div key={section.title}>
|
|
565
|
+
<ReqoreH4>{section.title}</ReqoreH4>
|
|
566
|
+
<ReqoreVerticalSpacer height={4} />
|
|
567
|
+
{section.paragraphs.map((text) => (
|
|
568
|
+
<div key={text}>
|
|
569
|
+
<ReqoreP size='small' effect={{ weight: 500 }}>
|
|
570
|
+
{text}
|
|
571
|
+
</ReqoreP>
|
|
572
|
+
<ReqoreVerticalSpacer height={6} />
|
|
573
|
+
</div>
|
|
574
|
+
))}
|
|
575
|
+
<ReqoreVerticalSpacer height={14} />
|
|
576
|
+
</div>
|
|
577
|
+
))}
|
|
578
|
+
</ReqoreModal>
|
|
579
|
+
</ReqorePanel>
|
|
580
|
+
);
|
|
581
|
+
};
|