Connect profile picture upload to backend (#533)

* Connect profile picture upload to backend

* Fix tests

* Revert onboarding state changes
This commit is contained in:
Charles Bochet 2023-07-07 17:50:02 -07:00 committed by GitHub
parent 6446692f25
commit a975935f49
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1522 additions and 1325 deletions

View File

@ -11,6 +11,7 @@ jobs:
env:
REACT_APP_API_URL: http://127.0.0.1:3000/graphql
REACT_APP_AUTH_URL: http://127.0.0.1:3000/auth
REACT_APP_FILES_URL: http://127.0.0.1:3000/files
steps:
- uses: actions/checkout@v3
if: github.event_name == 'push'
@ -32,6 +33,7 @@ jobs:
touch .env
echo "REACT_APP_API_URL: $REACT_APP_API_URL" >> .env
echo "REACT_APP_AUTH_URL: $REACT_APP_AUTH_URL" >> .env
echo "REACT_APP_FILES_URL: $REACT_APP_FILES_URL" >> .env
- name: Front / Install Dependencies
run: cd front && yarn
- name: Publish to Chromatic

View File

@ -10,6 +10,7 @@ jobs:
env:
REACT_APP_API_URL: http://127.0.0.1:3000/graphql
REACT_APP_AUTH_URL: http://127.0.0.1:3000/auth
REACT_APP_FILES_URL: http://127.0.0.1:3000/files
steps:
- uses: actions/checkout@v3
if: github.event_name == 'push'
@ -29,6 +30,7 @@ jobs:
touch .env
echo "REACT_APP_API_URL: $REACT_APP_API_URL" >> .env
echo "REACT_APP_AUTH_URL: $REACT_APP_AUTH_URL" >> .env
echo "REACT_APP_FILES_URL: $REACT_APP_FILES_URL" >> .env
- name: Front / Install Dependencies
run: cd front && yarn
- name: Front / Install Playwright

View File

@ -1,4 +1,5 @@
REACT_APP_API_URL=http://localhost:3000/graphql
REACT_APP_AUTH_URL=http://localhost:3000/auth
REACT_APP_FILES_URL=http://localhost:3000/files
CHROMATIC_PROJECT_TOKEN=REPLACE_ME

View File

@ -16,6 +16,7 @@
"@types/react-dom": "^18.0.9",
"@types/react-modal": "^3.16.0",
"apollo-link-rest": "^0.9.0",
"apollo-upload-client": "^17.0.0",
"cmdk": "^0.2.0",
"date-fns": "^2.30.0",
"framer-motion": "^10.12.17",
@ -114,6 +115,7 @@
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/apollo-upload-client": "^17.0.2",
"@types/jest": "^27.5.2",
"@types/js-cookie": "^3.0.3",
"@types/lodash.debounce": "^4.0.7",

View File

@ -1043,6 +1043,7 @@ export type Mutation = {
updateWorkspace: Workspace;
uploadFile: Scalars['String'];
uploadImage: Scalars['String'];
uploadProfilePicture: Scalars['String'];
verify: Verify;
};
@ -1156,6 +1157,11 @@ export type MutationUploadImageArgs = {
};
export type MutationUploadProfilePictureArgs = {
file: Scalars['Upload'];
};
export type MutationVerifyArgs = {
loginToken: Scalars['String'];
};
@ -3030,6 +3036,13 @@ export type SearchCompanyQueryVariables = Exact<{
export type SearchCompanyQuery = { __typename?: 'Query', searchResults: Array<{ __typename?: 'Company', id: string, name: string, domainName: string }> };
export type UploadProfilePictureMutationVariables = Exact<{
file: Scalars['Upload'];
}>;
export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProfilePicture: string };
export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
@ -4339,6 +4352,37 @@ export function useSearchCompanyLazyQuery(baseOptions?: Apollo.LazyQueryHookOpti
export type SearchCompanyQueryHookResult = ReturnType<typeof useSearchCompanyQuery>;
export type SearchCompanyLazyQueryHookResult = ReturnType<typeof useSearchCompanyLazyQuery>;
export type SearchCompanyQueryResult = Apollo.QueryResult<SearchCompanyQuery, SearchCompanyQueryVariables>;
export const UploadProfilePictureDocument = gql`
mutation UploadProfilePicture($file: Upload!) {
uploadProfilePicture(file: $file)
}
`;
export type UploadProfilePictureMutationFn = Apollo.MutationFunction<UploadProfilePictureMutation, UploadProfilePictureMutationVariables>;
/**
* __useUploadProfilePictureMutation__
*
* To run a mutation, you first call `useUploadProfilePictureMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useUploadProfilePictureMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [uploadProfilePictureMutation, { data, loading, error }] = useUploadProfilePictureMutation({
* variables: {
* file: // value for 'file'
* },
* });
*/
export function useUploadProfilePictureMutation(baseOptions?: Apollo.MutationHookOptions<UploadProfilePictureMutation, UploadProfilePictureMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UploadProfilePictureMutation, UploadProfilePictureMutationVariables>(UploadProfilePictureDocument, options);
}
export type UploadProfilePictureMutationHookResult = ReturnType<typeof useUploadProfilePictureMutation>;
export type UploadProfilePictureMutationResult = Apollo.MutationResult<UploadProfilePictureMutation>;
export type UploadProfilePictureMutationOptions = Apollo.BaseMutationOptions<UploadProfilePictureMutation, UploadProfilePictureMutationVariables>;
export const GetCurrentUserDocument = gql`
query GetCurrentUser {
currentUser {

View File

@ -3,7 +3,6 @@ import {
ApolloClient,
ApolloClientOptions,
ApolloLink,
createHttpLink,
ServerError,
ServerParseError,
} from '@apollo/client';
@ -11,6 +10,7 @@ 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 { createUploadLink } from 'apollo-upload-client';
import { renewToken } from '@/auth/services/AuthService';
import { AuthTokenPair } from '~/generated/graphql';
@ -53,7 +53,7 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
this.tokenPair = tokenPair;
const buildApolloLink = (): ApolloLink => {
const httpLink = createHttpLink({
const httpLink = createUploadLink({
uri,
});

View File

@ -0,0 +1,17 @@
import { useRecoilValue } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState';
import { TextInput } from '@/ui/components/inputs/TextInput';
export function EmailField() {
const currentUser = useRecoilValue(currentUserState);
return (
<TextInput
value={currentUser?.email}
disabled
fullWidth
key={'email-' + currentUser?.id}
/>
);
}

View File

@ -0,0 +1,87 @@
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';
import { TextInput } from '@/ui/components/inputs/TextInput';
import { GET_CURRENT_USER } from '@/users/services';
import { useUpdateUserMutation } from '~/generated/graphql';
const StyledComboInputContainer = styled.div`
display: flex;
flex-direction: row;
> * + * {
margin-left: ${({ theme }) => theme.spacing(4)};
}
`;
export function NameFields() {
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 {
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 (
<StyledComboInputContainer>
<TextInput
label="First Name"
value={firstName}
onChange={setFirstName}
placeholder="Tim"
fullWidth
/>
<TextInput
label="Last Name"
value={lastName}
onChange={setLastName}
placeholder="Cook"
fullWidth
/>
</StyledComboInputContainer>
);
}

View File

@ -0,0 +1,25 @@
import { getOperationName } from '@apollo/client/utilities';
import { useRecoilState } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState';
import { ImageInput } from '@/ui/components/inputs/ImageInput';
import { GET_CURRENT_USER } from '@/users/services';
import { useUploadProfilePictureMutation } from '~/generated/graphql';
export function PictureUploader() {
const [uploadPicture] = useUploadProfilePictureMutation();
const [currentUser] = useRecoilState(currentUserState);
async function onUpload(file: File) {
await uploadPicture({
variables: {
file,
},
refetchQueries: [getOperationName(GET_CURRENT_USER) ?? ''],
});
}
const pictureUrl = currentUser?.avatarUrl
? `${process.env.REACT_APP_FILES_URL}/${currentUser?.avatarUrl}`
: null;
return <ImageInput picture={pictureUrl} onUpload={onUpload} />;
}

View File

@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const UPDATE_PROFILE_PICTURE = gql`
mutation UploadProfilePicture($file: Upload!) {
uploadProfilePicture(file: $file)
}
`;

View File

@ -28,7 +28,6 @@ const Picture = styled.button<{ withPicture: boolean }>`
width: 66px;
img {
height: 100%;
object-fit: cover;
width: 100%;
}
@ -67,9 +66,13 @@ const Text = styled.span`
font-size: ${({ theme }) => theme.font.size.xs};
`;
const StyledHiddenFileInput = styled.input`
display: none;
`;
type Props = Omit<React.ComponentProps<'div'>, 'children'> & {
picture: string | null | undefined;
onUpload?: () => void;
onUpload?: (file: File) => void;
onRemove?: () => void;
disabled?: boolean;
};
@ -82,10 +85,18 @@ export function ImageInput({
...restProps
}: Props) {
const theme = useTheme();
const hiddenFileInput = React.useRef<HTMLInputElement>(null);
const onUploadButtonClick = () => {
hiddenFileInput.current?.click();
};
return (
<Container {...restProps}>
<Picture withPicture={!!picture} disabled={disabled} onClick={onUpload}>
<Picture
withPicture={!!picture}
disabled={disabled}
onClick={onUploadButtonClick}
>
{picture ? (
<img
src={picture || '/images/default-profile-picture.png'}
@ -97,9 +108,20 @@ export function ImageInput({
</Picture>
<Content>
<ButtonContainer>
<StyledHiddenFileInput
type="file"
ref={hiddenFileInput}
onChange={(event) => {
if (onUpload) {
if (event.target.files) {
onUpload(event.target.files[0]);
}
}
}}
/>
<Button
icon={<IconUpload size={theme.icon.size.sm} />}
onClick={onUpload}
onClick={onUploadButtonClick}
variant="secondary"
title="Upload"
disabled={disabled}
@ -110,7 +132,7 @@ export function ImageInput({
onClick={onRemove}
variant="secondary"
title="Remove"
disabled={!picture || disabled}
disabled
fullWidth
/>
</ButtonContainer>

View File

@ -1,17 +1,11 @@
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';
import { ImageInput } from '@/ui/components/inputs/ImageInput';
import { TextInput } from '@/ui/components/inputs/TextInput';
import { EmailField } from '@/settings/profile/components/EmailField';
import { NameFields } from '@/settings/profile/components/NameFields';
import { PictureUploader } from '@/settings/profile/components/PictureUploader';
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;
@ -29,108 +23,28 @@ const StyledSectionContainer = styled.div`
}
`;
const StyledComboInputContainer = styled.div`
display: flex;
flex-direction: row;
> * + * {
margin-left: ${({ theme }) => theme.spacing(4)};
}
`;
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>
<MainSectionTitle>Profile</MainSectionTitle>
<StyledSectionContainer>
<SubSectionTitle title="Picture" />
<ImageInput picture={null} disabled />
<PictureUploader />
</StyledSectionContainer>
<StyledSectionContainer>
<SubSectionTitle
title="Name"
description="Your name as it will be displayed"
/>
<StyledComboInputContainer>
<TextInput
label="First Name"
value={firstName}
onChange={setFirstName}
placeholder="Tim"
fullWidth
/>
<TextInput
label="Last Name"
value={lastName}
onChange={setLastName}
placeholder="Cook"
fullWidth
/>
</StyledComboInputContainer>
<NameFields />
</StyledSectionContainer>
<StyledSectionContainer>
<SubSectionTitle
title="Email"
description="The email associated to your account"
/>
<TextInput
value={currentUser?.email}
disabled
fullWidth
key={'email-' + currentUser?.id}
/>
<EmailField />
</StyledSectionContainer>
</StyledContainer>
</NoTopBarContainer>

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,7 @@ FROM node:18.16.0-alpine as front
ARG REACT_APP_API_URL
ARG REACT_APP_AUTH_URL
ARG REACT_APP_FILES_URL
COPY ./packages/ /app/packages

View File

@ -29,6 +29,7 @@ import GraphQLJSON from 'graphql-type-json';
error.extensions.stacktrace = undefined;
return error;
},
csrfPrevention: false,
}),
PrismaModule,
HealthModule,

View File

@ -1,6 +1,5 @@
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { GraphQLUpload, FileUpload } from 'graphql-upload';
import { v4 as uuidV4 } from 'uuid';
import { FileUploadService } from '../services/file-upload.service';
import { UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
@ -21,18 +20,15 @@ export class FileUploadResolver {
): Promise<string> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
const ext = filename.split('.')?.[1];
const id = uuidV4();
const name = `${id}${ext ? `.${ext}` : ''}`;
const path = await this.fileUploadService.uploadFile({
const { path } = await this.fileUploadService.uploadFile({
file: buffer,
name,
filename,
mimeType: mimetype,
fileFolder,
});
return path.name;
return path;
}
@Mutation(() => String)
@ -44,17 +40,14 @@ export class FileUploadResolver {
): Promise<string> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
const ext = filename.split('.')?.[1];
const id = uuidV4();
const name = `${id}${ext ? `.${ext}` : ''}`;
const path = await this.fileUploadService.uploadImage({
const { paths } = await this.fileUploadService.uploadImage({
file: buffer,
name,
filename,
mimeType: mimetype,
fileFolder,
});
return path.name;
return paths[0];
}
}

View File

@ -4,45 +4,73 @@ import { getCropSize } from 'src/utils/image';
import { settings } from 'src/constants/settings';
import { FileFolder } from '../interfaces/file-folder.interface';
import { FileStorageService } from 'src/integrations/file-storage/file-storage.service';
import { v4 as uuidV4 } from 'uuid';
@Injectable()
export class FileUploadService {
constructor(private readonly fileStorage: FileStorageService) {}
async uploadFile({
private async _uploadFile({
file,
name,
filename,
mimeType,
fileFolder,
}: {
file: Buffer | Uint8Array | string;
name: string;
filename: string;
mimeType: string | undefined;
fileFolder: FileFolder;
}) {
await this.fileStorage.write({
file,
name,
name: filename,
mimeType,
folder: fileFolder,
});
}
async uploadFile({
file,
filename,
mimeType,
fileFolder,
}: {
file: Buffer | Uint8Array | string;
filename: string;
mimeType: string | undefined;
fileFolder: FileFolder;
}) {
const ext = filename.split('.')?.[1];
const id = uuidV4();
const name = `${id}${ext ? `.${ext}` : ''}`;
await this._uploadFile({
file,
filename,
mimeType,
fileFolder,
});
return {
name: `/${name}`,
path: `${fileFolder}/${name}`,
};
}
async uploadImage({
file,
name,
filename,
mimeType,
fileFolder,
}: {
file: Buffer | Uint8Array | string;
name: string;
filename: string;
mimeType: string | undefined;
fileFolder: FileFolder;
}) {
const ext = filename.split('.')?.[1];
const id = uuidV4();
const name = `${id}${ext ? `.${ext}` : ''}`;
// Get all cropSizes for this fileFolder
const cropSizes = settings.storage.imageCropSizes[fileFolder];
// Extract the values from ShortCropSize
@ -56,14 +84,18 @@ export class FileUploadService {
),
);
const paths: Array<string> = [];
// Upload all images to corresponding folders
await Promise.all(
images.map(async (image, index) => {
const buffer = await image.toBuffer();
paths.push(`profile-picture/${cropSizes[index]}/${name}`);
return this.uploadFile({
file: buffer,
name: `${cropSizes[index]}/${name}`,
filename: `${cropSizes[index]}/${name}`,
mimeType,
fileFolder,
});
@ -71,7 +103,7 @@ export class FileUploadService {
);
return {
name: `/${name}`,
paths,
};
}
}

View File

@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserResolver } from './user.resolver';
import { FileModule } from '../file/file.module';
@Module({
imports: [],
imports: [FileModule],
providers: [UserService, UserResolver],
exports: [UserService],
})

View File

@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { UserResolver } from './user.resolver';
import { UserService } from './user.service';
import { AbilityFactory } from 'src/ability/ability.factory';
import { FileUploadService } from '../file/services/file-upload.service';
describe('UserResolver', () => {
let resolver: UserResolver;
@ -18,6 +19,10 @@ describe('UserResolver', () => {
provide: AbilityFactory,
useValue: {},
},
{
provide: FileUploadService,
useValue: {},
},
],
}).compile();

View File

@ -29,11 +29,18 @@ import { AuthUser } from 'src/decorators/auth-user.decorator';
import { assert } from 'src/utils/assert';
import { UpdateOneUserArgs } from '../@generated/user/update-one-user.args';
import { Prisma } from '@prisma/client';
import { FileUpload, GraphQLUpload } from 'graphql-upload';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
import { FileUploadService } from '../file/services/file-upload.service';
import { FileFolder } from '../file/interfaces/file-folder.interface';
@UseGuards(JwtAuthGuard)
@Resolver(() => User)
export class UserResolver {
constructor(private readonly userService: UserService) {}
constructor(
private readonly userService: UserService,
private readonly fileUploadService: FileUploadService,
) {}
@Query(() => User)
async currentUser(
@ -109,4 +116,31 @@ export class UserResolver {
displayName(@Parent() parent: User): string {
return `${parent.firstName ?? ''} ${parent.lastName ?? ''}`;
}
@Mutation(() => String)
async uploadProfilePicture(
@AuthUser() { id }: User,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename, mimetype }: FileUpload,
): Promise<string> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
const fileFolder = FileFolder.ProfilePicture;
const { paths } = await this.fileUploadService.uploadImage({
file: buffer,
filename,
mimeType: mimetype,
fileFolder,
});
await this.userService.update({
where: { id },
data: {
avatarUrl: paths[0],
},
});
return paths[0];
}
}

View File

@ -4,7 +4,9 @@ import { AppModule } from './app.module';
import { graphqlUploadExpress } from 'graphql-upload';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { cors: true });
const app = await NestFactory.create(AppModule, {
cors: true,
});
// Apply validation pipes globally
app.useGlobalPipes(new ValidationPipe());