Add more tests and cleanup missions more
This commit is contained in:
@@ -0,0 +1,107 @@
|
|||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { Mission } from '../../api/missions';
|
||||||
|
import { MissionsTable } from './MissionsTable';
|
||||||
|
import type { MissionLaunch } from '../../api/missionLaunches';
|
||||||
|
|
||||||
|
function missionLaunches(): MissionLaunch[] {
|
||||||
|
return [{
|
||||||
|
id: 'launch id',
|
||||||
|
scheduledFor: '2026-07-07T13:56:00+00:00',
|
||||||
|
status: 'Pending',
|
||||||
|
createdAt: '2026-07-07T13:56:00+00:00',
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMockMissions(withLaunches = false): Mission[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'a mission id',
|
||||||
|
name: 'mission name is here!',
|
||||||
|
description: null,
|
||||||
|
status: 'Planned',
|
||||||
|
createdAt: '',
|
||||||
|
missionLaunches: withLaunches ? missionLaunches() : [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MissionsTable', () => {
|
||||||
|
it('does not show launches on load', () => {
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MissionsTable missions={getMockMissions()} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Launches')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows launches when table row is expanded', async () => {
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MissionsTable missions={getMockMissions()} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
screen.getAllByTestId('row-expand')[0].click();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Launches')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no launches message when no launches have been made for a mission', async () => {
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MissionsTable missions={getMockMissions()} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
screen.getAllByTestId('row-expand')[0].click();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByText(
|
||||||
|
'No launches have been created for this mission yet!',
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows does not show no launches message when launches have been made for a mission', async () => {
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MissionsTable missions={getMockMissions(true)} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
screen.getAllByTestId('row-expand')[0].click();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByText(
|
||||||
|
'No launches have been created for this mission yet!',
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// This test doesn't work as we don't pass in the query client, I would like to extract most of the clients to the top-page level//
|
||||||
|
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// describe('CreateMissionLaunchForm', () => {
|
||||||
|
// it('shows form when create new launch is clicked', async () => {
|
||||||
|
// render(
|
||||||
|
// <QueryClientProvider client={queryClient}>
|
||||||
|
// <MantineProvider>
|
||||||
|
// <MissionsTable missions={missions} />
|
||||||
|
// </MantineProvider>,
|
||||||
|
// </QueryClientProvider>
|
||||||
|
// );
|
||||||
|
|
||||||
|
// screen.getAllByTestId('row-expand')[0].click();
|
||||||
|
|
||||||
|
// (await screen.findByText('Create new Launch')).click();
|
||||||
|
|
||||||
|
// expect(await screen.findByText('Create launch')).toBeVisible();
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
|
||||||
|
// TODO: Would write many more tests here - particularly around form validation!
|
||||||
|
});
|
||||||
@@ -1,34 +1,36 @@
|
|||||||
import { Loader, Alert, Table, ActionIcon, Popover, Button, Text, Group, Paper, Select, Stack } from "@mantine/core";
|
import {
|
||||||
import { IconChevronDown, IconChevronRight } from "@tabler/icons-react";
|
ActionIcon,
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
Button,
|
||||||
import { useState } from "react";
|
Group,
|
||||||
import { listMissions, type Mission } from "../../api/missions";
|
Paper,
|
||||||
import { toReadableDateTime } from "../../utils/dateUtils";
|
Popover,
|
||||||
import { MissionLaunchStatusBadge } from "../MissionLaunchStatusBadge";
|
Select,
|
||||||
import { MissionStatusBadge } from "../MissionStatusBadge";
|
Stack,
|
||||||
import { DateTimePicker } from "@mantine/dates";
|
Table,
|
||||||
import { useForm } from "@mantine/form";
|
Text,
|
||||||
import { notifications } from "@mantine/notifications";
|
} from '@mantine/core';
|
||||||
import { createMissionLaunch, type MissionLaunchStatus, MissionLaunchStatuses } from "../../api/missionLaunches";
|
import { DateTimePicker } from '@mantine/dates';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { IconChevronDown, IconChevronRight } from '@tabler/icons-react';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
createMissionLaunch,
|
||||||
|
MissionLaunchStatuses,
|
||||||
|
type MissionLaunchStatus,
|
||||||
|
} from '../../api/missionLaunches';
|
||||||
|
import { type Mission } from '../../api/missions';
|
||||||
|
import { toReadableDateTime } from '../../utils/dateUtils';
|
||||||
|
import { MissionLaunchStatusBadge } from '../MissionLaunchStatusBadge';
|
||||||
|
import { MissionStatusBadge } from '../MissionStatusBadge';
|
||||||
|
|
||||||
export function MissionsTable() {
|
type Props = {
|
||||||
const missionsQuery = useQuery({
|
missions: Mission[];
|
||||||
queryKey: ['missions'],
|
invalidateMissions?: () => void;
|
||||||
queryFn: listMissions,
|
};
|
||||||
});
|
|
||||||
|
|
||||||
if (missionsQuery.isPending) {
|
|
||||||
return <Loader />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (missionsQuery.isError) {
|
|
||||||
return (
|
|
||||||
<Alert color="red" title="Could not load missions">
|
|
||||||
Is the API running?
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export function MissionsTable({ missions, invalidateMissions }: Props) {
|
||||||
return (
|
return (
|
||||||
<Table highlightOnHover>
|
<Table highlightOnHover>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
@@ -40,22 +42,35 @@ export function MissionsTable() {
|
|||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{missionsQuery.data.map((mission) => (
|
{missions.map((mission) => (
|
||||||
<MissionsTableRow mission={mission} />
|
<MissionsTableRow
|
||||||
|
key={mission.id}
|
||||||
|
mission={mission}
|
||||||
|
invalidateMissions={invalidateMissions}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MissionsTableRow({ mission }: { mission: Mission }) {
|
function MissionsTableRow({
|
||||||
|
mission,
|
||||||
|
invalidateMissions,
|
||||||
|
}: {
|
||||||
|
mission: Mission;
|
||||||
|
invalidateMissions?: () => void;
|
||||||
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Table.Tr key={mission.id}>
|
<Table.Tr>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<ActionIcon onClick={() => setExpanded((expanded) => !expanded)}>
|
<ActionIcon
|
||||||
|
onClick={() => setExpanded((expanded) => !expanded)}
|
||||||
|
data-testid="row-expand"
|
||||||
|
>
|
||||||
{expanded ? <IconChevronDown /> : <IconChevronRight />}
|
{expanded ? <IconChevronDown /> : <IconChevronRight />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
@@ -73,29 +88,33 @@ function MissionsTableRow({ mission }: { mission: Mission }) {
|
|||||||
<>
|
<>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td colSpan={12}>
|
<Table.Td colSpan={12}>
|
||||||
<h3>
|
<h3>Launches</h3>
|
||||||
Launches
|
{mission.missionLaunches.length === 0 ? (
|
||||||
</h3>
|
<span>No launches have been created for this mission yet!</span>
|
||||||
<Table>
|
) : (
|
||||||
<Table.Thead>
|
<Table>
|
||||||
<Table.Tr>
|
<Table.Thead>
|
||||||
<Table.Th>Scheduled For</Table.Th>
|
|
||||||
<Table.Th>Status</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{mission.missionLaunches.map((missionLaunch) => (
|
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td>{toReadableDateTime(missionLaunch.scheduledFor)}</Table.Td>
|
<Table.Th>Scheduled For</Table.Th>
|
||||||
<Table.Td>
|
<Table.Th>Status</Table.Th>
|
||||||
<MissionLaunchStatusBadge
|
|
||||||
status={missionLaunch.status}
|
|
||||||
/>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
</Table.Thead>
|
||||||
</Table.Tbody>
|
<Table.Tbody>
|
||||||
</Table>
|
{mission.missionLaunches.map((missionLaunch) => (
|
||||||
|
<Table.Tr key={missionLaunch.id}>
|
||||||
|
<Table.Td>
|
||||||
|
{toReadableDateTime(missionLaunch.scheduledFor)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<MissionLaunchStatusBadge
|
||||||
|
status={missionLaunch.status}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
@@ -105,7 +124,10 @@ function MissionsTableRow({ mission }: { mission: Mission }) {
|
|||||||
<Button variant="default">Create new Launch</Button>
|
<Button variant="default">Create new Launch</Button>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
<CreateMissionLaunchForm missionId={mission.id} />
|
<CreateMissionLaunchForm
|
||||||
|
missionId={mission.id}
|
||||||
|
invalidateMissions={invalidateMissions}
|
||||||
|
/>
|
||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
</Popover>
|
</Popover>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
@@ -116,9 +138,13 @@ function MissionsTableRow({ mission }: { mission: Mission }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateMissionLaunchForm({ missionId }: { missionId: string }) {
|
function CreateMissionLaunchForm({
|
||||||
const queryClient = useQueryClient();
|
missionId,
|
||||||
|
invalidateMissions,
|
||||||
|
}: {
|
||||||
|
missionId: string;
|
||||||
|
invalidateMissions?: () => void;
|
||||||
|
}) {
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
mode: 'uncontrolled',
|
mode: 'uncontrolled',
|
||||||
initialValues: { scheduledFor: '', status: '' },
|
initialValues: { scheduledFor: '', status: '' },
|
||||||
@@ -139,7 +165,7 @@ function CreateMissionLaunchForm({ missionId }: { missionId: string }) {
|
|||||||
color: 'green',
|
color: 'green',
|
||||||
});
|
});
|
||||||
form.reset();
|
form.reset();
|
||||||
return queryClient.invalidateQueries({ queryKey: ['missions'] });
|
invalidateMissions?.();
|
||||||
},
|
},
|
||||||
onError: () =>
|
onError: () =>
|
||||||
notifications.show({
|
notifications.show({
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
|
Loader,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
Textarea,
|
Textarea,
|
||||||
@@ -9,9 +11,9 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useForm } from '@mantine/form';
|
import { useForm } from '@mantine/form';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { createMission } from '../api/missions';
|
import { createMission, listMissions } from '../api/missions';
|
||||||
import { MissionsTable } from '../components/MissionsTable/MissionsTable';
|
import { MissionsTable } from '../components/MissionsTable/MissionsTable';
|
||||||
|
|
||||||
export const Route = createFileRoute('/missions')({
|
export const Route = createFileRoute('/missions')({
|
||||||
@@ -19,11 +21,27 @@ export const Route = createFileRoute('/missions')({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function MissionsPage() {
|
function MissionsPage() {
|
||||||
|
const missionsQuery = useQuery({
|
||||||
|
queryKey: ['missions'],
|
||||||
|
queryFn: listMissions,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack maw={900}>
|
<Stack maw={900}>
|
||||||
<Title order={2}>Missions</Title>
|
<Title order={2}>Missions</Title>
|
||||||
<CreateMissionForm />
|
<CreateMissionForm />
|
||||||
<MissionsTable />
|
{missionsQuery.isPending ? <Loader /> : null}
|
||||||
|
{missionsQuery.isError ? (
|
||||||
|
<Alert color="red" title="Could not load missions">
|
||||||
|
An error has occurred!
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
{missionsQuery.isSuccess && missionsQuery.data ? (
|
||||||
|
<MissionsTable
|
||||||
|
missions={missionsQuery.data}
|
||||||
|
invalidateMissions={() => missionsQuery.refetch()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -91,4 +109,4 @@ function CreateMissionForm() {
|
|||||||
</form>
|
</form>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { toReadableDateTime } from "./dateUtils";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubEnv('TZ', 'UTC');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dateUtils', () => {
|
||||||
|
describe('toReadableDateTime', () => {
|
||||||
|
it('returns invalid date when datetime passed is in incorrect format', () => {
|
||||||
|
const date = toReadableDateTime('invalid');
|
||||||
|
|
||||||
|
expect(date).toBe("Invalid Date");
|
||||||
|
});
|
||||||
|
|
||||||
|
it ('returns readable date when datetime passed is in valid format', () => {
|
||||||
|
const date = toReadableDateTime('2026-07-27T23:27:22.1976394+00:00');
|
||||||
|
|
||||||
|
expect(date).toBe('27/07/2026, 23:27:22');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
export function toReadableDateTime(dateTime: string) {
|
export function toReadableDateTime(dateTime: string) {
|
||||||
const date = new Date(dateTime);
|
const date = new Date(dateTime);
|
||||||
|
|
||||||
return date.toLocaleString();
|
return date.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user