onboardme-sdk 0.0.1
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/ARCHITECTURE-v2.md +225 -0
- package/dist/sdk.iife.js +348 -0
- package/package.json +22 -0
- package/src/__tests__/day1.test.ts +37 -0
- package/src/__tests__/day2.test.ts +447 -0
- package/src/__tests__/day3.test.ts +110 -0
- package/src/__tests__/day4.test.ts +115 -0
- package/src/__tests__/day5.test.ts +102 -0
- package/src/__tests__/snapshot-dom-collector.test.ts +153 -0
- package/src/__tests__/snapshot-sender.test.ts +111 -0
- package/src/__tests__/v2-integration.test.ts +305 -0
- package/src/__tests__/v2-positioner.test.ts +115 -0
- package/src/__tests__/v2-renderer.test.ts +189 -0
- package/src/__tests__/v2-types.test.ts +74 -0
- package/src/__tests__/week2-day1.test.ts +62 -0
- package/src/__tests__/week2-day2.test.ts +128 -0
- package/src/__tests__/week2-day3.test.ts +128 -0
- package/src/__tests__/week2-day4.test.ts +177 -0
- package/src/__tests__/week2-day5.test.ts +294 -0
- package/src/__tests__/week3-day1.test.ts +169 -0
- package/src/__tests__/week3-day2.test.ts +267 -0
- package/src/__tests__/week3-day3.test.ts +213 -0
- package/src/__tests__/week3-day4.test.ts +213 -0
- package/src/__tests__/week3-day5.test.ts +350 -0
- package/src/__tests__/week4-day1.test.ts +277 -0
- package/src/__tests__/week4-day2.test.ts +227 -0
- package/src/__tests__/week4-day3.test.ts +323 -0
- package/src/__tests__/week4-day4.test.ts +210 -0
- package/src/__tests__/week4-day5.test.ts +503 -0
- package/src/__tests__/week5-day1.test.ts +152 -0
- package/src/__tests__/week5-day2.test.ts +222 -0
- package/src/__tests__/week5-day3.test.ts +297 -0
- package/src/__tests__/week5-day4.test.ts +306 -0
- package/src/__tests__/week5-day5.test.ts +345 -0
- package/src/__tests__/week7-day5-api-flows.test.ts +353 -0
- package/src/auto-generate/context-collector.ts +47 -0
- package/src/auto-generate/flow-generator-client.ts +97 -0
- package/src/browser.ts +5 -0
- package/src/components/celebration.ts +44 -0
- package/src/components/checklist-css.ts +159 -0
- package/src/components/checklist.ts +295 -0
- package/src/components/modal-css.ts +96 -0
- package/src/components/modal.ts +171 -0
- package/src/components/shadow-host.ts +30 -0
- package/src/core/api-client.ts +39 -0
- package/src/core/api-flows.ts +204 -0
- package/src/core/config.ts +37 -0
- package/src/core/event-batcher.ts +169 -0
- package/src/core/sdk.ts +301 -0
- package/src/detection/user-detection.ts +55 -0
- package/src/index.ts +95 -0
- package/src/snapshot/dom-collector.ts +193 -0
- package/src/snapshot/sender.ts +105 -0
- package/src/storage/event-listener.ts +59 -0
- package/src/storage/progress-tracker.ts +78 -0
- package/src/styles/checklist-css.ts +159 -0
- package/src/styles/checklist.css +166 -0
- package/src/styles/modal-css.ts +96 -0
- package/src/styles/modal.css +102 -0
- package/src/utils/dom.ts +49 -0
- package/src/utils/fingerprint.ts +20 -0
- package/src/utils/logger.ts +17 -0
- package/src/v2/positioner.ts +105 -0
- package/src/v2/renderer.ts +287 -0
- package/src/v2/styles.ts +89 -0
- package/src/v2/types.ts +53 -0
- package/tsconfig.json +11 -0
- package/vite.config.ts +28 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Week 3 Day 1 — Progress Tracker (storage layer)
|
|
3
|
+
*
|
|
4
|
+
* Verifies all five functions in src/storage/progress-tracker.ts:
|
|
5
|
+
* loadProgress — reads from localStorage, returns default when absent/corrupt
|
|
6
|
+
* saveProgress — persists completedSteps + timestamp
|
|
7
|
+
* markStepComplete — idempotent write; no duplicate step IDs
|
|
8
|
+
* isStepComplete — boolean check against stored completedSteps
|
|
9
|
+
* clearProgress — removes the key entirely
|
|
10
|
+
*
|
|
11
|
+
* Storage key format: onboardme_progress_{productId}_{flowId}
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
15
|
+
import {
|
|
16
|
+
loadProgress,
|
|
17
|
+
saveProgress,
|
|
18
|
+
markStepComplete,
|
|
19
|
+
isStepComplete,
|
|
20
|
+
clearProgress,
|
|
21
|
+
} from '../storage/progress-tracker.js';
|
|
22
|
+
|
|
23
|
+
const PID = 'test-product';
|
|
24
|
+
const FID = 'flow-1';
|
|
25
|
+
const KEY = `onboardme_progress_${PID}_${FID}`;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
localStorage.clear();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// loadProgress
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
describe('Week 3 Day 1 — loadProgress', () => {
|
|
36
|
+
it('returns { completedSteps: [], lastUpdated: 0 } when key is absent', () => {
|
|
37
|
+
const result = loadProgress(PID, FID);
|
|
38
|
+
expect(result.completedSteps).toEqual([]);
|
|
39
|
+
expect(result.lastUpdated).toBe(0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns the empty default when stored value is corrupted JSON', () => {
|
|
43
|
+
localStorage.setItem(KEY, '%%not-valid-json%%');
|
|
44
|
+
const result = loadProgress(PID, FID);
|
|
45
|
+
expect(result.completedSteps).toEqual([]);
|
|
46
|
+
expect(result.lastUpdated).toBe(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('does not throw when localStorage contains corrupted JSON', () => {
|
|
50
|
+
localStorage.setItem(KEY, '{broken:');
|
|
51
|
+
expect(() => loadProgress(PID, FID)).not.toThrow();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// saveProgress
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
describe('Week 3 Day 1 — saveProgress', () => {
|
|
60
|
+
it('writes data under the correct localStorage key', () => {
|
|
61
|
+
saveProgress(PID, FID, ['step-1']);
|
|
62
|
+
expect(localStorage.getItem(KEY)).not.toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('persists the completedSteps array', () => {
|
|
66
|
+
saveProgress(PID, FID, ['step-1', 'step-2']);
|
|
67
|
+
const result = loadProgress(PID, FID);
|
|
68
|
+
expect(result.completedSteps).toEqual(['step-1', 'step-2']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('stores a non-zero lastUpdated timestamp', () => {
|
|
72
|
+
const before = Date.now();
|
|
73
|
+
saveProgress(PID, FID, ['step-1']);
|
|
74
|
+
const result = loadProgress(PID, FID);
|
|
75
|
+
expect(result.lastUpdated).toBeGreaterThanOrEqual(before);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('overwrites previous progress for the same productId + flowId', () => {
|
|
79
|
+
saveProgress(PID, FID, ['step-1', 'step-2']);
|
|
80
|
+
saveProgress(PID, FID, ['step-3']);
|
|
81
|
+
const result = loadProgress(PID, FID);
|
|
82
|
+
expect(result.completedSteps).toEqual(['step-3']);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('correctly persists an empty completedSteps array', () => {
|
|
86
|
+
saveProgress(PID, FID, []);
|
|
87
|
+
const result = loadProgress(PID, FID);
|
|
88
|
+
expect(result.completedSteps).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// markStepComplete
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
describe('Week 3 Day 1 — markStepComplete', () => {
|
|
97
|
+
it('adds the stepId to completedSteps', () => {
|
|
98
|
+
markStepComplete(PID, FID, 'step-a');
|
|
99
|
+
const result = loadProgress(PID, FID);
|
|
100
|
+
expect(result.completedSteps).toContain('step-a');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('calling twice with the same stepId does not create a duplicate', () => {
|
|
104
|
+
markStepComplete(PID, FID, 'step-a');
|
|
105
|
+
markStepComplete(PID, FID, 'step-a');
|
|
106
|
+
const result = loadProgress(PID, FID);
|
|
107
|
+
expect(result.completedSteps.filter((id) => id === 'step-a')).toHaveLength(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('accumulates multiple different step IDs', () => {
|
|
111
|
+
markStepComplete(PID, FID, 'step-a');
|
|
112
|
+
markStepComplete(PID, FID, 'step-b');
|
|
113
|
+
const result = loadProgress(PID, FID);
|
|
114
|
+
expect(result.completedSteps).toEqual(['step-a', 'step-b']);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// isStepComplete
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
describe('Week 3 Day 1 — isStepComplete', () => {
|
|
123
|
+
it('returns true for a step that has been marked complete', () => {
|
|
124
|
+
markStepComplete(PID, FID, 'step-x');
|
|
125
|
+
expect(isStepComplete(PID, FID, 'step-x')).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('returns false for a step that has not been marked complete', () => {
|
|
129
|
+
expect(isStepComplete(PID, FID, 'step-x')).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// clearProgress
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
describe('Week 3 Day 1 — clearProgress', () => {
|
|
138
|
+
it('removes the localStorage key entirely', () => {
|
|
139
|
+
saveProgress(PID, FID, ['step-1']);
|
|
140
|
+
clearProgress(PID, FID);
|
|
141
|
+
expect(localStorage.getItem(KEY)).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('loadProgress returns the empty default after clearProgress', () => {
|
|
145
|
+
saveProgress(PID, FID, ['step-1', 'step-2']);
|
|
146
|
+
clearProgress(PID, FID);
|
|
147
|
+
const result = loadProgress(PID, FID);
|
|
148
|
+
expect(result.completedSteps).toEqual([]);
|
|
149
|
+
expect(result.lastUpdated).toBe(0);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Isolation
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
describe('Week 3 Day 1 — isolation', () => {
|
|
158
|
+
it('progress is isolated per productId — saving for product A does not affect product B', () => {
|
|
159
|
+
saveProgress('product-a', FID, ['step-1']);
|
|
160
|
+
const resultB = loadProgress('product-b', FID);
|
|
161
|
+
expect(resultB.completedSteps).toEqual([]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('progress is isolated per flowId — saving for flow A does not affect flow B', () => {
|
|
165
|
+
saveProgress(PID, 'flow-a', ['step-1']);
|
|
166
|
+
const resultB = loadProgress(PID, 'flow-b');
|
|
167
|
+
expect(resultB.completedSteps).toEqual([]);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Week 3 Day 2 — Checklist HTML Structure (checklist.ts)
|
|
3
|
+
*
|
|
4
|
+
* Verifies renderChecklist() builds the correct DOM inside a Shadow DOM root:
|
|
5
|
+
* ✓ Panel appended to shadow root with correct class
|
|
6
|
+
* ✓ Header shows "Getting started" title + collapse button
|
|
7
|
+
* ✓ Progress label reflects completedSteps count
|
|
8
|
+
* ✓ Progress bar fill width reflects required-item completion ratio
|
|
9
|
+
* ✓ Items rendered in order field sequence (not array order)
|
|
10
|
+
* ✓ Completed items get .om-checklist__item--done
|
|
11
|
+
* ✓ optional items (required: false) get .om-checklist__item--optional + "Optional" badge
|
|
12
|
+
* ✓ Required items have no optional badge
|
|
13
|
+
* ✓ >7 items → console.warn fired, all items still rendered
|
|
14
|
+
* ✓ CSS injected into shadow root style element (not document.head)
|
|
15
|
+
* ✓ Calling renderChecklist twice replaces panel (no duplicate)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
19
|
+
import { renderChecklist } from '../components/checklist.js';
|
|
20
|
+
import { getShadowRoot } from '../components/shadow-host.js';
|
|
21
|
+
import type { FlowConfig, FlowStep, ChecklistItem } from '@onboardme/types';
|
|
22
|
+
import type { Progress } from '../storage/progress-tracker.js';
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
function makeItem(overrides: Partial<ChecklistItem> = {}): ChecklistItem {
|
|
29
|
+
return {
|
|
30
|
+
id: 'item-1',
|
|
31
|
+
label: 'Do the thing',
|
|
32
|
+
order: 1,
|
|
33
|
+
...overrides,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeFlow(items: ChecklistItem[]): FlowConfig {
|
|
38
|
+
const checklistStep: FlowStep = {
|
|
39
|
+
id: 'cl-step',
|
|
40
|
+
type: 'checklist',
|
|
41
|
+
order: 1,
|
|
42
|
+
title: 'Getting started',
|
|
43
|
+
body: '',
|
|
44
|
+
items,
|
|
45
|
+
trigger: { type: 'page_load' },
|
|
46
|
+
completionGoal: 'onboarding_complete',
|
|
47
|
+
} as unknown as FlowStep;
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
id: 'flow-1',
|
|
51
|
+
name: 'Onboarding',
|
|
52
|
+
steps: [checklistStep],
|
|
53
|
+
completionGoal: 'onboarding_complete',
|
|
54
|
+
priority: 1,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const EMPTY_PROGRESS: Progress = { completedSteps: [], lastUpdated: 0 };
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Setup
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
let shadowRoot: ShadowRoot;
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
document.body.innerHTML = '';
|
|
68
|
+
shadowRoot = getShadowRoot();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Panel structure
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
describe('Week 3 Day 2 — renderChecklist panel structure', () => {
|
|
76
|
+
it('appends .om-checklist panel to the shadow root', () => {
|
|
77
|
+
renderChecklist(makeFlow([makeItem()]), EMPTY_PROGRESS, shadowRoot);
|
|
78
|
+
const panel = shadowRoot.querySelector('.om-checklist');
|
|
79
|
+
expect(panel).not.toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('panel has data-onboardme="checklist" attribute', () => {
|
|
83
|
+
renderChecklist(makeFlow([makeItem()]), EMPTY_PROGRESS, shadowRoot);
|
|
84
|
+
const panel = shadowRoot.querySelector('[data-onboardme="checklist"]');
|
|
85
|
+
expect(panel).not.toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('header contains the title "Getting started"', () => {
|
|
89
|
+
renderChecklist(makeFlow([makeItem()]), EMPTY_PROGRESS, shadowRoot);
|
|
90
|
+
const title = shadowRoot.querySelector('.om-checklist__title');
|
|
91
|
+
expect(title?.textContent).toBe('Getting started');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('header contains a collapse button', () => {
|
|
95
|
+
renderChecklist(makeFlow([makeItem()]), EMPTY_PROGRESS, shadowRoot);
|
|
96
|
+
const btn = shadowRoot.querySelector('[data-onboardme="checklist-collapse"]');
|
|
97
|
+
expect(btn).not.toBeNull();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Progress label + bar
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
describe('Week 3 Day 2 — progress label', () => {
|
|
106
|
+
it('shows "0 of N complete" when no items are done', () => {
|
|
107
|
+
const items = [makeItem({ id: 'a', order: 1 }), makeItem({ id: 'b', order: 2 })];
|
|
108
|
+
renderChecklist(makeFlow(items), EMPTY_PROGRESS, shadowRoot);
|
|
109
|
+
const label = shadowRoot.querySelector('[data-onboardme="progress-label"]');
|
|
110
|
+
expect(label?.textContent).toBe('0 of 2 complete');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('shows "X of N complete" when some items are done', () => {
|
|
114
|
+
const items = [makeItem({ id: 'a', order: 1 }), makeItem({ id: 'b', order: 2 }), makeItem({ id: 'c', order: 3 })];
|
|
115
|
+
const progress: Progress = { completedSteps: ['a', 'b'], lastUpdated: 1 };
|
|
116
|
+
renderChecklist(makeFlow(items), progress, shadowRoot);
|
|
117
|
+
const label = shadowRoot.querySelector('[data-onboardme="progress-label"]');
|
|
118
|
+
expect(label?.textContent).toBe('2 of 3 complete');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('progress bar fill width reflects required completion ratio', () => {
|
|
122
|
+
const items = [
|
|
123
|
+
makeItem({ id: 'a', order: 1, required: true }),
|
|
124
|
+
makeItem({ id: 'b', order: 2, required: true }),
|
|
125
|
+
makeItem({ id: 'c', order: 3, required: false }),
|
|
126
|
+
];
|
|
127
|
+
// 1 of 2 required done = 50%
|
|
128
|
+
const progress: Progress = { completedSteps: ['a'], lastUpdated: 1 };
|
|
129
|
+
renderChecklist(makeFlow(items), progress, shadowRoot);
|
|
130
|
+
const fill = shadowRoot.querySelector<HTMLElement>('.om-checklist__progress-bar-fill');
|
|
131
|
+
expect(fill?.style.width).toBe('50%');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Item ordering
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
describe('Week 3 Day 2 — item ordering', () => {
|
|
140
|
+
it('renders items in order field sequence regardless of array order', () => {
|
|
141
|
+
const items = [
|
|
142
|
+
makeItem({ id: 'c', label: 'Third', order: 3 }),
|
|
143
|
+
makeItem({ id: 'a', label: 'First', order: 1 }),
|
|
144
|
+
makeItem({ id: 'b', label: 'Second', order: 2 }),
|
|
145
|
+
];
|
|
146
|
+
renderChecklist(makeFlow(items), EMPTY_PROGRESS, shadowRoot);
|
|
147
|
+
const rendered = shadowRoot.querySelectorAll('.om-checklist__item');
|
|
148
|
+
expect(rendered[0]?.getAttribute('data-item-id')).toBe('a');
|
|
149
|
+
expect(rendered[1]?.getAttribute('data-item-id')).toBe('b');
|
|
150
|
+
expect(rendered[2]?.getAttribute('data-item-id')).toBe('c');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Done state
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
describe('Week 3 Day 2 — done state', () => {
|
|
159
|
+
it('completed item has .om-checklist__item--done', () => {
|
|
160
|
+
const items = [makeItem({ id: 'done-item', order: 1 })];
|
|
161
|
+
const progress: Progress = { completedSteps: ['done-item'], lastUpdated: 1 };
|
|
162
|
+
renderChecklist(makeFlow(items), progress, shadowRoot);
|
|
163
|
+
const li = shadowRoot.querySelector('[data-item-id="done-item"]');
|
|
164
|
+
expect(li?.classList.contains('om-checklist__item--done')).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('uncompleted item does NOT have .om-checklist__item--done', () => {
|
|
168
|
+
const items = [makeItem({ id: 'todo-item', order: 1 })];
|
|
169
|
+
renderChecklist(makeFlow(items), EMPTY_PROGRESS, shadowRoot);
|
|
170
|
+
const li = shadowRoot.querySelector('[data-item-id="todo-item"]');
|
|
171
|
+
expect(li?.classList.contains('om-checklist__item--done')).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Optional badge
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
describe('Week 3 Day 2 — optional badge', () => {
|
|
180
|
+
it('item with required: false has .om-checklist__item--optional class', () => {
|
|
181
|
+
const items = [makeItem({ id: 'opt', order: 1, required: false })];
|
|
182
|
+
renderChecklist(makeFlow(items), EMPTY_PROGRESS, shadowRoot);
|
|
183
|
+
const li = shadowRoot.querySelector('[data-item-id="opt"]');
|
|
184
|
+
expect(li?.classList.contains('om-checklist__item--optional')).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('item with required: false shows "Optional" badge in the DOM', () => {
|
|
188
|
+
const items = [makeItem({ id: 'opt', order: 1, required: false })];
|
|
189
|
+
renderChecklist(makeFlow(items), EMPTY_PROGRESS, shadowRoot);
|
|
190
|
+
const badge = shadowRoot.querySelector('[data-item-id="opt"] .om-badge');
|
|
191
|
+
expect(badge?.textContent).toBe('Optional');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('required item does NOT have the optional badge', () => {
|
|
195
|
+
const items = [makeItem({ id: 'req', order: 1, required: true })];
|
|
196
|
+
renderChecklist(makeFlow(items), EMPTY_PROGRESS, shadowRoot);
|
|
197
|
+
const badge = shadowRoot.querySelector('[data-item-id="req"] .om-badge');
|
|
198
|
+
expect(badge).toBeNull();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// >7 items warning
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
describe('Week 3 Day 2 — >7 items warning', () => {
|
|
207
|
+
it('logs a console.warn when more than 7 items are present (debug mode)', () => {
|
|
208
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
209
|
+
const items = Array.from({ length: 8 }, (_, i) =>
|
|
210
|
+
makeItem({ id: `item-${i}`, label: `Item ${i}`, order: i + 1 }),
|
|
211
|
+
);
|
|
212
|
+
renderChecklist(makeFlow(items), EMPTY_PROGRESS, shadowRoot, true);
|
|
213
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('more than 7 items'));
|
|
214
|
+
warnSpy.mockRestore();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('still renders all items even when >7 are present', () => {
|
|
218
|
+
const items = Array.from({ length: 8 }, (_, i) =>
|
|
219
|
+
makeItem({ id: `item-${i}`, label: `Item ${i}`, order: i + 1 }),
|
|
220
|
+
);
|
|
221
|
+
renderChecklist(makeFlow(items), EMPTY_PROGRESS, shadowRoot, true);
|
|
222
|
+
const rendered = shadowRoot.querySelectorAll('.om-checklist__item');
|
|
223
|
+
expect(rendered).toHaveLength(8);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('does NOT warn when 7 or fewer items are present', () => {
|
|
227
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
228
|
+
const items = Array.from({ length: 7 }, (_, i) =>
|
|
229
|
+
makeItem({ id: `item-${i}`, label: `Item ${i}`, order: i + 1 }),
|
|
230
|
+
);
|
|
231
|
+
renderChecklist(makeFlow(items), EMPTY_PROGRESS, shadowRoot, true);
|
|
232
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
233
|
+
warnSpy.mockRestore();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Style isolation
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
describe('Week 3 Day 2 — style isolation', () => {
|
|
242
|
+
it('injects CSS into the shadow root style element', () => {
|
|
243
|
+
renderChecklist(makeFlow([makeItem()]), EMPTY_PROGRESS, shadowRoot);
|
|
244
|
+
const style = shadowRoot.querySelector('style');
|
|
245
|
+
expect(style?.textContent).toContain('.om-checklist');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('does not inject checklist CSS into document.head', () => {
|
|
249
|
+
renderChecklist(makeFlow([makeItem()]), EMPTY_PROGRESS, shadowRoot);
|
|
250
|
+
const headStyles = document.head.querySelectorAll('style');
|
|
251
|
+
const leaked = Array.from(headStyles).some((s) => s.textContent?.includes('.om-checklist'));
|
|
252
|
+
expect(leaked).toBe(false);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Re-render deduplication
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
describe('Week 3 Day 2 — re-render deduplication', () => {
|
|
261
|
+
it('calling renderChecklist twice does not create a duplicate panel', () => {
|
|
262
|
+
renderChecklist(makeFlow([makeItem()]), EMPTY_PROGRESS, shadowRoot);
|
|
263
|
+
renderChecklist(makeFlow([makeItem()]), EMPTY_PROGRESS, shadowRoot);
|
|
264
|
+
const panels = shadowRoot.querySelectorAll('[data-onboardme="checklist"]');
|
|
265
|
+
expect(panels).toHaveLength(1);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Week 3 Day 3 — Checklist Interactivity + Persistence
|
|
3
|
+
*
|
|
4
|
+
* Verifies the click and collapse behaviour added to checklist.ts:
|
|
5
|
+
* ✓ Clicking unchecked item → adds .om-checklist__item--done + check mark
|
|
6
|
+
* ✓ Clicking unchecked item → calls markStepComplete (verified via isStepComplete)
|
|
7
|
+
* ✓ Clicking unchecked item → updates progress label
|
|
8
|
+
* ✓ Clicking unchecked item → updates progress bar fill
|
|
9
|
+
* ✓ Clicking already-done item → no change (progress is additive, not a toggle)
|
|
10
|
+
* ✓ Clicking already-done item → no duplicate in completedSteps
|
|
11
|
+
* ✓ Collapse button → adds .om-checklist--collapsed to the panel
|
|
12
|
+
* ✓ Clicking collapsed panel → removes .om-checklist--collapsed
|
|
13
|
+
* ✓ Collapsed pill shows correct "X / N" count
|
|
14
|
+
* ✓ Collapse state is NOT persisted — renderChecklist always renders expanded
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
18
|
+
import { renderChecklist } from '../components/checklist.js';
|
|
19
|
+
import { getShadowRoot } from '../components/shadow-host.js';
|
|
20
|
+
import { isStepComplete, clearProgress } from '../storage/progress-tracker.js';
|
|
21
|
+
import type { FlowConfig, FlowStep, ChecklistItem } from '@onboardme/types';
|
|
22
|
+
import type { Progress } from '../storage/progress-tracker.js';
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const PID = 'day3-product';
|
|
29
|
+
const FID = 'day3-flow';
|
|
30
|
+
|
|
31
|
+
function makeItem(overrides: Partial<ChecklistItem> = {}): ChecklistItem {
|
|
32
|
+
return { id: 'item-1', label: 'Do the thing', order: 1, ...overrides };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeFlow(items: ChecklistItem[]): FlowConfig {
|
|
36
|
+
const step: FlowStep = {
|
|
37
|
+
id: 'cl-step',
|
|
38
|
+
type: 'checklist',
|
|
39
|
+
order: 1,
|
|
40
|
+
title: 'Getting started',
|
|
41
|
+
body: '',
|
|
42
|
+
items,
|
|
43
|
+
trigger: { type: 'page_load' },
|
|
44
|
+
completionGoal: 'done',
|
|
45
|
+
} as unknown as FlowStep;
|
|
46
|
+
return { id: FID, name: 'Onboarding', steps: [step], completionGoal: 'done', priority: 1 };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const EMPTY: Progress = { completedSteps: [], lastUpdated: 0 };
|
|
50
|
+
|
|
51
|
+
function click(el: Element | null): void {
|
|
52
|
+
el?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Setup
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
let shadowRoot: ShadowRoot;
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
document.body.innerHTML = '';
|
|
63
|
+
localStorage.clear();
|
|
64
|
+
shadowRoot = getShadowRoot();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Item click — visual
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
describe('Week 3 Day 3 — item click (visual)', () => {
|
|
72
|
+
it('clicking an unchecked item adds .om-checklist__item--done', () => {
|
|
73
|
+
renderChecklist(makeFlow([makeItem({ id: 'a' })]), EMPTY, shadowRoot);
|
|
74
|
+
const li = shadowRoot.querySelector('[data-item-id="a"]');
|
|
75
|
+
click(li);
|
|
76
|
+
expect(li?.classList.contains('om-checklist__item--done')).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('clicking an unchecked item shows the check mark inside the circle', () => {
|
|
80
|
+
renderChecklist(makeFlow([makeItem({ id: 'a' })]), EMPTY, shadowRoot);
|
|
81
|
+
const li = shadowRoot.querySelector('[data-item-id="a"]');
|
|
82
|
+
click(li);
|
|
83
|
+
const checkEl = li?.querySelector('.om-checklist__item-check');
|
|
84
|
+
expect(checkEl?.textContent).toBe('✓');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('clicking an already-done item does not change its classes', () => {
|
|
88
|
+
const progress: Progress = { completedSteps: ['a'], lastUpdated: 1 };
|
|
89
|
+
renderChecklist(makeFlow([makeItem({ id: 'a' })]), progress, shadowRoot);
|
|
90
|
+
const li = shadowRoot.querySelector('[data-item-id="a"]');
|
|
91
|
+
click(li);
|
|
92
|
+
// Still has done class — nothing was removed
|
|
93
|
+
expect(li?.classList.contains('om-checklist__item--done')).toBe(true);
|
|
94
|
+
// classList length is the same as before the click
|
|
95
|
+
const classList = Array.from(li?.classList ?? []);
|
|
96
|
+
expect(classList.filter((c) => c === 'om-checklist__item--done')).toHaveLength(1);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Item click — persistence
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
describe('Week 3 Day 3 — item click (persistence)', () => {
|
|
105
|
+
it('clicking an unchecked item calls markStepComplete (isStepComplete returns true)', () => {
|
|
106
|
+
renderChecklist(makeFlow([makeItem({ id: 'step-a' })]), EMPTY, shadowRoot, false, PID, FID);
|
|
107
|
+
click(shadowRoot.querySelector('[data-item-id="step-a"]'));
|
|
108
|
+
expect(isStepComplete(PID, FID, 'step-a')).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('clicking the same item twice does not add a duplicate to completedSteps', () => {
|
|
112
|
+
clearProgress(PID, FID);
|
|
113
|
+
renderChecklist(makeFlow([makeItem({ id: 'step-b' })]), EMPTY, shadowRoot, false, PID, FID);
|
|
114
|
+
click(shadowRoot.querySelector('[data-item-id="step-b"]'));
|
|
115
|
+
click(shadowRoot.querySelector('[data-item-id="step-b"]'));
|
|
116
|
+
// Read progress directly from localStorage via loadProgress indirection
|
|
117
|
+
// We can verify via the label — should still say "1 of 1 complete"
|
|
118
|
+
const label = shadowRoot.querySelector('[data-onboardme="progress-label"]');
|
|
119
|
+
expect(label?.textContent).toBe('1 of 1 complete');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Item click — progress bar + label
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
describe('Week 3 Day 3 — item click (progress updates)', () => {
|
|
128
|
+
it('clicking an item updates the progress label', () => {
|
|
129
|
+
const items = [makeItem({ id: 'a', order: 1 }), makeItem({ id: 'b', order: 2 })];
|
|
130
|
+
renderChecklist(makeFlow(items), EMPTY, shadowRoot);
|
|
131
|
+
click(shadowRoot.querySelector('[data-item-id="a"]'));
|
|
132
|
+
const label = shadowRoot.querySelector('[data-onboardme="progress-label"]');
|
|
133
|
+
expect(label?.textContent).toBe('1 of 2 complete');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('clicking an item updates the progress bar fill', () => {
|
|
137
|
+
const items = [
|
|
138
|
+
makeItem({ id: 'a', order: 1, required: true }),
|
|
139
|
+
makeItem({ id: 'b', order: 2, required: true }),
|
|
140
|
+
];
|
|
141
|
+
renderChecklist(makeFlow(items), EMPTY, shadowRoot);
|
|
142
|
+
click(shadowRoot.querySelector('[data-item-id="a"]'));
|
|
143
|
+
const fill = shadowRoot.querySelector<HTMLElement>('.om-checklist__progress-bar-fill');
|
|
144
|
+
expect(fill?.style.width).toBe('50%');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('optional items count in the label but not in the required bar fill', () => {
|
|
148
|
+
const items = [
|
|
149
|
+
makeItem({ id: 'req', order: 1, required: true }),
|
|
150
|
+
makeItem({ id: 'opt', order: 2, required: false }),
|
|
151
|
+
];
|
|
152
|
+
// Complete the optional item — bar should stay at 0%, label says 1 of 2
|
|
153
|
+
renderChecklist(makeFlow(items), EMPTY, shadowRoot);
|
|
154
|
+
click(shadowRoot.querySelector('[data-item-id="opt"]'));
|
|
155
|
+
const fill = shadowRoot.querySelector<HTMLElement>('.om-checklist__progress-bar-fill');
|
|
156
|
+
const label = shadowRoot.querySelector('[data-onboardme="progress-label"]');
|
|
157
|
+
expect(fill?.style.width).toBe('0%');
|
|
158
|
+
expect(label?.textContent).toBe('1 of 2 complete');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Collapse / expand
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
describe('Week 3 Day 3 — collapse / expand', () => {
|
|
167
|
+
it('collapse button adds .om-checklist--collapsed to the panel', () => {
|
|
168
|
+
renderChecklist(makeFlow([makeItem()]), EMPTY, shadowRoot);
|
|
169
|
+
const btn = shadowRoot.querySelector('[data-onboardme="checklist-collapse"]');
|
|
170
|
+
click(btn);
|
|
171
|
+
const panel = shadowRoot.querySelector('[data-onboardme="checklist"]');
|
|
172
|
+
expect(panel?.classList.contains('om-checklist--collapsed')).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('clicking the collapsed panel removes .om-checklist--collapsed', () => {
|
|
176
|
+
renderChecklist(makeFlow([makeItem()]), EMPTY, shadowRoot);
|
|
177
|
+
const btn = shadowRoot.querySelector('[data-onboardme="checklist-collapse"]');
|
|
178
|
+
const panel = shadowRoot.querySelector('[data-onboardme="checklist"]');
|
|
179
|
+
click(btn);
|
|
180
|
+
expect(panel?.classList.contains('om-checklist--collapsed')).toBe(true);
|
|
181
|
+
click(panel);
|
|
182
|
+
expect(panel?.classList.contains('om-checklist--collapsed')).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('collapsed pill shows correct "X / N" count', () => {
|
|
186
|
+
const items = [makeItem({ id: 'a', order: 1 }), makeItem({ id: 'b', order: 2 })];
|
|
187
|
+
const progress: Progress = { completedSteps: ['a'], lastUpdated: 1 };
|
|
188
|
+
renderChecklist(makeFlow(items), progress, shadowRoot);
|
|
189
|
+
const countEl = shadowRoot.querySelector('[data-onboardme="collapsed-count"]');
|
|
190
|
+
expect(countEl?.textContent).toBe('1 / 2');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('collapsed count updates when an item is checked then collapsed', () => {
|
|
194
|
+
const items = [makeItem({ id: 'a', order: 1 }), makeItem({ id: 'b', order: 2 })];
|
|
195
|
+
renderChecklist(makeFlow(items), EMPTY, shadowRoot);
|
|
196
|
+
// Check item a
|
|
197
|
+
click(shadowRoot.querySelector('[data-item-id="a"]'));
|
|
198
|
+
// Now collapse
|
|
199
|
+
click(shadowRoot.querySelector('[data-onboardme="checklist-collapse"]'));
|
|
200
|
+
const countEl = shadowRoot.querySelector('[data-onboardme="collapsed-count"]');
|
|
201
|
+
expect(countEl?.textContent).toBe('1 / 2');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('collapse state is not persisted — renderChecklist always renders expanded', () => {
|
|
205
|
+
renderChecklist(makeFlow([makeItem()]), EMPTY, shadowRoot);
|
|
206
|
+
// Collapse it
|
|
207
|
+
click(shadowRoot.querySelector('[data-onboardme="checklist-collapse"]'));
|
|
208
|
+
// Re-render (simulates page reload)
|
|
209
|
+
renderChecklist(makeFlow([makeItem()]), EMPTY, shadowRoot);
|
|
210
|
+
const panel = shadowRoot.querySelector('[data-onboardme="checklist"]');
|
|
211
|
+
expect(panel?.classList.contains('om-checklist--collapsed')).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
});
|