diff --git a/src/api/__tests__/live-issues.test.ts b/src/api/__tests__/live-issues.test.ts new file mode 100644 index 0000000..52834ed --- /dev/null +++ b/src/api/__tests__/live-issues.test.ts @@ -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); + }); +}); diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index ea2a634..118f657 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -59,6 +59,10 @@ function RootNavigator() { structurally impossible. */} + {/* Pushed over the tabs rather than being tab destinations: both are + things you enter from somewhere and come back from. */} + + diff --git a/src/app/issue/[id].tsx b/src/app/issue/[id].tsx new file mode 100644 index 0000000..9128aa0 --- /dev/null +++ b/src/app/issue/[id].tsx @@ -0,0 +1 @@ +export { IssueDetailScreen as default } from '@/issues/issue-detail-screen'; diff --git a/src/app/new-issue.tsx b/src/app/new-issue.tsx new file mode 100644 index 0000000..f9c83b3 --- /dev/null +++ b/src/app/new-issue.tsx @@ -0,0 +1 @@ +export { NewIssueScreen as default } from '@/issues/new-issue-screen'; diff --git a/src/board/board-screen.tsx b/src/board/board-screen.tsx index d21b2b2..2b4f7ec 100644 --- a/src/board/board-screen.tsx +++ b/src/board/board-screen.tsx @@ -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'; @@ -155,10 +156,14 @@ export function BoardScreen() { issues={issues} estimates={data.estimates} onMove={onMove} - onCardPress={setMoving} + onCardPress={(issue) => router.push(href(`/issue/${issue.id}`))} /> ) : ( - + router.push(href(`/issue/${issue.id}`))} + onMovePress={setMoving} + /> )} + 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, + }} + > + + + setSwitching(false)} diff --git a/src/board/issue-list.tsx b/src/board/issue-list.tsx index 2c4568e..66bda84 100644 --- a/src/board/issue-list.tsx +++ b/src/board/issue-list.tsx @@ -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(); @@ -35,6 +37,7 @@ export function IssueList({ onPress(item)} onLongPress={() => onMovePress(item)} style={{ flexDirection: 'row', diff --git a/src/issues/__tests__/use-issue-properties.test.ts b/src/issues/__tests__/use-issue-properties.test.ts new file mode 100644 index 0000000..4d2d7d6 --- /dev/null +++ b/src/issues/__tests__/use-issue-properties.test.ts @@ -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 }); + }); +}); diff --git a/src/issues/issue-detail-screen.tsx b/src/issues/issue-detail-screen.tsx new file mode 100644 index 0000000..7567228 --- /dev/null +++ b/src/issues/issue-detail-screen.tsx @@ -0,0 +1,386 @@ +import { useState } from 'react'; +import { Alert as RNAlert, Pressable, ScrollView, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { router, useLocalSearchParams } from 'expo-router'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + deleteIssueIssuesIssueIdDelete, + updateIssueIssuesIssueIdPatch, + useGetIssueIssuesIssueIdGet, +} from '@/api/generated/endpoints/issues/issues'; +import type { IssuePriority, IssueUpdate } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { + InlineCreate, + MultiPicker, + PropertyRow, + SinglePicker, +} from '@/issues/property-picker'; +import { labelFor, useIssueProperties } from '@/issues/use-issue-properties'; +import { useTeams } from '@/team/team-context'; +import { Icon } from '@/ui/icon'; +import { Alert, AppText, Button, Card, Field, Loading } from '@/ui/primitives'; +import { useTokens } from '@/ui/theme'; + +/** + * One issue, per `docs/design/mobile/144-issue-detail.svg`. + * + * Properties are written through as they are picked rather than collected + * behind a Save button: each is a single-field PATCH, and a form that has to be + * submitted invites half-made edits to be lost when the screen is dismissed. + * Title and description are the exception -- they are typed, so they save on + * blur. + */ +type Picker = + | 'status' + | 'priority' + | 'estimate' + | 'assignee' + | 'labels' + | 'project' + | 'cycle' + | null; + +export function IssueDetailScreen() { + const t = useTokens(); + const queryClient = useQueryClient(); + const { id } = useLocalSearchParams<{ id: string }>(); + const issueId = Number(id); + const { teams } = useTeams(); + + const issueQuery = useGetIssueIssuesIssueIdGet(issueId, { + query: { enabled: Number.isInteger(issueId) }, + }); + const issue = issueQuery.data; + + // The issue names its own team, which is not necessarily the active one -- + // a link can arrive from anywhere. + const team = teams.find((candidate) => candidate.id === issue?.team_id); + const props = useIssueProperties(team); + + const [picker, setPicker] = useState(null); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [title, setTitle] = useState(null); + const [description, setDescription] = useState(null); + + async function patch(update: IssueUpdate) { + if (!issue) return; + setError(null); + setSaving(true); + try { + await updateIssueIssuesIssueIdPatch(issue.id, update); + await queryClient.invalidateQueries({ queryKey: [`/issues/${issue.id}`] }); + // The board groups by status and totals points, so both have to re-read. + void queryClient.invalidateQueries({ queryKey: [`/teams/${issue.team_id}/issues`] }); + void queryClient.invalidateQueries({ queryKey: [`/teams/${issue.team_id}/estimates`] }); + } catch (err) { + setError(errorDetail(err, 'Could not save that change.')); + } finally { + setSaving(false); + } + } + + function confirmDelete() { + if (!issue) return; + RNAlert.alert( + `Delete ${issue.identifier}?`, + 'This also deletes its attachments. It cannot be undone.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + try { + await deleteIssueIssuesIssueIdDelete(issue.id); + await queryClient.invalidateQueries({ + queryKey: [`/teams/${issue.team_id}/issues`], + }); + router.back(); + } catch (err) { + setError(errorDetail(err, 'Could not delete the issue.')); + } + }, + }, + ], + ); + } + + if (issueQuery.isPending) return ; + + if (!issue) { + return ( + + + Issue not found + + It may have been deleted, or belong to a team you are not on. + + + + + ); + } + + const status = labelFor(props.statusOptions, issue.status.id, 'None'); + const priorityMeta = labelFor(props.priorityOptions, issue.priority, 'No priority'); + const points = labelFor(props.estimateOptions, issue.estimate ?? null, 'Not sized'); + const assignee = labelFor(props.assigneeOptions, issue.assignee?.id ?? null, 'Unassigned'); + const project = labelFor(props.projectOptions, issue.project_id ?? null, 'None'); + const cycle = labelFor(props.cycleOptions, issue.cycle_id ?? null, 'None'); + const labelIds = (issue.labels ?? []).map((label) => label.id); + + return ( + + + router.back()} + > + Back + + + {issue.identifier} + + {saving ? ( + Saving… + ) : ( + + )} + + + + {error ? {error} : null} + + {issue.blocked_by_count > 0 ? ( + + + + Blocked by {issue.blocked_by_count} issue + {issue.blocked_by_count === 1 ? '' : 's'} + + + ) : null} + + + { + if (title !== null && title.trim() && title.trim() !== issue.title) { + void patch({ title: title.trim() }); + } + setTitle(null); + }} + /> + { + if (description !== null && description !== (issue.description ?? '')) { + void patch({ description: description || null }); + } + setDescription(null); + }} + /> + + + + setPicker('status')} + /> + setPicker('priority')} + /> + setPicker('estimate')} + /> + setPicker('assignee')} + /> + label.name).join(', ') + } + muted={labelIds.length === 0} + onPress={() => setPicker('labels')} + /> + setPicker('project')} + /> + setPicker('cycle')} + /> + + + + + Opened by {issue.creator.full_name} + + {issue.child_count > 0 ? ( + + {issue.completed_child_count} of {issue.child_count} sub-issues done + + ) : null} + + + + + + Delete issue + + + + + value !== null && void patch({ status_id: value })} + onClose={() => setPicker(null)} + /> + void patch({ priority: (value ?? 'no_priority') as IssuePriority })} + onClose={() => setPicker(null)} + /> + void patch({ estimate: value as never })} + onClose={() => setPicker(null)} + /> + void patch({ assignee_id: value })} + onClose={() => setPicker(null)} + /> + + void patch({ + label_ids: labelIds.includes(labelId) + ? labelIds.filter((x) => x !== labelId) + : [...labelIds, labelId], + }) + } + onClose={() => setPicker(null)} + footer={ + { + const newId = await props.createLabel(name); + if (newId !== null) void patch({ label_ids: [...labelIds, newId] }); + }} + /> + } + /> + void patch({ project_id: value })} + onClose={() => setPicker(null)} + footer={ + { + const newId = await props.createProject(name); + if (newId !== null) void patch({ project_id: newId }); + }} + /> + } + /> + void patch({ cycle_id: value })} + onClose={() => setPicker(null)} + /> + + ); +} diff --git a/src/issues/new-issue-screen.tsx b/src/issues/new-issue-screen.tsx new file mode 100644 index 0000000..579efa7 --- /dev/null +++ b/src/issues/new-issue-screen.tsx @@ -0,0 +1,301 @@ +import { useState } from 'react'; +import { KeyboardAvoidingView, Platform, Pressable, ScrollView, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { router } from 'expo-router'; +import { useQueryClient } from '@tanstack/react-query'; + +import { createIssueTeamsTeamIdIssuesPost } from '@/api/generated/endpoints/issues/issues'; +import type { IssuePriority } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { + InlineCreate, + MultiPicker, + PropertyRow, + SinglePicker, +} from '@/issues/property-picker'; +import { labelFor, useIssueProperties } from '@/issues/use-issue-properties'; +import { useTeams } from '@/team/team-context'; +import { href } from '@/ui/href'; +import { Alert, AppText, Button, Card, Field } from '@/ui/primitives'; +import { useTokens } from '@/ui/theme'; + +/** + * Create an issue, per `docs/design/mobile/143-new-issue.svg`. + * + * Every property the web's new-issue modal offers, as tappable rows rather than + * a column of `` elements, which a phone has no equivalent of worth + * having. A sheet of rows is the mobile idiom, and it is the same component for + * creating an issue and for editing one -- the difference is only whether the + * change is held locally or written straight through. + */ +export type PickerOption = { + value: T; + label: string; + /** A dot before the label: status colours, label colours, priority. */ + color?: string; + hint?: string; +}; + +/** A tappable row showing a property's current value, opening the sheet. */ +export function PropertyRow({ + label, + value, + color, + onPress, + muted = false, +}: { + label: string; + value: string; + color?: string; + onPress: () => void; + muted?: boolean; +}) { + const t = useTokens(); + return ( + + + {label} + + {color ? ( + + ) : null} + + {value} + + + + ); +} + +function OptionRow({ + label, + color, + hint, + selected, + onPress, +}: { + label: string; + color?: string; + hint?: string; + selected: boolean; + onPress: () => void; +}) { + const t = useTokens(); + return ( + + {color ? ( + + ) : null} + + + {label} + + {hint ? {hint} : null} + + {selected ? : null} + + ); +} + +/** + * Pick one value, or none. + * + * `null` is a real choice here rather than an absent one -- "no assignee" and + * "not sized yet" are meaningful states the API distinguishes, so they get their + * own row instead of being expressed by closing the sheet. + */ +export function SinglePicker({ + visible, + title, + options, + selected, + emptyLabel, + onSelect, + onClose, + footer, +}: { + visible: boolean; + title: string; + options: PickerOption[]; + selected: T | null; + /** Row for "none"; omitted when the property cannot be empty. */ + emptyLabel?: string; + onSelect: (value: T | null) => void; + onClose: () => void; + footer?: ReactNode; +}) { + return ( + + + {emptyLabel ? ( + { + onSelect(null); + onClose(); + }} + /> + ) : null} + {options.map((option) => ( + { + onSelect(option.value); + onClose(); + }} + /> + ))} + {footer} + + + ); +} + +/** + * Pick any number of values. + * + * Stays open as things are ticked, because labels are chosen in handfuls -- + * closing after each one would mean reopening the sheet for every label. + */ +export function MultiPicker({ + visible, + title, + options, + selected, + onToggle, + onClose, + footer, +}: { + visible: boolean; + title: string; + options: PickerOption[]; + selected: T[]; + onToggle: (value: T) => void; + onClose: () => void; + footer?: ReactNode; +}) { + return ( + + + {options.map((option) => ( + onToggle(option.value)} + /> + ))} + {options.length === 0 ? ( + + Nothing to choose from yet. + + ) : null} + {footer} + + + ); +} + +/** + * The inline "create one while you are here" row. + * + * The web lets any member add a label or project from the issue form, and + * needing to leave for team settings to add a label mid-triage would be worse + * on a phone than it is on a desktop. + */ +export function InlineCreate({ + placeholder, + busy, + onCreate, +}: { + placeholder: string; + busy: boolean; + onCreate: (name: string) => void; +}) { + const t = useTokens(); + const [name, setName] = useState(''); + + return ( + + { + if (name.trim()) { + onCreate(name.trim()); + setName(''); + } + }} + /> + + + ); +} diff --git a/src/issues/use-issue-properties.ts b/src/issues/use-issue-properties.ts new file mode 100644 index 0000000..c97874e --- /dev/null +++ b/src/issues/use-issue-properties.ts @@ -0,0 +1,125 @@ +import { useCallback, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + +import { createLabelTeamsTeamIdLabelsPost } from '@/api/generated/endpoints/labels/labels'; +import { createProjectTeamsTeamIdProjectsPost } from '@/api/generated/endpoints/projects/projects'; +import type { IssuePriority, TeamRead } from '@/api/generated/models'; +import { useTeamData } from '@/board/use-team-data'; +import { PRIORITY_LABEL, PRIORITY_ORDER, priorityColor } from '@/issues/issue-meta'; +import type { PickerOption } from '@/issues/property-picker'; +import { useTokens } from '@/ui/theme'; + +/** The story-point scale, mirroring ESTIMATE_SCALE in the backend's issue model. */ +export const ESTIMATE_SCALE = [1, 2, 3, 5, 8] as const; + +/** + * The option lists every issue picker needs, plus inline creation for the two + * the web lets any member add from the issue form. + */ +export function useIssueProperties(team: TeamRead | undefined) { + const t = useTokens(); + const queryClient = useQueryClient(); + const data = useTeamData(team); + const [creating, setCreating] = useState(false); + + const statusOptions: PickerOption[] = data.statuses.map((status) => ({ + value: status.id, + label: status.name, + color: status.color, + })); + + const priorityOptions: PickerOption[] = PRIORITY_ORDER.map((priority) => ({ + value: priority, + label: PRIORITY_LABEL[priority], + color: priorityColor(priority, t), + })); + + const estimateOptions: PickerOption[] = ESTIMATE_SCALE.map((points) => ({ + value: points, + label: `${points} point${points === 1 ? '' : 's'}`, + })); + + const assigneeOptions: PickerOption[] = data.members.map((member) => ({ + value: member.user.id, + label: member.user.full_name, + color: member.user.avatar_color, + })); + + const labelOptions: PickerOption[] = data.labels.map((label) => ({ + value: label.id, + label: label.name, + color: label.color, + })); + + const projectOptions: PickerOption[] = data.projects.map((project) => ({ + value: project.id, + label: project.name, + })); + + const cycleOptions: PickerOption[] = data.cycles.map((cycle) => ({ + value: cycle.id, + label: cycle.display_name, + hint: cycle.state, + })); + + const createLabel = useCallback( + async (name: string): Promise => { + if (!team) return null; + setCreating(true); + try { + const label = await createLabelTeamsTeamIdLabelsPost(team.id, { name }); + await queryClient.invalidateQueries({ queryKey: [`/teams/${team.id}/labels`] }); + return label.id; + } catch { + return null; + } finally { + setCreating(false); + } + }, + [team, queryClient], + ); + + const createProject = useCallback( + async (name: string): Promise => { + if (!team) return null; + setCreating(true); + try { + const project = await createProjectTeamsTeamIdProjectsPost(team.id, { name }); + await queryClient.invalidateQueries({ queryKey: [`/teams/${team.id}/projects`] }); + return project.id; + } catch { + return null; + } finally { + setCreating(false); + } + }, + [team, queryClient], + ); + + return { + ...data, + statusOptions, + priorityOptions, + estimateOptions, + assigneeOptions, + labelOptions, + projectOptions, + cycleOptions, + createLabel, + createProject, + creating, + }; +} + +/** Look a value's label up for a property row, falling back to a placeholder. */ +export function labelFor( + options: PickerOption[], + value: T | null, + empty: string, +): { text: string; color?: string; muted: boolean } { + if (value === null) return { text: empty, muted: true }; + const match = options.find((option) => option.value === value); + // A property naming a row the team has since deleted still has to render. + if (!match) return { text: String(value), muted: true }; + return { text: match.label, color: match.color, muted: false }; +} diff --git a/src/ui/icon.tsx b/src/ui/icon.tsx index e4b68ac..5faf38d 100644 --- a/src/ui/icon.tsx +++ b/src/ui/icon.tsx @@ -20,7 +20,9 @@ export type IconName = | 'sun' | 'moon' | 'check' - | 'logout'; + | 'logout' + | 'trash' + | 'plus'; export function Icon({ name, @@ -111,6 +113,15 @@ export function Icon({ {name === 'check' ? : null} + {name === 'plus' ? : null} + + {name === 'trash' ? ( + + ) : null} + {name === 'logout' ? (