Skip to content

Commit 83862f2

Browse files
New User Onboarding Flow UI (#16501)
* disable rule for better dx - linter will catch * add styles for other input types * adding more options to input components * Building out onboarding form * adjusting form * breaking onboarding flow into pieces * adding personalize step * removing old code * removing un-needed code * Plug in ThemeSelector component * cleanup * update onboarding logic * disable or members query if no current org present * adjusting where we save onboarding data * make label optional * change signup goals to an array * change to company * adjust spacing/layout * Add additional comments for context * rename isLoading to isSaving for clarity * fix typo * set type on button, don't submit if invalid * adding required on required fields * fix typos * Adjusting titles, styles and adding avatar * account for new profile fields for tracking * remove check for signupGoals * updating options * Adding exploration reasons question
1 parent df3e15a commit 83862f2

File tree

20 files changed

+853
-111
lines changed

20 files changed

+853
-111
lines changed

components/dashboard/src/app/AppRoutes.tsx

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import { ContextURL, Team, User } from "@gitpod/gitpod-protocol";
88
import React, { FunctionComponent, useContext, useState } from "react";
9-
import { Redirect, Route, Switch, useLocation } from "react-router";
9+
import { Redirect, Route, Switch, useLocation, useParams } from "react-router";
1010
import { AppNotifications } from "../AppNotifications";
1111
import Menu from "../menu/Menu";
1212
import OAuthClientApproval from "../OauthClientApproval";
@@ -46,6 +46,7 @@ import { OrgRequiredRoute } from "./OrgRequiredRoute";
4646
import { WebsocketClients } from "./WebsocketClients";
4747
import { StartWorkspaceOptions } from "../start/start-workspace-options";
4848
import { useFeatureFlags } from "../contexts/FeatureFlagContext";
49+
import { FORCE_ONBOARDING_PARAM, FORCE_ONBOARDING_PARAM_VALUE } from "../onboarding/UserOnboarding";
4950

5051
const Setup = React.lazy(() => import(/* webpackPrefetch: true */ "../Setup"));
5152
const Workspaces = React.lazy(() => import(/* webpackPrefetch: true */ "../workspaces/Workspaces"));
@@ -102,12 +103,7 @@ export const AppRoutes: FunctionComponent<AppRoutesProps> = ({ user, teams }) =>
102103
const newCreateWsPage = useNewCreateWorkspacePage();
103104
const location = useLocation();
104105
const { newSignupFlow } = useFeatureFlags();
105-
106-
// Prefix with `/#referrer` will specify an IDE for workspace
107-
// We don't need to show IDE preference in this case
108-
const [showUserIdePreference, setShowUserIdePreference] = useState(
109-
User.isOnboardingUser(user) && !hash.startsWith(ContextURL.REFERRER_PREFIX),
110-
);
106+
const search = new URLSearchParams(location.search);
111107

112108
// TODO: Add a Route for this instead of inspecting location manually
113109
if (location.pathname.startsWith("/blocked")) {
@@ -123,19 +119,26 @@ export const AppRoutes: FunctionComponent<AppRoutesProps> = ({ user, teams }) =>
123119
return <WhatsNew onClose={() => setWhatsNewShown(false)} />;
124120
}
125121

126-
// Placeholder for new signup flow
127-
if (newSignupFlow && User.isOnboardingUser(user)) {
122+
// Show new signup flow if:
123+
// * feature flag enabled
124+
// * User is onboarding (no ide selected yet) OR query param `onboarding=force` is set
125+
const showNewSignupFlow =
126+
newSignupFlow &&
127+
(User.isOnboardingUser(user) || search.get(FORCE_ONBOARDING_PARAM) === FORCE_ONBOARDING_PARAM_VALUE);
128+
if (showNewSignupFlow) {
128129
return <UserOnboarding user={user} />;
129130
}
130131

131132
// TODO: Try and encapsulate this in a route for "/" (check for hash in route component, render or redirect accordingly)
132133
const isCreation = location.pathname === "/" && hash !== "";
133134
if (isCreation) {
134-
if (showUserIdePreference) {
135+
// Prefix with `/#referrer` will specify an IDE for workspace
136+
// After selection is saved, user will be updated, and this condition will be false
137+
const showIDESelection = User.isOnboardingUser(user) && !hash.startsWith(ContextURL.REFERRER_PREFIX);
138+
if (showIDESelection) {
135139
return (
136140
<StartPage phase={StartPhase.Checking}>
137-
{/* TODO: ensure we don't show this after new onboarding flow */}
138-
<SelectIDEModal location="workspace_start" onClose={() => setShowUserIdePreference(false)} />
141+
<SelectIDEModal location="workspace_start" />
139142
</StartPage>
140143
);
141144
} else if (new URLSearchParams(location.search).has("showOptions") || newCreateWsPage) {
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import classNames from "classnames";
8+
import { FC, useCallback, useContext, useState } from "react";
9+
import { ThemeContext } from "../theme-context";
10+
import SelectableCardSolid from "./SelectableCardSolid";
11+
12+
type Theme = "light" | "dark" | "system";
13+
14+
type Props = {
15+
className?: string;
16+
};
17+
// Theme Selection is purely clientside, so this component handles all state and writes to localStorage
18+
export const ThemeSelector: FC<Props> = ({ className }) => {
19+
const { setIsDark } = useContext(ThemeContext);
20+
const [theme, setTheme] = useState<Theme>(localStorage.theme || "system");
21+
22+
const actuallySetTheme = useCallback(
23+
(theme: Theme) => {
24+
if (theme === "dark" || theme === "light") {
25+
localStorage.theme = theme;
26+
} else {
27+
localStorage.removeItem("theme");
28+
}
29+
const isDark =
30+
localStorage.theme === "dark" ||
31+
(localStorage.theme !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
32+
setIsDark(isDark);
33+
setTheme(theme);
34+
},
35+
[setIsDark],
36+
);
37+
38+
return (
39+
<div className={classNames(className)}>
40+
<h3>Theme</h3>
41+
<p className="text-base text-gray-500 dark:text-gray-400">Early bird or night owl? Choose your side.</p>
42+
<div className="mt-4 flex items-center flex-wrap">
43+
<SelectableCardSolid
44+
className="w-36 h-32 m-1"
45+
title="Light"
46+
selected={theme === "light"}
47+
onClick={() => actuallySetTheme("light")}
48+
>
49+
<div className="flex-grow flex items-end p-1">
50+
<svg width="112" height="64" fill="none" xmlns="http://www.w3.org/2000/svg">
51+
<path
52+
d="M0 8a8 8 0 0 1 8-8h16a8 8 0 1 1 0 16H8a8 8 0 0 1-8-8ZM0 32a8 8 0 0 1 8-8h16a8 8 0 1 1 0 16H8a8 8 0 0 1-8-8ZM0 56a8 8 0 0 1 8-8h16a8 8 0 1 1 0 16H8a8 8 0 0 1-8-8ZM40 6a6 6 0 0 1 6-6h60a6 6 0 0 1 6 6v28a6 6 0 0 1-6 6H46a6 6 0 0 1-6-6V6Z"
53+
fill="#D6D3D1"
54+
/>
55+
</svg>
56+
</div>
57+
</SelectableCardSolid>
58+
<SelectableCardSolid
59+
className="w-36 h-32 m-1"
60+
title="Dark"
61+
selected={theme === "dark"}
62+
onClick={() => actuallySetTheme("dark")}
63+
>
64+
<div className="flex-grow flex items-end p-1">
65+
<svg width="112" height="64" fill="none" xmlns="http://www.w3.org/2000/svg">
66+
<path
67+
d="M0 8a8 8 0 0 1 8-8h16a8 8 0 1 1 0 16H8a8 8 0 0 1-8-8ZM0 32a8 8 0 0 1 8-8h16a8 8 0 1 1 0 16H8a8 8 0 0 1-8-8ZM0 56a8 8 0 0 1 8-8h16a8 8 0 1 1 0 16H8a8 8 0 0 1-8-8ZM40 6a6 6 0 0 1 6-6h60a6 6 0 0 1 6 6v28a6 6 0 0 1-6 6H46a6 6 0 0 1-6-6V6Z"
68+
fill="#78716C"
69+
/>
70+
</svg>
71+
</div>
72+
</SelectableCardSolid>
73+
<SelectableCardSolid
74+
className="w-36 h-32 m-1"
75+
title="System"
76+
selected={theme === "system"}
77+
onClick={() => actuallySetTheme("system")}
78+
>
79+
<div className="flex-grow flex items-end p-1">
80+
<svg width="112" height="64" fill="none" xmlns="http://www.w3.org/2000/svg">
81+
<path
82+
d="M0 8a8 8 0 0 1 8-8h16a8 8 0 1 1 0 16H8a8 8 0 0 1-8-8ZM40 6a6 6 0 0 1 6-6h60a6 6 0 0 1 6 6v28a6 6 0 0 1-6 6H46a6 6 0 0 1-6-6V6Z"
83+
fill="#D9D9D9"
84+
/>
85+
<path
86+
d="M84 0h22a6 6 0 0 1 6 6v28a6 6 0 0 1-6 6H68L84 0ZM0 32a8 8 0 0 1 8-8h16a8 8 0 1 1 0 16H8a8 8 0 0 1-8-8Z"
87+
fill="#78716C"
88+
/>
89+
<path d="M0 56a8 8 0 0 1 8-8h16a8 8 0 1 1 0 16H8a8 8 0 0 1-8-8Z" fill="#D9D9D9" />
90+
</svg>
91+
</div>
92+
</SelectableCardSolid>
93+
</div>
94+
</div>
95+
);
96+
};

components/dashboard/src/components/forms/InputField.tsx

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,27 @@ import classNames from "classnames";
88
import { FunctionComponent, memo, ReactNode } from "react";
99

1010
type Props = {
11-
label: ReactNode;
11+
label?: ReactNode;
1212
id?: string;
1313
hint?: ReactNode;
1414
error?: ReactNode;
15+
className?: string;
1516
};
1617

17-
export const InputField: FunctionComponent<Props> = memo(({ label, id, hint, error, children }) => {
18+
export const InputField: FunctionComponent<Props> = memo(({ label, id, hint, error, className, children }) => {
1819
return (
19-
<div className="mt-4 flex flex-col space-y-2">
20-
<label
21-
className={classNames(
22-
"text-sm font-semibold dark:text-gray-400",
23-
error ? "text-red-600" : "text-gray-600",
24-
)}
25-
htmlFor={id}
26-
>
27-
{label}
28-
</label>
20+
<div className={classNames("mt-4 flex flex-col space-y-2", className)}>
21+
{label && (
22+
<label
23+
className={classNames(
24+
"text-sm font-semibold dark:text-gray-400",
25+
error ? "text-red-600" : "text-gray-600",
26+
)}
27+
htmlFor={id}
28+
>
29+
{label}
30+
</label>
31+
)}
2932
{children}
3033
{error && <span className="text-red-500 text-sm">{error}</span>}
3134
{hint && <span className="text-gray-500 text-sm">{hint}</span>}

components/dashboard/src/components/forms/SelectInputField.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { useId } from "../../hooks/useId";
1010
import { InputField } from "./InputField";
1111

1212
type Props = {
13-
label: ReactNode;
13+
label?: ReactNode;
1414
value: string;
1515
id?: string;
1616
hint?: ReactNode;
@@ -31,6 +31,7 @@ export const SelectInputField: FunctionComponent<Props> = memo(
3131
<SelectInput
3232
id={elementId}
3333
value={value}
34+
className={error ? "error" : ""}
3435
onChange={onChange}
3536
disabled={disabled}
3637
required={required}

components/dashboard/src/components/forms/TextInputField.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,19 @@ import { FunctionComponent, memo, ReactNode, useCallback } from "react";
99
import { useId } from "../../hooks/useId";
1010
import { InputField } from "./InputField";
1111

12+
type TextInputFieldTypes = "text" | "password" | "email" | "url";
13+
1214
type Props = {
13-
type?: "text" | "password";
14-
label: ReactNode;
15+
type?: TextInputFieldTypes;
16+
label?: ReactNode;
1517
value: string;
1618
id?: string;
1719
hint?: ReactNode;
1820
error?: ReactNode;
1921
placeholder?: string;
2022
disabled?: boolean;
2123
required?: boolean;
24+
containerClassName?: string;
2225
onChange: (newValue: string) => void;
2326
onBlur?: () => void;
2427
};
@@ -34,22 +37,23 @@ export const TextInputField: FunctionComponent<Props> = memo(
3437
error,
3538
disabled = false,
3639
required = false,
40+
containerClassName,
3741
onChange,
3842
onBlur,
3943
}) => {
4044
const maybeId = useId();
4145
const elementId = id || maybeId;
4246

4347
return (
44-
<InputField id={elementId} label={label} hint={hint} error={error}>
48+
<InputField id={elementId} label={label} hint={hint} error={error} className={containerClassName}>
4549
<TextInput
4650
id={elementId}
4751
value={value}
4852
type={type}
4953
placeholder={placeholder}
5054
disabled={disabled}
5155
required={required}
52-
className={error ? "border-red-500" : ""}
56+
className={error ? "error" : ""}
5357
onChange={onChange}
5458
onBlur={onBlur}
5559
/>
@@ -59,7 +63,7 @@ export const TextInputField: FunctionComponent<Props> = memo(
5963
);
6064

6165
type TextInputProps = {
62-
type?: "text" | "password";
66+
type?: TextInputFieldTypes;
6367
value: string;
6468
className?: string;
6569
id?: string;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { User } from "@gitpod/gitpod-protocol";
8+
import { useMutation } from "@tanstack/react-query";
9+
import { getGitpodService } from "../../service/service";
10+
11+
type UpdateCurrentUserArgs = Partial<User>;
12+
13+
export const useUpdateCurrentUserMutation = () => {
14+
return useMutation({
15+
mutationFn: async (partialUser: UpdateCurrentUserArgs) => {
16+
return await getGitpodService().server.updateLoggedInUser(partialUser);
17+
},
18+
});
19+
};

components/dashboard/src/data/organizations/org-members-query.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ export const useOrgMembers = () => {
2626

2727
return publicApiTeamMembersToProtocol(resp.team?.members || []);
2828
},
29+
/**
30+
* If no current org is set, disable query
31+
* This is to prevent making a request to the API when there is no organization selected.
32+
* This happens if the user has their personal account selected, or when first loggin in
33+
*/
34+
enabled: !!organization,
2935
});
3036
};
3137

components/dashboard/src/index.css

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@
8585
input[type="tel"],
8686
input[type="number"],
8787
input[type="password"],
88+
input[type="email"],
89+
input[type="url"],
8890
select {
8991
@apply block w-56 text-gray-600 dark:text-gray-400 dark:bg-gray-800 bg-white rounded-md border border-gray-300 dark:border-gray-500 focus:border-gray-400 dark:focus:border-gray-400 focus:ring-0;
9092
}
@@ -93,14 +95,18 @@
9395
input[type="tel"]::placeholder,
9496
input[type="number"]::placeholder,
9597
input[type="search"]::placeholder,
96-
input[type="password"]::placeholder {
98+
input[type="password"]::placeholder,
99+
input[type="email"]::placeholder,
100+
input[type="url"]::placeholder {
97101
@apply text-gray-400 dark:text-gray-500;
98102
}
99103
input[type="text"].error,
100104
input[type="tel"].error,
101105
input[type="number"].error,
102106
input[type="search"].error,
103107
input[type="password"].error,
108+
input[type="email"].error,
109+
input[type="url"].error,
104110
select.error {
105111
@apply border-gitpod-red dark:border-gitpod-red focus:border-gitpod-red dark:focus:border-gitpod-red;
106112
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { FC, FormEvent, useCallback } from "react";
8+
import Alert from "../components/Alert";
9+
10+
type Props = {
11+
title: string;
12+
subtitle: string;
13+
isValid: boolean;
14+
isSaving?: boolean;
15+
error?: string;
16+
onSubmit(): void;
17+
};
18+
export const OnboardingStep: FC<Props> = ({
19+
title,
20+
subtitle,
21+
isValid,
22+
isSaving = false,
23+
error,
24+
children,
25+
onSubmit,
26+
}) => {
27+
const handleSubmit = useCallback(
28+
async (e: FormEvent<HTMLFormElement>) => {
29+
e.preventDefault();
30+
if (isSaving || !isValid) {
31+
return;
32+
}
33+
34+
onSubmit();
35+
},
36+
[isSaving, isValid, onSubmit],
37+
);
38+
39+
return (
40+
<div className="flex flex-col items-center justify-center max-w-full">
41+
{/* TODO: Fix our base heading styles so we don't have to override */}
42+
<h2 className="text-3xl text-gray-900 dark:text-gray-100 font-bold">{title}</h2>
43+
<p className="text-base text-gray-500 dark:text-gray-400">{subtitle}</p>
44+
45+
<form className="mt-8 mb-14 max-w-lg" onSubmit={handleSubmit}>
46+
{/* Form contents provided as children */}
47+
{children}
48+
49+
{error && <Alert type="error">{error}</Alert>}
50+
51+
<div>
52+
<button type="submit" disabled={!isValid || isSaving} className="w-full mt-8">
53+
Continue
54+
</button>
55+
</div>
56+
</form>
57+
</div>
58+
);
59+
};

0 commit comments

Comments
 (0)