Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New User Onboarding Flow UI #16501

Merged
merged 29 commits into from
Feb 23, 2023
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
cb6af81
disable rule for better dx - linter will catch
selfcontained Feb 21, 2023
e853507
add styles for other input types
selfcontained Feb 21, 2023
d4b7832
adding more options to input components
selfcontained Feb 21, 2023
6cc4f18
Building out onboarding form
selfcontained Feb 21, 2023
5307855
adjusting form
selfcontained Feb 21, 2023
f78b0b2
breaking onboarding flow into pieces
selfcontained Feb 22, 2023
066820a
adding personalize step
selfcontained Feb 22, 2023
74263bc
removing old code
selfcontained Feb 22, 2023
05ebdd7
removing un-needed code
selfcontained Feb 22, 2023
f078fcd
Plug in ThemeSelector component
selfcontained Feb 22, 2023
aa05a79
cleanup
selfcontained Feb 22, 2023
4ec1fd7
update onboarding logic
selfcontained Feb 22, 2023
02b3cd3
disable or members query if no current org present
selfcontained Feb 22, 2023
0f12ff5
adjusting where we save onboarding data
selfcontained Feb 22, 2023
273525e
make label optional
selfcontained Feb 23, 2023
189542b
change signup goals to an array
selfcontained Feb 23, 2023
f2d868e
change to company
selfcontained Feb 23, 2023
6b91b5d
adjust spacing/layout
selfcontained Feb 23, 2023
1a5d15c
Add additional comments for context
selfcontained Feb 23, 2023
a676453
rename isLoading to isSaving for clarity
selfcontained Feb 23, 2023
cae16dd
fix typo
selfcontained Feb 23, 2023
1cc9482
set type on button, don't submit if invalid
selfcontained Feb 23, 2023
3cb0b60
adding required on required fields
selfcontained Feb 23, 2023
c019664
fix typos
selfcontained Feb 23, 2023
9f526a8
Adjusting titles, styles and adding avatar
selfcontained Feb 23, 2023
3be00ab
account for new profile fields for tracking
selfcontained Feb 23, 2023
6a9e763
remove check for signupGoals
selfcontained Feb 23, 2023
f49cd2f
updating options
selfcontained Feb 23, 2023
452874b
Adding exploration reasons question
selfcontained Feb 23, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 15 additions & 12 deletions components/dashboard/src/app/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import { ContextURL, Team, User } from "@gitpod/gitpod-protocol";
import React, { FunctionComponent, useContext, useState } from "react";
import { Redirect, Route, Switch, useLocation } from "react-router";
import { Redirect, Route, Switch, useLocation, useParams } from "react-router";
import { AppNotifications } from "../AppNotifications";
import Menu from "../menu/Menu";
import OAuthClientApproval from "../OauthClientApproval";
Expand Down Expand Up @@ -46,6 +46,7 @@ import { OrgRequiredRoute } from "./OrgRequiredRoute";
import { WebsocketClients } from "./WebsocketClients";
import { StartWorkspaceOptions } from "../start/start-workspace-options";
import { useFeatureFlags } from "../contexts/FeatureFlagContext";
import { FORCE_ONBOARDING_PARAM, FORCE_ONBOARDING_PARAM_VALUE } from "../onboarding/UserOnboarding";

const Setup = React.lazy(() => import(/* webpackPrefetch: true */ "../Setup"));
const Workspaces = React.lazy(() => import(/* webpackPrefetch: true */ "../workspaces/Workspaces"));
Expand Down Expand Up @@ -102,12 +103,7 @@ export const AppRoutes: FunctionComponent<AppRoutesProps> = ({ user, teams }) =>
const newCreateWsPage = useNewCreateWorkspacePage();
const location = useLocation();
const { newSignupFlow } = useFeatureFlags();

// Prefix with `/#referrer` will specify an IDE for workspace
// We don't need to show IDE preference in this case
const [showUserIdePreference, setShowUserIdePreference] = useState(
User.isOnboardingUser(user) && !hash.startsWith(ContextURL.REFERRER_PREFIX),
);
const search = new URLSearchParams(location.search);

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

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

// TODO: Try and encapsulate this in a route for "/" (check for hash in route component, render or redirect accordingly)
const isCreation = location.pathname === "/" && hash !== "";
if (isCreation) {
if (showUserIdePreference) {
// Prefix with `/#referrer` will specify an IDE for workspace
// After selection is saved, user will be updated, and this condition will be false
const showIDESelection = User.isOnboardingUser(user) && !hash.startsWith(ContextURL.REFERRER_PREFIX);
if (showIDESelection) {
return (
<StartPage phase={StartPhase.Checking}>
{/* TODO: ensure we don't show this after new onboarding flow */}
<SelectIDEModal location="workspace_start" onClose={() => setShowUserIdePreference(false)} />
<SelectIDEModal location="workspace_start" />
</StartPage>
);
} else if (new URLSearchParams(location.search).has("showOptions") || newCreateWsPage) {
Expand Down
96 changes: 96 additions & 0 deletions components/dashboard/src/components/ThemeSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
filiptronicek marked this conversation as resolved.
Show resolved Hide resolved
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License.AGPL.txt in the project root for license information.
*/

import classNames from "classnames";
import { FC, useCallback, useContext, useState } from "react";
import { ThemeContext } from "../theme-context";
import SelectableCardSolid from "./SelectableCardSolid";

type Theme = "light" | "dark" | "system";

type Props = {
className?: string;
};
// Theme Selection is purely clientside, so this component handles all state and writes to localStorage
export const ThemeSelector: FC<Props> = ({ className }) => {
const { setIsDark } = useContext(ThemeContext);
const [theme, setTheme] = useState<Theme>(localStorage.theme || "system");

const actuallySetTheme = useCallback(
(theme: Theme) => {
if (theme === "dark" || theme === "light") {
localStorage.theme = theme;
} else {
localStorage.removeItem("theme");
}
const isDark =
localStorage.theme === "dark" ||
(localStorage.theme !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
setIsDark(isDark);
setTheme(theme);
},
[setIsDark],
);

return (
<div className={classNames(className)}>
<h3>Theme</h3>
<p className="text-base text-gray-500 dark:text-gray-400">Early bird or night owl? Choose your side.</p>
<div className="mt-4 flex items-center justify-between flex-wrap">
<SelectableCardSolid
className="w-36 h-32 m-1"
title="Light"
selected={theme === "light"}
onClick={() => actuallySetTheme("light")}
>
<div className="flex-grow flex items-end p-1">
<svg width="112" height="64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
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"
fill="#D6D3D1"
/>
</svg>
</div>
</SelectableCardSolid>
<SelectableCardSolid
className="w-36 h-32 m-1"
title="Dark"
selected={theme === "dark"}
onClick={() => actuallySetTheme("dark")}
>
<div className="flex-grow flex items-end p-1">
<svg width="112" height="64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
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"
fill="#78716C"
/>
</svg>
</div>
</SelectableCardSolid>
<SelectableCardSolid
className="w-36 h-32 m-1"
title="System"
selected={theme === "system"}
onClick={() => actuallySetTheme("system")}
>
<div className="flex-grow flex items-end p-1">
<svg width="112" height="64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
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"
fill="#D9D9D9"
/>
<path
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"
fill="#78716C"
/>
<path d="M0 56a8 8 0 0 1 8-8h16a8 8 0 1 1 0 16H8a8 8 0 0 1-8-8Z" fill="#D9D9D9" />
</svg>
</div>
</SelectableCardSolid>
</div>
</div>
);
};
27 changes: 15 additions & 12 deletions components/dashboard/src/components/forms/InputField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,27 @@ import classNames from "classnames";
import { FunctionComponent, memo, ReactNode } from "react";

type Props = {
label: ReactNode;
label?: ReactNode;
id?: string;
hint?: ReactNode;
error?: ReactNode;
className?: string;
};

export const InputField: FunctionComponent<Props> = memo(({ label, id, hint, error, children }) => {
export const InputField: FunctionComponent<Props> = memo(({ label, id, hint, error, className, children }) => {
return (
<div className="mt-4 flex flex-col space-y-2">
<label
className={classNames(
"text-sm font-semibold dark:text-gray-400",
error ? "text-red-600" : "text-gray-600",
)}
htmlFor={id}
>
{label}
</label>
<div className={classNames("mt-4 flex flex-col space-y-2", className)}>
{label && (
<label
className={classNames(
"text-sm font-semibold dark:text-gray-400",
error ? "text-red-600" : "text-gray-600",
)}
htmlFor={id}
>
{label}
</label>
)}
{children}
{error && <span className="text-red-500 text-sm">{error}</span>}
{hint && <span className="text-gray-500 text-sm">{hint}</span>}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { useId } from "../../hooks/useId";
import { InputField } from "./InputField";

type Props = {
label: ReactNode;
label?: ReactNode;
value: string;
id?: string;
hint?: ReactNode;
Expand All @@ -31,6 +31,7 @@ export const SelectInputField: FunctionComponent<Props> = memo(
<SelectInput
id={elementId}
value={value}
className={error ? "error" : ""}
onChange={onChange}
disabled={disabled}
required={required}
Expand Down
14 changes: 9 additions & 5 deletions components/dashboard/src/components/forms/TextInputField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,19 @@ import { FunctionComponent, memo, ReactNode, useCallback } from "react";
import { useId } from "../../hooks/useId";
import { InputField } from "./InputField";

type TextInputFieldTypes = "text" | "password" | "email" | "url";

type Props = {
type?: "text" | "password";
label: ReactNode;
type?: TextInputFieldTypes;
label?: ReactNode;
value: string;
id?: string;
hint?: ReactNode;
error?: ReactNode;
placeholder?: string;
disabled?: boolean;
required?: boolean;
containerClassName?: string;
onChange: (newValue: string) => void;
onBlur?: () => void;
};
Expand All @@ -34,22 +37,23 @@ export const TextInputField: FunctionComponent<Props> = memo(
error,
disabled = false,
required = false,
containerClassName,
onChange,
onBlur,
}) => {
const maybeId = useId();
const elementId = id || maybeId;

return (
<InputField id={elementId} label={label} hint={hint} error={error}>
<InputField id={elementId} label={label} hint={hint} error={error} className={containerClassName}>
<TextInput
id={elementId}
value={value}
type={type}
placeholder={placeholder}
disabled={disabled}
required={required}
className={error ? "border-red-500" : ""}
className={error ? "error" : ""}
onChange={onChange}
onBlur={onBlur}
/>
Expand All @@ -59,7 +63,7 @@ export const TextInputField: FunctionComponent<Props> = memo(
);

type TextInputProps = {
type?: "text" | "password";
type?: TextInputFieldTypes;
value: string;
className?: string;
id?: string;
Expand Down
19 changes: 19 additions & 0 deletions components/dashboard/src/data/current-user/update-mutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License.AGPL.txt in the project root for license information.
*/

import { User } from "@gitpod/gitpod-protocol";
import { useMutation } from "@tanstack/react-query";
import { getGitpodService } from "../../service/service";

type UpdateCurrentUserArgs = Partial<User>;

export const useUpdateCurrentUserMutation = () => {
return useMutation({
mutationFn: async (partialUser: UpdateCurrentUserArgs) => {
return await getGitpodService().server.updateLoggedInUser(partialUser);
},
});
};
Comment on lines +13 to +19
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (non-blocking): How about we split it out in chunks, it can be also useful in the future to reuse the similar functionality any other place

Suggested change
export const useUpdateCurrentUserMutation = () => {
return useMutation({
mutationFn: async (partialUser: UpdateCurrentUserArgs) => {
return await getGitpodService().server.updateLoggedInUser(partialUser);
},
});
};
export const useUpdateCurrentUserMutation = () => {
return useMutation({
mutationFn: updateUser,
});
};
const updateUser = async (partialUser: UpdateCurrentUserArgs) => {
return await getGitpodService().server.updateLoggedInUser(partialUser);
};

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yah, good thinking. I'm going to hold off on extracting these for now until there's another mutation that would use the function, but that would be a good way to share some logic.

Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export const useOrgMembers = () => {

return publicApiTeamMembersToProtocol(resp.team?.members || []);
},
// If no current org is set, disable query
enabled: !!organization,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (non-blocking): We can add more context to get more info about this check.

Suggested change
// If no current org is set, disable query
enabled: !!organization,
/**
* If no current org is set, disable query
* This is to prevent making a request to the API when there is no organization selected.
* This is the case when the user is first logging in, or when the user is on the settings page, and no organization is selected.
*/
enabled: !!organization,

});
};

Expand Down
8 changes: 7 additions & 1 deletion components/dashboard/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@
input[type="tel"],
input[type="number"],
input[type="password"],
input[type="email"],
input[type="url"],
select {
@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;
}
Expand All @@ -93,14 +95,18 @@
input[type="tel"]::placeholder,
input[type="number"]::placeholder,
input[type="search"]::placeholder,
input[type="password"]::placeholder {
input[type="password"]::placeholder,
input[type="email"]::placeholder,
input[type="url"]::placeholder {
@apply text-gray-400 dark:text-gray-500;
}
input[type="text"].error,
input[type="tel"].error,
input[type="number"].error,
input[type="search"].error,
input[type="password"].error,
input[type="email"].error,
input[type="url"].error,
select.error {
@apply border-gitpod-red dark:border-gitpod-red focus:border-gitpod-red dark:focus:border-gitpod-red;
}
Expand Down
55 changes: 55 additions & 0 deletions components/dashboard/src/onboarding/OnboardingStep.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License.AGPL.txt in the project root for license information.
*/

import { FC, FormEvent, useCallback } from "react";
import Alert from "../components/Alert";

type Props = {
title: string;
subtitle: string;
isValid: boolean;
isLoading?: boolean;
error?: string;
onSubmit(): void;
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIK these props are for Onboarding Forms.

isLoading looks ambiguous as we are already using in List Workspace (code). e.g. isFormLoadingcould be good. Or for quick :shipit: we can just add the comment for context.

Suggested change
type Props = {
title: string;
subtitle: string;
isValid: boolean;
isLoading?: boolean;
error?: string;
onSubmit(): void;
};
type Props = {
// The title of the form
title: string;
// The subtitle of the form
subtitle: string;
// Whether the form is valid
isValid: boolean;
// Whether the form is loading
isLoading?: boolean;
// A message to display if there is an error
error?: string;
// A function to call when the form is submitted
onSubmit(): void;
};

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, the more I think about it, isLoading isn't very indicative of what's happening here, it's really indicating the form is saving, and puts it in a disabled state. I'll adjust it to something like isSaving - and probably need to add another check to avoid redundant submits if that flag is set.

export const OnboardingStep: FC<Props> = ({
title,
subtitle,
isValid,
isLoading = false,
error,
children,
onSubmit,
}) => {
const handleSubmit = useCallback(
async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();

onSubmit();
},
[onSubmit],
);

return (
<div className="flex flex-col items-center justify-center max-w-full">
<h1>{title}</h1>
<p>{subtitle}</p>

<form className="my-8 max-w-lg" onSubmit={handleSubmit}>
{/* Form contents provided as children */}
{children}

{error && <Alert type="error">{error}</Alert>}

<div>
<button disabled={!isValid || isLoading} className="w-full mt-8">
Continue
</button>
</div>
</form>
</div>
);
};
Loading