@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.
Files changed (53) hide show
  1. package/.claude/CLAUDE.md +1 -1
  2. package/README.md +1 -1
  3. package/design/TRACKING.md +82 -2
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +3 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/tracking/ui/ExperimentCard.d.ts +55 -0
  9. package/dist/tracking/ui/ExperimentCard.d.ts.map +1 -0
  10. package/dist/tracking/ui/ExperimentCard.js +396 -0
  11. package/dist/tracking/ui/ExperimentCard.js.map +1 -0
  12. package/dist/tracking/ui/ExperimentsOverlay.d.ts +59 -0
  13. package/dist/tracking/ui/ExperimentsOverlay.d.ts.map +1 -0
  14. package/dist/tracking/ui/ExperimentsOverlay.js +359 -0
  15. package/dist/tracking/ui/ExperimentsOverlay.js.map +1 -0
  16. package/dist/tracking/ui/anchor.d.ts +107 -0
  17. package/dist/tracking/ui/anchor.d.ts.map +1 -0
  18. package/dist/tracking/ui/anchor.js +108 -0
  19. package/dist/tracking/ui/anchor.js.map +1 -0
  20. package/dist/tracking/ui/card.d.ts +121 -0
  21. package/dist/tracking/ui/card.d.ts.map +1 -0
  22. package/dist/tracking/ui/card.js +417 -0
  23. package/dist/tracking/ui/card.js.map +1 -0
  24. package/dist/tracking/ui/client.d.ts +42 -0
  25. package/dist/tracking/ui/client.d.ts.map +1 -0
  26. package/dist/tracking/ui/client.js +123 -0
  27. package/dist/tracking/ui/client.js.map +1 -0
  28. package/dist/tracking/ui/index.d.ts +20 -0
  29. package/dist/tracking/ui/index.d.ts.map +1 -0
  30. package/dist/tracking/ui/index.js +60 -0
  31. package/dist/tracking/ui/index.js.map +1 -0
  32. package/dist/tracking/ui/types.d.ts +120 -0
  33. package/dist/tracking/ui/types.d.ts.map +1 -0
  34. package/dist/tracking/ui/types.js +8 -0
  35. package/dist/tracking/ui/types.js.map +1 -0
  36. package/dist/tracking/ui/useExperimentsAdmin.d.ts +31 -0
  37. package/dist/tracking/ui/useExperimentsAdmin.d.ts.map +1 -0
  38. package/dist/tracking/ui/useExperimentsAdmin.js +136 -0
  39. package/dist/tracking/ui/useExperimentsAdmin.js.map +1 -0
  40. package/package.json +3 -3
  41. package/src/index.tsx +3 -1
  42. package/src/stories/tracking.tsx +206 -0
  43. package/src/tracking/ui/ExperimentCard.stories.tsx +296 -0
  44. package/src/tracking/ui/ExperimentCard.tsx +667 -0
  45. package/src/tracking/ui/ExperimentsOverlay.stories.tsx +155 -0
  46. package/src/tracking/ui/ExperimentsOverlay.tsx +437 -0
  47. package/src/tracking/ui/anchor.ts +180 -0
  48. package/src/tracking/ui/card.ts +464 -0
  49. package/src/tracking/ui/client.ts +117 -0
  50. package/src/tracking/ui/index.ts +77 -0
  51. package/src/tracking/ui/types.ts +110 -0
  52. package/src/tracking/ui/useExperimentsAdmin.ts +103 -0
  53. package/src/tracking/react/Experiment.stories.tsx +0 -174
@@ -0,0 +1,296 @@
1
+ import { StoryObj } from '@storybook/react-vite';
2
+ import { expect, fireEvent, fn, waitFor, within } from 'storybook/test';
3
+ import {
4
+ CONCLUDED,
5
+ DRAFT,
6
+ EXPERIMENT_KEY,
7
+ NO_DATA_YET,
8
+ PAUSED,
9
+ RUNNING_FIVE,
10
+ RUNNING_TWO,
11
+ variant,
12
+ } from '../../stories/tracking';
13
+ import { StoryMeta } from '../../types';
14
+ import { ExperimentCard } from './ExperimentCard';
15
+
16
+ const ANALYTICS = 'https://example.com/analytics?dashboard=tracking&experiment=signup-cta';
17
+
18
+ const meta = {
19
+ title: 'Tracking/Experiment Card',
20
+ component: ExperimentCard,
21
+ args: {
22
+ experimentKey: EXPERIMENT_KEY,
23
+ shown: 'a',
24
+ codeVariants: ['a', 'b', 'c', 'd', 'e'],
25
+ detailsUrl: ANALYTICS,
26
+ versionDetailsUrl: (v: string) => `${ANALYTICS}&variant=${v}`,
27
+ onShow: fn(),
28
+ onAccept: fn(async () => undefined),
29
+ onRemove: fn(async () => undefined),
30
+ onStart: fn(async () => undefined),
31
+ onPause: fn(async () => undefined),
32
+ onStop: fn(async () => undefined),
33
+ onClose: fn(),
34
+ },
35
+ render: (args) => (
36
+ <div style={{ width: '100%', maxWidth: 440 }}>
37
+ <ExperimentCard {...args} />
38
+ </div>
39
+ ),
40
+ } as StoryMeta<typeof ExperimentCard>;
41
+
42
+ export default meta;
43
+ export type Story = StoryObj<typeof meta>;
44
+
45
+ const card = (canvasElement: HTMLElement) => within(canvasElement);
46
+ const rowsOf = (canvasElement: HTMLElement) =>
47
+ Array.from(canvasElement.querySelectorAll<HTMLElement>('[role="listitem"]'));
48
+ const order = (canvasElement: HTMLElement) => rowsOf(canvasElement).map((r) => r.dataset.version);
49
+ const buttonWith = (root: Element, text: string) =>
50
+ Array.from(root.querySelectorAll('button')).find((b) => b.textContent?.trim() === text);
51
+ const row = (canvasElement: HTMLElement, key: string) =>
52
+ canvasElement.querySelector(`[data-version="${key}"]`) as HTMLElement;
53
+
54
+ // ── draft ──────────────────────────────────────────────────────────────────────
55
+
56
+ const draft: Story = {
57
+ args: { test: DRAFT },
58
+ play: async ({ canvasElement }) => {
59
+ const c = card(canvasElement);
60
+ await expect(await c.findByText('Sign-up button copy')).toBeInTheDocument();
61
+ await expect(c.getByText('Draft')).toBeInTheDocument();
62
+ await expect(c.getByText(/Not started yet/)).toBeInTheDocument();
63
+ await expect(rowsOf(canvasElement)).toHaveLength(2);
64
+ await expect(c.getAllByText('Not started: no visitors yet')).toHaveLength(2);
65
+ // Remove is a bin icon at the far end of the row, labelled for assistive tech.
66
+ await expect(c.getByLabelText('Remove version Short copy')).toBeInTheDocument();
67
+ await expect(buttonWith(canvasElement, 'Remove')).toBeFalsy();
68
+ await expect(buttonWith(canvasElement, 'Start test')).toBeTruthy();
69
+ await expect(buttonWith(canvasElement, 'Pause')).toBeFalsy();
70
+ },
71
+ };
72
+ const DRAFT_TEXT =
73
+ 'Renders the card of a draft test with two versions: the "Draft" tag, the "not started" verdict, both rows without numbers, each with "Show analytics", Accept and a bin icon (Remove) at the far end, and Start test as the only solid button.';
74
+ export const Draft = variant(draft, DRAFT_TEXT, 'Dark');
75
+ export const DraftLight = variant(draft, DRAFT_TEXT, 'Light');
76
+ export const DraftPhone = variant(draft, DRAFT_TEXT, 'Phone');
77
+
78
+ // ── running, two versions ────────────────────────────────────────────────────────
79
+
80
+ const runningTwo: Story = {
81
+ args: { test: RUNNING_TWO },
82
+ play: async ({ canvasElement, args }) => {
83
+ const c = card(canvasElement);
84
+ await expect(await c.findByText('Running')).toBeInTheDocument();
85
+ await expect(c.getByText(/91% chance it beats Current button/)).toBeInTheDocument();
86
+ // The likely winner comes first and is marked; the original keeps its tag.
87
+ await expect(order(canvasElement)).toEqual(['b', 'a']);
88
+ await expect(within(row(canvasElement, 'b')).getByText('Winner')).toBeInTheDocument();
89
+ await expect(within(row(canvasElement, 'a')).getByText('Original')).toBeInTheDocument();
90
+ await expect(row(canvasElement, 'b').dataset.highlight).toBe('winner');
91
+ await expect(c.getAllByText('Show analytics')).toHaveLength(2);
92
+ await expect(c.queryByText('Shown')).toBeNull();
93
+ // The shown version is marked by its colour and for assistive tech.
94
+ await expect(row(canvasElement, 'a').getAttribute('aria-current')).toBe('true');
95
+ await expect(
96
+ c.getByText(/1,228 visitors · 11.3% “Started a sign-up” · 91% chance to beat the original/)
97
+ ).toBeInTheDocument();
98
+ await expect(c.getByLabelText('Show analytics for Short copy')).toBeInTheDocument();
99
+ // The whole row of the other version shows it, by click and by keyboard.
100
+ const rowB = row(canvasElement, 'b').querySelector('[role="button"]') as HTMLElement;
101
+ await fireEvent.click(rowB);
102
+ await expect(args.onShow).toHaveBeenCalledWith('b');
103
+ await fireEvent.keyDown(rowB, { key: 'Enter' });
104
+ await expect(args.onShow).toHaveBeenCalledTimes(2);
105
+ await expect(row(canvasElement, 'a').querySelector('[role="button"]')).toBeNull();
106
+ await expect(buttonWith(canvasElement, 'Pause')).toBeTruthy();
107
+ await expect(buttonWith(canvasElement, 'Stop')).toBeTruthy();
108
+ await expect(buttonWith(canvasElement, 'Open in admin portal')).toBeTruthy();
109
+ await fireEvent.click(c.getByLabelText('Close the test card'));
110
+ await expect(args.onClose).toHaveBeenCalled();
111
+ },
112
+ };
113
+ const RUNNING_TWO_TEXT =
114
+ 'Renders a running test with two versions, best first: "Short copy", the likely winner, tagged "Winner" and lit by a green glow and wash, above the "Original". Tags are soft pills. Each row shows visitors, conversion, chance to beat and a bar, "Show analytics", Accept and a bin icon; the shown version is the blue row. Clicking the other row (or Enter on it) shows it; ✕ closes the card.';
115
+ export const RunningTwoVersions = variant(runningTwo, RUNNING_TWO_TEXT, 'Dark');
116
+ export const RunningTwoVersionsLight = variant(runningTwo, RUNNING_TWO_TEXT, 'Light');
117
+ export const RunningTwoVersionsPhone = variant(runningTwo, RUNNING_TWO_TEXT, 'Phone');
118
+
119
+ // ── running, five versions ───────────────────────────────────────────────────────
120
+
121
+ const runningFive: Story = {
122
+ args: { test: RUNNING_FIVE },
123
+ play: async ({ canvasElement }) => {
124
+ const c = card(canvasElement);
125
+ await expect(await c.findByText('Pricing page headline')).toBeInTheDocument();
126
+ await expect(c.getByText('5 versions · goal: “Started a sign-up”')).toBeInTheDocument();
127
+ // Best conversion first; the leader is tagged while it is too early for a winner.
128
+ await expect(order(canvasElement)).toEqual(['b', 'd', 'a', 'c', 'e']);
129
+ await expect(within(row(canvasElement, 'b')).getByText('Leading')).toBeInTheDocument();
130
+ await expect(row(canvasElement, 'b').dataset.highlight).toBe('leading');
131
+ // A long title stays on one line (an ellipsis, the full name in its tooltip).
132
+ await expect(c.getAllByText('Pricing page headline').length).toBeGreaterThan(0);
133
+ // Three and a half rows are visible; the rest scroll inside the card.
134
+ const list = canvasElement.querySelector('.reqraft-experiment-versions') as HTMLElement;
135
+ await waitFor(() => expect(list.scrollHeight).toBeGreaterThan(list.clientHeight + 10));
136
+ },
137
+ };
138
+ const RUNNING_FIVE_TEXT =
139
+ 'Renders a running test with five versions sorted best first, the best one tagged "Leading" with a softer glow than a winner\'s: three and a half rows are visible and the list scrolls inside the card for the rest.';
140
+ export const RunningFiveVersions = variant(runningFive, RUNNING_FIVE_TEXT, 'Dark');
141
+ export const RunningFiveVersionsLight = variant(runningFive, RUNNING_FIVE_TEXT, 'Light');
142
+ export const RunningFiveVersionsPhone = variant(runningFive, RUNNING_FIVE_TEXT, 'Phone');
143
+
144
+ // ── paused ─────────────────────────────────────────────────────────────────────
145
+
146
+ const paused: Story = {
147
+ args: { test: PAUSED, actorLabels: { admin: 'in the admin portal' } },
148
+ play: async ({ canvasElement }) => {
149
+ const c = card(canvasElement);
150
+ await expect(await c.findByText('Paused · phase 2')).toBeInTheDocument();
151
+ await expect(
152
+ c.getByText('Counting since 20 Sept (phase 2): “Emoji” was removed in the admin portal.')
153
+ ).toBeInTheDocument();
154
+ await expect(buttonWith(canvasElement, 'Resume')).toBeTruthy();
155
+ await expect(buttonWith(canvasElement, 'Stop')).toBeTruthy();
156
+ await expect(buttonWith(canvasElement, 'Pause')).toBeFalsy();
157
+ },
158
+ };
159
+ const PAUSED_TEXT =
160
+ 'Renders a paused test in its second phase: the "Paused · phase 2" tag, a note saying since when it counts and which version was removed, the leading version first, and Resume (solid) and Stop in the footer.';
161
+ export const Paused = variant(paused, PAUSED_TEXT, 'Dark');
162
+ export const PausedLight = variant(paused, PAUSED_TEXT, 'Light');
163
+ export const PausedPhone = variant(paused, PAUSED_TEXT, 'Phone');
164
+
165
+ // ── concluded ──────────────────────────────────────────────────────────────────
166
+
167
+ const concluded: Story = {
168
+ args: { test: CONCLUDED, shown: 'b' },
169
+ play: async ({ canvasElement }) => {
170
+ const c = card(canvasElement);
171
+ await expect(await c.findByText('Ended')).toBeInTheDocument();
172
+ await expect(order(canvasElement)[0]).toBe('b');
173
+ await expect(within(row(canvasElement, 'b')).getByText('Winner')).toBeInTheDocument();
174
+ // Each version keeps its analytics link after the test ended.
175
+ await expect(c.getByLabelText('Show analytics for Short copy')).toBeInTheDocument();
176
+ await expect(c.getByLabelText('Show analytics for Current button')).toBeInTheDocument();
177
+ await expect(buttonWith(canvasElement, 'Start test')).toBeFalsy();
178
+ await expect(buttonWith(canvasElement, 'Stop')).toBeFalsy();
179
+ Array.from(canvasElement.querySelectorAll('button'))
180
+ .filter((b) => b.textContent?.includes('Accept'))
181
+ .forEach((b) => expect(b).toBeDisabled());
182
+ },
183
+ };
184
+ const CONCLUDED_TEXT =
185
+ 'Renders an ended test whose winner (Short copy) is live: the "Ended" tag, the final verdict, the winner first with a "Winner" tag, the winner\'s glow and the blue shown colour, "Show analytics" on every version, and no lifecycle buttons; Accept and Remove are disabled.';
186
+ export const Concluded = variant(concluded, CONCLUDED_TEXT, 'Dark');
187
+ export const ConcludedLight = variant(concluded, CONCLUDED_TEXT, 'Light');
188
+ export const ConcludedPhone = variant(concluded, CONCLUDED_TEXT, 'Phone');
189
+
190
+ // ── no data yet ────────────────────────────────────────────────────────────────
191
+
192
+ const noData: Story = {
193
+ args: { test: NO_DATA_YET },
194
+ play: async ({ canvasElement }) => {
195
+ const c = card(canvasElement);
196
+ await expect(await c.findByText('Too early to tell.')).toBeInTheDocument();
197
+ await expect(c.getAllByText('No visitors yet')).toHaveLength(2);
198
+ await expect(c.queryByText('Leading')).toBeNull();
199
+ await expect(canvasElement.querySelector('[role="progressbar"]')).toBeNull();
200
+ },
201
+ };
202
+ const NO_DATA_TEXT =
203
+ 'Renders a running test nobody has seen yet: "Too early to tell", "No visitors yet" on each version instead of numbers and bars, and no leader.';
204
+ export const NoDataYet = variant(noData, NO_DATA_TEXT, 'Dark');
205
+ export const NoDataYetLight = variant(noData, NO_DATA_TEXT, 'Light');
206
+ export const NoDataYetPhone = variant(noData, NO_DATA_TEXT, 'Phone');
207
+
208
+ // ── not set up yet ───────────────────────────────────────────────────────────────
209
+
210
+ const notSetUp: Story = {
211
+ args: { test: undefined, codeVariants: ['a', 'b'], name: 'signup-cta' },
212
+ play: async ({ canvasElement }) => {
213
+ const c = card(canvasElement);
214
+ await expect(await c.findByText('Not set up')).toBeInTheDocument();
215
+ await expect(
216
+ c.getByText(/in the page's code but isn't set up yet: ask the Designer to set it up/)
217
+ ).toBeInTheDocument();
218
+ await expect(c.getByText('Version A')).toBeInTheDocument();
219
+ await expect(
220
+ c.getByText('Start, Accept and Remove appear once the test is set up.')
221
+ ).toBeInTheDocument();
222
+ await expect(
223
+ Array.from(canvasElement.querySelectorAll('button')).some((b) =>
224
+ b.textContent?.includes('Accept')
225
+ )
226
+ ).toBe(false);
227
+ await expect(c.queryByLabelText(/Show analytics/)).toBeNull();
228
+ await expect(buttonWith(canvasElement, 'Open in admin portal')).toBeFalsy();
229
+ },
230
+ };
231
+ const NOT_SET_UP_TEXT =
232
+ 'Renders a test that is in the page\'s code but not registered in the analytics service yet: the "Not set up" tag, the words "ask the Designer to set it up", its versions from the code (switchable, without numbers), and no Accept, Remove, analytics links or lifecycle buttons.';
233
+ export const NotSetUp = variant(notSetUp, NOT_SET_UP_TEXT, 'Dark');
234
+ export const NotSetUpLight = variant(notSetUp, NOT_SET_UP_TEXT, 'Light');
235
+ export const NotSetUpPhone = variant(notSetUp, NOT_SET_UP_TEXT, 'Phone');
236
+
237
+ // ── the service cannot be reached ────────────────────────────────────────────────
238
+
239
+ const unreachable: Story = {
240
+ args: { test: undefined, codeVariants: ['a', 'b'], name: 'signup-cta', loadError: 'HTTP 502' },
241
+ play: async ({ canvasElement }) => {
242
+ const c = card(canvasElement);
243
+ await expect(await c.findByText('No connection')).toBeInTheDocument();
244
+ await expect(c.getByText(/Can't reach the analytics service right now/)).toBeInTheDocument();
245
+ await expect(
246
+ c.getByText('Start, Accept and Remove come back once the analytics service answers.')
247
+ ).toBeInTheDocument();
248
+ },
249
+ };
250
+ const UNREACHABLE_TEXT =
251
+ 'Renders the card when the analytics service cannot be reached: the "No connection" tag and an amber message saying so; the versions from the code can still be switched.';
252
+ export const ServiceUnreachable = variant(unreachable, UNREACHABLE_TEXT, 'Dark');
253
+ export const ServiceUnreachableLight = variant(unreachable, UNREACHABLE_TEXT, 'Light');
254
+ export const ServiceUnreachablePhone = variant(unreachable, UNREACHABLE_TEXT, 'Phone');
255
+
256
+ // ── the confirm step on Remove ───────────────────────────────────────────────────
257
+
258
+ const removeConfirm: Story = {
259
+ args: { test: RUNNING_FIVE },
260
+ play: async ({ canvasElement, args }) => {
261
+ await card(canvasElement).findByText('Pricing page headline');
262
+ await fireEvent.click(card(canvasElement).getByLabelText('Remove version Customer quote'));
263
+ const body = within(canvasElement.ownerDocument.body);
264
+ await expect(
265
+ await body.findByText('Remove “Customer quote” from the test?')
266
+ ).toBeInTheDocument();
267
+ await expect(body.getByText(/numbers start from zero/)).toBeInTheDocument();
268
+ // Nothing happens until it is confirmed.
269
+ await expect(args.onRemove).not.toHaveBeenCalled();
270
+ },
271
+ };
272
+ const REMOVE_TEXT =
273
+ 'Renders a running five-version test after the bin icon was clicked on "Customer quote": the confirm step asks first and says the numbers restart; nothing is removed until it is confirmed.';
274
+ export const RemoveConfirmStep = variant(removeConfirm, REMOVE_TEXT, 'Dark');
275
+ export const RemoveConfirmStepLight = variant(removeConfirm, REMOVE_TEXT, 'Light');
276
+ export const RemoveConfirmStepPhone = variant(removeConfirm, REMOVE_TEXT, 'Phone');
277
+
278
+ // ── the help ─────────────────────────────────────────────────────────────────────
279
+
280
+ const help: Story = {
281
+ args: { test: RUNNING_TWO },
282
+ play: async ({ canvasElement }) => {
283
+ await card(canvasElement).findByText('Sign-up button copy');
284
+ await fireEvent.click(card(canvasElement).getByLabelText('How A/B tests work'));
285
+ const body = within(canvasElement.ownerDocument.body);
286
+ await expect(await body.findByText('The buttons')).toBeInTheDocument();
287
+ await expect(body.getByText('When you can trust the result')).toBeInTheDocument();
288
+ await expect(body.getByText(/at least 200 visitors and 7 days/)).toBeInTheDocument();
289
+ await expect(body.getByText('Not set up yet?')).toBeInTheDocument();
290
+ },
291
+ };
292
+ const HELP_TEXT =
293
+ 'Renders the card with its "?" help open: a dialog explaining, in plain words, what the test is, what every button does, how visitors are split and counted, how to read the verdict and the chance to beat, when a result can be trusted, a test that is not set up yet, and where the full analytics are.';
294
+ export const Help = variant(help, HELP_TEXT, 'Dark');
295
+ export const HelpLight = variant(help, HELP_TEXT, 'Light');
296
+ export const HelpPhone = variant(help, HELP_TEXT, 'Phone');