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
7 changes: 7 additions & 0 deletions apps/website/src/__tests__/testUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,13 @@ export const createMockCourseRegistration = (overrides: Partial<CourseRegistrati
courseId: MOCK_COURSE_ID,
decision: 'Accept',
isDuplicate: null,
formFeedback: null,
impressiveProject: null,
motivationToFacilitate: null,
numGroupsToFacilitate: null,
prevEngagement: null,
prevFacilitationExperience: null,
skills: null,
email: 'user@example.com',
firstName: 'Test',
fullName: 'Test User',
Expand Down
33 changes: 33 additions & 0 deletions apps/website/src/components/courses/DropoutModal.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react';
import { TRPCError } from '@trpc/server';
import { trpcStorybookMsw } from '../../__tests__/trpcMswSetup.browser';
import { createMockCourseRegistration } from '../../__tests__/testUtils';
import DropoutModal from './DropoutModal';

const mockCourseRounds = {
Expand All @@ -21,6 +22,8 @@ const mockCourseRounds = {

const courseRoundsHandler = trpcStorybookMsw.courseRounds.getRoundsForCourse.query(() => mockCourseRounds);

const participantRegistrationsHandler = trpcStorybookMsw.courseRegistrations.getAll.query(() => []);

const meta = {
title: 'website/courses/DropoutModal',
component: DropoutModal,
Expand All @@ -43,6 +46,7 @@ export const Default: Story = {
msw: {
handlers: [
courseRoundsHandler,
participantRegistrationsHandler,
trpcStorybookMsw.dropout.dropoutOrDeferral.mutation(async ({ input }) => ({
id: 'new-dropout-id',
applicantId: [input.applicantId],
Expand All @@ -67,6 +71,7 @@ export const Error: Story = {
msw: {
handlers: [
courseRoundsHandler,
participantRegistrationsHandler,
trpcStorybookMsw.dropout.dropoutOrDeferral.mutation(async () => {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to submit request' });
}),
Expand All @@ -86,6 +91,34 @@ export const NoUpcomingRounds: Story = {
msw: {
handlers: [
trpcStorybookMsw.courseRounds.getRoundsForCourse.query(() => ({ intense: [], partTime: [] })),
participantRegistrationsHandler,
trpcStorybookMsw.dropout.dropoutOrDeferral.mutation(async ({ input }) => ({
id: 'new-dropout-id',
applicantId: [input.applicantId],
reason: input.reason ?? null,
type: input.type,
newRoundId: null,
oldRoundId: null,
})),
],
},
},
};

export const Facilitator: Story = {
args: {
handleClose() {},
applicantId: 'rec123456789',
courseSlug: 'agi-safety-fundamentals',
currentRoundId: null,
},
parameters: {
msw: {
handlers: [
courseRoundsHandler,
trpcStorybookMsw.courseRegistrations.getAll.query(() => [
createMockCourseRegistration({ id: 'rec123456789', role: 'Facilitator' }),
]),
trpcStorybookMsw.dropout.dropoutOrDeferral.mutation(async ({ input }) => ({
id: 'new-dropout-id',
applicantId: [input.applicantId],
Expand Down
47 changes: 47 additions & 0 deletions apps/website/src/components/courses/DropoutModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { server, trpcMsw } from '../../__tests__/trpcMswSetup';
import { TrpcProvider } from '../../__tests__/trpcProvider';
import { trpc } from '../../utils/trpc';
import { createMockCourseRegistration } from '../../__tests__/testUtils';
import DropoutModal from './DropoutModal';

const mockCourseRounds = {
Expand Down Expand Up @@ -101,4 +102,50 @@ describe('DropoutModal', () => {
expect(courseRegistrationsRequests).toBeGreaterThan(1);
});
});

it('greys out the deferral option for facilitators and still submits a drop out', async () => {
const user = userEvent.setup();
let submittedType: string | undefined;

server.use(
trpcMsw.courseRegistrations.getAll.query(() => [
createMockCourseRegistration({ id: 'reg-facilitator', role: 'Facilitator' }),
]),
trpcMsw.dropout.dropoutOrDeferral.mutation(({ input }) => {
submittedType = input.type;
return {
id: 'new-dropout-id',
applicantId: [input.applicantId],
reason: input.reason ?? null,
type: input.type,
newRoundId: null,
oldRoundId: null,
};
}),
);

render(
<DropoutModal
applicantId="reg-facilitator"
courseSlug="ai-safety"
currentRoundId="round-active"
handleClose={vi.fn()}
/>,
{ wrapper: TrpcProvider },
);

await user.click(screen.getByRole('button', { name: 'Action type' }));
const listbox = await screen.findByRole('listbox');
await waitFor(() => {
expect(within(listbox).getByRole('option', { name: 'Defer to a future round' })).toHaveClass('cursor-not-allowed');
});

await user.click(within(listbox).getByText('Drop out of the course'));
await user.click(screen.getByRole('button', { name: 'Submit' }));

await waitFor(() => {
expect(screen.getByText(/Your dropout request has been submitted/)).toBeInTheDocument();
});
expect(submittedType).toBe('Drop out');
});
});
9 changes: 8 additions & 1 deletion apps/website/src/components/courses/DropoutModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ const DropoutModal: React.FC<DropoutModalProps> = ({

const { data: courseRounds } = trpc.courseRounds.getRoundsForCourse.useQuery({ courseSlug });

const { data: registrations } = trpc.courseRegistrations.getAll.useQuery();
const allowDeferral = registrations?.find((r) => r.id === applicantId)?.role !== 'Facilitator';

const isDeferral = dropoutType === 'Deferral';

Comment thread
Will-Howard marked this conversation as resolved.
const futureRoundsByIntensity = useMemo(() => ({
Expand Down Expand Up @@ -202,7 +205,11 @@ const DropoutModal: React.FC<DropoutModalProps> = ({
ariaLabel="Action type"
value={dropoutType}
onChange={(value) => setDropoutType(value as DropoutType)}
options={TYPE_OPTIONS.map((opt) => ({ value: opt.value, label: opt.label, disabled: dropoutMutation.isPending }))}
options={TYPE_OPTIONS.map((opt) => ({
value: opt.value,
label: opt.label,
disabled: dropoutMutation.isPending || (opt.value === 'Deferral' && !allowDeferral),
}))}
placeholder="Choose an option"
/>
{isDeferral && renderRoundPicker()}
Expand Down
22 changes: 11 additions & 11 deletions apps/website/src/components/my-courses/CourseListRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,7 @@ describe('CourseListRow actions', () => {
test('no participant-only overflow items', () => {
const { container } = renderFacRow(facProps());
const items = openOverflowItems(container);
expect(items).not.toContain('Drop or defer course');
expect(items).not.toContain('Drop out');
expect(items).not.toContain('Switch group permanently');
});

Expand Down Expand Up @@ -566,9 +566,9 @@ describe('CourseListRow actions', () => {
expect(inlineLabels(container)).toContain('Edit your availability');
});

test('overflow offers only "Drop or defer course"', () => {
test('overflow offers only "Drop out"', () => {
const { container } = renderFacRow(pending());
expect(openOverflowItems(container)).toEqual(['Drop or defer course']);
expect(openOverflowItems(container)).toEqual(['Drop out']);
});

test('no availability CTA when application was rejected', () => {
Expand All @@ -580,29 +580,29 @@ describe('CourseListRow actions', () => {
expect(labels).not.toContain('Edit your availability');
});

test('no "Drop or defer course" when application was rejected', () => {
test('no "Drop out" when application was rejected', () => {
const { container } = renderFacRow(pending({
courseRegistration: createMockCourseRegistration({ roundStatus: 'Future', decision: 'Reject', role: 'Facilitator' }),
}));
expect(openOverflowItems(container)).not.toContain('Drop or defer course');
expect(openOverflowItems(container)).not.toContain('Drop out');
});
});

describe('Drop or defer course', () => {
describe('Drop out (facilitators cannot defer)', () => {
test('shown on a pending application (upcoming, no group assigned yet)', () => {
const { container } = renderFacRow(facProps({
courseRegistration: createMockCourseRegistration({ roundStatus: 'Future', decision: 'Accept', role: 'Facilitator' }),
group: null,
}));
expect(openOverflowItems(container)).toContain('Drop or defer course');
expect(openOverflowItems(container)).toContain('Drop out');
});

test('hidden once a group is assigned, even before the round starts', () => {
const { container } = renderFacRow(facProps({
courseRegistration: createMockCourseRegistration({ roundStatus: 'Future', decision: 'Accept', role: 'Facilitator' }),
// facProps default supplies a group
}));
expect(openOverflowItems(container)).not.toContain('Drop or defer course');
expect(openOverflowItems(container)).not.toContain('Drop out');
});
});

Expand All @@ -614,7 +614,7 @@ describe('CourseListRow actions', () => {
isDroppedOut: true,
}));
expect(container.textContent).toContain('Dropped');
expect(openOverflowItems(container)).not.toContain('Drop or defer course');
expect(openOverflowItems(container)).not.toContain('Drop out');
});
});

Expand All @@ -641,12 +641,12 @@ describe('CourseListRow actions', () => {
expect(labels).not.toContain('Edit feedback');
});

test('past overflow keeps doc/slack/participants but drops Update discussion time and Drop or defer course', () => {
test('past overflow keeps doc/slack/participants but drops Update discussion time and Drop out', () => {
const { container } = renderFacRow(past());
const items = openOverflowItems(container);
expect(items).toEqual(expect.arrayContaining(['Open discussion doc', 'Open Slack group', 'View participants']));
expect(items).not.toContain('Update discussion time');
expect(items).not.toContain('Drop or defer course');
expect(items).not.toContain('Drop out');
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ const getFacilitatorActions = (
isVisible: isPending && courseRegistration.decision !== 'Reject',
variant: 'overflow',
overflow: {
id: 'drop', label: 'Drop or defer course', onAction: triggers.openDropout,
id: 'drop', label: 'Drop out', onAction: triggers.openDropout,
},
},
];
Expand Down
48 changes: 48 additions & 0 deletions apps/website/src/server/routers/dropout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { courseRegistrationTable } from '@bluedot/db';
import { describe, expect, test } from 'vitest';
import {
createCaller, setupTestDb, testAuthContextLoggedIn, testAuthContextLoggedOut, testDb,
} from '../../__tests__/dbTestUtils';

setupTestDb();

const insertRegistration = (overrides: Record<string, unknown>) => testDb.insert(courseRegistrationTable, {
id: 'reg-1',
email: 'test@example.com',
courseId: 'course-1',
decision: 'Accept',
roundId: 'round-1',
...overrides,
});

describe('dropout.dropoutOrDeferral', () => {
test('rejects unauthenticated callers', async () => {
await expect(createCaller(testAuthContextLoggedOut).dropout.dropoutOrDeferral({ applicantId: 'reg-1', type: 'Drop out' })).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
});

test('rejects a deferral request from a facilitator', async () => {
await insertRegistration({ role: 'Facilitator' });

await expect(createCaller(testAuthContextLoggedIn).dropout.dropoutOrDeferral({
applicantId: 'reg-1', type: 'Deferral', newRoundId: 'round-2',
})).rejects.toMatchObject({ code: 'FORBIDDEN' });
});

test('lets a facilitator drop out', async () => {
await insertRegistration({ role: 'Facilitator' });

const result = await createCaller(testAuthContextLoggedIn).dropout.dropoutOrDeferral({
applicantId: 'reg-1', type: 'Drop out',
});
expect(result).toBeTruthy();
});

test('lets a participant defer', async () => {
await insertRegistration({ role: 'Participant' });

const result = await createCaller(testAuthContextLoggedIn).dropout.dropoutOrDeferral({
applicantId: 'reg-1', type: 'Deferral', newRoundId: 'round-2',
});
expect(result).toBeTruthy();
});
});
4 changes: 4 additions & 0 deletions apps/website/src/server/routers/dropout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ export const dropoutRouter = router({
throw new TRPCError({ code: 'NOT_FOUND', message: 'Course registration not found' });
}

if (type === 'Deferral' && courseRegistration.role === 'Facilitator') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Facilitators cannot defer a course.' });
}

const oldRoundId = courseRegistration.roundId ?? null;

return db.insert(dropoutTable, {
Expand Down
Loading