From a975935f494979c315af59ef8e813c2274f3e0ce Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Fri, 7 Jul 2023 17:50:02 -0700 Subject: [PATCH] Connect profile picture upload to backend (#533) * Connect profile picture upload to backend * Fix tests * Revert onboarding state changes --- .github/workflows/ci-chromatic.yaml | 2 + .github/workflows/ci-front.yaml | 2 + front/.env.example | 1 + front/package.json | 2 + front/src/generated/graphql.tsx | 44 + .../modules/apollo/services/apollo.factory.ts | 4 +- .../profile/components/EmailField.tsx | 17 + .../profile/components/NameFields.tsx | 87 + .../profile/components/PictureUploader.tsx | 25 + .../settings/profile/queries/index.tsx | 7 + .../ui/components/inputs/ImageInput.tsx | 32 +- front/src/pages/settings/SettingsProfile.tsx | 98 +- front/yarn.lock | 2407 +++++++++-------- infra/prod/front/Dockerfile | 1 + server/src/app.module.ts | 1 + .../file/resolvers/file-upload.resolver.ts | 19 +- .../core/file/services/file-upload.service.ts | 50 +- server/src/core/user/user.module.ts | 3 +- server/src/core/user/user.resolver.spec.ts | 5 + server/src/core/user/user.resolver.ts | 36 +- server/src/main.ts | 4 +- 21 files changed, 1522 insertions(+), 1325 deletions(-) create mode 100644 front/src/modules/settings/profile/components/EmailField.tsx create mode 100644 front/src/modules/settings/profile/components/NameFields.tsx create mode 100644 front/src/modules/settings/profile/components/PictureUploader.tsx create mode 100644 front/src/modules/settings/profile/queries/index.tsx diff --git a/.github/workflows/ci-chromatic.yaml b/.github/workflows/ci-chromatic.yaml index 084629a09..fec4c4491 100644 --- a/.github/workflows/ci-chromatic.yaml +++ b/.github/workflows/ci-chromatic.yaml @@ -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 diff --git a/.github/workflows/ci-front.yaml b/.github/workflows/ci-front.yaml index aa933a0a4..973027d6a 100644 --- a/.github/workflows/ci-front.yaml +++ b/.github/workflows/ci-front.yaml @@ -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 diff --git a/front/.env.example b/front/.env.example index 83471eece..330949348 100644 --- a/front/.env.example +++ b/front/.env.example @@ -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 \ No newline at end of file diff --git a/front/package.json b/front/package.json index afa1dc652..cc44cbc8f 100644 --- a/front/package.json +++ b/front/package.json @@ -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", diff --git a/front/src/generated/graphql.tsx b/front/src/generated/graphql.tsx index 2e22b453b..54e415872 100644 --- a/front/src/generated/graphql.tsx +++ b/front/src/generated/graphql.tsx @@ -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; export type SearchCompanyLazyQueryHookResult = ReturnType; export type SearchCompanyQueryResult = Apollo.QueryResult; +export const UploadProfilePictureDocument = gql` + mutation UploadProfilePicture($file: Upload!) { + uploadProfilePicture(file: $file) +} + `; +export type UploadProfilePictureMutationFn = Apollo.MutationFunction; + +/** + * __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) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UploadProfilePictureDocument, options); + } +export type UploadProfilePictureMutationHookResult = ReturnType; +export type UploadProfilePictureMutationResult = Apollo.MutationResult; +export type UploadProfilePictureMutationOptions = Apollo.BaseMutationOptions; export const GetCurrentUserDocument = gql` query GetCurrentUser { currentUser { diff --git a/front/src/modules/apollo/services/apollo.factory.ts b/front/src/modules/apollo/services/apollo.factory.ts index b1ef6c0ef..c4c76f653 100644 --- a/front/src/modules/apollo/services/apollo.factory.ts +++ b/front/src/modules/apollo/services/apollo.factory.ts @@ -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 implements ApolloManager { this.tokenPair = tokenPair; const buildApolloLink = (): ApolloLink => { - const httpLink = createHttpLink({ + const httpLink = createUploadLink({ uri, }); diff --git a/front/src/modules/settings/profile/components/EmailField.tsx b/front/src/modules/settings/profile/components/EmailField.tsx new file mode 100644 index 000000000..df189d3b7 --- /dev/null +++ b/front/src/modules/settings/profile/components/EmailField.tsx @@ -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 ( + + ); +} diff --git a/front/src/modules/settings/profile/components/NameFields.tsx b/front/src/modules/settings/profile/components/NameFields.tsx new file mode 100644 index 000000000..b84f03539 --- /dev/null +++ b/front/src/modules/settings/profile/components/NameFields.tsx @@ -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 ( + + + + + ); +} diff --git a/front/src/modules/settings/profile/components/PictureUploader.tsx b/front/src/modules/settings/profile/components/PictureUploader.tsx new file mode 100644 index 000000000..e83b73850 --- /dev/null +++ b/front/src/modules/settings/profile/components/PictureUploader.tsx @@ -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 ; +} diff --git a/front/src/modules/settings/profile/queries/index.tsx b/front/src/modules/settings/profile/queries/index.tsx new file mode 100644 index 000000000..573fb878c --- /dev/null +++ b/front/src/modules/settings/profile/queries/index.tsx @@ -0,0 +1,7 @@ +import { gql } from '@apollo/client'; + +export const UPDATE_PROFILE_PICTURE = gql` + mutation UploadProfilePicture($file: Upload!) { + uploadProfilePicture(file: $file) + } +`; diff --git a/front/src/modules/ui/components/inputs/ImageInput.tsx b/front/src/modules/ui/components/inputs/ImageInput.tsx index e5eb7d721..6935c8aa4 100644 --- a/front/src/modules/ui/components/inputs/ImageInput.tsx +++ b/front/src/modules/ui/components/inputs/ImageInput.tsx @@ -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, '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(null); + const onUploadButtonClick = () => { + hiddenFileInput.current?.click(); + }; return ( - + {picture ? ( + { + if (onUpload) { + if (event.target.files) { + onUpload(event.target.files[0]); + } + } + }} + />