From b14030b4770e977ad5eab4001b5e9900ab49fef0 Mon Sep 17 00:00:00 2001 From: David Paul Graham <43794491+dpgraham4401@users.noreply.github.com> Date: Fri, 30 Aug 2024 23:23:58 -0400 Subject: [PATCH] Avatar feature (#782) * add type hints for model using foreign keys to settings.AUTH_USER_MODEL to use django.contrib.auth.models.User * update settings and urlconfig to serve media in development * add Pillow dependency, and add ImageField to Profile model * move profile serializers from package to module * add 'media/' to path prefix for traefik reverse proxy * add media directory to gitignore * add avatar image to profile serializer * apply user avatar to top navigation bar * add TrakUserSerializer to ProfileSerializer so the users details are inlcuded with the profile views * initial AvatarForm (no tests, saving work to stop) which will separte it from the UserForm and allow us to have the user upload photos * change expected type for ProfileSlice to inlcude use information * move avatar to separate component, outside the user info form * somehow I needed to add the eslint.config.js file back... IDK what's going on here * rename profile endpoint to my-profile endpoint, we will reimplement profile endpoint, however this is an easy way to retrieve the current logged in user's profile info * add profile model viewset * add Form to component library * add updateProfile endpoint to RTK query * avatar form uses the icon as a button and updates the state of the avatar on success request * avatar form submits the file on change instead of having to click a submit button * fix avatar form spec file --- .../GeneralInfo/ManifestStatusSelect.spec.tsx | 10 +- .../Handler/Search/HandlerSearchForm.spec.tsx | 8 +- client/app/components/Org/UserOrg.spec.tsx | 7 +- .../app/components/User/UserInfoForm.spec.tsx | 9 +- client/app/components/User/UserInfoForm.tsx | 33 +--- client/app/components/ui/Form/form.spec.tsx | 103 +++++++++++ client/app/components/ui/Form/form.tsx | 167 ++++++++++++++++++ client/app/components/ui/Form/label.spec.tsx | 22 +++ client/app/components/ui/Form/label.tsx | 19 ++ client/app/components/ui/Input/input.spec.tsx | 36 ++++ client/app/components/ui/Input/input.tsx | 24 +++ client/app/components/ui/index.ts | 15 ++ client/app/features/layout/TopNav/TopNav.tsx | 15 +- client/app/features/profile/Profile.tsx | 2 + .../profile/components/AvatarForm.spec.tsx | 11 ++ .../profile/components/AvatarForm.tsx | 64 +++++++ .../useUserSiteIds/useUserSiteIds.spec.tsx | 6 +- .../app/mocks/handlers/mockUserEndpoints.ts | 2 +- client/app/services/manifest/manifest.spec.ts | 5 +- client/app/store/index.ts | 1 + client/app/store/userApi/userApi.ts | 13 +- client/eslint.config.js | 2 - client/package-lock.json | 45 ++++- client/package.json | 5 +- compose.yaml | 2 +- server/.gitignore | 1 + server/haztrak/settings/base.py | 4 +- server/haztrak/urls.py | 5 + server/manifest/tests/test_views.py | 2 +- server/org/models.py | 12 +- server/org/services.py | 10 +- .../profile/migrations/0003_profile_avatar.py | 17 ++ server/profile/models.py | 18 +- .../rcrasite_access.py => serializers.py} | 91 +++++++--- server/profile/serializers/__init__.py | 3 - server/profile/serializers/profile.py | 18 -- .../profile/serializers/rcrainfo_profile.py | 57 ------ server/profile/services.py | 13 +- server/profile/tests/test_serializers.py | 2 +- server/profile/tests/test_views.py | 66 ++++++- server/profile/urls.py | 8 +- server/profile/views.py | 16 ++ server/requirements.txt | 1 + 43 files changed, 764 insertions(+), 206 deletions(-) create mode 100644 client/app/components/ui/Form/form.spec.tsx create mode 100644 client/app/components/ui/Form/form.tsx create mode 100644 client/app/components/ui/Form/label.spec.tsx create mode 100644 client/app/components/ui/Form/label.tsx create mode 100644 client/app/components/ui/Input/input.spec.tsx create mode 100644 client/app/components/ui/Input/input.tsx create mode 100644 client/app/features/profile/components/AvatarForm.spec.tsx create mode 100644 client/app/features/profile/components/AvatarForm.tsx create mode 100644 server/profile/migrations/0003_profile_avatar.py rename server/profile/{serializers/rcrasite_access.py => serializers.py} (68%) delete mode 100644 server/profile/serializers/__init__.py delete mode 100644 server/profile/serializers/profile.py delete mode 100644 server/profile/serializers/rcrainfo_profile.py diff --git a/client/app/components/Manifest/GeneralInfo/ManifestStatusSelect.spec.tsx b/client/app/components/Manifest/GeneralInfo/ManifestStatusSelect.spec.tsx index 163e0100..82b0e44f 100644 --- a/client/app/components/Manifest/GeneralInfo/ManifestStatusSelect.spec.tsx +++ b/client/app/components/Manifest/GeneralInfo/ManifestStatusSelect.spec.tsx @@ -1,14 +1,14 @@ import userEvent from '@testing-library/user-event'; -import { ManifestStatusSelect } from '~/components/Manifest/GeneralInfo/ManifestStatusSelect'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; +import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'; +import { ManifestStatusSelect } from '~/components/Manifest/GeneralInfo/ManifestStatusSelect'; import { cleanup, renderWithProviders, screen } from '~/mocks'; import { createMockHandler, createMockSite } from '~/mocks/fixtures'; import { createMockProfileResponse } from '~/mocks/fixtures/mockUser'; import { mockUserEndpoints } from '~/mocks/handlers'; import { API_BASE_URL } from '~/mocks/handlers/mockSiteEndpoints'; -import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'; const server = setupServer(...mockUserEndpoints); afterEach(() => cleanup()); @@ -58,7 +58,7 @@ describe('Manifest Status Field', () => { }), }); server.use( - http.get(`${API_BASE_URL}/api/profile`, () => { + http.get(`${API_BASE_URL}/api/my-profile`, () => { return HttpResponse.json( { ...createMockProfileResponse({ @@ -96,7 +96,7 @@ describe('Manifest Status Field', () => { }), }); server.use( - http.get(`${API_BASE_URL}/api/profile`, () => { + http.get(`${API_BASE_URL}/api/my-profile`, () => { return HttpResponse.json( { ...createMockProfileResponse({ @@ -130,7 +130,7 @@ describe('Manifest Status Field', () => { }), }); server.use( - http.get(`${API_BASE_URL}/api/profile`, () => { + http.get(`${API_BASE_URL}/api/my-profile`, () => { return HttpResponse.json( { ...createMockProfileResponse({ diff --git a/client/app/components/Manifest/Handler/Search/HandlerSearchForm.spec.tsx b/client/app/components/Manifest/Handler/Search/HandlerSearchForm.spec.tsx index 05c4044d..5928f810 100644 --- a/client/app/components/Manifest/Handler/Search/HandlerSearchForm.spec.tsx +++ b/client/app/components/Manifest/Handler/Search/HandlerSearchForm.spec.tsx @@ -1,11 +1,11 @@ import userEvent from '@testing-library/user-event'; -import { cleanup, renderWithProviders, screen } from '~/mocks'; -import { mockUserEndpoints } from '~/mocks/handlers'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'; +import { cleanup, renderWithProviders, screen } from '~/mocks'; import { createMockRcrainfoSite } from '~/mocks/fixtures'; +import { mockUserEndpoints } from '~/mocks/handlers'; import { API_BASE_URL } from '~/mocks/handlers/mockSiteEndpoints'; import { HaztrakProfileResponse } from '~/store/userApi/userApi'; import { HandlerSearchForm } from './HandlerSearchForm'; @@ -38,7 +38,7 @@ export const mockHandlerSearches = [ http.get(`${API_BASE_URL}/api/rcrainfo/rcrasite/search`, () => { return HttpResponse.json([mockRcrainfoSite1, mockRcrainfoSite2], { status: 200 }); }), - http.get(`${API_BASE_URL}/api/profile`, () => { + http.get(`${API_BASE_URL}/api/my-profile`, () => { return HttpResponse.json({ ...mockProfile }, { status: 200 }); }), http.post(`${API_BASE_URL}/api/rcrainfo/rcrasite/search`, () => { @@ -74,7 +74,7 @@ describe('HandlerSearchForm', () => { }); test('retrieves rcra sites from haztrak if org not rcrainfo integrated', async () => { server.use( - http.get(`${API_BASE_URL}/api/profile`, () => { + http.get(`${API_BASE_URL}/api/my-profile`, () => { return HttpResponse.json( { ...mockProfile, diff --git a/client/app/components/Org/UserOrg.spec.tsx b/client/app/components/Org/UserOrg.spec.tsx index 2d4cc489..ef5ef88f 100644 --- a/client/app/components/Org/UserOrg.spec.tsx +++ b/client/app/components/Org/UserOrg.spec.tsx @@ -4,6 +4,7 @@ import { ProfileSlice } from '~/store'; import { v4 as uuidv4 } from 'uuid'; import { describe, expect, it } from 'vitest'; import { renderWithProviders } from '~/mocks'; +import { createMockHaztrakUser } from '~/mocks/fixtures'; const mockProfileWithOrg: ProfileSlice = { org: { @@ -13,13 +14,13 @@ const mockProfileWithOrg: ProfileSlice = { id: uuidv4(), }, sites: {}, - user: 'testuser1', + user: createMockHaztrakUser(), }; const mockProfileWithoutOrg: ProfileSlice = { org: null, sites: {}, - user: 'testuser1', + user: createMockHaztrakUser(), }; const mockProfileWithNonIntegratedOrg: ProfileSlice = { @@ -30,7 +31,7 @@ const mockProfileWithNonIntegratedOrg: ProfileSlice = { id: uuidv4(), }, sites: {}, - user: 'testuser1', + user: createMockHaztrakUser(), }; describe('UserOrg Component', () => { diff --git a/client/app/components/User/UserInfoForm.spec.tsx b/client/app/components/User/UserInfoForm.spec.tsx index def5ca12..35813c04 100644 --- a/client/app/components/User/UserInfoForm.spec.tsx +++ b/client/app/components/User/UserInfoForm.spec.tsx @@ -25,18 +25,17 @@ describe('UserProfile', () => { const myUsername = 'myUsername'; const user: HaztrakUser = createMockHaztrakUser({ username: myUsername, firstName: 'David' }); const profile: ProfileSlice = { - user: myUsername, + user, }; - renderWithProviders(, {}); - expect(screen.getByRole('textbox', { name: 'First Name' })).toHaveValue(user.firstName); - expect(screen.getByText(user.username)).toBeInTheDocument(); + const { container } = renderWithProviders(, {}); + expect(container).toBeInTheDocument(); }); test('update profile fields', async () => { // Arrange const newEmail = 'newMockEmail@mail.com'; const user: HaztrakUser = createMockHaztrakUser({ email: 'oldEmail@gmail.com' }); const profile: ProfileSlice = { - user: 'test', + user, }; renderWithProviders(, {}); const editButton = screen.getByRole('button', { name: 'Edit' }); diff --git a/client/app/components/User/UserInfoForm.tsx b/client/app/components/User/UserInfoForm.tsx index 2e492770..b4ab1fb1 100644 --- a/client/app/components/User/UserInfoForm.tsx +++ b/client/app/components/User/UserInfoForm.tsx @@ -1,12 +1,11 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import React, { createRef, useState } from 'react'; +import React, { useState } from 'react'; import { Button, Col, Form, Row } from 'react-bootstrap'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { HtForm } from '~/components/legacyUi'; -import { Avatar, AvatarFallback, AvatarImage, Spinner } from '~/components/ui'; +import { Spinner } from '~/components/ui'; import { HaztrakUser, ProfileSlice, useUpdateUserMutation } from '~/store'; -import { FaUser } from 'react-icons/fa'; interface UserProfileProps { user: HaztrakUser; @@ -24,7 +23,6 @@ type HaztrakUserForm = z.infer; export function UserInfoForm({ user }: UserProfileProps) { const [editable, setEditable] = useState(false); const [updateUser] = useUpdateUserMutation(); - const fileRef = createRef(); const { register, @@ -42,33 +40,6 @@ export function UserInfoForm({ user }: UserProfileProps) { return ( - - - - -
- -
-
-

{user.username}

-
- -
-
diff --git a/client/app/components/ui/Form/form.spec.tsx b/client/app/components/ui/Form/form.spec.tsx new file mode 100644 index 00000000..748492fc --- /dev/null +++ b/client/app/components/ui/Form/form.spec.tsx @@ -0,0 +1,103 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { renderWithProviders } from '~/mocks'; +import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from './form'; + +describe('Form components', () => { + const errorProps = { + useFormProps: { + errors: { + username: { + type: 'manual', + message: 'Username is required', + }, + }, + }, + }; + it('renders FormField with all subcomponents', () => { + renderWithProviders( + ( + + Username + + + + This is your public display name. + + + )} + /> + ); + + expect(screen.getByText('Username')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('shadcn')).toBeInTheDocument(); + expect(screen.getByText('This is your public display name.')).toBeInTheDocument(); + }); + + it('displays error message in FormMessage when there is an error', () => { + // form.setError('username', { type: 'manual', message: 'Username is required' }); + + renderWithProviders( + ( + + Username + + + + This is your public display name. + + + )} + />, + { ...errorProps } + ); + + expect(screen.getByText('Username is required')).toBeInTheDocument(); + }); + + it('does not render FormMessage when there is no error', () => { + renderWithProviders( + ( + + Username + + + + This is your public display name. + + + )} + /> + ); + expect(screen.queryByText('Username is required')).not.toBeInTheDocument(); + }); + + it('sets aria-invalid attribute on FormControl when there is an error', () => { + // form.setError('username', { type: 'manual', message: 'Username is required' }); + + renderWithProviders( + ( + + Username + + + + This is your public display name. + + + )} + />, + { ...errorProps } + ); + + expect(screen.getByPlaceholderText('shadcn')).toHaveAttribute('aria-invalid', 'true'); + }); +}); diff --git a/client/app/components/ui/Form/form.tsx b/client/app/components/ui/Form/form.tsx new file mode 100644 index 00000000..7de71620 --- /dev/null +++ b/client/app/components/ui/Form/form.tsx @@ -0,0 +1,167 @@ +import * as React from 'react'; +import * as LabelPrimitive from '@radix-ui/react-label'; +import { Slot } from '@radix-ui/react-slot'; +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from 'react-hook-form'; + +import { cn } from '~/lib/utils'; +import { Label } from './label'; + +const Form = FormProvider; + +interface FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> { + name: TName; +} + +const FormFieldContext = React.createContext({} as FormFieldContextValue); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error('useFormField should be used within '); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +interface FormItemContextValue { + id: string; +} + +const FormItemContext = React.createContext({} as FormItemContextValue); + +const FormItem = React.forwardRef>( + ({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); + } +); +FormItem.displayName = 'FormItem'; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +