Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions apps/backend/src/users/users.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { userSchemaDto } from './dtos/userSchema.dto';
import { Test, TestingModule } from '@nestjs/testing';
import { mock } from 'jest-mock-extended';
import { UpdateUserInfoDto } from './dtos/update-user-info.dto';
import { Pantry } from '../pantries/pantries.entity';
import { BadRequestException } from '@nestjs/common';
import { AuthenticatedRequest } from '../auth/authenticated-request';

Expand Down Expand Up @@ -48,13 +47,18 @@ describe('UsersController', () => {
expect(controller).toBeDefined();
});

describe('GET /my-id', () => {
it('should return the current user id', () => {
const req = { user: { id: 1 } } as AuthenticatedRequest;
describe('GET /me', () => {
it('should return the current user', async () => {
const req = {
user: {
id: 1,
},
} as AuthenticatedRequest;

const result = controller.getCurrentUserId(req);
mockUserService.findOne.mockResolvedValueOnce(mockUser1 as User);
const result = await controller.getCurrentUser(req);

expect(result).toBe(1);
expect(result).toEqual(mockUser1);
});
});

Expand Down
7 changes: 3 additions & 4 deletions apps/backend/src/users/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
Body,
Patch,
Req,
ValidationPipe,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { User } from './users.entity';
Expand All @@ -23,9 +22,9 @@ export class UsersController {
constructor(private usersService: UsersService) {}

@UseGuards(JwtAuthGuard)
@Get('/my-id')
getCurrentUserId(@Req() req: AuthenticatedRequest): number {
return req.user.id;
@Get('/me')
getCurrentUser(@Req() req: AuthenticatedRequest): Promise<User> {
return this.usersService.findOne(req.user.id);
}

@Get('/:id')
Expand Down
21 changes: 10 additions & 11 deletions apps/frontend/src/api/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
FoodRequestSummaryDto,
PantryWithUser,
Assignments,
UpdateProfileFields,
} from 'types/types';

const defaultBaseUrl =
Expand Down Expand Up @@ -153,10 +154,6 @@ export class ApiClient {
return this.axiosInstance.post(`/api/users`, data);
}

public async getPantrySSFRep(pantryId: number): Promise<User> {
return this.get(`/api/pantries/${pantryId}/ssf-contact`) as Promise<User>;
}

public async getAllPendingPantries(): Promise<PantryWithUser[]> {
return this.axiosInstance
.get('/api/pantries/pending')
Expand Down Expand Up @@ -195,11 +192,13 @@ export class ApiClient {
return this.get(`/api/volunteers/${userId}/pantries`) as Promise<Pantry[]>;
}

public async updateUserVolunteerRole(
public async updateUser(
userId: number,
body: { role: string },
): Promise<void> {
await this.axiosInstance.put(`/api/users/${userId}/role`, body);
fields: UpdateProfileFields,
): Promise<User> {
return this.axiosInstance
.patch(`/api/users/${userId}`, fields)
.then((response) => response.data);
}

public async getFoodRequest(requestId: number): Promise<FoodRequest> {
Expand Down Expand Up @@ -329,9 +328,9 @@ export class ApiClient {
return data as number;
}

public async getMyId(): Promise<number> {
const data = await this.get('/api/users/my-id');
return data as number;
public async getMe(): Promise<User> {
const data = await this.get('/api/users/me');
return data as User;
}
}

Expand Down
9 changes: 9 additions & 0 deletions apps/frontend/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import FoodManufacturerApplication from '@containers/foodManufacturerApplication
import { submitManufacturerApplicationForm } from '@components/forms/manufacturerApplicationForm';
import AssignedPantries from '@containers/volunteerAssignedPantries';
import VolunteerRequestManagement from '@containers/volunteerRequestManagement';
import ProfilePage from '@containers/profilePage';

Amplify.configure(CognitoAuthConfig);

Expand Down Expand Up @@ -187,6 +188,14 @@ const router = createBrowserRouter([
</ProtectedRoute>
),
},
{
path: '/profile',
element: (
<ProtectedRoute>
<ProfilePage />
</ProtectedRoute>
),
},
{
path: '/confirm-delivery',
action: submitDeliveryConfirmationFormModal,
Expand Down
230 changes: 230 additions & 0 deletions apps/frontend/src/components/forms/profileAccountInfo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Text,
SimpleGrid,
Input,
Button,
HStack,
Tabs,
} from '@chakra-ui/react';
import { Pencil } from 'lucide-react';
import { UpdateProfileFields, User } from 'types/types';

interface ProfileAccountInfoProps {
profile: User;
showTabs: boolean;
onSave: (fields: UpdateProfileFields) => Promise<boolean>;
}

type ProfileFieldProps =
Comment thread
amywng marked this conversation as resolved.
| {
label: string;
value: string;
}
| {
label: string;
value: string;
name: string;
isEditing: boolean;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
};

const labelStyles = {
fontSize: '14px',
fontWeight: 600,
fontFamily: 'inter',
color: 'neutral.800',
};

const ProfileField: React.FC<ProfileFieldProps> = (props) => (
<Box>
<Text {...labelStyles} mb={1}>
{props.label}
</Text>
{'name' in props && props.isEditing ? (
<Input
name={props.name}
value={props.value}
onChange={props.onChange}
size="sm"
borderRadius="md"
width="3/4"
color="neutral.600"
/>
) : (
<Text color="neutral.800" textStyle="p2">
{props.value}
</Text>
)}
</Box>
);

const ProfileAccountInfo: React.FC<ProfileAccountInfoProps> = ({
profile,
showTabs,
onSave,
}) => {
const { firstName, lastName, email, phone } = profile;
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [form, setForm] = useState({ firstName, lastName, phone });

useEffect(() => {
setForm({ firstName, lastName, phone });
}, [firstName, lastName, phone]);

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }));

const handleCancel = () => {
setForm({ firstName, lastName, phone });
setIsEditing(false);
};

const handleSave = async () => {
const changed: UpdateProfileFields = {};
if (form.firstName !== firstName) changed.firstName = form.firstName;
if (form.lastName !== lastName) changed.lastName = form.lastName;
if (form.phone !== phone) changed.phone = form.phone;

if (Object.keys(changed).length === 0) {
setIsEditing(false);
return;
}

setIsSaving(true);
const success = await onSave(changed);
setIsSaving(false);
if (success) setIsEditing(false);
};

const editButton = (
<HStack
gap={1}
color="neutral.700"
textStyle="p2"
fontWeight={600}
cursor="pointer"
pb={2}
_hover={{ color: 'neutral.900' }}
onClick={() => setIsEditing((e) => !e)}
>
<Pencil size={14} />
<Text fontWeight={600} fontFamily="ibm">
{isEditing ? 'Editing' : 'Edit'}
</Text>
</HStack>
);

const fields = (
<Box>
<SimpleGrid columns={2} gapY={12}>
<ProfileField
label="First Name"
name="firstName"
value={form.firstName}
isEditing={isEditing}
onChange={handleChange}
/>
<ProfileField
label="Last Name"
name="lastName"
value={form.lastName}
isEditing={isEditing}
onChange={handleChange}
Comment thread
amywng marked this conversation as resolved.
/>
<ProfileField label="Email Address" value={email} />
<ProfileField
label="Phone Number"
name="phone"
value={form.phone}
isEditing={isEditing}
onChange={handleChange}
/>
</SimpleGrid>

{isEditing && (
<HStack justify="flex-end" gap={3} mt={24}>
<Button
variant="outline"
size="sm"
color="neutral.800"
onClick={handleCancel}
disabled={isSaving}
borderColor="neutral.200"
fontWeight={600}
>
Cancel
</Button>
<Button
color="white"
bg="blue.hover"
variant="solid"
size="sm"
px={7}
onClick={handleSave}
loading={isSaving}
fontWeight={600}
>
Save Changes
</Button>
</HStack>
)}
</Box>
);

if (showTabs) {
return (
<Tabs.Root defaultValue="Account" variant="line" mx={2}>
<HStack justify="space-between" mb={8}>
<Tabs.List>
<Tabs.Trigger
value="Account"
color="neutral.800"
textStyle="p2"
borderBottom="0.5px solid"
borderColor="neutral.100"
_selected={{ borderColor: 'neutral.700' }}
Comment thread
amywng marked this conversation as resolved.
>
Account
</Tabs.Trigger>
<Tabs.Trigger
value="Application"
color="neutral.800"
textStyle="p2"
borderBottom="0.5px solid"
borderColor="neutral.100"
_selected={{ borderColor: 'neutral.700' }}
>
Application
</Tabs.Trigger>
</Tabs.List>
{editButton}
</HStack>

<Tabs.Content value="Account">{fields}</Tabs.Content>
<Tabs.Content value="Application">
{/* TODO: add application tab content */}
<Box color="neutral.700" fontSize="sm">
Application details coming soon.
</Box>
</Tabs.Content>
</Tabs.Root>
);
} else {
return (
<Box mx={2}>
<HStack justify="space-between" my={4}>
<Text textStyle="p" fontWeight={600} mb={4}>
Account Details
</Text>
{editButton}
</HStack>
{fields}
</Box>
);
}
};

export default ProfileAccountInfo;
Loading
Loading