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
52 changes: 41 additions & 11 deletions src/app/components/ProjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,20 @@ export function ProjectForm({
onSubmit: (value: ProjectWriteRequest) => void;
}) {
const options = useProjectOptions(yearId);
const [name, setName] = useState(project?.name ?? '');
const [summary, setSummary] = useState(project?.summary ?? '');
const [repository, setRepository] = useState(project?.repository ?? '');
const [kind, setKind] = useState<ProjectWriteRequest['kind']>(
claim ? 'project' : (project?.kind ?? 'project'),
);
const [groupId, setGroupId] = useState(project?.group?.id ?? '');
const [memberIds, setMemberIds] = useState(project?.members.map(({id}) => id) ?? []);
const initial = initialValues(project, claim);
const [name, setName] = useState(initial.name);
const [summary, setSummary] = useState(initial.summary);
const [repository, setRepository] = useState(initial.repository);
const [kind, setKind] = useState<ProjectWriteRequest['kind']>(initial.kind);
const [groupId, setGroupId] = useState(initial.groupId);
const [memberIds, setMemberIds] = useState(initial.memberIds);
const [memberQuery, setMemberQuery] = useState('');
const [memberResultsOpen, setMemberResultsOpen] = useState(false);
const [highlightedMember, setHighlightedMember] = useState(-1);
const memberListboxId = useId();
const memberSearchId = useId();
const [needsHelp, setNeedsHelp] = useState(project?.needsHelp ?? false);
const [helpDetails, setHelpDetails] = useState(project?.helpDetails ?? '');
const [needsHelp, setNeedsHelp] = useState(initial.needsHelp);
const [helpDetails, setHelpDetails] = useState(initial.helpDetails);

useEffect(() => {
if (!project || claim) return;
Expand Down Expand Up @@ -68,6 +67,16 @@ export function ProjectForm({
.slice(0, 8)
: [];
const showMemberResults = memberResultsOpen && Boolean(normalizedMemberQuery);
const dirty =
name !== initial.name ||
summary !== initial.summary ||
repository !== initial.repository ||
kind !== initial.kind ||
groupId !== initial.groupId ||
needsHelp !== initial.needsHelp ||
helpDetails !== initial.helpDetails ||
memberIds.length !== initial.memberIds.length ||
memberIds.some((id) => !initial.memberIds.includes(id));

function addMember(id: string) {
setMemberIds((members) => (members.includes(id) ? members : [...members, id]));
Expand Down Expand Up @@ -104,6 +113,11 @@ export function ProjectForm({
}
}

function cancel() {
if (dirty && !window.confirm('Discard your unsaved changes to this project?')) return;
onCancel();
}

function submit(event: FormEvent) {
event.preventDefault();
onSubmit({
Expand Down Expand Up @@ -327,7 +341,7 @@ export function ProjectForm({
</p>
)}
<div className="formActions">
<button type="button" className="textAction" onClick={onCancel}>
<button type="button" className="textAction" onClick={cancel}>
Never mind
</button>
<button type="submit" className="primaryAction" disabled={saving}>
Expand All @@ -343,3 +357,19 @@ export function ProjectForm({
</form>
);
}

function initialValues(project: ProjectDetail | undefined, claim: boolean) {
const kind: ProjectWriteRequest['kind'] = claim
? 'project'
: (project?.kind ?? 'project');
return {
name: project?.name ?? '',
summary: project?.summary ?? '',
repository: project?.repository ?? '',
kind,
groupId: project?.group?.id ?? '',
memberIds: project?.members.map(({id}) => id) ?? [],
needsHelp: project?.needsHelp ?? false,
helpDetails: project?.helpDetails ?? '',
};
}
64 changes: 62 additions & 2 deletions test/app/ProjectForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,13 @@ import type {

const fetchMock = vi.fn<typeof fetch>();
vi.stubGlobal('fetch', fetchMock);
const confirmMock = vi.fn<typeof window.confirm>();
vi.stubGlobal('confirm', confirmMock);

afterEach(() => fetchMock.mockReset());
afterEach(() => {
fetchMock.mockReset();
confirmMock.mockReset();
});

describe('ProjectForm team picker', () => {
it('filters members by partial name and email', async () => {
Expand Down Expand Up @@ -127,12 +132,67 @@ describe('ProjectForm team picker', () => {
});
});

describe('ProjectForm cancel confirmation', () => {
it('cancels without confirmation when nothing has been edited', async () => {
const onCancel = vi.fn();
renderProjectForm({project: projectFixture, onCancel});

await userEvent.click(await screen.findByRole('button', {name: 'Never mind'}));

expect(confirmMock).not.toHaveBeenCalled();
expect(onCancel).toHaveBeenCalledTimes(1);
});

it('keeps the edited form when the discard confirmation is declined', async () => {
const onCancel = vi.fn();
confirmMock.mockReturnValue(false);
renderProjectForm({project: projectFixture, onCancel});

await userEvent.type(await screen.findByLabelText('Name'), '!');
await userEvent.click(screen.getByRole('button', {name: 'Never mind'}));

expect(confirmMock).toHaveBeenCalledTimes(1);
expect(onCancel).not.toHaveBeenCalled();
expect(screen.getByLabelText('Name')).toHaveProperty(
'value',
`${projectFixture.name}!`,
);
});

it('cancels an edited form when the discard confirmation is accepted', async () => {
const onCancel = vi.fn();
confirmMock.mockReturnValue(true);
renderProjectForm({project: projectFixture, onCancel});

await userEvent.type(await screen.findByLabelText('Name'), '!');
await userEvent.click(screen.getByRole('button', {name: 'Never mind'}));

expect(onCancel).toHaveBeenCalledTimes(1);
});

it('confirms before discarding a team change on an otherwise untouched form', async () => {
const onCancel = vi.fn();
confirmMock.mockReturnValue(false);
renderProjectForm({project: projectFixture, onCancel});

await userEvent.click(
await screen.findByRole('button', {name: 'Remove Alice Example from team'}),
);
await userEvent.click(screen.getByRole('button', {name: 'Never mind'}));

expect(confirmMock).toHaveBeenCalledTimes(1);
expect(onCancel).not.toHaveBeenCalled();
});
});

function renderProjectForm({
project,
onCancel = () => {},
onSubmit = () => {},
users = [alice, bob],
}: {
project?: ProjectDetail;
onCancel?: () => void;
onSubmit?: (value: ProjectWriteRequest) => void;
users?: ProjectMember[];
} = {}) {
Expand All @@ -150,7 +210,7 @@ function renderProjectForm({
project={project}
saving={false}
error={null}
onCancel={() => {}}
onCancel={onCancel}
onSubmit={onSubmit}
/>
</QueryClientProvider>,
Expand Down
Loading