diff --git a/components/dashboard/src/app/AppRoutes.tsx b/components/dashboard/src/app/AppRoutes.tsx index bd0d4f6a44bb1b..f10a1f4e132bf8 100644 --- a/components/dashboard/src/app/AppRoutes.tsx +++ b/components/dashboard/src/app/AppRoutes.tsx @@ -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"; @@ -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")); @@ -102,12 +103,7 @@ export const AppRoutes: FunctionComponent = ({ 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")) { @@ -123,19 +119,26 @@ export const AppRoutes: FunctionComponent = ({ user, teams }) => return 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 ; } // 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 ( - {/* TODO: ensure we don't show this after new onboarding flow */} - setShowUserIdePreference(false)} /> + ); } else if (new URLSearchParams(location.search).has("showOptions") || newCreateWsPage) { diff --git a/components/dashboard/src/components/ThemeSelector.tsx b/components/dashboard/src/components/ThemeSelector.tsx new file mode 100644 index 00000000000000..d1f0cae1e64cef --- /dev/null +++ b/components/dashboard/src/components/ThemeSelector.tsx @@ -0,0 +1,96 @@ +/** + * 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 = ({ className }) => { + const { setIsDark } = useContext(ThemeContext); + const [theme, setTheme] = useState(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 ( +
+

Theme

+

Early bird or night owl? Choose your side.

+
+ actuallySetTheme("light")} + > +
+ + + +
+
+ actuallySetTheme("dark")} + > +
+ + + +
+
+ actuallySetTheme("system")} + > +
+ + + + + +
+
+
+
+ ); +}; diff --git a/components/dashboard/src/components/forms/InputField.tsx b/components/dashboard/src/components/forms/InputField.tsx index e78758f219fddc..71948045ab9891 100644 --- a/components/dashboard/src/components/forms/InputField.tsx +++ b/components/dashboard/src/components/forms/InputField.tsx @@ -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 = memo(({ label, id, hint, error, children }) => { +export const InputField: FunctionComponent = memo(({ label, id, hint, error, className, children }) => { return ( -
- +
+ {label && ( + + )} {children} {error && {error}} {hint && {hint}} diff --git a/components/dashboard/src/components/forms/SelectInputField.tsx b/components/dashboard/src/components/forms/SelectInputField.tsx index c59c5886a0fc9b..8463f20457a4a8 100644 --- a/components/dashboard/src/components/forms/SelectInputField.tsx +++ b/components/dashboard/src/components/forms/SelectInputField.tsx @@ -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; @@ -31,6 +31,7 @@ export const SelectInputField: FunctionComponent = memo( void; onBlur?: () => void; }; @@ -34,6 +37,7 @@ export const TextInputField: FunctionComponent = memo( error, disabled = false, required = false, + containerClassName, onChange, onBlur, }) => { @@ -41,7 +45,7 @@ export const TextInputField: FunctionComponent = memo( const elementId = id || maybeId; return ( - + = memo( placeholder={placeholder} disabled={disabled} required={required} - className={error ? "border-red-500" : ""} + className={error ? "error" : ""} onChange={onChange} onBlur={onBlur} /> @@ -59,7 +63,7 @@ export const TextInputField: FunctionComponent = memo( ); type TextInputProps = { - type?: "text" | "password"; + type?: TextInputFieldTypes; value: string; className?: string; id?: string; diff --git a/components/dashboard/src/data/current-user/update-mutation.ts b/components/dashboard/src/data/current-user/update-mutation.ts new file mode 100644 index 00000000000000..e683f06f5ddd1d --- /dev/null +++ b/components/dashboard/src/data/current-user/update-mutation.ts @@ -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; + +export const useUpdateCurrentUserMutation = () => { + return useMutation({ + mutationFn: async (partialUser: UpdateCurrentUserArgs) => { + return await getGitpodService().server.updateLoggedInUser(partialUser); + }, + }); +}; diff --git a/components/dashboard/src/data/organizations/org-members-query.ts b/components/dashboard/src/data/organizations/org-members-query.ts index 7c9ee98c2787bc..df1ba801980c3f 100644 --- a/components/dashboard/src/data/organizations/org-members-query.ts +++ b/components/dashboard/src/data/organizations/org-members-query.ts @@ -26,6 +26,12 @@ export const useOrgMembers = () => { return publicApiTeamMembersToProtocol(resp.team?.members || []); }, + /** + * 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 happens if the user has their personal account selected, or when first loggin in + */ + enabled: !!organization, }); }; diff --git a/components/dashboard/src/index.css b/components/dashboard/src/index.css index 0a738e04c14a73..9ed055a4c28a4a 100644 --- a/components/dashboard/src/index.css +++ b/components/dashboard/src/index.css @@ -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; } @@ -93,7 +95,9 @@ 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, @@ -101,6 +105,8 @@ 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; } diff --git a/components/dashboard/src/onboarding/OnboardingStep.tsx b/components/dashboard/src/onboarding/OnboardingStep.tsx new file mode 100644 index 00000000000000..72aa75e235841c --- /dev/null +++ b/components/dashboard/src/onboarding/OnboardingStep.tsx @@ -0,0 +1,59 @@ +/** + * 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; + isSaving?: boolean; + error?: string; + onSubmit(): void; +}; +export const OnboardingStep: FC = ({ + title, + subtitle, + isValid, + isSaving = false, + error, + children, + onSubmit, +}) => { + const handleSubmit = useCallback( + async (e: FormEvent) => { + e.preventDefault(); + if (isSaving || !isValid) { + return; + } + + onSubmit(); + }, + [isSaving, isValid, onSubmit], + ); + + return ( +
+ {/* TODO: Fix our base heading styles so we don't have to override */} +

{title}

+

{subtitle}

+ +
+ {/* Form contents provided as children */} + {children} + + {error && {error}} + +
+ +
+
+
+ ); +}; diff --git a/components/dashboard/src/onboarding/StepOrgInfo.tsx b/components/dashboard/src/onboarding/StepOrgInfo.tsx new file mode 100644 index 00000000000000..d6fc0f49c2d543 --- /dev/null +++ b/components/dashboard/src/onboarding/StepOrgInfo.tsx @@ -0,0 +1,241 @@ +/** + * 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 { FC, useCallback, useMemo, useState } from "react"; +import { InputField } from "../components/forms/InputField"; +import { SelectInputField } from "../components/forms/SelectInputField"; +import { TextInputField } from "../components/forms/TextInputField"; +import { useUpdateCurrentUserMutation } from "../data/current-user/update-mutation"; +import { useOnBlurError } from "../hooks/use-onblur-error"; +import { getExplorationReasons } from "./exploration-reasons"; +import { getJobRoleOptions, JOB_ROLE_OTHER } from "./job-roles"; +import { OnboardingStep } from "./OnboardingStep"; +import { getSignupGoalsOptions, SIGNUP_GOALS_OTHER } from "./signup-goals"; + +type Props = { + user: User; + onComplete(user: User): void; +}; +export const StepOrgInfo: FC = ({ user, onComplete }) => { + const updateUser = useUpdateCurrentUserMutation(); + const jobRoleOptions = useMemo(getJobRoleOptions, []); + const explorationReasonsOptions = useMemo(getExplorationReasons, []); + const signupGoalsOptions = useMemo(getSignupGoalsOptions, []); + + const [jobRole, setJobRole] = useState(user.additionalData?.profile?.jobRole ?? ""); + const [jobRoleOther, setJobRoleOther] = useState(user.additionalData?.profile?.jobRoleOther ?? ""); + const [explorationReasons, setExplorationReasons] = useState( + user.additionalData?.profile?.explorationReasons ?? [], + ); + const [signupGoals, setSignupGoals] = useState(user.additionalData?.profile?.signupGoals ?? []); + const [signupGoalsOther, setSignupGoalsOther] = useState(user.additionalData?.profile?.signupGoalsOther ?? ""); + const [companyWebsite, setCompanyWebsite] = useState(user.additionalData?.profile?.companyWebsite ?? ""); + + const addSignupGoal = useCallback( + (goal: string) => { + if (!signupGoals.includes(goal)) { + setSignupGoals([...signupGoals, goal]); + } + }, + [signupGoals], + ); + + const removeSignupGoal = useCallback( + (goal: string) => { + if (signupGoals.includes(goal)) { + const idx = signupGoals.indexOf(goal); + const newGoals = [...signupGoals]; + newGoals.splice(idx, 1); + setSignupGoals(newGoals); + } + // clear out freeform other if removing option + if (goal === SIGNUP_GOALS_OTHER) { + setSignupGoalsOther(""); + } + }, + [signupGoals], + ); + + const addExplorationReason = useCallback( + (reason: string) => { + if (!explorationReasons.includes(reason)) { + setExplorationReasons([...explorationReasons, reason]); + } + }, + [explorationReasons], + ); + + const removeExplorationReason = useCallback( + (reason: string) => { + if (explorationReasons.includes(reason)) { + const idx = explorationReasons.indexOf(reason); + const newReasons = [...explorationReasons]; + newReasons.splice(idx, 1); + setExplorationReasons(newReasons); + } + }, + [explorationReasons], + ); + + const handleSubmit = useCallback(async () => { + const additionalData = user.additionalData || {}; + const profile = additionalData.profile || {}; + + // Filter out any values not present in options + const filteredReasons = explorationReasons.filter((val) => + explorationReasonsOptions.find((o) => o.value === val), + ); + const filteredGoals = signupGoals.filter((val) => signupGoalsOptions.find((o) => o.value === val)); + + const updates = { + additionalData: { + ...additionalData, + profile: { + ...profile, + jobRole, + jobRoleOther, + explorationReasons: filteredReasons, + signupGoals: filteredGoals, + signupGoalsOther, + companyWebsite, + }, + }, + }; + + try { + const updatedUser = await updateUser.mutateAsync(updates); + onComplete(updatedUser); + } catch (e) { + console.error(e); + } + }, [ + companyWebsite, + explorationReasons, + explorationReasonsOptions, + jobRole, + jobRoleOther, + onComplete, + signupGoals, + signupGoalsOptions, + signupGoalsOther, + updateUser, + user.additionalData, + ]); + + const jobRoleError = useOnBlurError("Please select one", !!jobRole); + const isValid = jobRoleError.isValid && signupGoals.length > 0; + + return ( + + { + if (val !== "other") { + setJobRoleOther(""); + } + setJobRole(val); + }} + hint={ + jobRole !== JOB_ROLE_OTHER + ? "Please select the role that best describes the type of work you'll use Gitpod for" + : "" + } + error={jobRoleError.message} + onBlur={jobRoleError.onBlur} + > + {jobRoleOptions.map((o) => ( + + ))} + + + {jobRole === JOB_ROLE_OTHER && ( + + )} + + + + +
+ {explorationReasonsOptions.map((o) => ( +
+ { + if (e.target.checked) { + addExplorationReason(o.value); + } else { + removeExplorationReason(o.value); + } + }} + /> + +
+ ))} +
+ + +
+ {signupGoalsOptions.map((o) => ( +
+ { + if (e.target.checked) { + addSignupGoal(o.value); + } else { + removeSignupGoal(o.value); + } + }} + /> + +
+ ))} +
+ + {signupGoals.includes(SIGNUP_GOALS_OTHER) && ( + + )} +
+ ); +}; diff --git a/components/dashboard/src/onboarding/StepPersonalize.tsx b/components/dashboard/src/onboarding/StepPersonalize.tsx new file mode 100644 index 00000000000000..8eef8c36942299 --- /dev/null +++ b/components/dashboard/src/onboarding/StepPersonalize.tsx @@ -0,0 +1,49 @@ +/** + * 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 { FC, useCallback, useState } from "react"; +import SelectIDEComponent from "../components/SelectIDEComponent"; +import { ThemeSelector } from "../components/ThemeSelector"; +import { OnboardingStep } from "./OnboardingStep"; + +type Props = { + user: User; + onComplete(ide: string, useLatest: boolean): void; +}; +export const StepPersonalize: FC = ({ user, onComplete }) => { + const [ide, setIDE] = useState(user?.additionalData?.ideSettings?.defaultIde || "code"); + const [useLatest, setUseLatest] = useState(user?.additionalData?.ideSettings?.useLatestVersion ?? false); + + // This step doesn't save the ide selection yet (happens at the end), just passes them along + const handleSubmitted = useCallback(() => { + onComplete(ide, useLatest); + }, [ide, onComplete, useLatest]); + + const isValid = !!ide; + + return ( + +

Choose an editor

+

You can change this later in your user preferences.

+ { + setIDE(ide); + setUseLatest(latest); + }} + selectedIdeOption={ide} + useLatest={useLatest} + /> + + +
+ ); +}; diff --git a/components/dashboard/src/onboarding/StepUserInfo.tsx b/components/dashboard/src/onboarding/StepUserInfo.tsx new file mode 100644 index 00000000000000..6b7db25bf32758 --- /dev/null +++ b/components/dashboard/src/onboarding/StepUserInfo.tsx @@ -0,0 +1,122 @@ +/** + * 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 { FC, useCallback, useState } from "react"; +import { TextInputField } from "../components/forms/TextInputField"; +import { useUpdateCurrentUserMutation } from "../data/current-user/update-mutation"; +import { useOnBlurError } from "../hooks/use-onblur-error"; +import { OnboardingStep } from "./OnboardingStep"; + +type Props = { + user: User; + onComplete(user: User): void; +}; +export const StepUserInfo: FC = ({ user, onComplete }) => { + const updateUser = useUpdateCurrentUserMutation(); + // attempt to split provided name for default input values + const { first, last } = getInitialNameParts(user); + + const [firstName, setFirstName] = useState(first); + const [lastName, setLastName] = useState(last); + const [emailAddress, setEmailAddress] = useState(User.getPrimaryEmail(user) ?? ""); + + const handleSubmit = useCallback(async () => { + const additionalData = user.additionalData || {}; + const profile = additionalData.profile || {}; + + const updates = { + // we only split these out currently for form collection, but combine in the db + fullName: `${firstName} ${lastName}`, + additionalData: { + ...additionalData, + profile: { + ...profile, + emailAddress, + lastUpdatedDetailsNudge: new Date().toISOString(), + }, + }, + }; + + try { + const updatedUser = await updateUser.mutateAsync(updates); + onComplete(updatedUser); + } catch (e) { + console.error(e); + } + }, [emailAddress, firstName, lastName, onComplete, updateUser, user.additionalData]); + + const firstNameError = useOnBlurError("Please enter a value", !!firstName); + const lastNameError = useOnBlurError("Please enter a value", !!lastName); + const emailError = useOnBlurError("Please enter your email address", !!emailAddress); + + const isValid = [firstNameError, lastNameError, emailError].every((e) => e.isValid); + + return ( + + {user.avatarUrl && ( +
+ {user.fullName +
+ )} + +
+ + + +
+ + +
+ ); +}; + +// Intentionally not using User.getName() here to avoid relying on identity.authName (likely not user's real name) +const getInitialNameParts = (user: User) => { + const name = user.fullName || user.name || ""; + let first = name; + let last = ""; + + const parts = name.split(" "); + if (parts.length > 1) { + first = parts.shift() || ""; + last = parts.join(" "); + } + + return { first, last }; +}; diff --git a/components/dashboard/src/onboarding/UserOnboarding.tsx b/components/dashboard/src/onboarding/UserOnboarding.tsx index 26bdea832ea01e..4a3236524912b8 100644 --- a/components/dashboard/src/onboarding/UserOnboarding.tsx +++ b/components/dashboard/src/onboarding/UserOnboarding.tsx @@ -5,18 +5,137 @@ */ import { User } from "@gitpod/gitpod-protocol"; -import { FunctionComponent } from "react"; +import { FunctionComponent, useCallback, useContext, useState } from "react"; +import gitpodIcon from "../icons/gitpod.svg"; +import Separator from "../components/Separator"; +import { useHistory, useLocation } from "react-router"; +import { StepUserInfo } from "./StepUserInfo"; +import { UserContext } from "../user-context"; +import { StepOrgInfo } from "./StepOrgInfo"; +import { StepPersonalize } from "./StepPersonalize"; +import { useUpdateCurrentUserMutation } from "../data/current-user/update-mutation"; +import Alert from "../components/Alert"; +// This param is optionally present to force an onboarding flow +// Can be used if other conditions aren't true, i.e. if user has already onboarded, but we want to force the flow again +export const FORCE_ONBOARDING_PARAM = "onboarding"; +export const FORCE_ONBOARDING_PARAM_VALUE = "force"; + +const STEPS = { + ONE: "one", + TWO: "two", + THREE: "three", +}; type Props = { user: User; }; const UserOnboarding: FunctionComponent = ({ user }) => { - // Placeholder UI to start stubbing out new flow + const history = useHistory(); + const location = useLocation(); + const { setUser } = useContext(UserContext); + const updateUser = useUpdateCurrentUserMutation(); + + const [step, setStep] = useState(STEPS.ONE); + const [completingError, setCompletingError] = useState(""); + + // We track this state here so we can persist it at the end of the flow instead of when it's selected + // This is because setting the ide is how we indicate a user has onboarded, and want to defer that until the end + // even though we may ask for it earlier in the flow. The tradeoff is a potential error state at the end of the flow when updating the IDE + const [ideOptions, setIDEOptions] = useState({ ide: "code", useLatest: false }); + + // TODO: This logic can be simplified in the future if we put existing users through onboarding and track the onboarded timestamp + // When onboarding is complete (last step finished), we do the following + // * Update the user's IDE selection (current logic relies on this for considering a user onboarded, so we wait until the end) + // * Set an user's onboarded timestamp + // * Update the `user` context w/ the latest user, which will close out this onboarding flow + const onboardingComplete = useCallback( + async (updatedUser: User) => { + try { + const additionalData = updatedUser.additionalData || {}; + const profile = additionalData.profile || {}; + const ideSettings = additionalData.ideSettings || {}; + + const updates = { + additionalData: { + ...additionalData, + profile: { + ...profile, + onboardedTimestamp: new Date().toISOString(), + }, + ideSettings: { + ...ideSettings, + settingVersion: "2.0", + defaultIde: ideOptions.ide, + useLatestVersion: ideOptions.useLatest, + }, + }, + }; + + try { + const onboardedUser = await updateUser.mutateAsync(updates); + setUser(onboardedUser); + + // Look for the `onboarding=force` query param, and remove if present + const queryParams = new URLSearchParams(location.search); + if (queryParams.get(FORCE_ONBOARDING_PARAM) === FORCE_ONBOARDING_PARAM_VALUE) { + queryParams.delete(FORCE_ONBOARDING_PARAM); + history.replace({ + pathname: location.pathname, + search: queryParams.toString(), + hash: location.hash, + }); + } + } catch (e) { + console.error(e); + setCompletingError("There was a problem completing your onboarding"); + } + } catch (e) { + console.error(e); + } + }, + [ + history, + ideOptions.ide, + ideOptions.useLatest, + location.hash, + location.pathname, + location.search, + setUser, + updateUser, + ], + ); + return (
-

Welcome

+
+
+ Gitpod's logo +
+ +
+ {step === STEPS.ONE && ( + { + setUser(updatedUser); + setStep(STEPS.TWO); + }} + /> + )} + {step === STEPS.TWO && ( + { + setIDEOptions({ ide, useLatest }); + setStep(STEPS.THREE); + }} + /> + )} + {step === STEPS.THREE && } -

Help us get to know you a bit better

+ {!!completingError && {completingError}} +
+
); }; diff --git a/components/dashboard/src/onboarding/exploration-reasons.ts b/components/dashboard/src/onboarding/exploration-reasons.ts new file mode 100644 index 00000000000000..ed315edcf8c19d --- /dev/null +++ b/components/dashboard/src/onboarding/exploration-reasons.ts @@ -0,0 +1,16 @@ +/** + * 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. + */ + +export const getExplorationReasons = () => { + return [ + { value: "explore-professional", label: "For work" }, + { value: "explore-personal", label: "For personal projects, or open-source" }, + { + value: "replace-remote-dev", + label: "To replace remote/containerized development (VDI, VM based, Docker Desktop,...)", + }, + ]; +}; diff --git a/components/dashboard/src/onboarding/job-roles.ts b/components/dashboard/src/onboarding/job-roles.ts new file mode 100644 index 00000000000000..d3378b3494b5cc --- /dev/null +++ b/components/dashboard/src/onboarding/job-roles.ts @@ -0,0 +1,21 @@ +/** + * 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. + */ + +export const JOB_ROLE_OTHER = "other"; + +export const getJobRoleOptions = () => { + return [ + { value: "", label: "Please select one" }, + { value: "software-eng", label: "Software Engineer" }, + { value: "data", label: "Data / Analytics" }, + { value: "academics", label: "Academic (Student, Researcher)" }, + { value: "enabling", label: "Enabling team (Platform, Developer Experience)" }, + { value: "team-lead", label: "Team / Function Lead" }, + { value: "devrel", label: "DevRel" }, + { value: "product-design", label: "Product (PM, Designer)" }, + { value: JOB_ROLE_OTHER, label: "Other - please specify / prefer not to say" }, + ]; +}; diff --git a/components/dashboard/src/onboarding/signup-goals.ts b/components/dashboard/src/onboarding/signup-goals.ts new file mode 100644 index 00000000000000..2904ec5f35f5d5 --- /dev/null +++ b/components/dashboard/src/onboarding/signup-goals.ts @@ -0,0 +1,17 @@ +/** + * 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. + */ + +export const SIGNUP_GOALS_OTHER = "other"; + +export const getSignupGoalsOptions = () => { + return [ + { value: "efficiency-collab", label: "Dev efficiency & collaboration" }, + { value: "onboarding", label: "Faster onboarding" }, + { value: "powerful-resources", label: "More powerful dev resources" }, + { value: "security", label: "More secure dev process" }, + { value: SIGNUP_GOALS_OTHER, label: "Other - please specify / prefer not to say" }, + ]; +}; diff --git a/components/dashboard/src/user-settings/Preferences.tsx b/components/dashboard/src/user-settings/Preferences.tsx index defa8dca7be2b5..3d208466d2979a 100644 --- a/components/dashboard/src/user-settings/Preferences.tsx +++ b/components/dashboard/src/user-settings/Preferences.tsx @@ -5,33 +5,15 @@ */ import { useContext, useState } from "react"; -import SelectableCardSolid from "../components/SelectableCardSolid"; import { getGitpodService } from "../service/service"; -import { ThemeContext } from "../theme-context"; import { UserContext } from "../user-context"; import { trackEvent } from "../Analytics"; import SelectIDE from "./SelectIDE"; import { PageWithSettingsSubMenu } from "./PageWithSettingsSubMenu"; - -type Theme = "light" | "dark" | "system"; +import { ThemeSelector } from "../components/ThemeSelector"; export default function Preferences() { const { user } = useContext(UserContext); - const { setIsDark } = useContext(ThemeContext); - - const [theme, setTheme] = useState(localStorage.theme || "system"); - const actuallySetTheme = (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); - }; const [dotfileRepo, setDotfileRepo] = useState(user?.additionalData?.dotfileRepo || ""); const actuallySetDotfileRepo = async (value: string) => { @@ -63,60 +45,8 @@ export default function Preferences() {

-

Theme

-

Early bird or night owl? Choose your side.

-
- actuallySetTheme("light")} - > -
- - - -
-
- actuallySetTheme("dark")} - > -
- - - -
-
- actuallySetTheme("system")} - > -
- - - - - -
-
-
+ +

Dotfiles

Customize workspaces using dotfiles.

diff --git a/components/dashboard/src/workspaces/Workspaces.tsx b/components/dashboard/src/workspaces/Workspaces.tsx index 91eda460d65c08..83d46aaa19f418 100644 --- a/components/dashboard/src/workspaces/Workspaces.tsx +++ b/components/dashboard/src/workspaces/Workspaces.tsx @@ -96,8 +96,10 @@ const WorkspacesPage: FunctionComponent = () => { /> )} + {/* TODO: can remove this once newSignupFlow flag is enabled */} {isOnboardingUser && !newSignupFlow && } + {/* TODO: can remove this once newSignupFlow flag is enabled */} {!isOnboardingUser && !newSignupFlow && } {!isLoading && diff --git a/components/dashboard/tsconfig.json b/components/dashboard/tsconfig.json index 583b996eae568e..b413f8e11d1ea0 100644 --- a/components/dashboard/tsconfig.json +++ b/components/dashboard/tsconfig.json @@ -13,7 +13,7 @@ "strict": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, - "noUnusedLocals": true, + "noUnusedLocals": false, "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, diff --git a/components/gitpod-protocol/src/protocol.ts b/components/gitpod-protocol/src/protocol.ts index 6d866419dc0aab..665b018fb9c886 100644 --- a/components/gitpod-protocol/src/protocol.ts +++ b/components/gitpod-protocol/src/protocol.ts @@ -153,12 +153,21 @@ export namespace User { user.additionalData.ideSettings = newIDESettings; } + // TODO: make it more explicit that these field names are relied for our tracking purposes + // and decouple frontend from relying on them - instead use user.additionalData.profile object directly in FE export function getProfile(user: User): Profile { return { name: User.getName(user!) || "", email: User.getPrimaryEmail(user!) || "", company: user?.additionalData?.profile?.companyName, avatarURL: user?.avatarUrl, + companyWebsite: user?.additionalData?.profile?.companyName, + jobRole: user?.additionalData?.profile?.jobRole, + jobRoleOther: user?.additionalData?.profile?.jobRoleOther, + explorationReasons: user?.additionalData?.profile?.explorationReasons, + signupGoals: user?.additionalData?.profile?.signupGoals, + signupGoalsOther: user?.additionalData?.profile?.signupGoalsOther, + onboardedTimestamp: user?.additionalData?.profile?.onboardedTimestamp, }; } @@ -190,12 +199,21 @@ export namespace User { return AttributionId.create(user); } + // TODO: refactor where this is referenced so it's more clearly tied to just analytics-tracking + // Let other places rely on the ProfileDetails type since that's what we store // The actual Profile of a User export interface Profile { name: string; email: string; company?: string; avatarURL?: string; + companyWebsite?: string; + jobRole?: string; + jobRoleOther?: string; + explorationReasons?: string[]; + signupGoals?: string[]; + signupGoalsOther?: string; + onboardedTimestamp?: string; } export namespace Profile { export function hasChanges(before: Profile, after: Profile) { @@ -203,7 +221,13 @@ export namespace User { before.name !== after.name || before.email !== after.email || before.company !== after.company || - before.avatarURL !== after.avatarURL + before.avatarURL !== after.avatarURL || + before.companyWebsite !== after.companyWebsite || + before.jobRole !== after.jobRole || + before.jobRoleOther !== after.jobRoleOther || + // not checking explorationReasons or signupGoals atm as it's an array - need to check deep equality + before.signupGoalsOther !== after.signupGoalsOther || + before.onboardedTimestamp !== after.onboardedTimestamp ); } } @@ -260,10 +284,14 @@ export interface ProfileDetails { jobRole?: string; // freeform entry for job role user works in (when jobRole is "other") jobRoleOther?: string; + // Reasons user is exploring Gitpod when they signed up + explorationReasons?: string[]; // what user hopes to accomplish when they signed up - signupGoals?: string; + signupGoals?: string[]; // freeform entry for signup goals (when signupGoals is "other") signupGoalsOther?: string; + // Set after a user completes the onboarding flow + onboardedTimestamp?: string; } export interface EmailNotificationSettings {