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
6 changes: 3 additions & 3 deletions CLIENT_STRUCTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,11 @@ features/
├── interactions/ # Interactions tab of the trainee and organisation profiles
├── login/ # Login feature
├── organisations/ # Organisations list, the dialog to add one, and the organisation profile
├── profile-picture/ # Picture of the trainee and organisation profiles
├── profile-picture/ # Picture of the trainee, organisation and volunteer profiles
├── search/ # Search feature
├── trainee-profile/ # Trainee profile feature (see detailed structure below)
├── trainees/ # Trainees list, grouped by cohort (see below)
└── volunteers/ # Volunteers page, a placeholder for now
└── volunteers/ # Volunteers list, the dialog to add one, and the volunteer profile
```

### `/src/layout`
Expand Down Expand Up @@ -179,7 +179,7 @@ trainee-profile/
└── create/ # Dialog to add a trainee
```

**Profile tabs:** the profile (`TraineeProfile`, `OrganisationProfile`) owns the page padding, so the tabs have none. A tab is a stack of `ProfileSection`s from `components/profile/`; lay out fields in `FieldRow`s with `ProfileTextField`, `ProfileSelect` and the fields a single profile adds (like `ProfileDateField` for trainees and `ProfileUserPicker` for organisations), so every field gets the same width, spacing, and read-only and edit behavior. Until the profile is edited, a field is its label and value as text, not a read-only input; while editing it is a small outlined input with the label above it, in the same place. Use `<FieldRow fill>` when the fields should share the width of the row instead.
**Profile tabs:** the profile (`TraineeProfile`, `OrganisationProfile`, `VolunteerProfile`) owns the page padding, so the tabs have none. A tab is a stack of `ProfileSection`s from `components/profile/`; lay out fields in `FieldRow`s with `ProfileTextField`, `ProfileSelect` and the fields a single profile adds (like `ProfileDateField` for trainees and `ProfileUserPicker` for organisations), so every field gets the same width, spacing, and read-only and edit behavior. Until the profile is edited, a field is its label and value as text, not a read-only input; while editing it is a small outlined input with the label above it, in the same place. Use `<FieldRow fill>` when the fields should share the width of the row instead.

### Example: Trainees Feature

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Dojo is HackYourFuture's in-house management tool designed to track HackYourFutu
- Generate real-time reports
- Trainees overview, grouped by cohort
- Partner organisations overview, with a profile to view and update each one
- Volunteers overview, with a profile to view and update each one
- Secure authentication using Google OAuth

## Technology Stack
Expand Down
4 changes: 4 additions & 0 deletions client/src/components/DropdownSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ type DropdownSelectProps = {
options: SelectOption[];
disabled?: boolean;
error?: string;
// Adds an empty option, which clears the field.
nullable?: boolean;
onChange: (event: SelectChangeEvent<string>) => void;
width?: string | number;
};
Expand All @@ -23,6 +25,7 @@ export const DropdownSelect = ({
options,
disabled = false,
error,
nullable = false,
onChange,
width = '25ch',
}: DropdownSelectProps) => {
Expand All @@ -41,6 +44,7 @@ export const DropdownSelect = ({
error={!!error}
onChange={onChange}
>
{nullable && <MenuItem value="">-</MenuItem>}
{options.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
Expand Down
8 changes: 6 additions & 2 deletions client/src/data/types/ProfileType.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
// The kinds of profile, like the server's ProfileType.
export type ProfileType = 'trainee' | 'organisation';
export type ProfileType = 'trainee' | 'organisation' | 'volunteer';

// The path of each kind of profile in the API.
export const PROFILE_PATHS: Record<ProfileType, string> = { trainee: 'trainees', organisation: 'organisations' };
export const PROFILE_PATHS: Record<ProfileType, string> = {
trainee: 'trainees',
organisation: 'organisations',
volunteer: 'volunteers',
};
10 changes: 9 additions & 1 deletion client/src/features/interactions/Interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export enum InteractionType {
Other = 'other',
}

// The types each profile offers when adding or editing an interaction. The server accepts every type on both.
// The types each profile offers when adding or editing an interaction. The server accepts every type on every profile.
export const INTERACTION_TYPES: Record<ProfileType, InteractionType[]> = {
trainee: [
InteractionType.Call,
Expand All @@ -53,4 +53,12 @@ export const INTERACTION_TYPES: Record<ProfileType, InteractionType[]> = {
InteractionType.InPerson,
InteractionType.Other,
],
volunteer: [
InteractionType.Call,
InteractionType.Chat,
InteractionType.Email,
InteractionType.Feedback,
InteractionType.InPerson,
InteractionType.Other,
],
};
6 changes: 4 additions & 2 deletions client/src/features/profile-picture/data/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import { cropPicture } from '../utils/pictureHelper';
import { organisationKeys } from '../../organisations/data/keys';
import { searchKeys } from '../../search/data/keys';
import { traineeKeys } from '../../trainee-profile/data/keys';
import { volunteerKeys } from '../../volunteers/data/keys';

// Where each kind of profile is cached.
const PROFILE_KEYS: Record<ProfileType, (profileId: string) => QueryKey> = {
trainee: traineeKeys.details,
organisation: organisationKeys.details,
volunteer: volunteerKeys.details,
};

const updatePictureQueries = (
Expand All @@ -31,7 +33,7 @@ const updatePictureQueries = (
queryClient.resetQueries({ queryKey: searchKeys.all() });
};

/** Hook to crop a new picture for a trainee or an organisation and upload it. */
/** Hook to crop a new picture for a profile and upload it. */
export const useUploadPicture = (profileType: ProfileType, profileId: string) => {
const queryClient = useQueryClient();

Expand All @@ -43,7 +45,7 @@ export const useUploadPicture = (profileType: ProfileType, profileId: string) =>
});
};

/** Hook to delete the picture of a trainee or an organisation. */
/** Hook to delete the picture of a profile. */
export const useDeletePicture = (profileType: ProfileType, profileId: string) => {
const queryClient = useQueryClient();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ interface SearchResultsListProps {
query: string;
}

/** The search results for the query, each a link that opens its trainee or organisation. */
/** The search results for the query, each a link that opens its profile. */
const SearchResultsList = ({ query }: SearchResultsListProps) => {
const { isLoading, data, error } = useGetSearchResults(query);

Expand Down
1 change: 1 addition & 0 deletions client/src/features/search/models/search-result.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// The kinds of records the search finds.
export enum SearchResultType {
Trainee = 'trainee',
Volunteer = 'volunteer',
Organisation = 'organisation',
ContactPerson = 'contact-person',
}
Expand Down
55 changes: 55 additions & 0 deletions client/src/features/volunteers/Volunteer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Gender } from '../../data/types/Person';
import { VolunteerStatus } from './VolunteerStatus';

export interface VolunteerSummary {
readonly id: string;
displayName: string;
profilePath: string;
thumbnailUrl: string | null;
status: VolunteerStatus;
companyName: string | null;
jobRole: string | null;
email: string;
githubHandle: string | null;
slackId: string | null;
linkedinUrl: string | null;
}

export interface Volunteer extends VolunteerSummary {
pictureUrl: string | null;
firstName: string;
lastName: string;
gender: Gender | null;
pronouns: string | null;
phone: string | null;
notes: string | null;
}

// The fields of the profile that can be edited.
export const EDITABLE_VOLUNTEER_FIELDS = [
'firstName',
'lastName',
'status',
'gender',
'pronouns',
'companyName',
'jobRole',
'email',
'phone',
'slackId',
'githubHandle',
'linkedinUrl',
'notes',
] as const;

export type EditableVolunteerField = (typeof EDITABLE_VOLUNTEER_FIELDS)[number];

export type VolunteerChanges = Partial<Pick<Volunteer, EditableVolunteerField>>;

// The fields of the add dialog, where the gender is null until one is picked.
export interface NewVolunteer {
firstName: string;
lastName: string;
gender: Gender | null;
email: string;
}
34 changes: 34 additions & 0 deletions client/src/features/volunteers/VolunteerProfilePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { ErrorBox, Loader } from '../../components';

import { Box } from '@mui/material';
import VolunteerProfile from './profile/VolunteerProfile';
import { useGetVolunteer } from './data/volunteer-queries';
import { useParams } from 'react-router';

/** The profile page of a volunteer. */
const VolunteerProfilePage = () => {
// The name and the id of the volunteer, joined by an underscore. A bare id works too.
const { volunteerInfo } = useParams();
const volunteerId = volunteerInfo?.split('_').pop() ?? '';
const { isLoading, data, isError, error, isFetching } = useGetVolunteer(volunteerId);

// Show spinner only for the first load
if ((isLoading || isFetching) && data === undefined) {
return <Loader />;
}

if (isError && error instanceof Error) {
return (
<Box sx={{ width: '50%', margin: 'auto', marginTop: '2rem' }}>
<ErrorBox errorMessage={error.message} />
</Box>
);
}

if (data) {
// Keyed by volunteer, so going straight from one volunteer to another does not keep the previous one's state.
return <VolunteerProfile key={volunteerId} volunteer={data} />;
}
};

export default VolunteerProfilePage;
12 changes: 12 additions & 0 deletions client/src/features/volunteers/VolunteerStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Where the volunteer stands with HackYourFuture.
export enum VolunteerStatus {
Active = 'active',
Paused = 'paused',
Stopped = 'stopped',
}

export const volunteerStatusLabels: Record<VolunteerStatus, string> = {
[VolunteerStatus.Active]: 'Active',
[VolunteerStatus.Paused]: 'Paused',
[VolunteerStatus.Stopped]: 'Stopped',
};
44 changes: 44 additions & 0 deletions client/src/features/volunteers/VolunteersOverviewPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Box, Button, Container, Typography } from '@mui/material';

import AddIcon from '@mui/icons-material/Add';
import { AddVolunteerDialog } from './components/AddVolunteerDialog';
import { ListLoader } from '../../components/ListLoader';
import { VolunteersTable } from './components/VolunteersTable';
import { useGetVolunteers } from './data/volunteer-queries';
import { usePageTitle } from '../../hooks/usePageTitle';
import { useState } from 'react';

/** The volunteers page, with the volunteers ordered by first name. */
const VolunteersOverviewPage = () => {
usePageTitle('Volunteers');

const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);

const volunteersQuery = useGetVolunteers();
const { data: volunteers } = volunteersQuery;

return (
<Container fixed>
<Box sx={{ p: 2 }}>
<Typography variant="h4">Volunteers</Typography>
<Box sx={{ my: 2, py: 2, pr: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setIsAddDialogOpen(true)}>
Add Volunteer
</Button>
</Box>
{volunteers &&
(volunteers.length > 0 ? (
<VolunteersTable volunteers={volunteers} />
) : (
<Typography sx={{ color: 'text.secondary' }}>No volunteers yet.</Typography>
))}

<ListLoader query={volunteersQuery} />
</Box>

<AddVolunteerDialog isOpen={isAddDialogOpen} handleClose={() => setIsAddDialogOpen(false)} />
</Container>
);
};

export default VolunteersOverviewPage;
20 changes: 0 additions & 20 deletions client/src/features/volunteers/VolunteersPage.tsx

This file was deleted.

36 changes: 36 additions & 0 deletions client/src/features/volunteers/api/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { NewVolunteer, VolunteerChanges } from '../Volunteer';
import { PAGE_SIZE, PagedModel, mapPageToDomain } from '../../../data/pagination';
import { VolunteerResponse, VolunteerSummaryResponse } from './types';
import {
mapDomainToCreateVolunteerRequest,
mapDomainToUpdateVolunteerRequest,
mapVolunteerSummaryToDomain,
mapVolunteerToDomain,
} from './mapper';

import axios from 'axios';

// Sorted by first name, from A to Z.
export const getVolunteerSummaries = async (page: number) => {
const { data } = await axios.get<PagedModel<VolunteerSummaryResponse>>('/api/volunteers', {
params: { page, size: PAGE_SIZE },
});
return mapPageToDomain(data, mapVolunteerSummaryToDomain);
};

export const getVolunteer = async (volunteerId: string) => {
const { data } = await axios.get<VolunteerResponse>(`/api/volunteers/${volunteerId}`);
return mapVolunteerToDomain(data);
};

export const updateVolunteer = async (volunteerId: string, changes: VolunteerChanges) => {
const volunteerRequest = mapDomainToUpdateVolunteerRequest(changes);
const { data } = await axios.patch<VolunteerResponse>(`/api/volunteers/${volunteerId}`, volunteerRequest);
return mapVolunteerToDomain(data);
};

export const createVolunteer = async (newVolunteer: NewVolunteer) => {
const volunteerRequest = mapDomainToCreateVolunteerRequest(newVolunteer);
const { data } = await axios.post<VolunteerResponse>('/api/volunteers', volunteerRequest);
return mapVolunteerToDomain(data);
};
49 changes: 49 additions & 0 deletions client/src/features/volunteers/api/mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { CreateVolunteerRequest, UpdateVolunteerRequest, VolunteerResponse, VolunteerSummaryResponse } from './types';
import { NewVolunteer, Volunteer, VolunteerChanges, VolunteerSummary } from '../Volunteer';

import { VolunteerStatus } from '../VolunteerStatus';
import { blankFieldsToNull } from '../../../data/text';

export const mapVolunteerSummaryToDomain = (volunteer: VolunteerSummaryResponse): VolunteerSummary => {
return {
id: volunteer.id,
displayName: volunteer.displayName,
profilePath: volunteer.profilePath,
thumbnailUrl: volunteer.thumbnailUrl,
status: volunteer.status,
companyName: volunteer.companyName,
jobRole: volunteer.jobRole,
email: volunteer.email,
githubHandle: volunteer.githubHandle,
slackId: volunteer.slackId,
linkedinUrl: volunteer.linkedinUrl,
};
};

export const mapVolunteerToDomain = (volunteer: VolunteerResponse): Volunteer => {
return {
...mapVolunteerSummaryToDomain(volunteer),
pictureUrl: volunteer.pictureUrl,
firstName: volunteer.firstName,
lastName: volunteer.lastName,
gender: volunteer.gender,
pronouns: volunteer.pronouns,
phone: volunteer.phone,
notes: volunteer.notes,
};
};

export const mapDomainToUpdateVolunteerRequest = (changes: VolunteerChanges): UpdateVolunteerRequest => {
return blankFieldsToNull<UpdateVolunteerRequest>(changes);
};

export const mapDomainToCreateVolunteerRequest = (newVolunteer: NewVolunteer): CreateVolunteerRequest => {
return {
firstName: newVolunteer.firstName,
lastName: newVolunteer.lastName,
gender: newVolunteer.gender,
email: newVolunteer.email,
// A new volunteer starts as active.
status: VolunteerStatus.Active,
};
};
Loading
Loading