feat: onboarding & profile edition (#507)

* feat: wip onboarding

* fix: generate graphql front

* wip: onboarding

* feat: login/register and edit profile

* fix: unused import

* fix: test

* Use DEBUG_MODE instead of STAGE and mute typescript depth exceed errors

* Fix seeds

* Fix onboarding when coming from google

* Fix

* Fix lint

* Fix ci

* Fix tests

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Jérémy M
2023-07-07 02:05:15 +02:00
committed by GitHub
parent 0b7a023f3d
commit 1144bd13ed
141 changed files with 2660 additions and 962 deletions

View File

@ -2,8 +2,8 @@ import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import { AnimatePresence, LayoutGroup } from 'framer-motion';
import { useTrackPageView } from '@/analytics/hooks/useTrackPageView';
import { RequireAuth } from '@/auth/components/RequireAuth';
import { RequireNotAuth } from '@/auth/components/RequireNotAuth';
import { RequireOnboarded } from '@/auth/components/RequireOnboarded';
import { RequireOnboarding } from '@/auth/components/RequireOnboarding';
import { AuthModal } from '@/auth/components/ui/Modal';
import { useGoToHotkeys } from '@/hotkeys/hooks/useGoToHotkeys';
import { AuthLayout } from '@/ui/layout/AuthLayout';
@ -52,10 +52,20 @@ export function App() {
return (
<DefaultLayout>
<Routes>
<Route
path="auth/*"
element={
<RequireOnboarding>
<AuthLayout>
<AuthRoutes />
</AuthLayout>
</RequireOnboarding>
}
/>
<Route
path="*"
element={
<RequireAuth>
<RequireOnboarded>
<Routes>
<Route path="" element={<Navigate to="/people" replace />} />
<Route path="people" element={<People />} />
@ -70,17 +80,7 @@ export function App() {
}
/>
</Routes>
</RequireAuth>
}
/>
<Route
path="auth/*"
element={
<RequireNotAuth>
<AuthLayout>
<AuthRoutes />
</AuthLayout>
</RequireNotAuth>
</RequireOnboarded>
}
/>
</Routes>

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,16 @@
import { useEffect, useMemo, useRef } from 'react';
import { InMemoryCache, NormalizedCacheObject } from '@apollo/client';
import {
ApolloLink,
InMemoryCache,
NormalizedCacheObject,
} from '@apollo/client';
import { useRecoilState } from 'recoil';
import { isMockModeState } from '@/auth/states/isMockModeState';
import { tokenPairState } from '@/auth/states/tokenPairState';
import { CommentThreadTarget } from '~/generated/graphql';
import { mockedCompaniesData } from '~/testing/mock-data/companies';
import { mockedUsersData } from '~/testing/mock-data/users';
import { ApolloFactory } from '../services/apollo.factory';
@ -11,8 +18,22 @@ export function useApolloFactory() {
const apolloRef = useRef<ApolloFactory<NormalizedCacheObject> | null>(null);
const [tokenPair, setTokenPair] = useRecoilState(tokenPairState);
const [isMockMode] = useRecoilState(isMockModeState);
const apolloClient = useMemo(() => {
const mockLink = new ApolloLink((operation, forward) => {
return forward(operation).map((response) => {
if (operation.operationName === 'GetCompanies') {
return { data: { companies: mockedCompaniesData } };
}
if (operation.operationName === 'GetCurrentUser') {
return { data: { currentUser: mockedUsersData[0] } };
}
return response;
});
});
apolloRef.current = new ApolloFactory({
uri: `${process.env.REACT_APP_API_URL}`,
cache: new InMemoryCache({
@ -42,10 +63,11 @@ export function useApolloFactory() {
onUnauthenticatedError() {
setTokenPair(null);
},
extraLinks: isMockMode ? [mockLink] : [],
});
return apolloRef.current.getClient();
}, [setTokenPair]);
}, [isMockMode, setTokenPair]);
useEffect(() => {
if (apolloRef.current) {

View File

@ -1,44 +0,0 @@
import { useMemo } from 'react';
import {
ApolloClient,
ApolloLink,
createHttpLink,
from,
InMemoryCache,
} from '@apollo/client';
import { mockedCompaniesData } from '~/testing/mock-data/companies';
import { mockedUsersData } from '~/testing/mock-data/users';
export default function useApolloMocked() {
const mockedClient = useMemo(() => {
const apiLink = createHttpLink({
uri: `${process.env.REACT_APP_API_URL}`,
});
const mockLink = new ApolloLink((operation, forward) => {
return forward(operation).map((response) => {
if (operation.operationName === 'GetCompanies') {
return { data: { companies: mockedCompaniesData } };
}
if (operation.operationName === 'GetCurrentUser') {
return { data: { users: [mockedUsersData[0]] } };
}
return response;
});
});
return new ApolloClient({
link: from([mockLink, apiLink]),
cache: new InMemoryCache(),
defaultOptions: {
query: {
fetchPolicy: 'cache-first',
},
},
});
}, []);
return mockedClient;
}

View File

@ -11,31 +11,24 @@ import { GraphQLErrors } from '@apollo/client/errors';
import { setContext } from '@apollo/client/link/context';
import { onError } from '@apollo/client/link/error';
import { RetryLink } from '@apollo/client/link/retry';
import { Observable } from '@apollo/client/utilities';
import { renewToken } from '@/auth/services/AuthService';
import { AuthTokenPair } from '~/generated/graphql';
import { loggerLink } from '../../utils/apollo-logger';
import { assertNotNull } from '../../utils/assert';
import { promiseToObservable } from '../../utils/promise-to-observable';
import { ApolloManager } from '../interfaces/apolloManager.interface';
const logger = loggerLink(() => 'Twenty');
let isRefreshing = false;
let pendingRequests: (() => void)[] = [];
const resolvePendingRequests = () => {
pendingRequests.map((callback) => callback());
pendingRequests = [];
};
export interface Options<TCacheShape> extends ApolloClientOptions<TCacheShape> {
onError?: (err: GraphQLErrors | undefined) => void;
onNetworkError?: (err: Error | ServerParseError | ServerError) => void;
onTokenPairChange?: (tokenPair: AuthTokenPair) => void;
onUnauthenticatedError?: () => void;
extraLinks?: ApolloLink[];
}
export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
@ -49,6 +42,7 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
onNetworkError,
onTokenPairChange,
onUnauthenticatedError,
extraLinks,
...options
} = opts;
@ -86,38 +80,22 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
for (const graphQLError of graphQLErrors) {
switch (graphQLError?.extensions?.code) {
case 'UNAUTHENTICATED': {
// error code is set to UNAUTHENTICATED
// when AuthenticationError thrown in resolver
let forward$: Observable<boolean>;
if (!isRefreshing) {
isRefreshing = true;
forward$ = promiseToObservable(
renewToken(uri, this.tokenPair)
.then((tokens) => {
onTokenPairChange?.(tokens);
resolvePendingRequests();
return true;
})
.catch(() => {
pendingRequests = [];
onUnauthenticatedError?.();
return false;
})
.finally(() => {
isRefreshing = false;
}),
).filter((value) => Boolean(value));
} else {
// Will only emit once the Promise is resolved
forward$ = promiseToObservable(
new Promise<boolean>((resolve) => {
pendingRequests.push(() => resolve(true));
}),
);
renewToken(uri, this.tokenPair)
.then((tokens) => {
onTokenPairChange?.(tokens);
return true;
})
.catch(() => {
onUnauthenticatedError?.();
return false;
})
.finally(() => {
isRefreshing = false;
});
}
return forward$.flatMap(() => forward(operation));
return forward(operation);
}
default:
if (process.env.NODE_ENV === 'development') {
@ -148,6 +126,7 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
[
errorLink,
authLink,
...(extraLinks ? extraLinks : []),
// Only show logger in dev mode
process.env.NODE_ENV !== 'production' ? logger : null,
retryLink,

View File

@ -1,56 +0,0 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { keyframes } from '@emotion/react';
import styled from '@emotion/styled';
import { useIsLogged } from '../hooks/useIsLogged';
const EmptyContainer = styled.div`
align-items: center;
display: flex;
height: 100%;
justify-content: center;
width: 100%;
`;
const fadeIn = keyframes`
from {
opacity: 0;
}
to {
opacity: 1;
}
`;
const FadeInStyle = styled.div`
animation: ${fadeIn} 1s forwards;
opacity: 0;
`;
export function RequireAuth({
children,
}: {
children: JSX.Element;
}): JSX.Element {
const navigate = useNavigate();
const isLogged = useIsLogged();
useEffect(() => {
if (!isLogged) {
navigate('/auth');
}
}, [isLogged, navigate]);
if (!isLogged) {
return (
<EmptyContainer>
<FadeInStyle>
Please hold on a moment, we're directing you to our login page...
</FadeInStyle>
</EmptyContainer>
);
}
return children;
}

View File

@ -0,0 +1,83 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { keyframes } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { captureHotkeyTypeInFocusState } from '@/hotkeys/states/captureHotkeyTypeInFocusState';
import { useOnboardingStatus } from '../hooks/useOnboardingStatus';
import { OnboardingStatus } from '../utils/getOnboardingStatus';
const EmptyContainer = styled.div`
align-items: center;
display: flex;
height: 100%;
justify-content: center;
width: 100%;
`;
const fadeIn = keyframes`
from {
opacity: 0;
}
to {
opacity: 1;
}
`;
const FadeInStyle = styled.div`
animation: ${fadeIn} 1s forwards;
opacity: 0;
`;
export function RequireOnboarded({
children,
}: {
children: JSX.Element;
}): JSX.Element {
const navigate = useNavigate();
const [, setCaptureHotkeyTypeInFocus] = useRecoilState(
captureHotkeyTypeInFocusState,
);
const onboardingStatus = useOnboardingStatus();
useEffect(() => {
if (onboardingStatus === OnboardingStatus.OngoingUserCreation) {
navigate('/auth');
} else if (onboardingStatus === OnboardingStatus.OngoingWorkspaceCreation) {
navigate('/auth/create/workspace');
} else if (onboardingStatus === OnboardingStatus.OngoingProfileCreation) {
navigate('/auth/create/profile');
}
}, [onboardingStatus, navigate]);
useEffect(() => {
if (onboardingStatus === OnboardingStatus.Completed) {
setCaptureHotkeyTypeInFocus(false);
}
}, [setCaptureHotkeyTypeInFocus, onboardingStatus]);
if (onboardingStatus !== OnboardingStatus.Completed) {
return (
<EmptyContainer>
<FadeInStyle>
{onboardingStatus === OnboardingStatus.OngoingUserCreation && (
<div>
Please hold on a moment, we're directing you to our login page...
</div>
)}
{onboardingStatus !== OnboardingStatus.OngoingUserCreation && (
<div>
Please hold on a moment, we're directing you to our onboarding
flow...
</div>
)}
</FadeInStyle>
</EmptyContainer>
);
}
return children;
}

View File

@ -3,7 +3,8 @@ import { useNavigate } from 'react-router-dom';
import { keyframes } from '@emotion/react';
import styled from '@emotion/styled';
import { useIsLogged } from '../hooks/useIsLogged';
import { useOnboardingStatus } from '../hooks/useOnboardingStatus';
import { OnboardingStatus } from '../utils/getOnboardingStatus';
const EmptyContainer = styled.div`
align-items: center;
@ -27,22 +28,22 @@ const FadeInStyle = styled.div`
opacity: 0;
`;
export function RequireNotAuth({
export function RequireOnboarding({
children,
}: {
children: JSX.Element;
}): JSX.Element {
const navigate = useNavigate();
const isLogged = useIsLogged();
const onboardingStatus = useOnboardingStatus();
useEffect(() => {
if (isLogged) {
if (onboardingStatus === OnboardingStatus.Completed) {
navigate('/');
}
}, [isLogged, navigate]);
}, [navigate, onboardingStatus]);
if (isLogged) {
if (onboardingStatus === OnboardingStatus.Completed) {
return (
<EmptyContainer>
<FadeInStyle>

View File

@ -1,13 +0,0 @@
import jwt from 'jwt-decode';
import { AuthTokenPair, useGetCurrentUserQuery } from '~/generated/graphql';
export function useFetchCurrentUser(tokenPair: AuthTokenPair | null) {
const userId = tokenPair?.accessToken.token
? jwt<{ sub: string }>(tokenPair.accessToken.token).sub
: null;
const { data } = useGetCurrentUserQuery({
variables: { uuid: userId },
});
return data?.users?.[0];
}

View File

@ -0,0 +1,20 @@
import { useMemo } from 'react';
import { useRecoilState } from 'recoil';
import { useIsLogged } from '../hooks/useIsLogged';
import { currentUserState } from '../states/currentUserState';
import {
getOnboardingStatus,
OnboardingStatus,
} from '../utils/getOnboardingStatus';
export function useOnboardingStatus(): OnboardingStatus {
const [currentUser] = useRecoilState(currentUserState);
const isLoggedIn = useIsLogged();
const onboardingStatus = useMemo(
() => getOnboardingStatus(isLoggedIn, currentUser),
[currentUser, isLoggedIn],
);
return onboardingStatus;
}

View File

@ -1,14 +1,8 @@
import { atom } from 'recoil';
import { User, Workspace, WorkspaceMember } from '~/generated/graphql';
import { GetCurrentUserQuery } from '~/generated/graphql';
type CurrentUser = Pick<User, 'id' | 'email' | 'displayName'> & {
workspaceMember?:
| (Pick<WorkspaceMember, 'id'> & {
workspace: Pick<Workspace, 'id' | 'displayName' | 'logo'>;
})
| null;
};
export type CurrentUser = GetCurrentUserQuery['currentUser'];
export const currentUserState = atom<CurrentUser | null>({
key: 'currentUserState',

View File

@ -0,0 +1,25 @@
import { CurrentUser } from '../states/currentUserState';
export enum OnboardingStatus {
OngoingUserCreation = 'ongoing_user_creation',
OngoingWorkspaceCreation = 'ongoing_workspace_creation',
OngoingProfileCreation = 'ongoing_profile_creation',
Completed = 'completed',
}
export function getOnboardingStatus(
isLoggedIn: boolean,
currentUser: CurrentUser | null,
) {
if (!isLoggedIn || !currentUser) {
return OnboardingStatus.OngoingUserCreation;
}
if (!currentUser.workspaceMember?.workspace.displayName) {
return OnboardingStatus.OngoingWorkspaceCreation;
}
if (!currentUser.firstName || !currentUser.lastName) {
return OnboardingStatus.OngoingProfileCreation;
}
return OnboardingStatus.Completed;
}

View File

@ -26,8 +26,6 @@ export function BoardActionBarButtonDeletePipelineProgress() {
},
});
console.log('boardItems', boardItems);
setBoardItems(
Object.fromEntries(
Object.entries(boardItems).filter(

View File

@ -1,4 +1,4 @@
import { debounce } from 'lodash';
import debounce from 'lodash.debounce';
import { EntityForSelect } from '@/relation-picker/types/EntityForSelect';
import { DropdownMenu } from '@/ui/components/menu/DropdownMenu';

View File

@ -1,4 +1,4 @@
import { debounce } from 'lodash';
import debounce from 'lodash.debounce';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';

View File

@ -12,7 +12,11 @@ import {
type SelectStringKeys<T> = NonNullable<
{
[K in keyof T]: T[K] extends string ? K : never;
[K in keyof T]: K extends '__typename'
? never
: T[K] extends string | undefined | null
? K
: never;
}[keyof T]
>;

View File

@ -1,13 +1,15 @@
import { gql } from '@apollo/client';
export * from './update';
export const GET_CURRENT_USER = gql`
query GetCurrentUser($uuid: String) {
users: findManyUser(where: { id: { equals: $uuid } }) {
query GetCurrentUser {
currentUser {
id
email
displayName
firstName
lastName
avatarUrl
workspaceMember {
id
workspace {

View File

@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const UPDATE_USER = gql`
mutation UpdateUser($data: UserUpdateInput!, $where: UserWhereUniqueInput!) {
updateUser(data: $data, where: $where) {
id
email
displayName
firstName
lastName
avatarUrl
}
}
`;

View File

@ -0,0 +1 @@
export * from './update';

View File

@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
export const UPDATE_WORKSPACE = gql`
mutation UpdateWorkspace($data: WorkspaceUpdateInput!) {
updateWorkspace(data: $data) {
id
domainName
displayName
logo
}
}
`;

View File

@ -1,14 +1,22 @@
import { useCallback } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useNavigate } from 'react-router-dom';
import { getOperationName } from '@apollo/client/utilities';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { SubTitle } from '@/auth/components/ui/SubTitle';
import { Title } from '@/auth/components/ui/Title';
import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus';
import { currentUserState } from '@/auth/states/currentUserState';
import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus';
import { captureHotkeyTypeInFocusState } from '@/hotkeys/states/captureHotkeyTypeInFocusState';
import { MainButton } from '@/ui/components/buttons/MainButton';
import { ImageInput } from '@/ui/components/inputs/ImageInput';
import { TextInput } from '@/ui/components/inputs/TextInput';
import { SubSectionTitle } from '@/ui/components/section-titles/SubSectionTitle';
import { GET_CURRENT_USER } from '@/users/services';
import { useUpdateUserMutation } from '~/generated/graphql';
const StyledContentContainer = styled.div`
width: 100%;
@ -37,10 +45,58 @@ const StyledComboInputContainer = styled.div`
export function CreateProfile() {
const navigate = useNavigate();
const onboardingStatus = useOnboardingStatus();
const [currentUser] = useRecoilState(currentUserState);
const [, setCaptureHotkeyTypeInFocus] = useRecoilState(
captureHotkeyTypeInFocusState,
);
const [updateUser] = useUpdateUserMutation();
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const handleCreate = useCallback(async () => {
navigate('/');
}, [navigate]);
try {
if (!currentUser?.id) {
throw new Error('User is not logged in');
}
const { data, errors } = await updateUser({
variables: {
where: {
id: currentUser?.id,
},
data: {
firstName: {
set: firstName,
},
lastName: {
set: lastName,
},
},
},
refetchQueries: [getOperationName(GET_CURRENT_USER) ?? ''],
});
if (errors || !data?.updateUser) {
throw errors;
}
setCaptureHotkeyTypeInFocus(false);
navigate('/');
} catch (error) {
console.error(error);
}
}, [
currentUser?.id,
firstName,
lastName,
navigate,
setCaptureHotkeyTypeInFocus,
updateUser,
]);
useHotkeys(
'enter',
@ -54,10 +110,20 @@ export function CreateProfile() {
[handleCreate],
);
useEffect(() => {
if (onboardingStatus !== OnboardingStatus.OngoingProfileCreation) {
navigate('/');
}
}, [onboardingStatus, navigate]);
useEffect(() => {
setCaptureHotkeyTypeInFocus(true);
}, [setCaptureHotkeyTypeInFocus]);
return (
<>
<Title>Create profile</Title>
<SubTitle>How you'll be identify on the app.</SubTitle>
<SubTitle>How you'll be identified on the app.</SubTitle>
<StyledContentContainer>
<StyledSectionContainer>
<SubSectionTitle title="Picture" />
@ -71,21 +137,28 @@ export function CreateProfile() {
<StyledComboInputContainer>
<TextInput
label="First Name"
value=""
value={firstName}
placeholder="Tim"
onChange={setFirstName}
fullWidth
/>
<TextInput
label="Last Name"
value=""
value={lastName}
placeholder="Cook"
onChange={setLastName}
fullWidth
/>
</StyledComboInputContainer>
</StyledSectionContainer>
</StyledContentContainer>
<StyledButtonContainer>
<MainButton title="Continue" onClick={handleCreate} fullWidth />
<MainButton
title="Continue"
onClick={handleCreate}
disabled={!firstName || !lastName}
fullWidth
/>
</StyledButtonContainer>
</>
);

View File

@ -1,14 +1,19 @@
import { useCallback } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useNavigate } from 'react-router-dom';
import { getOperationName } from '@apollo/client/utilities';
import styled from '@emotion/styled';
import { SubTitle } from '@/auth/components/ui/SubTitle';
import { Title } from '@/auth/components/ui/Title';
import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus';
import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus';
import { MainButton } from '@/ui/components/buttons/MainButton';
import { ImageInput } from '@/ui/components/inputs/ImageInput';
import { TextInput } from '@/ui/components/inputs/TextInput';
import { SubSectionTitle } from '@/ui/components/section-titles/SubSectionTitle';
import { GET_CURRENT_USER } from '@/users/services';
import { useUpdateWorkspaceMutation } from '~/generated/graphql';
const StyledContentContainer = styled.div`
width: 100%;
@ -29,10 +34,38 @@ const StyledButtonContainer = styled.div`
export function CreateWorkspace() {
const navigate = useNavigate();
const onboardingStatus = useOnboardingStatus();
const [workspaceName, setWorkspaceName] = useState('');
const [updateWorkspace] = useUpdateWorkspaceMutation();
const handleCreate = useCallback(async () => {
navigate('/auth/create/profile');
}, [navigate]);
try {
if (!workspaceName) {
throw new Error('Workspace name is required');
}
const { data, errors } = await updateWorkspace({
variables: {
data: {
displayName: {
set: workspaceName,
},
},
},
refetchQueries: [getOperationName(GET_CURRENT_USER) ?? ''],
});
if (errors || !data?.updateWorkspace) {
throw errors;
}
navigate('/auth/create/profile');
} catch (error) {
console.error(error);
}
}, [navigate, updateWorkspace, workspaceName]);
useHotkeys(
'enter',
@ -46,6 +79,12 @@ export function CreateWorkspace() {
[handleCreate],
);
useEffect(() => {
if (onboardingStatus !== OnboardingStatus.OngoingWorkspaceCreation) {
navigate('/auth/create/profile');
}
}, [onboardingStatus, navigate]);
return (
<>
<Title>Create your workspace</Title>
@ -63,11 +102,21 @@ export function CreateWorkspace() {
title="Workspace name"
description="The name of your organization"
/>
<TextInput value="" placeholder="Apple" fullWidth />
<TextInput
value={workspaceName}
placeholder="Apple"
onChange={setWorkspaceName}
fullWidth
/>
</StyledSectionContainer>
</StyledContentContainer>
<StyledButtonContainer>
<MainButton title="Continue" onClick={handleCreate} fullWidth />
<MainButton
title="Continue"
onClick={handleCreate}
disabled={!workspaceName}
fullWidth
/>
</StyledButtonContainer>
</>
);

View File

@ -1,5 +1,6 @@
import { useCallback, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useNavigate } from 'react-router-dom';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { useRecoilState } from 'recoil';
@ -10,7 +11,6 @@ import { Title } from '@/auth/components/ui/Title';
import { useAuth } from '@/auth/hooks/useAuth';
import { authFlowUserEmailState } from '@/auth/states/authFlowUserEmailState';
import { isMockModeState } from '@/auth/states/isMockModeState';
import { captureHotkeyTypeInFocusState } from '@/hotkeys/states/captureHotkeyTypeInFocusState';
import { MainButton } from '@/ui/components/buttons/MainButton';
import { TextInput } from '@/ui/components/inputs/TextInput';
import { SubSectionTitle } from '@/ui/components/section-titles/SubSectionTitle';
@ -47,17 +47,15 @@ const StyledErrorContainer = styled.div`
`;
export function PasswordLogin() {
const [, setMockMode] = useRecoilState(isMockModeState);
const [, setCaptureHotkeyTypeInFocus] = useRecoilState(
captureHotkeyTypeInFocusState,
);
const navigate = useNavigate();
const prefillPassword =
process.env.NODE_ENV === 'development' ? 'applecar2025' : '';
process.env.NODE_ENV === 'development' ? 'Applecar2025' : '';
const [authFlowUserEmail, setAuthFlowUserEmail] = useRecoilState(
authFlowUserEmailState,
);
const [, setMockMode] = useRecoilState(isMockModeState);
const [internalPassword, setInternalPassword] = useState(prefillPassword);
const [formError, setFormError] = useState('');
@ -65,21 +63,15 @@ export function PasswordLogin() {
const handleLogin = useCallback(async () => {
try {
await login(authFlowUserEmail, internalPassword);
setMockMode(false);
setCaptureHotkeyTypeInFocus(false);
// TODO: Navigate to the workspace selection page when it's ready
// navigate('/auth/create/workspace');
await login(authFlowUserEmail, internalPassword);
navigate('/auth/create/workspace');
} catch (err: any) {
setFormError(err.message);
}
}, [
authFlowUserEmail,
internalPassword,
login,
setMockMode,
setCaptureHotkeyTypeInFocus,
]);
}, [login, authFlowUserEmail, internalPassword, setMockMode, navigate]);
useHotkeys(
'enter',

View File

@ -23,7 +23,7 @@ export const FilterByName: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const filterButton = canvas.getByText('Filter');
const filterButton = await canvas.findByText('Filter');
await userEvent.click(filterButton);
const nameFilterButton = canvas
@ -60,7 +60,7 @@ export const FilterByAccountOwner: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const filterButton = canvas.getByText('Filter');
const filterButton = await canvas.findByText('Filter');
await userEvent.click(filterButton);
const accountOwnerFilterButton = (
@ -83,7 +83,6 @@ export const FilterByAccountOwner: Story = {
const charlesChip = canvas
.getAllByTestId('dropdown-menu-item')
.find((item) => {
console.log({ item });
return item.textContent?.includes('Charles Test');
});

View File

@ -21,7 +21,7 @@ export const SortByName: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const sortButton = canvas.getByText('Sort');
const sortButton = await canvas.findByText('Sort');
await userEvent.click(sortButton);
const nameSortButton = canvas.getByText('Name', { selector: 'li' });

View File

@ -23,7 +23,7 @@ export const Email: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const filterButton = canvas.getByText('Filter');
const filterButton = await canvas.findByText('Filter');
await userEvent.click(filterButton);
const emailFilterButton = canvas
@ -59,7 +59,7 @@ export const CompanyName: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const filterButton = canvas.getByText('Filter');
const filterButton = await canvas.findByText('Filter');
await userEvent.click(filterButton);
const companyFilterButton = canvas

View File

@ -22,7 +22,7 @@ export const Email: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const sortButton = canvas.getByText('Sort');
const sortButton = await canvas.findByText('Sort');
await userEvent.click(sortButton);
const emailSortButton = canvas.getByText('Email', { selector: 'li' });
@ -48,7 +48,7 @@ export const Cancel: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const sortButton = canvas.getByText('Sort');
const sortButton = await canvas.findByText('Sort');
await userEvent.click(sortButton);
const emailSortButton = canvas.getByText('Email', { selector: 'li' });

View File

@ -1,4 +1,7 @@
import { useEffect, useState } from 'react';
import { getOperationName } from '@apollo/client/utilities';
import styled from '@emotion/styled';
import debounce from 'lodash.debounce';
import { useRecoilValue } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState';
@ -7,6 +10,8 @@ import { TextInput } from '@/ui/components/inputs/TextInput';
import { MainSectionTitle } from '@/ui/components/section-titles/MainSectionTitle';
import { SubSectionTitle } from '@/ui/components/section-titles/SubSectionTitle';
import { NoTopBarContainer } from '@/ui/layout/containers/NoTopBarContainer';
import { GET_CURRENT_USER } from '@/users/services';
import { useUpdateUserMutation } from '~/generated/graphql';
const StyledContainer = styled.div`
display: flex;
@ -34,6 +39,57 @@ const StyledComboInputContainer = styled.div`
export function SettingsProfile() {
const currentUser = useRecoilValue(currentUserState);
const [firstName, setFirstName] = useState(currentUser?.firstName ?? '');
const [lastName, setLastName] = useState(currentUser?.lastName ?? '');
const [updateUser] = useUpdateUserMutation();
// TODO: Enhance this with react-hook-form (https://www.react-hook-form.com)
const debouncedUpdate = debounce(async () => {
try {
if (!currentUser?.id) {
throw new Error('User is not logged in');
}
const { data, errors } = await updateUser({
variables: {
where: {
id: currentUser?.id,
},
data: {
firstName: {
set: firstName,
},
lastName: {
set: lastName,
},
},
},
refetchQueries: [getOperationName(GET_CURRENT_USER) ?? ''],
});
if (errors || !data?.updateUser) {
throw errors;
}
} catch (error) {
console.error(error);
}
}, 500);
useEffect(() => {
if (
currentUser?.firstName !== firstName ||
currentUser?.lastName !== lastName
) {
debouncedUpdate();
}
return () => {
debouncedUpdate.cancel();
};
}, [firstName, lastName, currentUser, debouncedUpdate]);
return (
<NoTopBarContainer>
<StyledContainer>
@ -50,13 +106,15 @@ export function SettingsProfile() {
<StyledComboInputContainer>
<TextInput
label="First Name"
value={currentUser?.displayName}
value={firstName}
onChange={setFirstName}
placeholder="Tim"
fullWidth
/>
<TextInput
label="Last Name"
value=""
value={lastName}
onChange={setLastName}
placeholder="Cook"
fullWidth
/>

View File

@ -1,19 +1,11 @@
import { ApolloProvider as ApolloProviderBase } from '@apollo/client';
import { useRecoilState } from 'recoil';
import { useApolloFactory } from '@/apollo/hooks/useApolloFactory';
import useApolloMocked from '@/apollo/hooks/useApolloMocked';
import { isMockModeState } from '@/auth/states/isMockModeState';
export function ApolloProvider({ children }: React.PropsWithChildren) {
const apolloClient = useApolloFactory();
const mockedClient = useApolloMocked();
const [isMockMode] = useRecoilState(isMockModeState);
return (
<ApolloProviderBase client={isMockMode ? mockedClient : apolloClient}>
{children}
</ApolloProviderBase>
<ApolloProviderBase client={apolloClient}>{children}</ApolloProviderBase>
);
}

View File

@ -1,20 +1,18 @@
import { useEffect } from 'react';
import { useRecoilState } from 'recoil';
import { useFetchCurrentUser } from '@/auth/hooks/useFetchCurrentUser';
import { currentUserState } from '@/auth/states/currentUserState';
import { tokenPairState } from '@/auth/states/tokenPairState';
import { useGetCurrentUserQuery } from '~/generated/graphql';
export function UserProvider({ children }: React.PropsWithChildren) {
const [, setCurrentUser] = useRecoilState(currentUserState);
const [tokenPair] = useRecoilState(tokenPairState);
const user = useFetchCurrentUser(tokenPair);
const { data, loading } = useGetCurrentUserQuery();
useEffect(() => {
if (user) {
setCurrentUser(user);
if (data?.currentUser) {
setCurrentUser(data?.currentUser);
}
}, [setCurrentUser, user]);
}, [setCurrentUser, data]);
return <>{children}</>;
return loading ? <></> : <>{children}</>;
}

View File

@ -75,7 +75,7 @@ export const graphqlMocks = [
graphql.query(getOperationName(GET_CURRENT_USER) ?? '', (req, res, ctx) => {
return res(
ctx.data({
users: [mockedUsersData[0]],
currentUser: mockedUsersData[0],
}),
);
}),

View File

@ -20,7 +20,7 @@ type MockedUser = Pick<
export const mockedUsersData: Array<MockedUser> = [
{
id: '374fe3a5-df1e-4119-afe0-2a62a2ba481e',
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6d',
__typename: 'User',
email: 'charles@test.com',
displayName: 'Charles Test',