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
18 changes: 18 additions & 0 deletions apps/backend/src/users/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,24 @@ import { Roles } from '../auth/roles.decorator';
export class UsersController {
constructor(private usersService: UsersService) {}

/**
* Exposes an endpoint to get all users.
* @returns {User[]} All users in the system.
*/
@Get()
async getAllUsers(): Promise<User[]> {
return this.usersService.findAll();
}

/**
* Exposes an endpoint to get all users of standard type.
* @returns {User[]} All users in the system of standard type.
*/
@Get('standard')
async getAllStandardUsers(): Promise<User[]> {
return this.usersService.findStandard();
}

/**
* Exposes an endpoint to get a user's information by their email.
* @param email The email of the desired user (URL-encoded).
Expand Down
16 changes: 16 additions & 0 deletions apps/backend/src/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ export class UsersService {
return this.repo.save(user);
}

/**
* Returns all users.
* @returns All users in the repository.
*/
findAll(): Promise<User[]> {
return this.repo.find();
}

/**
* Returns a user by email.
* @param email The email of the user to find.
Expand All @@ -55,6 +63,14 @@ export class UsersService {
return this.repo.find({ where: { email } });
}

/**
* Returns all users of type standard
* @returns Array of users with userType = standard
*/
findStandard(): Promise<User[]> {
return this.repo.find({ where: { userType: UserType.STANDARD } });
}

/**
* Updates a user by email.
* @param email The email of the user to update.
Expand Down
8 changes: 8 additions & 0 deletions apps/frontend/src/api/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,18 @@ export class ApiClient {
return this.get('/api') as Promise<string>;
}

public async getApplications(): Promise<Application[]> {
return this.get('/api/applications') as Promise<Application[]>;
}
Comment thread
ostepan8 marked this conversation as resolved.

public async getApplication(appId: number): Promise<Application> {
return this.get(`/api/applications/${appId}`) as Promise<Application>;
}

public async getApplicants(): Promise<User[]> {
return this.get('/api/users/standard') as Promise<User[]>;
}

public async getLearnerInfo(appId: number): Promise<LearnerInfo> {
return this.get(`/api/learner_info/${appId}`) as Promise<LearnerInfo>;
}
Expand Down
4 changes: 2 additions & 2 deletions apps/frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ export interface AvailabilityFields {
export interface Application extends AvailabilityFields {
appId: number;
email: string;
proposedStartDate: string;
actualStartDate?: string;
discipline: DISCIPLINE_VALUES;
otherDisciplineDescription?: string;
appStatus: AppStatus;
Expand All @@ -109,8 +111,6 @@ export interface Application extends AvailabilityFields {
emergencyContactPhone: string;
emergencyContactRelationship: string;
heardAboutFrom: HeardAboutFrom[];
proposedStartDate: Date;
actualStartDate?: Date;
endDate?: Date;
}

Expand Down
155 changes: 155 additions & 0 deletions apps/frontend/src/components/ApplicationTable.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { ChakraProvider, defaultSystem } from '@chakra-ui/react';
import ApplicationTable from './ApplicationTable';
import type { ApplicationRow } from '@hooks/useApplications';

function renderWithChakra(ui: React.ReactElement) {
return render(<ChakraProvider value={defaultSystem}>{ui}</ChakraProvider>);
}

const mockApplications: ApplicationRow[] = [
{
appId: 1,
name: 'Jane Doe',
email: 'jane@example.com',
proposedStartDate: '2026-01-15',
actualStartDate: '2026-02-01',
discipline: 'RN',
applicantType: 'Learner',
status: 'App submitted',
},
{
appId: 2,
name: 'John Smith',
email: 'john@example.com',
proposedStartDate: '2026-03-01',
actualStartDate: '',
discipline: 'Social Work',
applicantType: 'Volunteer',
status: 'Accepted',
},
];

describe('ApplicationTable', () => {
it('should render all column headers', () => {
renderWithChakra(<ApplicationTable applications={[]} />);

const expectedColumns = [
'Name',
'Proposed Date',
'Actual Start Date',
'Discipline',
'Applicant Type',
'Status',
];

expectedColumns.forEach((col) => {
expect(screen.getByText(col)).toBeDefined();
});
});

it('should render application rows with correct data', () => {
renderWithChakra(<ApplicationTable applications={mockApplications} />);

expect(screen.getByText('Jane Doe')).toBeDefined();
expect(screen.getByText('John Smith')).toBeDefined();
expect(screen.getByText('RN')).toBeDefined();
expect(screen.getByText('Social Work')).toBeDefined();
expect(screen.getByText('Learner')).toBeDefined();
expect(screen.getByText('Volunteer')).toBeDefined();
expect(screen.getByText('App submitted')).toBeDefined();
expect(screen.getByText('Accepted')).toBeDefined();
});

it('should format dates correctly', () => {
renderWithChakra(<ApplicationTable applications={mockApplications} />);

expect(screen.getByText('01/15/2026')).toBeDefined();
expect(screen.getByText('02/01/2026')).toBeDefined();
expect(screen.getByText('03/01/2026')).toBeDefined();
});

it('should display em-dash for missing actual start date', () => {
renderWithChakra(<ApplicationTable applications={mockApplications} />);

const cells = screen.getAllByRole('cell');
const emDashCells = cells.filter((cell) => cell.textContent === '—');
expect(emDashCells.length).toBeGreaterThanOrEqual(1);
});

it('should render an empty table body when no applications provided', () => {
renderWithChakra(<ApplicationTable applications={[]} />);

const rows = screen.queryAllByRole('row');
expect(rows).toHaveLength(1);
});

it('should filter applications by search query on name', () => {
renderWithChakra(
<ApplicationTable applications={mockApplications} searchQuery="Jane" />,
);

expect(screen.getByText('Jane Doe')).toBeDefined();
expect(screen.queryByText('John Smith')).toBeNull();
});

it('should filter applications by search query on discipline', () => {
renderWithChakra(
<ApplicationTable applications={mockApplications} searchQuery="Social" />,
);

expect(screen.queryByText('Jane Doe')).toBeNull();
expect(screen.getByText('John Smith')).toBeDefined();
});

it('should filter applications by search query on status', () => {
renderWithChakra(
<ApplicationTable
applications={mockApplications}
searchQuery="accepted"
/>,
);

expect(screen.queryByText('Jane Doe')).toBeNull();
expect(screen.getByText('John Smith')).toBeDefined();
});

it('should filter applications by search query on email', () => {
renderWithChakra(
<ApplicationTable applications={mockApplications} searchQuery="jane@" />,
);

expect(screen.getByText('Jane Doe')).toBeDefined();
expect(screen.queryByText('John Smith')).toBeNull();
});

it('should be case-insensitive when filtering', () => {
renderWithChakra(
<ApplicationTable applications={mockApplications} searchQuery="JANE" />,
);

expect(screen.getByText('Jane Doe')).toBeDefined();
});

it('should show all applications when search query is empty', () => {
renderWithChakra(
<ApplicationTable applications={mockApplications} searchQuery="" />,
);

expect(screen.getByText('Jane Doe')).toBeDefined();
expect(screen.getByText('John Smith')).toBeDefined();
});

it('should show no application rows when search matches nothing', () => {
renderWithChakra(
<ApplicationTable
applications={mockApplications}
searchQuery="zzzznonexistent"
/>,
);

const rows = screen.queryAllByRole('row');
expect(rows).toHaveLength(1);
});
});
112 changes: 49 additions & 63 deletions apps/frontend/src/components/ApplicationTable.tsx
Original file line number Diff line number Diff line change
@@ -1,76 +1,58 @@
import { Table } from '@chakra-ui/react';
import StatusPill, { StatusPillConfig, StatusVariant } from './StatusPill';
import type { ApplicationRow } from '@hooks/useApplications';

const COLUMNS = [
'Name',
'Proposed Date',
'Actual Start Date',
'Discipline',
'Discipline Admin Name',
'Applicant Type',
'Status',
];

const APPLICATIONS = [
{
id: '1',
name: 'Firstname Lastname',
proposedDate: '01-01-2026',
actualStartDate: '01-07-2026',
discipline: 'Nursing',
disciplineAdminName: 'Firstname Lastname',
status: 'submitted',
},
{
id: '2',
name: 'Firstname Lastname',
proposedDate: '01-01-2026',
actualStartDate: '01-06-2026',
discipline: 'Nursing',
disciplineAdminName: 'Firstname Lastname',
status: 'review',
},
{
id: '3',
name: 'Firstname Lastname',
proposedDate: '01-01-2026',
actualStartDate: '01-05-2026',
discipline: 'Nursing',
disciplineAdminName: 'Firstname Lastname',
status: 'accepted',
},
{
id: '4',
name: 'Firstname Lastname',
proposedDate: '01-01-2026',
actualStartDate: '01-03-2026',
discipline: 'Nursing',
disciplineAdminName: 'Firstname Lastname',
status: 'accepted',
},
{
id: '5',
name: 'Firstname Lastname',
proposedDate: '01-01-2026',
actualStartDate: '01-02-2026',
discipline: 'Nursing',
disciplineAdminName: 'Firstname Lastname',
status: 'inactive',
},
];

interface ApplicationTableProps {
applications: ApplicationRow[];
searchQuery?: string;
}

export function ApplicationTable({ searchQuery = '' }: ApplicationTableProps) {
const filteredApplications = APPLICATIONS.filter((application) => {
function formatDate(dateStr: string): string {
if (!dateStr) return '—';
const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!match) return dateStr;
const [, year, month, day] = match;
return `${month}/${day}/${year}`;
}

function titleCaseName(name?: string): string {
if (!name) return '—';
const cleaned = name.trim().replace(/\s+/g, ' ');
return cleaned
.split(' ')
.map((part) =>
part
.split('-')
.map((sub) =>
sub.length > 0
? sub.charAt(0).toUpperCase() + sub.slice(1).toLowerCase()
: sub,
)
.join('-'),
)
.join(' ');
}

export function ApplicationTable({
applications,
searchQuery = '',
}: ApplicationTableProps) {
const filteredApplications = applications.filter((application) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
application.name.toLowerCase().includes(query) ||
application.discipline.toLowerCase().includes(query) ||
application.disciplineAdminName.toLowerCase().includes(query) ||
application.status.toLowerCase().includes(query)
application.status.toLowerCase().includes(query) ||
application.email.toLowerCase().includes(query)
);
});

Expand All @@ -91,17 +73,21 @@ export function ApplicationTable({ searchQuery = '' }: ApplicationTableProps) {
</Table.Header>
<Table.Body>
{filteredApplications.map((application) => (
<Table.Row key={application.id}>
<Table.Cell>{application.name}</Table.Cell>
<Table.Cell>{application.proposedDate}</Table.Cell>
<Table.Cell>{application.actualStartDate}</Table.Cell>
<Table.Cell>{application.discipline}</Table.Cell>
<Table.Cell>{application.disciplineAdminName}</Table.Cell>
<Table.Row key={application.appId}>
<Table.Cell>
<StatusPill variant={application.status as StatusVariant}>
{StatusPillConfig[application.status as StatusVariant].label}
</StatusPill>
<a
href={`/admin/view-application/${application.appId}`}
aria-label={`View application ${application.appId}`}
style={{ color: '#0b5fff', textDecoration: 'underline' }}
>
{titleCaseName(application.name)}
</a>
</Table.Cell>
<Table.Cell>{formatDate(application.proposedStartDate)}</Table.Cell>
<Table.Cell>{formatDate(application.actualStartDate)}</Table.Cell>
<Table.Cell>{application.discipline}</Table.Cell>
<Table.Cell>{application.applicantType}</Table.Cell>
<Table.Cell>{application.status}</Table.Cell>
</Table.Row>
))}
</Table.Body>
Expand Down
Loading
Loading