Skip to content

Commit

Permalink
Avatar feature (#782)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
dpgraham4401 authored Aug 31, 2024
1 parent c05e0d0 commit b14030b
Show file tree
Hide file tree
Showing 43 changed files with 764 additions and 206 deletions.
Original file line number Diff line number Diff line change
@@ -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());
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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`, () => {
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 4 additions & 3 deletions client/app/components/Org/UserOrg.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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 = {
Expand All @@ -30,7 +31,7 @@ const mockProfileWithNonIntegratedOrg: ProfileSlice = {
id: uuidv4(),
},
sites: {},
user: 'testuser1',
user: createMockHaztrakUser(),
};

describe('UserOrg Component', () => {
Expand Down
9 changes: 4 additions & 5 deletions client/app/components/User/UserInfoForm.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,17 @@ describe('UserProfile', () => {
const myUsername = 'myUsername';
const user: HaztrakUser = createMockHaztrakUser({ username: myUsername, firstName: 'David' });
const profile: ProfileSlice = {
user: myUsername,
user,
};
renderWithProviders(<UserInfoForm user={user} profile={profile} />, {});
expect(screen.getByRole('textbox', { name: 'First Name' })).toHaveValue(user.firstName);
expect(screen.getByText(user.username)).toBeInTheDocument();
const { container } = renderWithProviders(<UserInfoForm user={user} profile={profile} />, {});
expect(container).toBeInTheDocument();
});
test('update profile fields', async () => {
// Arrange
const newEmail = '[email protected]';
const user: HaztrakUser = createMockHaztrakUser({ email: '[email protected]' });
const profile: ProfileSlice = {
user: 'test',
user,
};
renderWithProviders(<UserInfoForm user={user} profile={profile} />, {});
const editButton = screen.getByRole('button', { name: 'Edit' });
Expand Down
33 changes: 2 additions & 31 deletions client/app/components/User/UserInfoForm.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -24,7 +23,6 @@ type HaztrakUserForm = z.infer<typeof haztrakUserForm>;
export function UserInfoForm({ user }: UserProfileProps) {
const [editable, setEditable] = useState(false);
const [updateUser] = useUpdateUserMutation();
const fileRef = createRef<HTMLInputElement>();

const {
register,
Expand All @@ -42,33 +40,6 @@ export function UserInfoForm({ user }: UserProfileProps) {

return (
<HtForm onSubmit={handleSubmit(onSubmit)}>
<Row className="p-2">
<input
type="file"
accept="image/png,image/jpeg"
aria-label="Profile Picture"
ref={fileRef}
style={{ display: 'none' }}
/>
<Row>
<Col>
<div className="avatar-container d-flex justify-content-center">
<button onClick={() => fileRef.current?.click()}>
<Avatar className="tw-h-40 tw-w-40">
{/* ToDo: pipe dream, we currently do not use avatar iamges */}
<AvatarImage src="" alt="avatar" />
<AvatarFallback>
<FaUser size={80} />
</AvatarFallback>
</Avatar>
</button>
</div>
<div className="d-flex justify-content-center">
<h2>{user.username}</h2>
</div>
</Col>
</Row>
</Row>
<Row className="mb-2">
<Col>
<HtForm.Group>
Expand Down
103 changes: 103 additions & 0 deletions client/app/components/ui/Form/form.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(
<FormField
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<input placeholder="shadcn" {...field} />
</FormControl>
<FormDescription>This is your public display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);

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(
<FormField
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<input placeholder="shadcn" {...field} />
</FormControl>
<FormDescription>This is your public display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>,
{ ...errorProps }
);

expect(screen.getByText('Username is required')).toBeInTheDocument();
});

it('does not render FormMessage when there is no error', () => {
renderWithProviders(
<FormField
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<input placeholder="shadcn" {...field} />
</FormControl>
<FormDescription>This is your public display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
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(
<FormField
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<input placeholder="shadcn" {...field} />
</FormControl>
<FormDescription>This is your public display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>,
{ ...errorProps }
);

expect(screen.getByPlaceholderText('shadcn')).toHaveAttribute('aria-invalid', 'true');
});
});
Loading

0 comments on commit b14030b

Please sign in to comment.