Skip to content

Commit b2a8713

Browse files
authored
feat(onboarding): simplifies wizard to single-step and adds re-auth flow (#10)
* feat(onboarding): simplifies wizard to single-step and adds re-auth flow * test: fixes hardcoded state key and adds localStorage flush assertion * fix: addresses review findings in changed files * fix: hardens reactivity tracking, UX, and accessibility * fix: converts to Show, adds stale-fetch guard and onboarding redirect * perf: converts org list from For to Index to avoid DOM recreation * fix: addresses PR review findings across all domains * fix(test): spies on localStorage instance instead of Storage prototype * fix(test): removes conditional localStorage mock, always uses vi.fn()
1 parent 00b42c8 commit b2a8713

File tree

12 files changed

+859
-375
lines changed

12 files changed

+859
-375
lines changed

src/app/components/onboarding/OnboardingWizard.tsx

Lines changed: 86 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,57 @@
1-
import { createSignal, Show } from "solid-js";
1+
import {
2+
createSignal,
3+
createMemo,
4+
onMount,
5+
Show,
6+
Switch,
7+
Match,
8+
} from "solid-js";
29
import { config, updateConfig, CONFIG_STORAGE_KEY } from "../../stores/config";
3-
import { RepoRef } from "../../services/api";
4-
import OrgSelector from "./OrgSelector";
10+
import { fetchOrgs, type OrgEntry, type RepoRef } from "../../services/api";
11+
import { getClient } from "../../services/github";
512
import RepoSelector from "./RepoSelector";
6-
7-
const STEPS = ["Select Organizations", "Select Repositories"] as const;
13+
import LoadingSpinner from "../shared/LoadingSpinner";
814

915
export default function OnboardingWizard() {
10-
const [step, setStep] = createSignal(0);
11-
const [selectedOrgs, setSelectedOrgs] = createSignal<string[]>(
12-
config.selectedOrgs.length > 0 ? [...config.selectedOrgs] : []
13-
);
1416
const [selectedRepos, setSelectedRepos] = createSignal<RepoRef[]>(
1517
config.selectedRepos.length > 0 ? [...config.selectedRepos] : []
1618
);
1719

18-
function handleNext() {
19-
if (step() === 0) {
20-
updateConfig({ selectedOrgs: selectedOrgs() });
21-
setStep(1);
20+
const [loading, setLoading] = createSignal(true);
21+
const [error, setError] = createSignal<string | null>(null);
22+
const [orgEntries, setOrgEntries] = createSignal<OrgEntry[]>([]);
23+
24+
const allOrgLogins = createMemo(() => orgEntries().map((o) => o.login));
25+
26+
async function loadOrgs() {
27+
setLoading(true);
28+
setError(null);
29+
try {
30+
const client = getClient();
31+
if (!client) throw new Error("No GitHub client available");
32+
const result = await fetchOrgs(client);
33+
setOrgEntries(result);
34+
} catch (err) {
35+
setError(
36+
err instanceof Error ? err.message : "Failed to load organizations"
37+
);
38+
} finally {
39+
setLoading(false);
2240
}
2341
}
2442

43+
onMount(() => {
44+
if (config.onboardingComplete) {
45+
window.location.replace("/dashboard");
46+
return;
47+
}
48+
void loadOrgs();
49+
});
50+
2551
function handleFinish() {
52+
const uniqueOrgs = [...new Set(selectedRepos().map((r) => r.owner))];
2653
updateConfig({
54+
selectedOrgs: uniqueOrgs,
2755
selectedRepos: selectedRepos(),
2856
onboardingComplete: true,
2957
});
@@ -32,15 +60,6 @@ export default function OnboardingWizard() {
3260
window.location.replace("/dashboard");
3361
}
3462

35-
function handleBack() {
36-
setStep((s) => Math.max(0, s - 1));
37-
}
38-
39-
const canProceed = () => {
40-
if (step() === 0) return selectedOrgs().length > 0;
41-
return selectedRepos().length > 0;
42-
};
43-
4463
return (
4564
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
4665
<div class="mx-auto max-w-2xl px-4 py-12">
@@ -50,136 +69,66 @@ export default function OnboardingWizard() {
5069
GitHub Tracker Setup
5170
</h1>
5271
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
53-
Step {step() + 1} of {STEPS.length}
72+
Select the repositories you want to track.
5473
</p>
5574
</div>
5675

57-
{/* Step indicator */}
58-
<nav class="mb-8" aria-label="Progress">
59-
<ol class="flex items-center justify-center gap-4">
60-
{STEPS.map((label, i) => (
61-
<li class="flex items-center gap-2">
62-
<span
63-
class={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold ${
64-
i < step()
65-
? "bg-blue-600 text-white dark:bg-blue-500"
66-
: i === step()
67-
? "bg-blue-600 text-white ring-4 ring-blue-100 dark:bg-blue-500 dark:ring-blue-900"
68-
: "bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400"
69-
}`}
70-
aria-current={i === step() ? "step" : undefined}
71-
>
72-
{i < step() ? (
73-
<svg
74-
class="h-4 w-4"
75-
viewBox="0 0 20 20"
76-
fill="currentColor"
77-
aria-hidden="true"
78-
>
79-
<path
80-
fill-rule="evenodd"
81-
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
82-
clip-rule="evenodd"
83-
/>
84-
</svg>
85-
) : (
86-
i + 1
87-
)}
88-
</span>
89-
<span
90-
class={`text-sm font-medium ${
91-
i === step()
92-
? "text-gray-900 dark:text-gray-100"
93-
: "text-gray-400 dark:text-gray-500"
94-
}`}
95-
>
96-
{label}
97-
</span>
98-
{i < STEPS.length - 1 && (
99-
<span class="mx-2 text-gray-300 dark:text-gray-600">
100-
&rsaquo;
101-
</span>
102-
)}
103-
</li>
104-
))}
105-
</ol>
106-
</nav>
107-
108-
{/* Step content */}
76+
{/* Content */}
10977
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-800">
110-
<Show when={step() === 0}>
111-
<div class="mb-5">
112-
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
113-
Select Organizations
114-
</h2>
115-
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
116-
Choose the GitHub organizations and personal account to track.
117-
</p>
118-
</div>
119-
<OrgSelector
120-
selected={selectedOrgs()}
121-
onChange={setSelectedOrgs}
122-
/>
123-
</Show>
124-
125-
<Show when={step() === 1}>
126-
<div class="mb-5">
127-
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
128-
Select Repositories
129-
</h2>
130-
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
131-
Choose which repositories to track within your selected
132-
organizations.
133-
</p>
134-
</div>
135-
<RepoSelector
136-
selectedOrgs={selectedOrgs()}
137-
selected={selectedRepos()}
138-
onChange={setSelectedRepos}
139-
/>
140-
</Show>
78+
<Switch>
79+
<Match when={error()}>
80+
<div class="flex flex-col items-center gap-3 py-12">
81+
<p class="text-sm text-red-600 dark:text-red-400">
82+
{error()}
83+
</p>
84+
<button
85+
type="button"
86+
onClick={() => void loadOrgs()}
87+
class="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
88+
>
89+
Retry
90+
</button>
91+
</div>
92+
</Match>
93+
<Match when={loading()}>
94+
<div class="flex items-center justify-center py-12">
95+
<LoadingSpinner size="md" label="Loading organizations..." />
96+
</div>
97+
</Match>
98+
<Match when={!loading() && !error()}>
99+
<div class="mb-5">
100+
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
101+
Select Repositories
102+
</h2>
103+
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
104+
Choose which repositories to track.
105+
</p>
106+
</div>
107+
<RepoSelector
108+
selectedOrgs={allOrgLogins()}
109+
orgEntries={orgEntries()}
110+
selected={selectedRepos()}
111+
onChange={setSelectedRepos}
112+
/>
113+
</Match>
114+
</Switch>
141115
</div>
142116

143-
{/* Navigation buttons */}
144-
<div class="mt-6 flex items-center justify-between">
145-
<Show
146-
when={step() > 0}
147-
fallback={<div />}
148-
>
149-
<button
150-
type="button"
151-
onClick={handleBack}
152-
class="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
153-
>
154-
Back
155-
</button>
156-
</Show>
157-
158-
<Show
159-
when={step() === STEPS.length - 1}
160-
fallback={
161-
<button
162-
type="button"
163-
onClick={handleNext}
164-
disabled={!canProceed()}
165-
class="ml-auto rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-blue-500 dark:hover:bg-blue-600"
166-
>
167-
Next
168-
</button>
169-
}
170-
>
117+
{/* Navigation buttons — hidden during loading/error to avoid confusion */}
118+
<Show when={!loading() && !error()}>
119+
<div class="mt-6 flex items-center justify-end">
171120
<button
172121
type="button"
173122
onClick={handleFinish}
174123
disabled={selectedRepos().length === 0}
175-
class="ml-auto rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-blue-500 dark:hover:bg-blue-600"
124+
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-blue-500 dark:hover:bg-blue-600"
176125
>
177126
{selectedRepos().length === 0
178127
? "Finish Setup"
179128
: `Finish Setup (${selectedRepos().length} ${selectedRepos().length === 1 ? "repo" : "repos"})`}
180129
</button>
181-
</Show>
182-
</div>
130+
</div>
131+
</Show>
183132
</div>
184133
</div>
185134
);

0 commit comments

Comments
 (0)