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
155 changes: 155 additions & 0 deletions src/api/__tests__/live-issues.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* @jest-environment node
*/
import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth';
import {
createIssueTeamsTeamIdIssuesPost,
deleteIssueIssuesIssueIdDelete,
getIssueIssuesIssueIdGet,
listIssuesTeamsTeamIdIssuesGet,
updateIssueIssuesIssueIdPatch,
} from '@/api/generated/endpoints/issues/issues';
import { createLabelTeamsTeamIdLabelsPost } from '@/api/generated/endpoints/labels/labels';
import { createProjectTeamsTeamIdProjectsPost } from '@/api/generated/endpoints/projects/projects';
import { listStatusesTeamsTeamIdStatusesGet } from '@/api/generated/endpoints/statuses/statuses';
import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams';
import type { StatusRead, TeamRead } from '@/api/generated/models';
import { setInstanceUrl } from '@/api/instance';
import { persistToken } from '@/auth/session';
import { ESTIMATE_SCALE } from '@/issues/use-issue-properties';

/**
* Creating and editing issues against a real instance.
*
* The acceptance criterion for #6 is that an issue created on mobile is
* indistinguishable from a web-created one, so these assert the identifier
* shape and that every property round-trips -- including the two that mean
* something by being absent.
*
* SOFTTRACK_LIVE_URL=http://localhost:8000 npm test
*/
const LIVE_URL = process.env.SOFTTRACK_LIVE_URL;
const describeLive = LIVE_URL ? describe : describe.skip;

const RUN = Date.now().toString(36).slice(-5);

describeLive('issues against a live instance', () => {
let team: TeamRead;
let statuses: StatusRead[];
const created: number[] = [];

beforeAll(async () => {
await setInstanceUrl(LIVE_URL!);
const token = await loginAuthLoginPost({
username: process.env.SOFTTRACK_LIVE_EMAIL ?? 'demo@softtrack.dev',
password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123',
});
await persistToken(token.access_token);
team = (await listMyTeamsTeamsGet())[0];
statuses = await listStatusesTeamsTeamIdStatusesGet(team.id);
});

afterAll(async () => {
// These are real rows; do not leave them behind.
for (const id of created) {
await deleteIssueIssuesIssueIdDelete(id).catch(() => undefined);
}
});

it('creates an issue that looks exactly like a web-created one', async () => {
const issue = await createIssueTeamsTeamIdIssuesPost(team.id, {
title: `Mobile create ${RUN}`,
});
created.push(issue.id);

// TEAMKEY-n, allocated by the server -- the client never invents one.
expect(issue.identifier).toBe(`${team.key}-${issue.number}`);
expect(issue.team_id).toBe(team.id);
expect(issue.creator).toHaveProperty('id');
// The defaults a title-only issue gets.
expect(issue.priority).toBe('no_priority');
expect(issue.estimate ?? null).toBeNull();
expect(issue.assignee ?? null).toBeNull();
// A status is assigned even though none was asked for.
expect(statuses.map((s) => s.id)).toContain(issue.status.id);
});

it('round-trips every property the form offers', async () => {
const label = await createLabelTeamsTeamIdLabelsPost(team.id, {
name: `mob-${RUN}`,
});
const project = await createProjectTeamsTeamIdProjectsPost(team.id, {
name: `Mobile ${RUN}`,
});
const status = statuses[1] ?? statuses[0];

const issue = await createIssueTeamsTeamIdIssuesPost(team.id, {
title: `Mobile full ${RUN}`,
description: '## Heading\n\n- [ ] a task',
status_id: status.id,
priority: 'high',
estimate: ESTIMATE_SCALE[3],
label_ids: [label.id],
project_id: project.id,
});
created.push(issue.id);

expect(issue.title).toBe(`Mobile full ${RUN}`);
expect(issue.description).toContain('## Heading');
expect(issue.status.id).toBe(status.id);
expect(issue.priority).toBe('high');
expect(issue.estimate).toBe(ESTIMATE_SCALE[3]);
expect(issue.project_id).toBe(project.id);
expect((issue.labels ?? []).map((l) => l.id)).toEqual([label.id]);

// And it reads back the same, which is what the detail screen renders.
const fetched = await getIssueIssuesIssueIdGet(issue.id);
expect(fetched.identifier).toBe(issue.identifier);
expect(fetched.estimate).toBe(ESTIMATE_SCALE[3]);
});

it('accepts the estimate scale the picker offers, and nothing else', async () => {
const issue = await createIssueTeamsTeamIdIssuesPost(team.id, {
title: `Mobile estimate ${RUN}`,
});
created.push(issue.id);

for (const points of ESTIMATE_SCALE) {
const updated = await updateIssueIssuesIssueIdPatch(issue.id, { estimate: points });
expect(updated.estimate).toBe(points);
}

// Off-scale values are refused, which is why the picker is a fixed list.
await expect(
updateIssueIssuesIssueIdPatch(issue.id, { estimate: 4 as never }),
).rejects.toMatchObject({ response: { status: 422 } });
});

it('clears a property back to nothing', async () => {
// "Not sized" and "unassigned" are states, not the absence of an edit --
// the pickers offer them as rows and null is what expresses them.
const issue = await createIssueTeamsTeamIdIssuesPost(team.id, {
title: `Mobile clear ${RUN}`,
estimate: 5,
});
created.push(issue.id);
expect(issue.estimate).toBe(5);

const cleared = await updateIssueIssuesIssueIdPatch(issue.id, { estimate: null });
expect(cleared.estimate ?? null).toBeNull();
});

it('deletes an issue and stops listing it', async () => {
const issue = await createIssueTeamsTeamIdIssuesPost(team.id, {
title: `Mobile delete ${RUN}`,
});

await deleteIssueIssuesIssueIdDelete(issue.id);

await expect(getIssueIssuesIssueIdGet(issue.id)).rejects.toMatchObject({
response: { status: 404 },
});
const page = await listIssuesTeamsTeamIdIssuesGet(team.id, { limit: 200 });
expect(page.items.map((i) => i.id)).not.toContain(issue.id);
});
});
4 changes: 4 additions & 0 deletions src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ function RootNavigator() {
structurally impossible. */}
<Stack.Protected guard={status !== 'signedOut'}>
<Stack.Screen name="(app)" />
{/* Pushed over the tabs rather than being tab destinations: both are
things you enter from somewhere and come back from. */}
<Stack.Screen name="issue/[id]" />
<Stack.Screen name="new-issue" options={{ presentation: 'modal' }} />
</Stack.Protected>
<Stack.Protected guard={status === 'signedOut'}>
<Stack.Screen name="(auth)" />
Expand Down
1 change: 1 addition & 0 deletions src/app/issue/[id].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { IssueDetailScreen as default } from '@/issues/issue-detail-screen';
1 change: 1 addition & 0 deletions src/app/new-issue.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { NewIssueScreen as default } from '@/issues/new-issue-screen';
33 changes: 31 additions & 2 deletions src/board/board-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useTeamData } from '@/board/use-team-data';
import { NewTeamSheet } from '@/team/new-team-sheet';
import { useTeams } from '@/team/team-context';
import { TeamSwitcher } from '@/team/team-switcher';
import { href } from '@/ui/href';
import { Icon } from '@/ui/icon';
import { useIsMultiPane } from '@/ui/layout';
import { AppText, Button, Loading, TeamBadge } from '@/ui/primitives';
Expand Down Expand Up @@ -155,10 +156,14 @@ export function BoardScreen() {
issues={issues}
estimates={data.estimates}
onMove={onMove}
onCardPress={setMoving}
onCardPress={(issue) => router.push(href(`/issue/${issue.id}`))}
/>
) : (
<IssueList issues={issues} onMovePress={setMoving} />
<IssueList
issues={issues}
onPress={(issue) => router.push(href(`/issue/${issue.id}`))}
onMovePress={setMoving}
/>
)}

<FilterSheet
Expand Down Expand Up @@ -221,6 +226,30 @@ export function BoardScreen() {
</View>
</Sheet>

<Pressable
accessibilityRole="button"
accessibilityLabel="New issue"
onPress={() => router.push('/new-issue')}
style={{
position: 'absolute',
right: 20,
bottom: 24,
width: 56,
height: 56,
borderRadius: 28,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: t.brand[600],
shadowColor: '#000',
shadowOpacity: 0.25,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 6,
}}
>
<Icon name="plus" size={24} color="#ffffff" />
</Pressable>

<TeamSwitcher
visible={switching}
onClose={() => setSwitching(false)}
Expand Down
3 changes: 3 additions & 0 deletions src/board/issue-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import { useTokens } from '@/ui/theme';
*/
export function IssueList({
issues,
onPress,
onMovePress,
}: {
issues: IssueRead[];
onPress: (issue: IssueRead) => void;
onMovePress: (issue: IssueRead) => void;
}) {
const t = useTokens();
Expand All @@ -35,6 +37,7 @@ export function IssueList({
<Pressable
accessibilityRole="button"
accessibilityLabel={`${item.identifier} ${item.title}`}
onPress={() => onPress(item)}
onLongPress={() => onMovePress(item)}
style={{
flexDirection: 'row',
Expand Down
31 changes: 31 additions & 0 deletions src/issues/__tests__/use-issue-properties.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { labelFor } from '@/issues/use-issue-properties';

const OPTIONS = [
{ value: 1, label: 'Todo', color: '#abc' },
{ value: 2, label: 'Done', color: '#def' },
];

describe('labelFor', () => {
it('names the chosen value and carries its colour', () => {
expect(labelFor(OPTIONS, 1, 'None')).toEqual({
text: 'Todo',
color: '#abc',
muted: false,
});
});

it('shows the empty label when nothing is chosen', () => {
// "Unassigned" and "Not sized" are real states the API distinguishes, so
// they read as values rather than as a blank row.
expect(labelFor(OPTIONS, null, 'Unassigned')).toEqual({
text: 'Unassigned',
muted: true,
});
});

it('still renders a value whose row the team has deleted', () => {
// An issue can outlive the label or project it points at; the row has to
// draw something rather than crash or vanish.
expect(labelFor(OPTIONS, 99, 'None')).toEqual({ text: '99', muted: true });
});
});
Loading
Loading