From f96ea1fd2ab49326db3ae3e4a7f25fc4d6da8b85 Mon Sep 17 00:00:00 2001 From: Waleed Khalid Date: Fri, 11 Sep 2026 21:45:59 +0500 Subject: [PATCH 01/18] Add teams home, switcher, creation and /ENG links Closes #4. A tab bar has no room for the web's per-team URLs, where every board is its own address. So the active team lives in a context and is persisted, and /ENG is a redirect that sets it rather than a second shell. Lowercase tab routes can never be shadowed by a team key, because the backend uppercases keys on the way in. Member counts cost one request per team and that is deliberate: TeamRead carries no count, GET /teams returns only your own teams, and the design asks for them. Counts stay undefined rather than zero while loading or on failure, so a row shows no count instead of claiming a team is empty. A member_count field upstream would remove the fan-out and help the web too. The switcher and the create form share one sheet component that enters from the bottom on a phone and from the side on anything wider, because a full-width panel sliding up over a multi-pane layout would cover work still being read. The key is suggested from the name rather than imposed, since it is permanent once created. Co-Authored-By: Claude Opus 5 --- src/app/(app)/board.tsx | 91 +++++++++++- src/app/[teamKey].tsx | 1 + src/app/_layout.tsx | 16 ++- src/storage/keys.ts | 3 + src/team/__tests__/new-team-sheet.test.ts | 25 ++++ src/team/new-team-sheet.tsx | 119 ++++++++++++++++ src/team/team-context.tsx | 82 +++++++++++ src/team/team-route.tsx | 44 ++++++ src/team/team-switcher.tsx | 101 +++++++++++++ src/team/teams-home.tsx | 165 ++++++++++++++++------ src/team/use-member-counts.ts | 43 ++++++ src/ui/sheet.tsx | 95 +++++++++++++ 12 files changed, 736 insertions(+), 49 deletions(-) create mode 100644 src/app/[teamKey].tsx create mode 100644 src/team/__tests__/new-team-sheet.test.ts create mode 100644 src/team/new-team-sheet.tsx create mode 100644 src/team/team-context.tsx create mode 100644 src/team/team-route.tsx create mode 100644 src/team/team-switcher.tsx create mode 100644 src/team/use-member-counts.ts create mode 100644 src/ui/sheet.tsx diff --git a/src/app/(app)/board.tsx b/src/app/(app)/board.tsx index e30adab..3495754 100644 --- a/src/app/(app)/board.tsx +++ b/src/app/(app)/board.tsx @@ -1,5 +1,92 @@ -import { Placeholder } from '@/ui/placeholder'; +import { useState } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { NewTeamSheet } from '@/team/new-team-sheet'; +import { useTeams } from '@/team/team-context'; +import { TeamSwitcher } from '@/team/team-switcher'; +import { Icon } from '@/ui/icon'; +import { useIsMultiPane } from '@/ui/layout'; +import { AppText, Button, TeamBadge } from '@/ui/primitives'; +import { useTokens } from '@/ui/theme'; + +/** + * Board — the team-scoped destination. + * + * The board itself arrives with issue #5. What is here now is the part that + * belongs to the team work: the header that says which team you are looking at + * and the switcher that changes it, which is the affordance the web puts in its + * sidebar and a tab bar has nowhere else to put. + */ export default function BoardRoute() { - return ; + const t = useTokens(); + const multiPane = useIsMultiPane(); + const { team, teams, isPending } = useTeams(); + + const [switching, setSwitching] = useState(false); + const [creating, setCreating] = useState(false); + + return ( + + + {team ? ( + setSwitching(true)} + style={{ flexDirection: 'row', alignItems: 'center', gap: 10, flex: 1 }} + > + + + + {team.name} + + + + + ) : ( + Board + )} + + + + {isPending ? null : teams.length === 0 ? ( + <> + No teams yet + + A board belongs to a team. Create one to get started. + + + + ) : ( + <> + {team?.key} board + + Columns, filters and drag-and-drop arrive with issue #5. + + + )} + + + setSwitching(false)} + onCreateTeam={() => setCreating(true)} + /> + setCreating(false)} /> + + ); } diff --git a/src/app/[teamKey].tsx b/src/app/[teamKey].tsx new file mode 100644 index 0000000..5d709ba --- /dev/null +++ b/src/app/[teamKey].tsx @@ -0,0 +1 @@ +export { TeamRoute as default } from '@/team/team-route'; diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index bd518df..47dffa7 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -12,6 +12,7 @@ import { queryClient } from '@/api/query-client'; import { AuthProvider, useAuth } from '@/auth/auth-context'; import { hydrateSession } from '@/auth/session'; import { useDeepLinkCapture } from '@/auth/use-deep-link-capture'; +import { hydrateActiveTeam, TeamProvider } from '@/team/team-context'; import { AppText, Button, Loading } from '@/ui/primitives'; import { hydrateTheme, @@ -32,7 +33,7 @@ import { */ void SplashScreen.preventAutoHideAsync(); -type Hydrated = { theme: ThemePreference }; +type Hydrated = { theme: ThemePreference; teamKey: string | null }; function RootNavigator() { const { status } = useAuth(); @@ -61,6 +62,10 @@ function RootNavigator() { + + {/* Guarded by neither: a team link has to resolve whatever session it + finds, and decides for itself what to do about it. */} + ); } @@ -142,12 +147,13 @@ export default function RootLayout() { // the instance URL, and the token -- each into its module-level mirror so // the axios interceptor can read them synchronously from the first request. void (async () => { - const [theme] = await Promise.all([ + const [theme, , , teamKey] = await Promise.all([ hydrateTheme(), hydrateInstanceUrl(), hydrateSession(), + hydrateActiveTeam(), ]); - setHydrated({ theme }); + setHydrated({ theme, teamKey }); })(); }, []); @@ -159,7 +165,9 @@ export default function RootLayout() { - + + + diff --git a/src/storage/keys.ts b/src/storage/keys.ts index b9f3a4f..be3e630 100644 --- a/src/storage/keys.ts +++ b/src/storage/keys.ts @@ -11,3 +11,6 @@ export const THEME_KEY = 'softtrack.theme'; /** Base URL of the instance the user signed in to. */ export const INSTANCE_KEY = 'softtrack.instance'; + +/** The team key whose board the Board tab shows. */ +export const ACTIVE_TEAM_KEY = 'softtrack.team'; diff --git a/src/team/__tests__/new-team-sheet.test.ts b/src/team/__tests__/new-team-sheet.test.ts new file mode 100644 index 0000000..20f243a --- /dev/null +++ b/src/team/__tests__/new-team-sheet.test.ts @@ -0,0 +1,25 @@ +import { suggestKey } from '@/team/new-team-sheet'; + +describe('suggestKey', () => { + it('takes initials from a multi-word name', () => { + expect(suggestKey('Mobile Platform')).toBe('MP'); + expect(suggestKey('Site Reliability Engineering')).toBe('SRE'); + }); + + it('truncates a single word', () => { + expect(suggestKey('Engineering')).toBe('ENG'); + expect(suggestKey('Ops')).toBe('OPS'); + }); + + it('stops at three initials, since the key caps at six characters', () => { + expect(suggestKey('One Two Three Four Five')).toBe('OTT'); + }); + + it('ignores anything that is not a letter', () => { + // The backend uppercases the key and the field strips non-letters, so a + // suggestion containing either would never survive submission. + expect(suggestKey('design-systems')).toBe('DS'); + expect(suggestKey(' ')).toBe(''); + expect(suggestKey('123')).toBe(''); + }); +}); diff --git a/src/team/new-team-sheet.tsx b/src/team/new-team-sheet.tsx new file mode 100644 index 0000000..5d9ab2d --- /dev/null +++ b/src/team/new-team-sheet.tsx @@ -0,0 +1,119 @@ +import { useState } from 'react'; +import { View } from 'react-native'; +import { useQueryClient } from '@tanstack/react-query'; + +import { createTeamTeamsPost } from '@/api/generated/endpoints/teams/teams'; +import { errorDetail } from '@/api/errors'; +import { useTeams } from '@/team/team-context'; +import { Alert, AppText, Button, Field } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; + +/** + * Suggest a key from the team name: "Mobile Platform" -> "MOB". + * + * A suggestion only. The key is permanent once created + * (`backend/lib_softtrack/models/teams.py:27-36` -- PATCH accepts name and + * description, never key), so it is offered rather than imposed, and typing in + * the field stops the suggestion from overwriting the choice. + */ +export function suggestKey(name: string): string { + const words = name + .toUpperCase() + .split(/[^A-Z]+/) + .filter(Boolean); + if (words.length === 0) return ''; + if (words.length === 1) return words[0].slice(0, 3); + return words + .slice(0, 3) + .map((word) => word[0]) + .join(''); +} + +export function NewTeamSheet({ + visible, + onClose, +}: { + visible: boolean; + onClose: () => void; +}) { + const queryClient = useQueryClient(); + const { setTeamKey } = useTeams(); + + const [name, setName] = useState(''); + const [typedKey, setTypedKey] = useState(null); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + // Derived, not synced: the suggestion applies until someone types their own. + const key = typedKey ?? suggestKey(name); + + function reset() { + setName(''); + setTypedKey(null); + setError(null); + } + + async function submit() { + setError(null); + if (key.length < 2) { + setError('A key needs at least 2 letters.'); + return; + } + + setSubmitting(true); + try { + const team = await createTeamTeamsPost({ name: name.trim(), key }); + await queryClient.invalidateQueries({ queryKey: ['/teams'], refetchType: 'all' }); + setTeamKey(team.key); + reset(); + onClose(); + } catch (err) { + setError(errorDetail(err, 'Could not create the team.')); + } finally { + setSubmitting(false); + } + } + + return ( + { + reset(); + onClose(); + }} + > + + Teams group issues, cycles and projects. + + {error ? {error} : null} + + + setTypedKey(value.toUpperCase().replace(/[^A-Z]/g, ''))} + placeholder="MOB" + autoCapitalize="characters" + autoCorrect={false} + maxLength={6} + returnKeyType="go" + onSubmitEditing={submit} + hint="Permanent — it appears in issue IDs like ENG-42." + /> + + + + + ); +} diff --git a/src/team/team-context.tsx b/src/team/team-context.tsx new file mode 100644 index 0000000..f8d5062 --- /dev/null +++ b/src/team/team-context.tsx @@ -0,0 +1,82 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useState, + type ReactNode, +} from 'react'; + +import { useListMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; +import type { TeamRead } from '@/api/generated/models'; +import { ACTIVE_TEAM_KEY } from '@/storage/keys'; +import { readPref, writePref } from '@/storage/prefs'; + +/** + * Which team the team-scoped destinations are showing. + * + * The web keys this off the URL (`/:teamKey`, `frontend/src/app/App.tsx:72`) + * because every board view is its own address. A tab bar has no room for that: + * Board is one destination whose subject changes, so the key lives here and is + * persisted, and the `/[teamKey]` route sets it rather than replacing the shell. + */ +type TeamContextValue = { + teams: TeamRead[]; + team: TeamRead | undefined; + teamKey: string | null; + setTeamKey: (key: string) => void; + isPending: boolean; + isError: boolean; +}; + +const TeamContext = createContext(null); + +/** Load the remembered team key. Called once, behind the splash. */ +export async function hydrateActiveTeam(): Promise { + return readPref(ACTIVE_TEAM_KEY); +} + +export function TeamProvider({ + initialTeamKey, + children, +}: { + initialTeamKey: string | null; + children: ReactNode; +}) { + const [storedKey, setStoredKey] = useState(initialTeamKey); + const teamsQuery = useListMyTeamsTeamsGet({ query: { staleTime: 30_000 } }); + const teams = useMemo(() => teamsQuery.data ?? [], [teamsQuery.data]); + + const setTeamKey = useCallback((key: string) => { + setStoredKey(key); + void writePref(ACTIVE_TEAM_KEY, key); + }, []); + + // Fall back to the first team when nothing is remembered, or when the + // remembered one is gone -- left the team, or it was deleted. Without the + // fallback the board would sit empty pointing at a team that no longer exists. + const team = useMemo(() => { + const match = teams.find((t) => t.key.toLowerCase() === storedKey?.toLowerCase()); + return match ?? teams[0]; + }, [teams, storedKey]); + + const value = useMemo( + () => ({ + teams, + team, + teamKey: team?.key ?? null, + setTeamKey, + isPending: teamsQuery.isPending, + isError: teamsQuery.isError, + }), + [teams, team, setTeamKey, teamsQuery.isPending, teamsQuery.isError], + ); + + return {children}; +} + +export function useTeams(): TeamContextValue { + const value = useContext(TeamContext); + if (!value) throw new Error('useTeams must be used inside a TeamProvider'); + return value; +} diff --git a/src/team/team-route.tsx b/src/team/team-route.tsx new file mode 100644 index 0000000..2523795 --- /dev/null +++ b/src/team/team-route.tsx @@ -0,0 +1,44 @@ +import { useEffect } from 'react'; +import { router, useLocalSearchParams } from 'expo-router'; + +import { useAuth } from '@/auth/auth-context'; +import { rememberDestination } from '@/auth/pending-destination'; +import { useTeams } from '@/team/team-context'; +import { Loading } from '@/ui/primitives'; + +/** + * `/ENG` — web URL parity for a team. + * + * The web makes every board its own address (`/:teamKey`, + * `frontend/src/app/App.tsx:72`). A tab bar cannot: Board is one destination + * whose subject changes. So this route is a redirect rather than a screen — it + * sets the active team and hands over to the Board tab, which keeps links from + * the web working without the shell growing a second shape. + * + * Lowercase tab names (`/board`, `/search`) can never be shadowed by this, + * because team keys are uppercased server-side + * (`backend/lib_softtrack/teams.py:69`) and expo-router matches static segments + * case-sensitively. + */ +export function TeamRoute() { + const { teamKey = '' } = useLocalSearchParams<{ teamKey: string }>(); + const { isAuthenticated, status } = useAuth(); + const { teams, setTeamKey, isPending } = useTeams(); + + useEffect(() => { + if (!isAuthenticated) { + // Come back here once there is a session to resolve the key against. + if (status === 'signedOut') rememberDestination(`/${teamKey}`); + return; + } + if (isPending) return; + + const match = teams.find((team) => team.key.toLowerCase() === teamKey.toLowerCase()); + if (match) setTeamKey(match.key); + // An unknown key falls through to Home rather than a dead end -- you may + // have left the team, or the link may be for an instance you are not on. + router.replace(match ? '/board' : '/'); + }, [isAuthenticated, status, isPending, teams, teamKey, setTeamKey]); + + return ; +} diff --git a/src/team/team-switcher.tsx b/src/team/team-switcher.tsx new file mode 100644 index 0000000..e493755 --- /dev/null +++ b/src/team/team-switcher.tsx @@ -0,0 +1,101 @@ +import { Pressable, View } from 'react-native'; + +import { useTeams } from '@/team/team-context'; +import { Icon } from '@/ui/icon'; +import { AppText, TeamBadge } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * Switch which team the team-scoped destinations are showing. + * + * A bottom sheet on a phone and a side sheet on anything wider, per + * `docs/design/mobile/141-teams.svg`, with a checkmark on the active team. + * The web does this with a `` worth having. Only the title is required, so capturing a thought costs one field and a tap. On the detail screen 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 must be submitted invites half-made edits to be lost when the screen is dismissed. Title and description are the exception, because they are typed, so they save on blur. "Not sized" and "Unassigned" get their own rows rather than being expressed by closing the sheet. The API distinguishes them from an absent edit, and the board's point totals read differently for an unsized issue than a zero one. Labels and projects can be created inline, as the web allows any member to do -- leaving for team settings to add a label mid-triage is worse on a phone than at a desk. The detail screen resolves its team from the issue rather than the active one, since a link can arrive from anywhere. Verified against the reference stack: the identifier is server-allocated as TEAMKEY-n, every property round-trips, the estimate scale is exactly what the picker offers (4 is refused), clearing back to null works, and a deleted issue stops being listed. Co-Authored-By: Claude Opus 5 --- src/api/__tests__/live-issues.test.ts | 155 +++++++ src/app/_layout.tsx | 4 + src/app/issue/[id].tsx | 1 + src/app/new-issue.tsx | 1 + src/board/board-screen.tsx | 33 +- src/board/issue-list.tsx | 3 + .../__tests__/use-issue-properties.test.ts | 31 ++ src/issues/issue-detail-screen.tsx | 386 ++++++++++++++++++ src/issues/new-issue-screen.tsx | 301 ++++++++++++++ src/issues/property-picker.tsx | 275 +++++++++++++ src/issues/use-issue-properties.ts | 125 ++++++ src/ui/icon.tsx | 13 +- 12 files changed, 1325 insertions(+), 3 deletions(-) create mode 100644 src/api/__tests__/live-issues.test.ts create mode 100644 src/app/issue/[id].tsx create mode 100644 src/app/new-issue.tsx create mode 100644 src/issues/__tests__/use-issue-properties.test.ts create mode 100644 src/issues/issue-detail-screen.tsx create mode 100644 src/issues/new-issue-screen.tsx create mode 100644 src/issues/property-picker.tsx create mode 100644 src/issues/use-issue-properties.ts 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' ? ( Date: Fri, 11 Sep 2026 22:04:51 +0500 Subject: [PATCH 06/18] Add issue detail sections: sub-issues, links, development, watch Closes #7. Sub-issue progress reads the issue's own child_count and completed_child_count rather than tallying the child list: the server excludes cancelled children from both, so re-deriving it here would quietly disagree. Verified against a live instance -- cancelling a child leaves the denominator rather than counting as outstanding work forever. Only three link types can be created. The inverses (blocked_by, duplicated_by) are derived server-side and appear on the other issue, so they are listed but not offered, with a line in the picker saying so. A blocking link is also what drives the board's blocked marker, so adding one re-reads the issue list. Development is read-only and hides itself when empty -- those rows arrive by webhook from the repository side, so there is nothing to create, and a permanently empty section is just noise on a phone. Watch reads the current state rather than assuming it, because the server auto-watches on assignment and commenting: the toggle reflects something that changes without being touched here. The link target picker filters the team's already-cached issue page on identifier as well as title, since "ENG-142" is what people actually type. Co-Authored-By: Claude Opus 5 --- src/api/__tests__/live-issue-detail.test.ts | 165 +++++++ src/issues/detail/issue-picker.tsx | 112 +++++ src/issues/detail/sections.tsx | 501 ++++++++++++++++++++ src/issues/issue-detail-screen.tsx | 27 +- 4 files changed, 791 insertions(+), 14 deletions(-) create mode 100644 src/api/__tests__/live-issue-detail.test.ts create mode 100644 src/issues/detail/issue-picker.tsx create mode 100644 src/issues/detail/sections.tsx diff --git a/src/api/__tests__/live-issue-detail.test.ts b/src/api/__tests__/live-issue-detail.test.ts new file mode 100644 index 0000000..b5a3a83 --- /dev/null +++ b/src/api/__tests__/live-issue-detail.test.ts @@ -0,0 +1,165 @@ +/** + * @jest-environment node + */ +import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth'; +import { + createIssueLinkIssuesIssueIdLinksPost, + createIssueTeamsTeamIdIssuesPost, + deleteIssueIssuesIssueIdDelete, + deleteIssueLinkIssuesIssueIdLinksLinkIdDelete, + getIssueIssuesIssueIdGet, + listIssueLinksIssuesIssueIdLinksGet, + listIssuesTeamsTeamIdIssuesGet, + updateIssueIssuesIssueIdPatch, +} from '@/api/generated/endpoints/issues/issues'; +import { listCodeLinksIssuesIssueIdCodeLinksGet } from '@/api/generated/endpoints/integrations/integrations'; +import { + getWatchStateIssuesIssueIdWatchGet, + setWatchStateIssuesIssueIdWatchPut, +} from '@/api/generated/endpoints/notifications/notifications'; +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'; + +/** + * The issue detail sections against a real instance: sub-issues, links and + * their derived inverses, watch, and code links. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); + +describeLive('issue detail against a live instance', () => { + let team: TeamRead; + let statuses: StatusRead[]; + const created: number[] = []; + + async function newIssue(title: string, parentId?: number) { + const issue = await createIssueTeamsTeamIdIssuesPost(team.id, { + title, + parent_id: parentId, + }); + created.push(issue.id); + return issue; + } + + 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 () => { + // Children first: deleting a parent with children may be refused. + for (const id of [...created].reverse()) { + await deleteIssueIssuesIssueIdDelete(id).catch(() => undefined); + } + }); + + it('lists children by parent and counts progress server-side', async () => { + const parent = await newIssue(`Parent ${RUN}`); + const childA = await newIssue(`Child A ${RUN}`, parent.id); + await newIssue(`Child B ${RUN}`, parent.id); + + // This is the query the sub-issues section runs. + const children = await listIssuesTeamsTeamIdIssuesGet(team.id, { + parent_id: parent.id, + limit: 200, + }); + expect(children.items.map((i) => i.id).sort()).toEqual( + [childA.id, created[created.length - 1]].sort(), + ); + + let refreshed = await getIssueIssuesIssueIdGet(parent.id); + expect(refreshed.child_count).toBe(2); + expect(refreshed.completed_child_count).toBe(0); + + const done = statuses.find((s) => s.category === 'done'); + if (done) { + await updateIssueIssuesIssueIdPatch(childA.id, { status_id: done.id }); + refreshed = await getIssueIssuesIssueIdGet(parent.id); + // Counted by the server, which is why the section reads these fields + // rather than tallying the child list itself. + expect(refreshed.completed_child_count).toBe(1); + } + + const cancelled = statuses.find((s) => s.category === 'cancelled'); + if (cancelled) { + await updateIssueIssuesIssueIdPatch(childA.id, { status_id: cancelled.id }); + refreshed = await getIssueIssuesIssueIdGet(parent.id); + // A cancelled child leaves the denominator, rather than counting as + // outstanding work forever. + expect(refreshed.child_count).toBe(1); + } + }); + + it('derives the inverse of a link on the other issue', async () => { + const blocker = await newIssue(`Blocker ${RUN}`); + const blocked = await newIssue(`Blocked ${RUN}`); + + await createIssueLinkIssuesIssueIdLinksPost(blocker.id, { + target_id: blocked.id, + type: 'blocks', + }); + + const fromBlocker = await listIssueLinksIssuesIssueIdLinksGet(blocker.id); + expect((fromBlocker.blocks ?? []).map((l) => l.issue.id)).toContain(blocked.id); + + // The inverse is not stored by the client -- it comes back on the other + // side, which is why only three types are offered when adding one. + const fromBlocked = await listIssueLinksIssuesIssueIdLinksGet(blocked.id); + expect((fromBlocked.blocked_by ?? []).map((l) => l.issue.id)).toContain(blocker.id); + + // And it is what drives the card's blocked marker. + const refreshed = await getIssueIssuesIssueIdGet(blocked.id); + expect(refreshed.blocked_by_count).toBe(1); + }); + + it('removes a link from both sides', async () => { + const a = await newIssue(`Relates A ${RUN}`); + const b = await newIssue(`Relates B ${RUN}`); + + await createIssueLinkIssuesIssueIdLinksPost(a.id, { + target_id: b.id, + type: 'relates_to', + }); + const links = await listIssueLinksIssuesIssueIdLinksGet(a.id); + const link = (links.relates_to ?? [])[0]; + expect(link).toBeDefined(); + + await deleteIssueLinkIssuesIssueIdLinksLinkIdDelete(a.id, link.id); + + expect((await listIssueLinksIssuesIssueIdLinksGet(a.id)).relates_to ?? []).toEqual([]); + expect((await listIssueLinksIssuesIssueIdLinksGet(b.id)).relates_to ?? []).toEqual([]); + }); + + it('toggles watch and reports the current state', async () => { + const issue = await newIssue(`Watch ${RUN}`); + + const before = await getWatchStateIssuesIssueIdWatchGet(issue.id); + expect(typeof before.watching).toBe('boolean'); + + await setWatchStateIssuesIssueIdWatchPut(issue.id, { watching: !before.watching }); + const after = await getWatchStateIssuesIssueIdWatchGet(issue.id); + expect(after.watching).toBe(!before.watching); + }); + + it('returns the development buckets the section renders', async () => { + const issue = await newIssue(`Code ${RUN}`); + const codeLinks = await listCodeLinksIssuesIssueIdCodeLinksGet(issue.id); + + // Nothing is connected in the demo stack, so these are empty -- the point + // is the shape the section groups by. + for (const key of ['branches', 'commits', 'pull_requests'] as const) { + expect(Array.isArray(codeLinks[key] ?? [])).toBe(true); + } + }); +}); diff --git a/src/issues/detail/issue-picker.tsx b/src/issues/detail/issue-picker.tsx new file mode 100644 index 0000000..124b71b --- /dev/null +++ b/src/issues/detail/issue-picker.tsx @@ -0,0 +1,112 @@ +import { useMemo, useState } from 'react'; +import { Pressable, View } from 'react-native'; + +import { useListIssuesTeamsTeamIdIssuesGet } from '@/api/generated/endpoints/issues/issues'; +import type { IssueRead } from '@/api/generated/models'; +import { AppText, Field, Loading } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * Choose another issue on the same team -- the target of a link, or a parent. + * + * Filtered client-side from the team's issue page rather than through /search: + * the list is already cached for the board, the set is bounded by the same + * 200-row cap, and matching on identifier as well as title is what people + * actually type ("ENG-142"). + */ +export function IssuePicker({ + visible, + title, + teamId, + excludeIds, + onSelect, + onClose, +}: { + visible: boolean; + title: string; + teamId: number; + excludeIds: number[]; + onSelect: (issue: IssueRead) => void; + onClose: () => void; +}) { + const t = useTokens(); + const [search, setSearch] = useState(''); + + const query = useListIssuesTeamsTeamIdIssuesGet( + teamId, + { limit: 200 }, + { query: { enabled: visible && teamId > 0 } }, + ); + + const matches = useMemo(() => { + const needle = search.trim().toLowerCase(); + const all = (query.data?.items ?? []).filter((issue) => !excludeIds.includes(issue.id)); + if (!needle) return all.slice(0, 40); + return all + .filter( + (issue) => + issue.title.toLowerCase().includes(needle) || + issue.identifier.toLowerCase().includes(needle), + ) + .slice(0, 40); + }, [query.data, search, excludeIds]); + + return ( + + + + + {query.isPending ? : null} + + {matches.map((issue) => ( + { + onSelect(issue); + onClose(); + }} + style={{ + flexDirection: 'row', + alignItems: 'center', + gap: 10, + paddingVertical: 10, + paddingHorizontal: 8, + borderRadius: t.radius.control, + }} + > + + + {issue.identifier} + + {issue.title} + + + + ))} + + {!query.isPending && matches.length === 0 ? ( + + No issue matches that. + + ) : null} + + + ); +} diff --git a/src/issues/detail/sections.tsx b/src/issues/detail/sections.tsx new file mode 100644 index 0000000..6ad0511 --- /dev/null +++ b/src/issues/detail/sections.tsx @@ -0,0 +1,501 @@ +import { useState, type ReactNode } from 'react'; +import { Linking, Pressable, StyleSheet, View } from 'react-native'; +import { router } from 'expo-router'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + createIssueLinkIssuesIssueIdLinksPost, + createIssueTeamsTeamIdIssuesPost, + deleteIssueLinkIssuesIssueIdLinksLinkIdDelete, + useListIssueLinksIssuesIssueIdLinksGet, + useListIssuesTeamsTeamIdIssuesGet, +} from '@/api/generated/endpoints/issues/issues'; +import { useListCodeLinksIssuesIssueIdCodeLinksGet } from '@/api/generated/endpoints/integrations/integrations'; +import { + useGetWatchStateIssuesIssueIdWatchGet, + setWatchStateIssuesIssueIdWatchPut, +} from '@/api/generated/endpoints/notifications/notifications'; +import { IssueLinkType, type IssueRead, type LinkedIssue } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { IssuePicker } from '@/issues/detail/issue-picker'; +import { Icon } from '@/ui/icon'; +import { href } from '@/ui/href'; +import { Alert, AppText, Button, Card, Field } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** A titled block on the detail screen, with an optional action in the header. */ +export function Section({ + title, + action, + children, +}: { + title: string; + action?: ReactNode; + children: ReactNode; +}) { + return ( + + + + {title} + + {action} + + {children} + + ); +} + +function LinkedRow({ + issue, + trailing, +}: { + issue: LinkedIssue; + trailing?: ReactNode; +}) { + const t = useTokens(); + return ( + router.push(href(`/issue/${issue.id}`))} + style={{ + flexDirection: 'row', + alignItems: 'center', + gap: 10, + paddingVertical: 10, + paddingHorizontal: 12, + }} + > + + + {issue.identifier} + + {issue.title} + + + {trailing} + + ); +} + +/** + * Children of this issue, one level deep. + * + * Progress comes from the issue's own child_count / completed_child_count + * rather than being counted here: the server excludes cancelled children from + * both, and re-deriving it from the child list would quietly disagree. + */ +export function SubIssuesSection({ issue }: { issue: IssueRead }) { + const t = useTokens(); + const queryClient = useQueryClient(); + const [adding, setAdding] = useState(false); + const [error, setError] = useState(null); + + const children = useListIssuesTeamsTeamIdIssuesGet(issue.team_id, { + parent_id: issue.id, + limit: 200, + }); + const items = children.data?.items ?? []; + + return ( +
0 + ? `SUB-ISSUES · ${issue.completed_child_count} OF ${issue.child_count} DONE` + : 'SUB-ISSUES' + } + action={ + setAdding(true)} + > + + + } + > + {error ? {error} : null} + + + {items.length === 0 ? ( + + No sub-issues yet. + + ) : ( + items.map((child, index) => ( + + + + )) + )} + + + setAdding(false)} + onCreated={async () => { + await queryClient.invalidateQueries({ queryKey: [`/issues/${issue.id}`] }); + await queryClient.invalidateQueries({ + queryKey: [`/teams/${issue.team_id}/issues`], + }); + }} + onError={setError} + /> +
+ ); +} + +/** Creating a child is creating an issue with a parent, so it is the same call. */ +function NewSubIssueSheet({ + visible, + parent, + onClose, + onCreated, + onError, +}: { + visible: boolean; + parent: IssueRead; + onClose: () => void; + onCreated: () => Promise; + onError: (message: string) => void; +}) { + const [title, setTitle] = useState(''); + const [busy, setBusy] = useState(false); + + return ( + + + + + + + ); +} + +/** The relation buckets the API returns, in the order the web lists them. */ +const RELATIONS = [ + { key: 'blocked_by', label: 'Blocked by' }, + { key: 'blocks', label: 'Blocks' }, + { key: 'duplicates', label: 'Duplicates' }, + { key: 'duplicated_by', label: 'Duplicated by' }, + { key: 'relates_to', label: 'Relates to' }, +] as const; + +const ADDABLE = [ + { type: IssueLinkType.blocks, label: 'Blocks' }, + { type: IssueLinkType.duplicates, label: 'Duplicates' }, + { type: IssueLinkType.relates_to, label: 'Relates to' }, +] as const; + +/** + * Links to other issues. + * + * Only three types can be created; the inverses (blocked_by, duplicated_by) are + * derived server-side, which is why they are listed but not offered. + */ +export function LinksSection({ issue }: { issue: IssueRead }) { + const t = useTokens(); + const queryClient = useQueryClient(); + const links = useListIssueLinksIssuesIssueIdLinksGet(issue.id); + const [picking, setPicking] = useState(null); + const [choosingType, setChoosingType] = useState(false); + const [error, setError] = useState(null); + + const buckets = links.data ?? {}; + const linkedIds = RELATIONS.flatMap( + (relation) => (buckets[relation.key] ?? []).map((link) => link.issue.id), + ); + + async function refresh() { + await queryClient.invalidateQueries({ queryKey: [`/issues/${issue.id}/links`] }); + // A blocking link changes the blocked marker on the board. + void queryClient.invalidateQueries({ queryKey: [`/issues/${issue.id}`] }); + void queryClient.invalidateQueries({ queryKey: [`/teams/${issue.team_id}/issues`] }); + } + + const anyLinks = RELATIONS.some((relation) => (buckets[relation.key] ?? []).length > 0); + + return ( +
setChoosingType(true)} + > + + + } + > + {error ? {error} : null} + + + {!anyLinks ? ( + + No linked issues. + + ) : ( + RELATIONS.map((relation) => { + const bucket = buckets[relation.key] ?? []; + if (bucket.length === 0) return null; + return ( + + + {relation.label} + + {bucket.map((link) => ( + { + setError(null); + try { + await deleteIssueLinkIssuesIssueIdLinksLinkIdDelete( + issue.id, + link.id, + ); + await refresh(); + } catch (err) { + setError(errorDetail(err, 'Could not remove that link.')); + } + }} + > + + × + + + } + /> + ))} + + ); + }) + )} + + + setChoosingType(false)} + title="Link type" + > + + {ADDABLE.map((option) => ( + { + setChoosingType(false); + setPicking(option.type); + }} + style={{ + paddingVertical: 12, + paddingHorizontal: 10, + borderRadius: t.radius.control, + }} + > + {option.label} + + ))} + + “Blocked by” and “Duplicated by” are the other side of these, and + appear on the issue you pick. + + + + + setPicking(null)} + onSelect={async (target) => { + if (!picking) return; + setError(null); + try { + await createIssueLinkIssuesIssueIdLinksPost(issue.id, { + target_id: target.id, + type: picking, + }); + await refresh(); + } catch (err) { + setError(errorDetail(err, 'Could not add that link.')); + } + }} + /> +
+ ); +} + +/** + * Branches, commits and pull requests from connected repositories. + * + * Read-only: these arrive by webhook from the repository side, so there is + * nothing to create here. Tapping opens the host in a browser. + */ +export function DevelopmentSection({ issue }: { issue: IssueRead }) { + const t = useTokens(); + const codeLinks = useListCodeLinksIssuesIssueIdCodeLinksGet(issue.id); + + const groups = [ + { key: 'pull_requests' as const, label: 'Pull requests' }, + { key: 'branches' as const, label: 'Branches' }, + { key: 'commits' as const, label: 'Commits' }, + ]; + + const data = codeLinks.data ?? {}; + const any = groups.some((group) => (data[group.key] ?? []).length > 0); + + // Nothing connected and nothing linked is the common case; a permanently + // empty section would just be noise on a phone. + if (!any) return null; + + return ( +
+ + {groups.map((group) => { + const items = data[group.key] ?? []; + if (items.length === 0) return null; + return ( + + + {group.label} + + {items.map((item) => ( + void Linking.openURL(item.url)} + style={{ + flexDirection: 'row', + alignItems: 'center', + gap: 10, + paddingVertical: 10, + paddingHorizontal: 12, + }} + > + + + {item.title ?? item.external_id} + + + {item.repository.full_name} + {item.state ? ` · ${item.state}` : ''} + + + + + ))} + + ); + })} + +
+ ); +} + +/** + * Watch toggle. + * + * The server auto-watches on assignment and commenting, so this reflects a + * state that changes without being touched here -- it reads the current value + * rather than assuming. + */ +export function WatchToggle({ issue }: { issue: IssueRead }) { + const t = useTokens(); + const queryClient = useQueryClient(); + const watch = useGetWatchStateIssuesIssueIdWatchGet(issue.id); + const [busy, setBusy] = useState(false); + + const watching = watch.data?.watching === true; + + return ( + { + setBusy(true); + try { + await setWatchStateIssuesIssueIdWatchPut(issue.id, { watching: !watching }); + await queryClient.invalidateQueries({ queryKey: [`/issues/${issue.id}/watch`] }); + } finally { + setBusy(false); + } + }} + style={{ + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: t.radius.pill, + backgroundColor: watching ? t.line.navActive : t.line.ghost, + }} + > + + + {watching ? 'Watching' : 'Watch'} + + + ); +} diff --git a/src/issues/issue-detail-screen.tsx b/src/issues/issue-detail-screen.tsx index 7567228..b53a286 100644 --- a/src/issues/issue-detail-screen.tsx +++ b/src/issues/issue-detail-screen.tsx @@ -17,6 +17,12 @@ import { PropertyRow, SinglePicker, } from '@/issues/property-picker'; +import { + DevelopmentSection, + LinksSection, + SubIssuesSection, + WatchToggle, +} from '@/issues/detail/sections'; import { labelFor, useIssueProperties } from '@/issues/use-issue-properties'; import { useTeams } from '@/team/team-context'; import { Icon } from '@/ui/icon'; @@ -155,11 +161,7 @@ export function IssueDetailScreen() { {issue.identifier} - {saving ? ( - Saving… - ) : ( - - )} + {saving ? Saving… : } @@ -263,15 +265,12 @@ export function IssueDetailScreen() { /> - - - Opened by {issue.creator.full_name} - - {issue.child_count > 0 ? ( - - {issue.completed_child_count} of {issue.child_count} sub-issues done - - ) : null} + + + + + + Opened by {issue.creator.full_name} Date: Fri, 11 Sep 2026 22:14:22 +0500 Subject: [PATCH 07/18] Add comments and GitHub-flavored markdown Closes #8. GFM comes from react-native-marked, except task lists, which it cannot render: its parser drops marked's `task` and `checked` flags before the renderer sees them, so a checklist would come out as bare bullets with no box. The source is lexed at block level here, task lists are drawn directly -- which is also what makes them tappable -- and every other block is handed back to the library by its own raw text. Mentions and TEAMKEY-123 links are resolved in the renderer's text() hook, which only ever receives text tokens. That is what keeps `@demo` inside backticks or a fenced block literal -- the same property the web gets from running this as a remark plugin rather than a regex over the source, and an issue tracker is full of shell snippets. Ticking a checkbox edits the source at that one character rather than re-serialising the parsed markdown, so bullets, indentation and trailing spaces the author chose stay exactly as typed. Verified byte for byte against a live instance. The `@` menu is driven by the caret rather than the last keystroke, so it also opens when you move back into a half-written handle, and stays shut on the second `@` of an email address. An unknown handle stays plain text: far more likely an email fragment or a shell variable than a person. Comments with no author are automation rules, and render as such. One correction on the way: I assumed commenting always starts you watching. It does not -- auto_watch only adds a row when you have never expressed a preference, so unwatching and then answering a question does not resubscribe you. The test now pins that actual contract. Co-Authored-By: Claude Opus 5 --- package-lock.json | 94 +++++++++ package.json | 1 + src/api/__tests__/live-comments.test.ts | 147 +++++++++++++ src/comments/comments-section.tsx | 205 +++++++++++++++++++ src/issues/detail/sections.tsx | 1 - src/issues/issue-detail-screen.tsx | 77 +++++-- src/markdown/__tests__/inline.test.ts | 77 +++++++ src/markdown/__tests__/tasks.test.ts | 52 +++++ src/markdown/inline.ts | 78 +++++++ src/markdown/markdown-editor.tsx | 162 +++++++++++++++ src/markdown/markdown.tsx | 261 ++++++++++++++++++++++++ src/markdown/mentions.ts | 70 +++++++ src/markdown/tasks.ts | 57 ++++++ 13 files changed, 1267 insertions(+), 15 deletions(-) create mode 100644 src/api/__tests__/live-comments.test.ts create mode 100644 src/comments/comments-section.tsx create mode 100644 src/markdown/__tests__/inline.test.ts create mode 100644 src/markdown/__tests__/tasks.test.ts create mode 100644 src/markdown/inline.ts create mode 100644 src/markdown/markdown-editor.tsx create mode 100644 src/markdown/markdown.tsx create mode 100644 src/markdown/mentions.ts create mode 100644 src/markdown/tasks.ts diff --git a/package-lock.json b/package-lock.json index a8d55a6..c902d9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "react-dom": "19.2.3", "react-native": "0.86.3", "react-native-gesture-handler": "~2.32.0", + "react-native-marked": "^8.3.0", "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", @@ -3179,6 +3180,23 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsamr/counter-style": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@jsamr/counter-style/-/counter-style-2.0.2.tgz", + "integrity": "sha512-2mXudGVtSzVxWEA7B9jZLKjoXUeUFYDDtFrQoC0IFX9/Dszz4t1vZOmafi3JSw/FxD+udMQ+4TAFR8Qs0J3URQ==", + "license": "MIT" + }, + "node_modules/@jsamr/react-native-li": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@jsamr/react-native-li/-/react-native-li-2.3.1.tgz", + "integrity": "sha512-Qbo4NEj48SQ4k8FZJHFE2fgZDKTWaUGmVxcIQh3msg5JezLdTMMHuRRDYctfdHI6L0FZGObmEv3haWbIvmol8w==", + "license": "MIT", + "peerDependencies": { + "@jsamr/counter-style": "^1.0.0 || ^2.0.0", + "react": "*", + "react-native": "*" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", @@ -9388,6 +9406,12 @@ "node": ">=6" } }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -9623,6 +9647,22 @@ "node": ">=12" } }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -12429,6 +12469,18 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/marked": { + "version": "18.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.12.tgz", + "integrity": "sha512-LEm4ga2YeI2T3GVHj9b0BaDPPk93LLTHMFeMyQbNIzPxc8vCI0y/scy0ZA6z6lXKyT9j9Nhl/OC6ZYKYGuFScA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/marky": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", @@ -14678,6 +14730,29 @@ "react-native": "*" } }, + "node_modules/react-native-marked": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/react-native-marked/-/react-native-marked-8.3.0.tgz", + "integrity": "sha512-OepvLJ3IctdZMpGOZEGEAi+x2hDtigsufZQv9Jcs84DJl3wnR3ABmEWVTiABxcBYriKCI9YdxsIN7onwYuxmZg==", + "license": "MIT", + "dependencies": { + "@jsamr/counter-style": "2.0.2", + "@jsamr/react-native-li": "2.3.1", + "github-slugger": "2.0.0", + "html-entities": "2.6.0", + "marked": "18.0.12", + "react-native-reanimated-table": "0.0.2", + "svg-parser": "2.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": ">=16.8.6", + "react-native": ">=0.76.0", + "react-native-svg": ">=12.3.0" + } + }, "node_modules/react-native-reanimated": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.1.tgz", @@ -14693,6 +14768,16 @@ "react-native-worklets": "0.10.x" } }, + "node_modules/react-native-reanimated-table": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/react-native-reanimated-table/-/react-native-reanimated-table-0.0.2.tgz", + "integrity": "sha512-OeuqfU1AFEmHNTJlEOLWrV78JgAXnM0/ZrCm0Ab+9e5nwYJ+xab/UFXkNKz3Gyf08ZfLSNzwMQRjt3eZWPWoGA==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-native": ">=0.6.0" + } + }, "node_modules/react-native-reanimated/node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -16062,6 +16147,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-parser": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.1.0.tgz", + "integrity": "sha512-bwLf38YmY+TDYHJw1Ex0Co8c4yeXuJAo8YnXGZrscxrvYoVVIvLeniEkV1Ks/54VteMnL4FtyN1+P+TZJdUPmQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/package.json b/package.json index 874e8ef..5e91c7d 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "react-dom": "19.2.3", "react-native": "0.86.3", "react-native-gesture-handler": "~2.32.0", + "react-native-marked": "^8.3.0", "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", diff --git a/src/api/__tests__/live-comments.test.ts b/src/api/__tests__/live-comments.test.ts new file mode 100644 index 0000000..1589376 --- /dev/null +++ b/src/api/__tests__/live-comments.test.ts @@ -0,0 +1,147 @@ +/** + * @jest-environment node + */ +import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth'; +import { + createCommentIssuesIssueIdCommentsPost, + listCommentsIssuesIssueIdCommentsGet, +} from '@/api/generated/endpoints/comments/comments'; +import { + createIssueTeamsTeamIdIssuesPost, + deleteIssueIssuesIssueIdDelete, + getIssueIssuesIssueIdGet, + updateIssueIssuesIssueIdPatch, +} from '@/api/generated/endpoints/issues/issues'; +import { + getWatchStateIssuesIssueIdWatchGet, + setWatchStateIssuesIssueIdWatchPut, +} from '@/api/generated/endpoints/notifications/notifications'; +import { + listMyTeamsTeamsGet, + listTeamMembersTeamsTeamIdMembersGet, +} from '@/api/generated/endpoints/teams/teams'; +import type { TeamRead } from '@/api/generated/models'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken } from '@/auth/session'; +import { toggleTaskAtIndex } from '@/markdown/tasks'; + +/** + * Comments and markdown against a real instance. + * + * The acceptance criterion for #8 is that a comment written on mobile renders + * identically on web, which means the *source* has to survive the round trip + * untouched -- so these assert the bytes, not a rendering. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); + +describeLive('comments against a live instance', () => { + let team: TeamRead; + 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]; + }); + + afterAll(async () => { + for (const id of created) { + await deleteIssueIssuesIssueIdDelete(id).catch(() => undefined); + } + }); + + async function newIssue(title: string, description?: string) { + const issue = await createIssueTeamsTeamIdIssuesPost(team.id, { title, description }); + created.push(issue.id); + return issue; + } + + it('stores comment markdown byte for byte', async () => { + const issue = await newIssue(`Comment ${RUN}`); + // Everything GFM that the issue names, including the shapes a parser would + // normalise if the client round-tripped through one. + const body = [ + '## Heading', + '', + 'Some **bold**, ~~struck~~ and `code`.', + '', + '| a | b |', + '| - | - |', + '| 1 | 2 |', + '', + '```sh', + 'echo "@demo is not a mention in here"', + '```', + '', + '- [ ] not done', + '- [x] done', + ].join('\n'); + + const comment = await createCommentIssuesIssueIdCommentsPost(issue.id, { body }); + expect(comment.body).toBe(body); + + const page = await listCommentsIssuesIssueIdCommentsGet(issue.id, { limit: 20 }); + expect(page.items.map((c) => c.body)).toContain(body); + }); + + it('paginates, which is what the "show more" control pages through', async () => { + const issue = await newIssue(`Paging ${RUN}`); + for (let n = 0; n < 3; n += 1) { + await createCommentIssuesIssueIdCommentsPost(issue.id, { body: `comment ${n}` }); + } + + const firstPage = await listCommentsIssuesIssueIdCommentsGet(issue.id, { + limit: 2, + offset: 0, + }); + expect(firstPage.items).toHaveLength(2); + expect(firstPage.total).toBe(3); + + const wider = await listCommentsIssuesIssueIdCommentsGet(issue.id, { limit: 20 }); + expect(wider.items).toHaveLength(3); + }); + + it('watches an issue you opened, and does not resubscribe you after you opt out', async () => { + // auto_watch only ever adds a row when none exists + // (`lib_softtrack/notifications.py:72`): someone who unwatched an issue and + // then answered a question on it meant to answer the question, not to + // resubscribe. This is what "the same auto-watch semantics as web" means, + // and it is why the toggle reads the server's state instead of predicting + // it after a comment. + const issue = await newIssue(`Autowatch ${RUN}`); + expect((await getWatchStateIssuesIssueIdWatchGet(issue.id)).watching).toBe(true); + + await setWatchStateIssuesIssueIdWatchPut(issue.id, { watching: false }); + await createCommentIssuesIssueIdCommentsPost(issue.id, { body: 'answering a question' }); + + expect((await getWatchStateIssuesIssueIdWatchGet(issue.id)).watching).toBe(false); + }); + + it('persists a ticked checkbox as a one-character edit', async () => { + const description = ['- [ ] first', '- [ ] second'].join('\n'); + const issue = await newIssue(`Tasks ${RUN}`, description); + + const toggled = toggleTaskAtIndex(issue.description ?? '', 1)!; + await updateIssueIssuesIssueIdPatch(issue.id, { description: toggled }); + + const refreshed = await getIssueIssuesIssueIdGet(issue.id); + expect(refreshed.description).toBe('- [ ] first\n- [x] second'); + }); + + it('restricts mentionable people to the issue team', async () => { + // The `@` menu offers exactly these, because they are who the API will + // notify. + const members = await listTeamMembersTeamsTeamIdMembersGet(team.id); + expect(members.length).toBeGreaterThan(0); + for (const member of members) { + expect(member.user.username).toEqual(expect.any(String)); + } + }); +}); diff --git a/src/comments/comments-section.tsx b/src/comments/comments-section.tsx new file mode 100644 index 0000000..7305cbf --- /dev/null +++ b/src/comments/comments-section.tsx @@ -0,0 +1,205 @@ +import { useState } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { router } from 'expo-router'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + createCommentIssuesIssueIdCommentsPost, + useListCommentsIssuesIssueIdCommentsGet, +} from '@/api/generated/endpoints/comments/comments'; +import { listIssuesTeamsTeamIdIssuesGet } from '@/api/generated/endpoints/issues/issues'; +import type { CommentRead, IssueRead, TeamMemberRead } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { Section } from '@/issues/detail/sections'; +import { MarkdownBody } from '@/markdown/markdown'; +import { MarkdownEditor } from '@/markdown/markdown-editor'; +import type { Mentionable } from '@/markdown/mentions'; +import { href } from '@/ui/href'; +import { Alert, AppText, Avatar, Button, Card } from '@/ui/primitives'; +import { useTokens } from '@/ui/theme'; + +const PAGE = 20; + +/** Members are what `@` can name, and the API restricts mentions to them. */ +export function mentionablesFrom(members: TeamMemberRead[]): Mentionable[] { + return members.map((member) => ({ + id: member.user.id, + full_name: member.user.full_name, + username: member.user.username, + })); +} + +/** + * Resolve `TEAMKEY-123` to an issue and open it. + * + * There is no lookup-by-identifier endpoint, so this searches the teams the + * viewer is actually on -- which is also the access rule: a link to an issue on + * a team you cannot see simply does not resolve, rather than erroring. + */ +export async function openIssueByIdentifier( + identifier: string, + teamIds: number[], +): Promise { + for (const teamId of teamIds) { + const page = await listIssuesTeamsTeamIdIssuesGet(teamId, { limit: 200 }).catch( + () => null, + ); + const match = page?.items.find( + (issue) => issue.identifier.toLowerCase() === identifier.toLowerCase(), + ); + if (match) { + router.push(href(`/issue/${match.id}`)); + return true; + } + } + return false; +} + +function CommentCard({ + comment, + people, + onOpenIssue, +}: { + comment: CommentRead; + people: Mentionable[]; + onOpenIssue: (identifier: string) => void; +}) { + const t = useTokens(); + // A comment with no author came from an automation rule, not a person. + const automation = comment.author === null; + + return ( + + + {automation ? ( + + ⚙ + + ) : ( + + )} + + {automation ? 'Automation' : comment.author?.full_name} + + + + + + ); +} + +export function CommentsSection({ + issue, + members, + teamIds, +}: { + issue: IssueRead; + members: TeamMemberRead[]; + teamIds: number[]; +}) { + const t = useTokens(); + const queryClient = useQueryClient(); + const people = mentionablesFrom(members); + + const [limit, setLimit] = useState(PAGE); + const [body, setBody] = useState(''); + const [error, setError] = useState(null); + const [posting, setPosting] = useState(false); + + const comments = useListCommentsIssuesIssueIdCommentsGet(issue.id, { limit, offset: 0 }); + const items = comments.data?.items ?? []; + const total = comments.data?.total ?? 0; + + const onOpenIssue = (identifier: string) => { + void openIssueByIdentifier(identifier, teamIds); + }; + + async function post() { + setError(null); + setPosting(true); + try { + await createCommentIssuesIssueIdCommentsPost(issue.id, { body: body.trim() }); + setBody(''); + await queryClient.invalidateQueries({ + queryKey: [`/issues/${issue.id}/comments`], + }); + // Commenting auto-watches server-side, so the toggle has to re-read. + void queryClient.invalidateQueries({ queryKey: [`/issues/${issue.id}/watch`] }); + } catch (err) { + setError(errorDetail(err, 'Could not post that comment.')); + } finally { + setPosting(false); + } + } + + return ( +
0 ? `COMMENTS · ${total}` : 'COMMENTS'}> + {error ? {error} : null} + + + {items.map((comment) => ( + + ))} + + {items.length === 0 && !comments.isPending ? ( + + No comments yet. + + ) : null} + + {total > items.length ? ( + setLimit((current) => current + PAGE)} + style={{ + paddingVertical: 12, + alignItems: 'center', + borderRadius: t.radius.control, + borderWidth: StyleSheet.hairlineWidth, + borderColor: t.line.hairline, + }} + > + + Show {Math.min(PAGE, total - items.length)} more + + + ) : null} + + + + + + +
+ ); +} diff --git a/src/issues/detail/sections.tsx b/src/issues/detail/sections.tsx index 6ad0511..1bc6e30 100644 --- a/src/issues/detail/sections.tsx +++ b/src/issues/detail/sections.tsx @@ -54,7 +54,6 @@ function LinkedRow({ issue: LinkedIssue; trailing?: ReactNode; }) { - const t = useTokens(); return ( - { - if (description !== null && description !== (issue.description ?? '')) { - void patch({ description: description || null }); - } - setDescription(null); - }} - /> + Description + {description === null ? ( + setDescription(issue.description ?? '')} + > + {issue.description?.trim() ? ( + + void openIssueByIdentifier(identifier, teams.map((team) => team.id)) + } + // Ticking a box edits the source at that one character + // rather than re-serialising the parsed markdown, so every + // byte the author typed stays where it was. + onToggleTask={(index) => { + const next = toggleTaskAtIndex(issue.description ?? '', index); + if (next !== null) void patch({ description: next }); + }} + /> + ) : ( + No description. Tap to add one. + )} + + ) : ( + + + + + + + + )}
@@ -269,6 +312,12 @@ export function IssueDetailScreen() { + candidate.id)} + /> + Opened by {issue.creator.full_name} diff --git a/src/markdown/__tests__/inline.test.ts b/src/markdown/__tests__/inline.test.ts new file mode 100644 index 0000000..be3e5d8 --- /dev/null +++ b/src/markdown/__tests__/inline.test.ts @@ -0,0 +1,77 @@ +import { segments } from '@/markdown/inline'; +import { matchMentions, mentionQueryAt, applyMention } from '@/markdown/mentions'; + +const PEOPLE = [ + { id: 1, full_name: 'Ada Lovelace', username: 'ada' }, + { id: 2, full_name: 'Grace Hopper', username: 'grace' }, +]; + +describe('segments', () => { + it('finds a mention of someone on the team', () => { + expect(segments('hey @ada look', PEOPLE)).toEqual([ + { kind: 'text', text: 'hey ' }, + { kind: 'mention', text: '@ada', person: PEOPLE[0] }, + { kind: 'text', text: ' look' }, + ]); + }); + + it('leaves an unknown handle as plain text', () => { + // Far more likely an email fragment or a shell variable than a person. + expect(segments('hi @nobody', PEOPLE)).toEqual([{ kind: 'text', text: 'hi @nobody' }]); + }); + + it('does not read an email address as a mention', () => { + expect(segments('write to ada@example.com', PEOPLE)).toEqual([ + { kind: 'text', text: 'write to ada@example.com' }, + ]); + }); + + it('links issue identifiers', () => { + expect(segments('see ENG-142 for detail', PEOPLE)).toEqual([ + { kind: 'text', text: 'see ' }, + { kind: 'issue', text: 'ENG-142', identifier: 'ENG-142' }, + { kind: 'text', text: ' for detail' }, + ]); + }); + + it('ignores identifier-like text inside a longer token', () => { + // A hyphenated word or a trailer should not become a link. + expect(segments('build-ENG-1 and lowercase eng-1', PEOPLE)).toEqual([ + { kind: 'text', text: 'build-ENG-1 and lowercase eng-1' }, + ]); + }); + + it('handles both kinds in one run', () => { + const parts = segments('@grace please see ENG-7', PEOPLE); + expect(parts.map((p) => p.kind)).toEqual(['mention', 'text', 'issue']); + }); +}); + +describe('mentionQueryAt', () => { + it('opens on a handle being typed', () => { + expect(mentionQueryAt('hi @ad', 6)).toEqual({ query: 'ad', start: 3 }); + }); + + it('stays closed inside an email address', () => { + expect(mentionQueryAt('mail ada@example', 16)).toBeNull(); + }); + + it('is driven by the caret, not the end of the text', () => { + expect(mentionQueryAt('hi @ad and more', 6)).toEqual({ query: 'ad', start: 3 }); + }); +}); + +describe('matchMentions', () => { + it('matches on handle or full name', () => { + expect(matchMentions(PEOPLE, 'lov').map((p) => p.username)).toEqual(['ada']); + expect(matchMentions(PEOPLE, 'gra').map((p) => p.username)).toEqual(['grace']); + }); +}); + +describe('applyMention', () => { + it('replaces the typed query and leaves the caret after a space', () => { + const result = applyMention('hi @ad', 3, 2, 'ada'); + expect(result.text).toBe('hi @ada '); + expect(result.caret).toBe(8); + }); +}); diff --git a/src/markdown/__tests__/tasks.test.ts b/src/markdown/__tests__/tasks.test.ts new file mode 100644 index 0000000..3c5476f --- /dev/null +++ b/src/markdown/__tests__/tasks.test.ts @@ -0,0 +1,52 @@ +import { hasTaskList, taskProgress, toggleTaskAtIndex } from '@/markdown/tasks'; + +const DOC = `# Plan + +- [ ] first +- [x] second +- [ ] third + +Some prose with a literal [x] that is not a task. + +1. [ ] numbered task +`; + +describe('toggleTaskAtIndex', () => { + it('ticks the nth box and leaves every other byte alone', () => { + const next = toggleTaskAtIndex(DOC, 0)!; + expect(next).toContain('- [x] first'); + // The surgical-edit property: nothing else moves, including the prose the + // author chose and the literal [x] that is not a task. + expect(next.replace('- [x] first', '- [ ] first')).toBe(DOC); + }); + + it('unticks a ticked box', () => { + expect(toggleTaskAtIndex(DOC, 1)).toContain('- [ ] second'); + }); + + it('counts ordered-list tasks in the same sequence', () => { + const next = toggleTaskAtIndex(DOC, 3)!; + expect(next).toContain('1. [x] numbered task'); + }); + + it('never mistakes a literal marker in prose for a task', () => { + // Four markers, not five -- the "[x]" in the sentence is not one. + expect(taskProgress(DOC)).toEqual({ done: 1, total: 4 }); + expect(toggleTaskAtIndex(DOC, 4)).toBeNull(); + }); + + it('does nothing when there is no such checkbox', () => { + // Writing a guess back to the server would be worse than ignoring the tap. + expect(toggleTaskAtIndex(DOC, 99)).toBeNull(); + expect(toggleTaskAtIndex(DOC, -1)).toBeNull(); + expect(toggleTaskAtIndex('no tasks here', 0)).toBeNull(); + }); +}); + +describe('hasTaskList', () => { + it('is true only for real task items', () => { + expect(hasTaskList(DOC)).toBe(true); + expect(hasTaskList('- a plain bullet')).toBe(false); + expect(hasTaskList('a line with [x] in it')).toBe(false); + }); +}); diff --git a/src/markdown/inline.ts b/src/markdown/inline.ts new file mode 100644 index 0000000..f95ec0f --- /dev/null +++ b/src/markdown/inline.ts @@ -0,0 +1,78 @@ +import { MENTION_PATTERN, type Mentionable, peopleByHandle } from '@/markdown/mentions'; + +/** + * Splitting a run of plain text into the things that should become tappable. + * + * Done on `text` tokens only, never on the raw source: `` `@demo` `` and fenced + * blocks parse to code tokens and never reach here, so a comment full of shell + * snippets keeps them literal. That is the same property the web gets from + * running this as a remark plugin rather than a string replacement. + */ + +/** + * `TEAMKEY-123`. Team keys are 2-6 uppercase letters (the backend uppercases + * them and caps the length), and the boundary stops it matching inside a longer + * token like a commit trailer or a hyphenated word. + */ +export const ISSUE_PATTERN = /(^|[^\w-])([A-Z][A-Z0-9]{1,5})-(\d+)\b/g; + +export type Segment = + | { kind: 'text'; text: string } + | { kind: 'mention'; text: string; person: Mentionable } + | { kind: 'issue'; text: string; identifier: string }; + +/** + * Break `text` into plain runs, mentions of known people, and issue references. + * + * An `@handle` matching nobody on the team stays plain text, exactly as on the + * web -- an unknown handle is far more likely to be an email fragment or a + * shell variable than a person. + */ +export function segments(text: string, people: Mentionable[]): Segment[] { + const byHandle = peopleByHandle(people); + const found: { start: number; end: number; segment: Segment }[] = []; + + MENTION_PATTERN.lastIndex = 0; + for (const match of text.matchAll(MENTION_PATTERN)) { + const [whole, boundary, handle] = match; + const person = byHandle.get(handle.toLowerCase()); + if (!person) continue; + const start = (match.index ?? 0) + boundary.length; + found.push({ + start, + end: start + whole.length - boundary.length, + segment: { kind: 'mention', text: `@${handle}`, person }, + }); + } + + ISSUE_PATTERN.lastIndex = 0; + for (const match of text.matchAll(ISSUE_PATTERN)) { + const [whole, boundary, key, number] = match; + const start = (match.index ?? 0) + boundary.length; + const end = start + whole.length - boundary.length; + // A mention and an identifier cannot overlap in practice, but if they did + // the mention was found first and wins. + if (found.some((f) => start < f.end && end > f.start)) continue; + found.push({ + start, + end, + segment: { kind: 'issue', text: `${key}-${number}`, identifier: `${key}-${number}` }, + }); + } + + if (found.length === 0) return [{ kind: 'text', text }]; + + found.sort((a, b) => a.start - b.start); + + const out: Segment[] = []; + let cursor = 0; + for (const item of found) { + if (item.start > cursor) { + out.push({ kind: 'text', text: text.slice(cursor, item.start) }); + } + out.push(item.segment); + cursor = item.end; + } + if (cursor < text.length) out.push({ kind: 'text', text: text.slice(cursor) }); + return out; +} diff --git a/src/markdown/markdown-editor.tsx b/src/markdown/markdown-editor.tsx new file mode 100644 index 0000000..9ff196c --- /dev/null +++ b/src/markdown/markdown-editor.tsx @@ -0,0 +1,162 @@ +import { useRef, useState } from 'react'; +import { Pressable, TextInput, View, type TextInputSelectionChangeEventData } from 'react-native'; + +import { MarkdownBody } from '@/markdown/markdown'; +import { + applyMention, + matchMentions, + mentionQueryAt, + type Mentionable, +} from '@/markdown/mentions'; +import { AppText } from '@/ui/primitives'; +import { useTokens } from '@/ui/theme'; + +/** + * A markdown field with a preview toggle and `@` autocomplete. + * + * The suggestion list is driven by the caret rather than by the last character + * typed, so it also opens when someone moves back into a half-written handle -- + * and closes on the second `@` of an email address, which is what stops it + * appearing every time an address is pasted into a comment. + */ +export function MarkdownEditor({ + value, + onChangeText, + people, + placeholder, + minHeight = 110, + autoFocus = false, +}: { + value: string; + onChangeText: (next: string) => void; + people: Mentionable[]; + placeholder?: string; + minHeight?: number; + autoFocus?: boolean; +}) { + const t = useTokens(); + const inputRef = useRef(null); + const [preview, setPreview] = useState(false); + const [caret, setCaret] = useState(0); + + const mention = mentionQueryAt(value, caret); + const suggestions = mention ? matchMentions(people, mention.query) : []; + + function onSelectionChange(event: { nativeEvent: TextInputSelectionChangeEventData }) { + setCaret(event.nativeEvent.selection.end); + } + + return ( + + + + {(['Write', 'Preview'] as const).map((mode, index) => { + const active = preview === (index === 1); + return ( + setPreview(index === 1)} + style={{ + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: t.radius.pill, + backgroundColor: active ? t.line.navActive : 'transparent', + }} + > + + {mode} + + + ); + })} + + + {preview ? ( + + {value.trim() ? ( + // Read-only: ticking a box in a draft would have nowhere to write. + + ) : ( + Nothing to preview yet. + )} + + ) : ( + + )} + + {!preview && mention && suggestions.length > 0 ? ( + + {suggestions.map((person) => ( + { + const next = applyMention( + value, + mention.start, + mention.query.length, + person.username, + ); + onChangeText(next.text); + setCaret(next.caret); + }} + style={{ paddingVertical: 10, paddingHorizontal: 12 }} + > + + {person.full_name}{' '} + @{person.username} + + + ))} + + ) : null} + + ); +} diff --git a/src/markdown/markdown.tsx b/src/markdown/markdown.tsx new file mode 100644 index 0000000..4a22576 --- /dev/null +++ b/src/markdown/markdown.tsx @@ -0,0 +1,261 @@ +import { useMemo, type ReactNode } from 'react'; +import { Linking, Pressable, Text, View } from 'react-native'; +import Markdown, { MarkedLexer, Renderer, type RendererInterface } from 'react-native-marked'; +import type { Token, Tokens } from 'marked'; + +import { segments } from '@/markdown/inline'; +import type { Mentionable } from '@/markdown/mentions'; +import { Icon } from '@/ui/icon'; +import { AppText } from '@/ui/primitives'; +import { useTokens } from '@/ui/theme'; +import type { Tokens as ThemeTokens } from '@/ui/tokens'; + +/** + * Markdown for descriptions and comments. + * + * GFM comes from `react-native-marked`, except for task lists, which it cannot + * render: its parser drops marked's `task` and `checked` flags before the + * renderer sees them, so a checklist would come out as bare bullets with no box. + * So the source is lexed at block level here, task lists are drawn directly -- + * which is also what makes them tappable -- and every other block is handed + * back to the library by its own `raw` text. + */ +export function MarkdownBody({ + source, + people = [], + onToggleTask, + onOpenIssue, +}: { + source: string; + people?: Mentionable[]; + /** Omit to render checkboxes read-only, where the viewer cannot edit. */ + onToggleTask?: (index: number) => void; + onOpenIssue?: (identifier: string) => void; +}) { + const t = useTokens(); + + /** + * Blocks, each already carrying where its task numbering starts. + * + * Counted here rather than while mapping, because a counter mutated during + * render is exactly what the compiler refuses -- and precomputing is clearer + * anyway: the nth checkbox on screen is the nth marker in the source. + */ + const blocks = useMemo(() => { + let tokens: Token[]; + try { + tokens = MarkedLexer(source ?? '', { gfm: true }) as Token[]; + } catch { + // A body that will not lex still has to render as something. + return []; + } + + let seen = 0; + return tokens.map((token) => { + const taskStart = seen; + if (token.type === 'list') { + seen += (token as Tokens.List).items.filter((item) => item.task).length; + } + return { token, taskStart }; + }); + }, [source]); + + const renderer = useMemo( + () => new SoftTrackRenderer(t, people, onOpenIssue), + [t, people, onOpenIssue], + ); + + if (blocks.length === 0) { + return source ? {source} : null; + } + + return ( + + {blocks.map(({ token, taskStart }, index) => { + if (token.type === 'space') return null; + + const list = token as Tokens.List; + if (token.type === 'list' && list.items.some((item) => item.task)) { + return ( + + ); + } + + return ( + + ); + })} + + ); +} + +function TaskList({ + items, + startIndex, + people, + onToggleTask, + onOpenIssue, +}: { + items: Tokens.ListItem[]; + startIndex: number; + people: Mentionable[]; + onToggleTask?: (index: number) => void; + onOpenIssue?: (identifier: string) => void; +}) { + const t = useTokens(); + // Only task items are numbered, so a plain item between two tasks must not + // consume an index. Precomputed for the same reason as above. + const indices: number[] = []; + let offset = startIndex; + for (const item of items) indices.push(item.task === true ? offset++ : -1); + + return ( + + {items.map((item, index) => { + const isTask = item.task === true; + const taskIndex = indices[index]; + + return ( + onToggleTask?.(taskIndex)} + style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 8 }} + > + + {item.checked ? : null} + + + + + + ); + })} + + ); +} + +/** Plain text with mentions and issue identifiers made tappable. */ +function InlineText({ + text, + people, + onOpenIssue, + style, +}: { + text: string; + people: Mentionable[]; + onOpenIssue?: (identifier: string) => void; + style?: object; +}) { + const t = useTokens(); + const parts = segments(text, people); + + return ( + <> + {parts.map((part, index) => { + if (part.kind === 'text') { + return ( + + {part.text} + + ); + } + if (part.kind === 'mention') { + return ( + + {part.text} + + ); + } + return ( + onOpenIssue?.(part.identifier)} + style={[style, { color: t.brand[600], fontWeight: '600' }]} + > + {part.text} + + ); + })} + + ); +} + +/** + * The library's renderer, themed and with inline text routed through the + * mention/identifier pass. + */ +class SoftTrackRenderer extends Renderer implements RendererInterface { + constructor( + private readonly tokens: ThemeTokens, + private readonly people: Mentionable[], + private readonly onOpenIssue?: (identifier: string) => void, + ) { + super(); + } + + text(text: string | ReactNode[], styles?: object): ReactNode { + if (typeof text !== 'string') return super.text(text, styles); + return ( + + + + ); + } + + link(children: string | ReactNode[], href: string, styles?: object): ReactNode { + return ( + void Linking.openURL(href)} + style={[styles, { color: this.tokens.brand[600] }]} + > + {children} + + ); + } + +} diff --git a/src/markdown/mentions.ts b/src/markdown/mentions.ts new file mode 100644 index 0000000..92ff153 --- /dev/null +++ b/src/markdown/mentions.ts @@ -0,0 +1,70 @@ +/** + * `@` mentions of team members. + * + * A port of `frontend/src/markdown/mentions.ts`. The handle is the user's own + * `username` column, so a mention keeps pointing at the same person after they + * change their email address. Usernames are unique instance-wide and validated + * against the same character class as MENTION_PATTERN. + */ +export type Mentionable = { + id: number; + full_name: string; + username: string; +}; + +/** Reverse lookup for the renderer. */ +export function peopleByHandle(people: Mentionable[]): Map { + return new Map(people.map((person) => [person.username.toLowerCase(), person])); +} + +/** + * Matches `@handle` where a mention can plausibly start. + * + * The leading boundary stops `user@example.com` in prose from being read as a + * mention of `@example.com`. + */ +export const MENTION_PATTERN = /(^|[^\w@/])@([a-z0-9][a-z0-9._-]*)/gi; + +/** People whose handle or name matches what has been typed after the `@`. */ +export function matchMentions( + people: Mentionable[], + query: string, + limit = 6, +): Mentionable[] { + const needle = query.toLowerCase(); + return people + .filter( + (person) => + !needle || + person.username.toLowerCase().includes(needle) || + person.full_name.toLowerCase().includes(needle), + ) + .slice(0, limit); +} + +/** The `@query` being typed immediately before the caret, if any. */ +export function mentionQueryAt( + value: string, + caret: number, +): { query: string; start: number } | null { + const upToCaret = value.slice(0, caret); + // An `@` starting a word, followed by no whitespace. Bailing out on the + // second `@` of an email address keeps the menu closed while someone is + // simply typing an address into a comment. + const match = /(^|[^\w@/])@([a-z0-9._@-]*)$/i.exec(upToCaret); + if (!match) return null; + return { query: match[2], start: caret - match[2].length - 1 }; +} + +/** Replace the `@query` at `start` with a handle, leaving a trailing space. */ +export function applyMention( + value: string, + start: number, + queryLength: number, + handle: string, +): { text: string; caret: number } { + const before = value.slice(0, start); + const after = value.slice(start + queryLength + 1); + const inserted = `@${handle} `; + return { text: `${before}${inserted}${after}`, caret: before.length + inserted.length }; +} diff --git a/src/markdown/tasks.ts b/src/markdown/tasks.ts new file mode 100644 index 0000000..a97ac7d --- /dev/null +++ b/src/markdown/tasks.ts @@ -0,0 +1,57 @@ +/** + * Toggling a task-list checkbox writes back to the markdown source. + * + * A port of `frontend/src/markdown/tasks.ts`, keyed by the checkbox's index in + * the document rather than by a source offset: the web's renderer carries hast + * positions, and React Native's does not. Order is the same either way, because + * markdown renders in source order. + * + * The edit stays surgical for the same reason it does on the web -- round + * tripping markdown through a parser normalises things the author chose on + * purpose (bullet characters, indentation, hard breaks, trailing spaces). + * Editing the source in place leaves every byte where it was except the one + * character being toggled. + */ + +/** Anchored on the list bullet, so a literal "[x]" in prose is never a task. */ +const TASK_MARKER = /^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)\[([ xX])\]/gm; + +/** Whether `source` contains at least one task-list item. */ +export function hasTaskList(source: string): boolean { + TASK_MARKER.lastIndex = 0; + return TASK_MARKER.test(source); +} + +/** `{done, total}` for the task lists in `source`, for a progress summary. */ +export function taskProgress(source: string): { done: number; total: number } { + let done = 0; + let total = 0; + TASK_MARKER.lastIndex = 0; + for (const match of source.matchAll(TASK_MARKER)) { + total += 1; + if (match[2] !== ' ') done += 1; + } + return { done, total }; +} + +/** + * `source` with the nth task checkbox flipped, or `null` when there is no such + * checkbox -- in which case the caller should do nothing rather than write a + * guess back to the server. + */ +export function toggleTaskAtIndex(source: string, index: number): string | null { + if (index < 0) return null; + + let seen = 0; + TASK_MARKER.lastIndex = 0; + for (const match of source.matchAll(TASK_MARKER)) { + if (seen === index) { + const markerStart = (match.index ?? 0) + match[1].length; + const next = match[2] === ' ' ? 'x' : ' '; + // markerStart points at "[", so the state is the character after it. + return source.slice(0, markerStart + 1) + next + source.slice(markerStart + 2); + } + seen += 1; + } + return null; +} From 15e40563cf8934bf31747a9ccdde94652d070b09 Mon Sep 17 00:00:00 2001 From: Waleed Khalid Date: Fri, 11 Sep 2026 22:19:09 +0500 Subject: [PATCH 08/18] Add attachments from camera, photo library and files Closes #9. Uploads bypass the generated client: Orval emits a Blob body, and React Native has no real Blob to give it -- a picked file is a file:// URI the native layer streams itself, which is what `{uri, name, type}` in FormData expresses. That also gives a progress callback, which reading the file into memory first would not. The server's allowed list is mirrored client-side so a phone refuses a file before spending a minute uploading something that was never going to land, and the SVG message says *why* it is refused -- it is a document that can carry script, so a generic "not allowed" would read as an oversight rather than a decision. Attachment bytes are authenticated, so images are fetched with the bearer token in a header rather than the query string, where it would end up in whatever logs the instance keeps. A plain image source would simply 401 -- there is a test that pins exactly that. Two corrections from checking rather than assuming: a disallowed extension is 415, and a file whose bytes do not match its name is 422 with the server naming the format. Both now assert what the server actually does. Co-Authored-By: Claude Opus 5 --- app.json | 8 + package-lock.json | 74 ++++- package.json | 4 + src/api/__tests__/live-attachments.test.ts | 139 ++++++++++ src/attachments/__tests__/rules.test.ts | 58 ++++ src/attachments/attachments-section.tsx | 304 +++++++++++++++++++++ src/attachments/rules.ts | 81 ++++++ src/attachments/upload.ts | 67 +++++ src/issues/issue-detail-screen.tsx | 3 + 9 files changed, 728 insertions(+), 10 deletions(-) create mode 100644 src/api/__tests__/live-attachments.test.ts create mode 100644 src/attachments/__tests__/rules.test.ts create mode 100644 src/attachments/attachments-section.tsx create mode 100644 src/attachments/rules.ts create mode 100644 src/attachments/upload.ts diff --git a/app.json b/app.json index 200a10a..ba0b969 100644 --- a/app.json +++ b/app.json @@ -44,6 +44,14 @@ "backgroundColor": "#0c0b15" } } + ], + "expo-image", + [ + "expo-image-picker", + { + "photosPermission": "SoftTrack uses your photo library to attach screenshots to issues.", + "cameraPermission": "SoftTrack uses the camera to attach a photo to an issue." + } ] ], "experiments": { diff --git a/package-lock.json b/package-lock.json index c902d9e..1db268b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,10 @@ "expo": "~57.0.22", "expo-blur": "~57.0.3", "expo-constants": "~57.0.18", + "expo-document-picker": "~57.0.2", + "expo-file-system": "~57.0.7", + "expo-image": "~57.0.5", + "expo-image-picker": "~57.0.17", "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.10", "expo-router": "~57.0.21", @@ -8103,6 +8107,25 @@ "react-native": "*" } }, + "node_modules/expo-document-picker": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-57.0.2.tgz", + "integrity": "sha512-vVBbBNg0piMJUIxjB+DR6JaZZpcSl90ZUoywLA/EgNHTfRkZW+9axP97UxM2a0Zm0W6WfH3cLzzGAEfP9OKGwg==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-file-system": { + "version": "57.0.7", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.7.tgz", + "integrity": "sha512-1vkZRadOsIputFDBcclqBa1pO3iXOO6iIsdqJipQWw4V5TSnWgmRTuOF7ro8IzrVue+PHbum++KCG3Tawem4cg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-font": { "version": "57.0.4", "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.4.tgz", @@ -8128,6 +8151,47 @@ "react-native": "*" } }, + "node_modules/expo-image": { + "version": "57.0.5", + "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-57.0.5.tgz", + "integrity": "sha512-jOm5lofaIKXhQ7i0Hz3I5JuNULw/aghmpIPZXddwQcEtw8+wUhlozq3M7LbsOrcsBEapcG4jFs85DsfuFoz1tQ==", + "license": "MIT", + "dependencies": { + "sf-symbols-typescript": "^2.2.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, + "node_modules/expo-image-loader": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-57.0.1.tgz", + "integrity": "sha512-uhrZKLT/cTl2mXyR28kPpVkS5O+PK9N1QA/07IFM4f5T4g0lTW1JHT3NEWwEEsGFldPmVX4j7LwUVVZxE+woug==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "57.0.17", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.17.tgz", + "integrity": "sha512-qPcCkGSoqDLpmdXCLiLe09Je01gzBrwEgLd4SBI03Od/5t531vDTkzUmGemjm7vcsCTG2iqZMh2VZXBscAPO3g==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~57.0.1" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-linear-gradient": { "version": "57.0.2", "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-57.0.2.tgz", @@ -8813,16 +8877,6 @@ "react-native": "*" } }, - "node_modules/expo/node_modules/expo-file-system": { - "version": "57.0.7", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.7.tgz", - "integrity": "sha512-1vkZRadOsIputFDBcclqBa1pO3iXOO6iIsdqJipQWw4V5TSnWgmRTuOF7ro8IzrVue+PHbum++KCG3Tawem4cg==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, "node_modules/expo/node_modules/expo-keep-awake": { "version": "57.0.2", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.2.tgz", diff --git a/package.json b/package.json index 5e91c7d..760153d 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,10 @@ "expo": "~57.0.22", "expo-blur": "~57.0.3", "expo-constants": "~57.0.18", + "expo-document-picker": "~57.0.2", + "expo-file-system": "~57.0.7", + "expo-image": "~57.0.5", + "expo-image-picker": "~57.0.17", "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.10", "expo-router": "~57.0.21", diff --git a/src/api/__tests__/live-attachments.test.ts b/src/api/__tests__/live-attachments.test.ts new file mode 100644 index 0000000..54ef82b --- /dev/null +++ b/src/api/__tests__/live-attachments.test.ts @@ -0,0 +1,139 @@ +/** + * @jest-environment node + */ +import Axios from 'axios'; + +import { AXIOS_INSTANCE } from '@/api/client'; +import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth'; +import { + deleteAttachmentAttachmentsAttachmentIdDelete, + listIssueAttachmentsIssuesIssueIdAttachmentsGet, +} from '@/api/generated/endpoints/attachments/attachments'; +import { + createIssueTeamsTeamIdIssuesPost, + deleteIssueIssuesIssueIdDelete, +} from '@/api/generated/endpoints/issues/issues'; +import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; +import type { AttachmentRead, TeamRead } from '@/api/generated/models'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken, getAccessToken } from '@/auth/session'; + +/** + * Attachments against a real instance. + * + * The acceptance criterion for #9 is that a photo taken on the phone lands on + * the issue and renders. A jest run has no camera, so a real PNG is built in + * memory and posted the same way the app posts one -- what is being checked is + * that multipart upload, the server's type rules, and authenticated reads of + * the bytes all line up. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); + +/** The smallest valid PNG: 1x1, and it really does start with the signature. */ +const PNG_1X1 = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', +); + +async function postFile(issueId: number, filename: string, bytes: Buffer, type: string) { + const form = new FormData(); + form.append('file', new Blob([new Uint8Array(bytes)], { type }), filename); + const { data } = await AXIOS_INSTANCE.post( + `/issues/${issueId}/attachments`, + form, + ); + return data; +} + +describeLive('attachments against a live instance', () => { + let team: TeamRead; + let issueId: 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]; + const issue = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Attachments ${RUN}`, + }); + issueId = issue.id; + }); + + afterAll(async () => { + await deleteIssueIssuesIssueIdDelete(issueId).catch(() => undefined); + }); + + it('accepts an image and marks it as one', async () => { + const attachment = await postFile(issueId, `shot-${RUN}.png`, PNG_1X1, 'image/png'); + + expect(attachment.is_image).toBe(true); + expect(attachment.content_type).toBe('image/png'); + expect(attachment.size_bytes).toBe(PNG_1X1.length); + // is_image is what decides between rendering inline and offering to open. + expect(attachment.url).toContain(String(attachment.id)); + + const listed = await listIssueAttachmentsIssuesIssueIdAttachmentsGet(issueId); + expect(listed.map((a) => a.id)).toContain(attachment.id); + + await deleteAttachmentAttachmentsAttachmentIdDelete(attachment.id); + }); + + it('serves the bytes only with the bearer token', async () => { + const attachment = await postFile(issueId, `auth-${RUN}.png`, PNG_1X1, 'image/png'); + const base = LIVE_URL!; + const path = `/attachments/${attachment.id}/content`; + + // With the header the app sends, which is why an image source carries it + // rather than relying on a plain URL. + const ok = await AXIOS_INSTANCE.get(path, { responseType: 'arraybuffer' }); + expect(ok.status).toBe(200); + expect(Buffer.from(ok.data).subarray(0, 8)).toEqual(PNG_1X1.subarray(0, 8)); + + // And without it: a bare client, the way a plain would ask. + expect(getAccessToken()).not.toBeNull(); + const anonymous = Axios.create({ baseURL: base }); + await expect(anonymous.get(path)).rejects.toMatchObject({ + response: { status: 401 }, + }); + + await deleteAttachmentAttachmentsAttachmentIdDelete(attachment.id); + }); + + it('refuses an SVG, which is why the client refuses it first', async () => { + // 415 for a type that is not on the list at all. + await expect( + postFile(issueId, `diagram-${RUN}.svg`, Buffer.from(''), 'image/svg+xml'), + ).rejects.toMatchObject({ response: { status: 415 } }); + }); + + it('refuses a file whose bytes are not the image its name claims', async () => { + // "Screenshot of the bug" is the whole point, so a mislabelled file fails + // now with a clear error rather than later as a broken image. + // 422 rather than 415: the extension is allowed, the bytes are not what it + // claims, and the server says exactly that. + await expect( + postFile(issueId, `fake-${RUN}.png`, Buffer.from('not a png at all'), 'image/png'), + ).rejects.toMatchObject({ + response: { status: 422, data: { detail: expect.stringContaining('not a valid PNG') } }, + }); + }); + + it('takes a non-image and does not mark it as one', async () => { + const attachment = await postFile( + issueId, + `notes-${RUN}.txt`, + Buffer.from('a log line'), + 'text/plain', + ); + expect(attachment.is_image).toBe(false); + expect(attachment.content_type).toBe('text/plain'); + await deleteAttachmentAttachmentsAttachmentIdDelete(attachment.id); + }); +}); diff --git a/src/attachments/__tests__/rules.test.ts b/src/attachments/__tests__/rules.test.ts new file mode 100644 index 0000000..39a561e --- /dev/null +++ b/src/attachments/__tests__/rules.test.ts @@ -0,0 +1,58 @@ +import { contentTypeFor, formatBytes, MAX_BYTES, rejectionFor } from '@/attachments/rules'; +import { filenameFrom } from '@/attachments/upload'; + +describe('contentTypeFor', () => { + it('accepts what the server accepts', () => { + expect(contentTypeFor('shot.PNG')).toBe('image/png'); + expect(contentTypeFor('notes.md')).toBe('text/markdown'); + expect(contentTypeFor('fix.patch')).toBe('text/plain'); + expect(contentTypeFor('clip.mov')).toBe('video/quicktime'); + }); + + it('refuses what it does not', () => { + expect(contentTypeFor('page.html')).toBeNull(); + expect(contentTypeFor('noextension')).toBeNull(); + }); +}); + +describe('rejectionFor', () => { + it('says why an SVG is refused rather than just refusing it', () => { + // The server leaves SVG out deliberately: it is a document that can carry + // script. A generic "not allowed" would read as an oversight. + const rejection = rejectionFor('diagram.svg', 1000); + expect(rejection?.reason).toBe('type'); + expect(rejection?.message).toContain('script'); + }); + + it('names the file and the limit when it is too big', () => { + const rejection = rejectionFor('huge.zip', MAX_BYTES + 1); + expect(rejection?.reason).toBe('size'); + expect(rejection?.message).toContain('huge.zip'); + expect(rejection?.message).toContain('25.0 MB'); + }); + + it('passes a file the server would take', () => { + expect(rejectionFor('shot.png', 500_000)).toBeNull(); + // Unknown size is not a reason to refuse; the server has the final say. + expect(rejectionFor('shot.png', undefined)).toBeNull(); + }); +}); + +describe('filenameFrom', () => { + it('keeps the extension the server validates on', () => { + expect(filenameFrom('file:///var/tmp/ABC123.jpg')).toBe('ABC123.jpg'); + expect(filenameFrom('file:///tmp/my%20shot.png')).toBe('my shot.png'); + }); + + it('invents a name with an extension when the camera gives none', () => { + expect(filenameFrom('file:///var/tmp/ABC123')).toMatch(/^upload-\d+\.jpg$/); + }); +}); + +describe('formatBytes', () => { + it('reads the way a person would say it', () => { + expect(formatBytes(512)).toBe('512 B'); + expect(formatBytes(2048)).toBe('2 KB'); + expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB'); + }); +}); diff --git a/src/attachments/attachments-section.tsx b/src/attachments/attachments-section.tsx new file mode 100644 index 0000000..06391f7 --- /dev/null +++ b/src/attachments/attachments-section.tsx @@ -0,0 +1,304 @@ +import { useState } from 'react'; +import { Linking, Modal, Pressable, View } from 'react-native'; +import { Image } from 'expo-image'; +import * as DocumentPicker from 'expo-document-picker'; +import * as ImagePicker from 'expo-image-picker'; +import { useQueryClient } from '@tanstack/react-query'; + +import { getInstanceUrl } from '@/api/instance'; +import { + deleteAttachmentAttachmentsAttachmentIdDelete, + useListIssueAttachmentsIssuesIssueIdAttachmentsGet, +} from '@/api/generated/endpoints/attachments/attachments'; +import type { AttachmentRead, IssueRead } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { getAccessToken } from '@/auth/session'; +import { formatBytes, rejectionFor } from '@/attachments/rules'; +import { filenameFrom, uploadAttachment } from '@/attachments/upload'; +import { Section } from '@/issues/detail/sections'; +import { Icon } from '@/ui/icon'; +import { Alert, AppText, Card, Loading } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * The URL an attachment's bytes come from, with the bearer token attached. + * + * Attachment content is authenticated, so a plain image source would 401. The + * token goes in a header rather than the query string -- it would otherwise end + * up in any log the instance keeps. + */ +function imageSource(attachment: AttachmentRead) { + const base = getInstanceUrl() ?? ''; + const token = getAccessToken(); + return { + uri: `${base}/attachments/${attachment.id}/content`, + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }; +} + +export function AttachmentsSection({ issue }: { issue: IssueRead }) { + const t = useTokens(); + const queryClient = useQueryClient(); + const attachments = useListIssueAttachmentsIssuesIssueIdAttachmentsGet(issue.id); + + const [picking, setPicking] = useState(false); + const [error, setError] = useState(null); + const [progress, setProgress] = useState(null); + const [retry, setRetry] = useState<{ uri: string; filename: string } | null>(null); + const [viewing, setViewing] = useState(null); + + const items = attachments.data ?? []; + + async function upload(uri: string, filename: string) { + setError(null); + setRetry(null); + + const rejection = rejectionFor(filename, undefined); + if (rejection) { + // Refused before spending a minute uploading something that was never + // going to land. + setError(rejection.message); + return; + } + + setProgress(0); + try { + await uploadAttachment({ + issueId: issue.id, + uri, + filename, + onProgress: setProgress, + }); + await queryClient.invalidateQueries({ + queryKey: [`/issues/${issue.id}/attachments`], + }); + } catch (err) { + setError(errorDetail(err, `Could not upload ${filename}.`)); + setRetry({ uri, filename }); + } finally { + setProgress(null); + } + } + + async function fromCamera() { + setPicking(false); + const permission = await ImagePicker.requestCameraPermissionsAsync(); + if (!permission.granted) { + setError('SoftTrack needs camera access to attach a photo.'); + return; + } + const result = await ImagePicker.launchCameraAsync({ quality: 0.8 }); + const asset = result.assets?.[0]; + if (!asset) return; + await upload(asset.uri, asset.fileName ?? filenameFrom(asset.uri)); + } + + async function fromLibrary() { + setPicking(false); + const result = await ImagePicker.launchImageLibraryAsync({ + quality: 0.8, + mediaTypes: ['images', 'videos'], + }); + const asset = result.assets?.[0]; + if (!asset) return; + await upload(asset.uri, asset.fileName ?? filenameFrom(asset.uri)); + } + + async function fromFiles() { + setPicking(false); + const result = await DocumentPicker.getDocumentAsync({ copyToCacheDirectory: true }); + const asset = result.assets?.[0]; + if (!asset) return; + + // The file picker knows the size, so an oversized file is caught here + // rather than by the server after the whole upload. + const rejection = rejectionFor(asset.name, asset.size); + if (rejection) { + setError(rejection.message); + return; + } + await upload(asset.uri, asset.name); + } + + return ( +
setPicking(true)} + > + + + } + > + {error ? {error} : null} + + {retry ? ( + void upload(retry.uri, retry.filename)} + style={{ + paddingVertical: 10, + alignItems: 'center', + borderRadius: t.radius.control, + backgroundColor: t.line.well, + }} + > + + Retry {retry.filename} + + + ) : null} + + {progress !== null ? ( + + Uploading… {Math.round(progress * 100)}% + + + + + ) : null} + + {attachments.isPending ? : null} + + {items.length > 0 ? ( + + {items.map((attachment) => ( + + {attachment.is_image ? ( + setViewing(attachment)} + > + + + ) : null} + + + + + {attachment.filename} + + + {formatBytes(attachment.size_bytes)} · {attachment.uploaded_by.full_name} + + + + {!attachment.is_image ? ( + { + const base = getInstanceUrl() ?? ''; + void Linking.openURL(`${base}/attachments/${attachment.id}/content`); + }} + > + + Open + + + ) : null} + + { + setError(null); + try { + await deleteAttachmentAttachmentsAttachmentIdDelete(attachment.id); + await queryClient.invalidateQueries({ + queryKey: [`/issues/${issue.id}/attachments`], + }); + } catch (err) { + setError(errorDetail(err, 'Could not delete that attachment.')); + } + }} + > + + + + + ))} + + ) : !attachments.isPending ? ( + + Nothing attached yet. + + ) : null} + + setPicking(false)} title="Attach"> + + {[ + { label: 'Take a photo', run: fromCamera }, + { label: 'Photo library', run: fromLibrary }, + { label: 'Files', run: fromFiles }, + ].map((option) => ( + void option.run()} + style={{ + paddingVertical: 14, + paddingHorizontal: 10, + borderRadius: t.radius.control, + }} + > + {option.label} + + ))} + + + + setViewing(null)} + supportedOrientations={['portrait', 'landscape']} + > + setViewing(null)} + style={{ + flex: 1, + backgroundColor: 'rgba(0,0,0,0.92)', + alignItems: 'center', + justifyContent: 'center', + }} + > + {viewing ? ( + + ) : null} + + +
+ ); +} diff --git a/src/attachments/rules.ts b/src/attachments/rules.ts new file mode 100644 index 0000000..f0c53b8 --- /dev/null +++ b/src/attachments/rules.ts @@ -0,0 +1,81 @@ +/** + * What the server will accept, mirrored so a phone can say no before spending + * a minute uploading something that was never going to land. + * + * Kept in sync by hand with `backend/lib_softtrack/attachments.py` -- the + * allowed list is not in the OpenAPI schema, so there is nothing to generate + * it from. + */ + +/** `.svg` is deliberately absent: it is a document that can carry script. */ +export const ALLOWED_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.pdf': 'application/pdf', + '.txt': 'text/plain', + '.log': 'text/plain', + '.md': 'text/markdown', + '.csv': 'text/csv', + '.json': 'application/json', + '.patch': 'text/plain', + '.diff': 'text/plain', + '.zip': 'application/zip', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.mov': 'video/quicktime', +}; + +/** The default cap; an instance can lower it, and the server has final say. */ +export const MAX_BYTES = 25 * 1024 * 1024; + +export function extensionOf(filename: string): string { + const dot = filename.lastIndexOf('.'); + return dot === -1 ? '' : filename.slice(dot).toLowerCase(); +} + +export function contentTypeFor(filename: string): string | null { + return ALLOWED_TYPES[extensionOf(filename)] ?? null; +} + +export type Rejection = { reason: 'type' | 'size'; message: string }; + +/** + * Why this file cannot be uploaded, or `null` if it can. + * + * Names the file rather than stating a rule in the abstract, because the person + * picking it is looking at a gallery of a hundred things. + */ +export function rejectionFor( + filename: string, + sizeBytes: number | undefined, + maxBytes = MAX_BYTES, +): Rejection | null { + if (contentTypeFor(filename) === null) { + const extension = extensionOf(filename); + return { + reason: 'type', + message: + extension === '.svg' + ? 'SVG files are not accepted, because they can carry script.' + : `${extension || 'That file type'} is not accepted. Try an image, PDF, text, CSV, JSON, zip or video file.`, + }; + } + + if (sizeBytes !== undefined && sizeBytes > maxBytes) { + return { + reason: 'size', + message: `${filename} is ${formatBytes(sizeBytes)}, over the ${formatBytes(maxBytes)} limit.`, + }; + } + + return null; +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/src/attachments/upload.ts b/src/attachments/upload.ts new file mode 100644 index 0000000..7520fa2 --- /dev/null +++ b/src/attachments/upload.ts @@ -0,0 +1,67 @@ +import { AXIOS_INSTANCE } from '@/api/client'; +import type { AttachmentRead } from '@/api/generated/models'; +import { contentTypeFor } from '@/attachments/rules'; + +/** + * Uploading a file the API expects as multipart. + * + * Not routed through the generated client: Orval emits a `Blob` body for the + * upload, and React Native has no real Blob to give it -- a file is a `file://` + * URI that the native networking layer streams itself. Handing axios a + * `FormData` with `{uri, name, type}` is how that is expressed, and it also + * gives a progress callback, which reading the whole file into memory first + * would not. + */ +export type UploadProgress = (fraction: number) => void; + +export async function uploadAttachment({ + issueId, + uri, + filename, + onProgress, + signal, +}: { + issueId: number; + uri: string; + filename: string; + onProgress?: UploadProgress; + signal?: AbortSignal; +}): Promise { + const form = new FormData(); + form.append('file', { + uri, + name: filename, + // The server derives the type from the extension anyway, but sending the + // right one avoids a generic octet-stream round trip. + type: contentTypeFor(filename) ?? 'application/octet-stream', + } as unknown as Blob); + + const { data } = await AXIOS_INSTANCE.post( + `/issues/${issueId}/attachments`, + form, + { + signal, + headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: (event) => { + if (!onProgress) return; + // `total` is absent on some platforms; an indeterminate bar is better + // than a bar that jumps to 100% and sits there. + if (event.total) onProgress(Math.min(1, event.loaded / event.total)); + }, + }, + ); + return data; +} + +/** + * A filename for something the picker gave no name to. + * + * The camera hands back a `file://.../ABC123.jpg` with no display name, and the + * extension is what the server validates on, so it has to survive. + */ +export function filenameFrom(uri: string, fallbackExtension = '.jpg'): string { + const withoutQuery = uri.split('?')[0]; + const last = withoutQuery.split('/').pop() ?? ''; + if (last.includes('.')) return decodeURIComponent(last); + return `upload-${Date.now()}${fallbackExtension}`; +} diff --git a/src/issues/issue-detail-screen.tsx b/src/issues/issue-detail-screen.tsx index 241dc16..df5e508 100644 --- a/src/issues/issue-detail-screen.tsx +++ b/src/issues/issue-detail-screen.tsx @@ -24,6 +24,7 @@ import { WatchToggle, } from '@/issues/detail/sections'; import { labelFor, useIssueProperties } from '@/issues/use-issue-properties'; +import { AttachmentsSection } from '@/attachments/attachments-section'; import { CommentsSection, mentionablesFrom, openIssueByIdentifier } from '@/comments/comments-section'; import { MarkdownBody } from '@/markdown/markdown'; import { MarkdownEditor } from '@/markdown/markdown-editor'; @@ -312,6 +313,8 @@ export function IssueDetailScreen() { + + Date: Fri, 11 Sep 2026 22:20:43 +0500 Subject: [PATCH 09/18] Add search across titles, descriptions and comments Closes #11. Matching, ranking and scoping all stay on the server, which is what makes the result set the same one web shows -- the client only settles the query before sending it and pages through what comes back. Two characters before the first request: a single character matches most of an instance and is never what anyone meant. Each result says where the match was found, because a hit on a comment and a hit on a title look identical otherwise, and carries the snippet that explains why it is there. Co-Authored-By: Claude Opus 5 --- src/api/__tests__/live-search.test.ts | 103 ++++++++++++ src/app/(app)/search.tsx | 6 +- src/search/search-screen.tsx | 219 ++++++++++++++++++++++++++ 3 files changed, 323 insertions(+), 5 deletions(-) create mode 100644 src/api/__tests__/live-search.test.ts create mode 100644 src/search/search-screen.tsx diff --git a/src/api/__tests__/live-search.test.ts b/src/api/__tests__/live-search.test.ts new file mode 100644 index 0000000..de7613b --- /dev/null +++ b/src/api/__tests__/live-search.test.ts @@ -0,0 +1,103 @@ +/** + * @jest-environment node + */ +import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth'; +import { createCommentIssuesIssueIdCommentsPost } from '@/api/generated/endpoints/comments/comments'; +import { + createIssueTeamsTeamIdIssuesPost, + deleteIssueIssuesIssueIdDelete, +} from '@/api/generated/endpoints/issues/issues'; +import { searchSearchGet } from '@/api/generated/endpoints/search/search'; +import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; +import type { TeamRead } from '@/api/generated/models'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken } from '@/auth/session'; + +/** + * Search against a real instance. + * + * The acceptance criterion for #11 is the same result set as web for the same + * query and scope, so what matters is that matching, ranking and scoping all + * stay on the server -- the client only debounces and pages. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); +const NEEDLE = `zqx${RUN}`; + +describeLive('search against a live instance', () => { + let team: TeamRead; + 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]; + + const inTitle = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Title match ${NEEDLE}`, + }); + const inDescription = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Description holder ${RUN}`, + description: `body mentions ${NEEDLE} here`, + }); + const inComment = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Comment holder ${RUN}`, + }); + await createCommentIssuesIssueIdCommentsPost(inComment.id, { + body: `a comment saying ${NEEDLE}`, + }); + created.push(inTitle.id, inDescription.id, inComment.id); + }); + + afterAll(async () => { + for (const id of created) { + await deleteIssueIssuesIssueIdDelete(id).catch(() => undefined); + } + }); + + it('searches titles, descriptions and comments alike', async () => { + const page = await searchSearchGet({ q: NEEDLE, limit: 25 }); + const ids = page.items.map((hit) => hit.id); + + for (const id of created) expect(ids).toContain(id); + + // `matched_in` is what the result row labels itself with. + const kinds = new Set(page.items.map((hit) => hit.matched_in)); + expect(kinds.size).toBeGreaterThan(1); + }); + + it('carries a snippet, which is why the result is worth a line', async () => { + const page = await searchSearchGet({ q: NEEDLE, limit: 25 }); + for (const hit of page.items) { + expect(typeof hit.snippet).toBe('string'); + expect(hit.identifier).toContain(hit.team_key); + } + }); + + it('scopes to one team when asked', async () => { + const scoped = await searchSearchGet({ q: NEEDLE, team_id: team.id, limit: 25 }); + for (const hit of scoped.items) expect(hit.team_id).toBe(team.id); + expect(scoped.items.length).toBeGreaterThan(0); + }); + + it('pages, which is what the "show more" control walks through', async () => { + const first = await searchSearchGet({ q: NEEDLE, limit: 1, offset: 0 }); + expect(first.items).toHaveLength(1); + expect(first.total).toBeGreaterThanOrEqual(3); + + const wider = await searchSearchGet({ q: NEEDLE, limit: 25 }); + expect(wider.items.length).toBe(wider.total); + }); + + it('finds nothing for a query that matches nothing', async () => { + const page = await searchSearchGet({ q: `${NEEDLE}-absent`, limit: 25 }); + expect(page.items).toEqual([]); + expect(page.total).toBe(0); + }); +}); diff --git a/src/app/(app)/search.tsx b/src/app/(app)/search.tsx index a7d84ea..4c1673c 100644 --- a/src/app/(app)/search.tsx +++ b/src/app/(app)/search.tsx @@ -1,5 +1 @@ -import { Placeholder } from '@/ui/placeholder'; - -export default function SearchRoute() { - return ; -} +export { SearchScreen as default } from '@/search/search-screen'; diff --git a/src/search/search-screen.tsx b/src/search/search-screen.tsx new file mode 100644 index 0000000..5cc8aa2 --- /dev/null +++ b/src/search/search-screen.tsx @@ -0,0 +1,219 @@ +import { useState } from 'react'; +import { FlatList, Pressable, StyleSheet, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { router } from 'expo-router'; + +import { useSearchSearchGet } from '@/api/generated/endpoints/search/search'; +import type { SearchHit } from '@/api/generated/models'; +import { useTeams } from '@/team/team-context'; +import { href } from '@/ui/href'; +import { useIsMultiPane } from '@/ui/layout'; +import { AppText, Card, Field, Loading, TeamBadge } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useDebouncedValue } from '@/ui/use-debounced-value'; +import { useTokens } from '@/ui/theme'; + +/** + * Search, per `docs/design/mobile/148-search.svg`. + * + * The query settles before it is sent, so typing a word does not cost a request + * per keystroke. Scope is all your teams or one of them, which is exactly the + * `team_id` parameter the endpoint takes -- ranking and matching stay on the + * server, so the result set is the web's. + */ +const DEBOUNCE_MS = 300; +const PAGE = 25; + +/** Where the match was found, as something to read rather than a column name. */ +function matchedInLabel(hit: SearchHit): string { + switch (hit.matched_in) { + case 'title': + return 'Title'; + case 'description': + return 'Description'; + case 'comment': + return 'Comment'; + default: + return hit.matched_in; + } +} + +export function SearchScreen() { + const t = useTokens(); + const multiPane = useIsMultiPane(); + const { teams } = useTeams(); + + const [query, setQuery] = useState(''); + const [scopeTeamId, setScopeTeamId] = useState(null); + const [scoping, setScoping] = useState(false); + const [limit, setLimit] = useState(PAGE); + + const settled = useDebouncedValue(query.trim(), DEBOUNCE_MS); + + const results = useSearchSearchGet( + { q: settled, team_id: scopeTeamId ?? undefined, limit, offset: 0 }, + // A single character matches most of an instance and is never what anyone + // meant, so the request waits for a second one. + { query: { enabled: settled.length >= 2 } }, + ); + + const hits = results.data?.items ?? []; + const total = results.data?.total ?? 0; + const scopeName = scopeTeamId + ? (teams.find((team) => team.id === scopeTeamId)?.name ?? 'One team') + : 'All teams'; + + return ( + + + Search + + setScoping(true)} + style={{ + alignSelf: 'flex-start', + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: t.radius.pill, + backgroundColor: scopeTeamId ? t.line.navActive : t.line.ghost, + }} + > + + {scopeName} + + + + + {settled.length < 2 ? ( + + + Type at least two characters to search. + + + ) : results.isPending ? ( + + ) : ( + String(hit.id)} + contentContainerStyle={{ padding: 16, gap: 10 }} + keyboardShouldPersistTaps="handled" + ListHeaderComponent={ + + {total} RESULT{total === 1 ? '' : 'S'} + + } + ListEmptyComponent={ + + Nothing matches “{settled}”. + + } + ListFooterComponent={ + total > hits.length ? ( + setLimit((current) => current + PAGE)} + style={{ + paddingVertical: 12, + alignItems: 'center', + borderRadius: t.radius.control, + borderWidth: StyleSheet.hairlineWidth, + borderColor: t.line.hairline, + }} + > + + Show more + + + ) : null + } + renderItem={({ item }) => ( + router.push(href(`/issue/${item.id}`))} + > + + + + {item.identifier} + + + {matchedInLabel(item)} + + + + {item.title} + + + {/* The snippet is why this result is here, so it is worth the + line even when it repeats part of the title. */} + {item.snippet ? ( + + {item.snippet} + + ) : null} + + + )} + /> + )} + + setScoping(false)} title="Search in"> + + {[{ id: null, name: 'All teams' }, ...teams].map((option) => { + const id = 'id' in option ? (option.id as number | null) : null; + const active = scopeTeamId === id; + return ( + { + setScopeTeamId(id); + setScoping(false); + }} + style={{ + paddingVertical: 12, + paddingHorizontal: 10, + borderRadius: t.radius.control, + backgroundColor: active ? t.line.navActive : 'transparent', + }} + > + {option.name} + + ); + })} + + + + ); +} From 177ea9793a6f476727c5570429c73b62d72e295a Mon Sep 17 00:00:00 2001 From: Waleed Khalid Date: Fri, 11 Sep 2026 22:23:18 +0500 Subject: [PATCH 10/18] Add saved views with default resolution Closes #14. Which view is showing is decided by comparing filters rather than by tracking an id, the same way the web's sidebar does it. That is what lets a pasted link carrying filters light up the matching row for someone who can see the view, and still work for someone who cannot. The board lands on the default from the server's own `effective_default_id` rather than re-deriving yours-then-the-team's on the client -- one place decides that order, and it is the one that also answers the web. Landing happens at most once per team, so clearing the filters on purpose does not bounce straight back. Filters translate through the same mapping the web uses, including `unassigned` being a flag beside assignee_id in storage and one value of assignee on the board -- the flag wins on the way back, as it does server-side. Verified against a live instance: a view stores what the board was showing, is private until shared, and a personal default overrides the team's while null clears rather than sets. Co-Authored-By: Claude Opus 5 --- src/api/__tests__/live-views.test.ts | 128 ++++++++++ src/board/board-screen.tsx | 60 ++++- src/views/__tests__/saved-views.test.ts | 35 +++ src/views/saved-views.ts | 36 +++ src/views/views-sheet.tsx | 297 ++++++++++++++++++++++++ 5 files changed, 554 insertions(+), 2 deletions(-) create mode 100644 src/api/__tests__/live-views.test.ts create mode 100644 src/views/__tests__/saved-views.test.ts create mode 100644 src/views/saved-views.ts create mode 100644 src/views/views-sheet.tsx diff --git a/src/api/__tests__/live-views.test.ts b/src/api/__tests__/live-views.test.ts new file mode 100644 index 0000000..ddb21f6 --- /dev/null +++ b/src/api/__tests__/live-views.test.ts @@ -0,0 +1,128 @@ +/** + * @jest-environment node + */ +import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth'; +import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; +import { + createViewTeamsTeamIdViewsPost, + deleteViewViewsViewIdDelete, + listViewsTeamsTeamIdViewsGet, + setMyDefaultViewTeamsTeamIdDefaultViewMePut, + setTeamDefaultViewTeamsTeamIdDefaultViewPut, + updateViewViewsViewIdPatch, +} from '@/api/generated/endpoints/views/views'; +import type { TeamRead } from '@/api/generated/models'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken } from '@/auth/session'; +import { NO_FILTERS, sameFilters } from '@/board/filters'; +import { fromViewFilters, toViewFilters } from '@/views/saved-views'; + +/** + * Saved views against a real instance. + * + * The acceptance criterion for #14 is that views created on mobile appear on web + * with identical filters and that defaults resolve the same way -- so these + * assert the stored shape and the server's own default resolution. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); + +describeLive('saved views against a live instance', () => { + let team: TeamRead; + 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]; + }); + + afterAll(async () => { + await setMyDefaultViewTeamsTeamIdDefaultViewMePut(team.id, { view_id: null }).catch( + () => undefined, + ); + await setTeamDefaultViewTeamsTeamIdDefaultViewPut(team.id, { view_id: null }).catch( + () => undefined, + ); + for (const id of created) { + await deleteViewViewsViewIdDelete(id).catch(() => undefined); + } + }); + + it('stores exactly the filters the board was showing', async () => { + const filters = { ...NO_FILTERS, priority: 'urgent' as const, assignee: 'unassigned' as const }; + const view = await createViewTeamsTeamIdViewsPost(team.id, { + name: `Urgent unassigned ${RUN}`, + filters: toViewFilters(filters), + }); + created.push(view.id); + + expect(view.is_shared).toBe(false); // private by default + expect(view.filters.priority).toBe('urgent'); + expect(view.filters.unassigned).toBe(true); + // And reading it back reconstructs the same question. + expect(sameFilters(fromViewFilters(view.filters), filters)).toBe(true); + }); + + it('renames and re-shares', async () => { + const view = await createViewTeamsTeamIdViewsPost(team.id, { + name: `Rename me ${RUN}`, + filters: toViewFilters(NO_FILTERS), + }); + created.push(view.id); + + const renamed = await updateViewViewsViewIdPatch(view.id, { + name: `Renamed ${RUN}`, + is_shared: true, + }); + expect(renamed.name).toBe(`Renamed ${RUN}`); + expect(renamed.is_shared).toBe(true); + }); + + it('resolves the effective default itself: yours over the team’s', async () => { + const teamView = await createViewTeamsTeamIdViewsPost(team.id, { + name: `Team default ${RUN}`, + is_shared: true, + filters: toViewFilters({ ...NO_FILTERS, priority: 'low' }), + }); + const myView = await createViewTeamsTeamIdViewsPost(team.id, { + name: `My default ${RUN}`, + filters: toViewFilters({ ...NO_FILTERS, priority: 'high' }), + }); + created.push(teamView.id, myView.id); + + await setTeamDefaultViewTeamsTeamIdDefaultViewPut(team.id, { view_id: teamView.id }); + let listed = await listViewsTeamsTeamIdViewsGet(team.id); + expect(listed.team_default_id).toBe(teamView.id); + // With no personal default, the team's is what a board lands on. + expect(listed.effective_default_id).toBe(teamView.id); + + await setMyDefaultViewTeamsTeamIdDefaultViewMePut(team.id, { view_id: myView.id }); + listed = await listViewsTeamsTeamIdViewsGet(team.id); + // Yours wins -- the client reads this rather than re-deriving the order. + expect(listed.effective_default_id).toBe(myView.id); + + // Null clears rather than sets. + await setMyDefaultViewTeamsTeamIdDefaultViewMePut(team.id, { view_id: null }); + listed = await listViewsTeamsTeamIdViewsGet(team.id); + expect(listed.my_default_id ?? null).toBeNull(); + expect(listed.effective_default_id).toBe(teamView.id); + }); + + it('stops listing a deleted view', async () => { + const view = await createViewTeamsTeamIdViewsPost(team.id, { + name: `Delete me ${RUN}`, + filters: toViewFilters(NO_FILTERS), + }); + await deleteViewViewsViewIdDelete(view.id); + + const listed = await listViewsTeamsTeamIdViewsGet(team.id); + expect(listed.items.map((v) => v.id)).not.toContain(view.id); + }); +}); diff --git a/src/board/board-screen.tsx b/src/board/board-screen.tsx index 2b4f7ec..e3c1db4 100644 --- a/src/board/board-screen.tsx +++ b/src/board/board-screen.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Pressable, StyleSheet, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { router, useLocalSearchParams } from 'expo-router'; @@ -7,10 +7,14 @@ import { useListIssuesTeamsTeamIdIssuesGet } from '@/api/generated/endpoints/iss import type { IssueRead, StatusRead } from '@/api/generated/models'; import { buildChips, FilterChips } from '@/board/filter-chips'; import { FilterSheet } from '@/board/filter-sheet'; -import { fromParams, toParams, toQueryParams, type BoardFilters } from '@/board/filters'; +import { fromParams, isEmpty, toParams, toQueryParams, type BoardFilters } from '@/board/filters'; import { IssueList } from '@/board/issue-list'; import { KanbanBoard } from '@/board/kanban-board'; import { useStatusChange } from '@/board/use-status-change'; +import { useListViewsTeamsTeamIdViewsGet } from '@/api/generated/endpoints/views/views'; +import { useAuth } from '@/auth/auth-context'; +import { fromViewFilters } from '@/views/saved-views'; +import { ViewsSheet } from '@/views/views-sheet'; import { useTeamData } from '@/board/use-team-data'; import { NewTeamSheet } from '@/team/new-team-sheet'; import { useTeams } from '@/team/team-context'; @@ -49,10 +53,37 @@ export function BoardScreen() { const [view, setView] = useState('board'); const [filterOpen, setFilterOpen] = useState(false); + const [viewsOpen, setViewsOpen] = useState(false); const [switching, setSwitching] = useState(false); const [creating, setCreating] = useState(false); const [moving, setMoving] = useState(null); + const { user } = useAuth(); + const views = useListViewsTeamsTeamIdViewsGet(team?.id ?? 0, { + query: { enabled: Boolean(team) }, + }); + + /** + * Land on the default view, at most once per team. + * + * `effective_default_id` is the server's own resolution of yours, then the + * team's, then nothing -- so the client does not re-derive that order. A ref + * rather than state because nothing renders differently for having landed; it + * only stops the redirect happening again after someone clears the filters on + * purpose. + */ + const landed = useRef(null); + useEffect(() => { + if (!team || views.isPending) return; + if (landed.current === team.id) return; + landed.current = team.id; + + const defaultId = views.data?.effective_default_id; + if (!defaultId || !isEmpty(filters)) return; + const view = views.data?.items.find((candidate) => candidate.id === defaultId); + if (view) router.setParams(toParams(fromViewFilters(view.filters))); + }, [team, views.isPending, views.data, filters]); + const data = useTeamData(team); const queryParams = useMemo( // The cap is the API's own MAX_LIMIT; a board wants every column at once, @@ -125,6 +156,22 @@ export function BoardScreen() { + setViewsOpen(true)} + style={{ + width: 36, + height: 36, + borderRadius: t.radius.control, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: t.line.ghost, + }} + > + + + )} + setViewsOpen(false)} + team={team} + user={user} + filters={filters} + onApply={setFilters} + /> + setFilterOpen(false)} diff --git a/src/views/__tests__/saved-views.test.ts b/src/views/__tests__/saved-views.test.ts new file mode 100644 index 0000000..8bad7d4 --- /dev/null +++ b/src/views/__tests__/saved-views.test.ts @@ -0,0 +1,35 @@ +import { NO_FILTERS, sameFilters } from '@/board/filters'; +import { fromViewFilters, toViewFilters } from '@/views/saved-views'; + +describe('saved view filters', () => { + it('round-trips every filter a board can carry', () => { + const filters = { + statusId: 3, + priority: 'high' as const, + assignee: 7, + labelId: 2, + projectId: 9, + cycleId: 4, + }; + expect(sameFilters(fromViewFilters(toViewFilters(filters)), filters)).toBe(true); + }); + + it('stores unassigned as the flag the API actually has', () => { + // A view stores assignee_id and unassigned side by side; the board models + // them as one question, so the flag has to win on the way back. + const stored = toViewFilters({ ...NO_FILTERS, assignee: 'unassigned' }); + expect(stored.unassigned).toBe(true); + expect(stored.assignee_id).toBeNull(); + expect(fromViewFilters(stored).assignee).toBe('unassigned'); + }); + + it('treats a view with no filters as showing everything', () => { + // Which is what makes an empty saved view sensible rather than a view that + // matches nothing. + expect(sameFilters(fromViewFilters({}), NO_FILTERS)).toBe(true); + }); + + it('prefers the unassigned flag over a stale assignee id', () => { + expect(fromViewFilters({ unassigned: true, assignee_id: 5 }).assignee).toBe('unassigned'); + }); +}); diff --git a/src/views/saved-views.ts b/src/views/saved-views.ts new file mode 100644 index 0000000..57d5a24 --- /dev/null +++ b/src/views/saved-views.ts @@ -0,0 +1,36 @@ +import type { ViewFilters } from '@/api/generated/models'; +import type { BoardFilters } from '@/board/filters'; + +/** + * Translating between the board's filters and the shape a saved view stores. + * + * Ported from `frontend/src/board/filters.ts` so a view created on either client + * reads identically on the other -- which is the acceptance criterion for #14. + */ + +/** A saved view's filters, as the board holds them. */ +export function fromViewFilters(filters: ViewFilters): BoardFilters { + return { + statusId: filters.status_id ?? null, + priority: filters.priority ?? null, + // `unassigned` is a flag beside assignee_id in storage, and one value of + // assignee in the board -- the flag wins, as it does server-side. + assignee: filters.unassigned ? 'unassigned' : (filters.assignee_id ?? null), + labelId: filters.label_id ?? null, + projectId: filters.project_id ?? null, + cycleId: filters.cycle_id ?? null, + }; +} + +/** The board's filters, as a saved view stores them. */ +export function toViewFilters(filters: BoardFilters): ViewFilters { + return { + status_id: filters.statusId, + priority: filters.priority, + assignee_id: typeof filters.assignee === 'number' ? filters.assignee : null, + unassigned: filters.assignee === 'unassigned', + label_id: filters.labelId, + project_id: filters.projectId, + cycle_id: filters.cycleId, + }; +} diff --git a/src/views/views-sheet.tsx b/src/views/views-sheet.tsx new file mode 100644 index 0000000..329f232 --- /dev/null +++ b/src/views/views-sheet.tsx @@ -0,0 +1,297 @@ +import { useState } from 'react'; +import { Alert as RNAlert, Pressable, Switch, View } from 'react-native'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + createViewTeamsTeamIdViewsPost, + deleteViewViewsViewIdDelete, + setMyDefaultViewTeamsTeamIdDefaultViewMePut, + setTeamDefaultViewTeamsTeamIdDefaultViewPut, + updateViewViewsViewIdPatch, + useListViewsTeamsTeamIdViewsGet, +} from '@/api/generated/endpoints/views/views'; +import type { SavedViewRead, TeamRead, UserMe } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { sameFilters, type BoardFilters } from '@/board/filters'; +import { fromViewFilters, toViewFilters } from '@/views/saved-views'; +import { Icon } from '@/ui/icon'; +import { Alert, AppText, Button, Field } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * Saved views for a team, per `docs/design/mobile/151-saved-views.svg`. + * + * Which view is showing is decided by comparing filters rather than by tracking + * an id, the same way the web's sidebar does it: that is what lets a pasted link + * carrying filters light up the matching row for someone who can see it, and + * still work for someone who cannot. + */ +export function ViewsSheet({ + visible, + onClose, + team, + user, + filters, + onApply, +}: { + visible: boolean; + onClose: () => void; + team: TeamRead; + user: UserMe | null; + filters: BoardFilters; + onApply: (next: BoardFilters) => void; +}) { + const t = useTokens(); + const queryClient = useQueryClient(); + const views = useListViewsTeamsTeamIdViewsGet(team.id, { + query: { enabled: visible }, + }); + + const [name, setName] = useState(''); + const [shared, setShared] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [editing, setEditing] = useState(null); + + const data = views.data; + const items = data?.items ?? []; + + async function refresh() { + await queryClient.invalidateQueries({ queryKey: [`/teams/${team.id}/views`] }); + } + + async function run(action: () => Promise, fallback: string) { + setError(null); + setBusy(true); + try { + await action(); + await refresh(); + } catch (err) { + setError(errorDetail(err, fallback)); + } finally { + setBusy(false); + } + } + + /** Owner or team admin; the server enforces it, this only hides what it would refuse. */ + function canManage(view: SavedViewRead): boolean { + return user !== null && view.owner.id === user.id; + } + + return ( + + + {error ? {error} : null} + + + {items.map((view) => { + const showing = sameFilters(filters, fromViewFilters(view.filters)); + const isTeamDefault = data?.team_default_id === view.id; + const isMyDefault = data?.my_default_id === view.id; + + return ( + { + onApply(fromViewFilters(view.filters)); + onClose(); + }} + onLongPress={() => canManage(view) && setEditing(view)} + style={{ + flexDirection: 'row', + alignItems: 'center', + gap: 10, + paddingVertical: 12, + paddingHorizontal: 10, + borderRadius: t.radius.control, + backgroundColor: showing ? t.line.navActive : 'transparent', + }} + > + + + {view.name} + + + {view.is_shared ? 'Shared with the team' : 'Private'} + {isMyDefault ? ' · your default' : ''} + {isTeamDefault ? ' · team default' : ''} + + + {showing ? : null} + + ); + })} + + {items.length === 0 && !views.isPending ? ( + + No saved views yet. Filter the board, then save it here. + + ) : null} + + + {/* Saving the filters as they are now, which is the only thing a view is. */} + + + + + + Share with the team + + + + Private unless shared. Long-press a view to manage it. + + + + setEditing(null)} + onRun={run} + teamId={team.id} + /> + + ); +} + +/** Rename, re-share, set as a default, or delete. */ +function ManageViewSheet({ + view, + data, + teamId, + onClose, + onRun, +}: { + view: SavedViewRead | null; + data: { team_default_id?: number | null; my_default_id?: number | null } | undefined; + teamId: number; + onClose: () => void; + onRun: (action: () => Promise, fallback: string) => Promise; +}) { + const [name, setName] = useState(''); + + if (!view) return null; + const isMyDefault = data?.my_default_id === view.id; + const isTeamDefault = data?.team_default_id === view.id; + + return ( + + + + + + + + + + + + + + + ); +} From 2d2fd3d4768967af4c2a780da83febe8f746598a Mon Sep 17 00:00:00 2001 From: Waleed Khalid Date: Fri, 11 Sep 2026 22:25:30 +0500 Subject: [PATCH 11/18] Add cycles: list, banner, CRUD, start and complete Closes #12. Progress comes from the server and carries the unsized-issue count alongside the points total, which the banner and the cards both show. A cycle whose points look complete while three issues were never sized is not finished, and that count is the only thing that says so. Completing a cycle reports where the unfinished work went -- the next cycle or the backlog, whichever the server chose. Without that, "completed" reads as though those issues had been finished. Dates are validated before submitting, including end-before-start, so the form answers rather than the server. Verified against a live instance by running a cycle start to finish: an unnamed cycle is numbered, an unfinished issue is carried over and a finished one is left where it was, and the cycle filter links the two features together. Co-Authored-By: Claude Opus 5 --- src/api/__tests__/live-cycles.test.ts | 173 +++++++++++ src/board/board-screen.tsx | 10 + src/cycles/cycles-screen.tsx | 419 ++++++++++++++++++++++++++ 3 files changed, 602 insertions(+) create mode 100644 src/api/__tests__/live-cycles.test.ts create mode 100644 src/cycles/cycles-screen.tsx diff --git a/src/api/__tests__/live-cycles.test.ts b/src/api/__tests__/live-cycles.test.ts new file mode 100644 index 0000000..bb34535 --- /dev/null +++ b/src/api/__tests__/live-cycles.test.ts @@ -0,0 +1,173 @@ +/** + * @jest-environment node + */ +import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth'; +import { + completeCycleCyclesCycleIdCompletePost, + createCycleTeamsTeamIdCyclesPost, + deleteCycleCyclesCycleIdDelete, + getCycleCyclesCycleIdGet, + listCyclesTeamsTeamIdCyclesGet, + startCycleCyclesCycleIdStartPost, +} from '@/api/generated/endpoints/cycles/cycles'; +import { + createIssueTeamsTeamIdIssuesPost, + deleteIssueIssuesIssueIdDelete, + getIssueIssuesIssueIdGet, + listIssuesTeamsTeamIdIssuesGet, +} from '@/api/generated/endpoints/issues/issues'; +import { listStatusesTeamsTeamIdStatusesGet } from '@/api/generated/endpoints/statuses/statuses'; +import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; +import type { TeamRead } from '@/api/generated/models'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken } from '@/auth/session'; + +/** + * Cycles against a real instance. + * + * The acceptance criterion for #12 is that a cycle can be run start to finish + * from mobile, so this does exactly that: create, start, complete, and check + * where the unfinished work went. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); + +/** Fixed dates: a test that depends on today is a test that breaks on a Monday. */ +const FROM = '2031-01-06'; +const TO = '2031-01-20'; +const NEXT_FROM = '2031-01-21'; +const NEXT_TO = '2031-02-03'; + +describeLive('cycles against a live instance', () => { + let team: TeamRead; + const cycles: number[] = []; + const issues: 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]; + }); + + afterAll(async () => { + for (const id of issues) await deleteIssueIssuesIssueIdDelete(id).catch(() => undefined); + for (const id of cycles) await deleteCycleCyclesCycleIdDelete(id).catch(() => undefined); + }); + + it('numbers a cycle when it is given no name', async () => { + const cycle = await createCycleTeamsTeamIdCyclesPost(team.id, { + starts_at: FROM, + ends_at: TO, + }); + cycles.push(cycle.id); + + expect(cycle.name ?? null).toBeNull(); + // display_name is always present, which is why every label reads that. + expect(cycle.display_name).toContain(String(cycle.number)); + expect(cycle.state).toBe('upcoming'); + }); + + it('reports progress including work that was never sized', async () => { + const cycle = await createCycleTeamsTeamIdCyclesPost(team.id, { + name: `Progress ${RUN}`, + starts_at: FROM, + ends_at: TO, + }); + cycles.push(cycle.id); + + const sized = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Sized ${RUN}`, + cycle_id: cycle.id, + estimate: 3, + }); + const unsized = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Unsized ${RUN}`, + cycle_id: cycle.id, + }); + issues.push(sized.id, unsized.id); + + const refreshed = await getCycleCyclesCycleIdGet(cycle.id); + expect(refreshed.progress.issues_total).toBe(2); + expect(refreshed.progress.points_total).toBe(3); + // The count that stops a points total reading as the whole story. + expect(refreshed.progress.issues_unestimated).toBe(1); + }); + + it('runs a cycle start to finish and says where unfinished work went', async () => { + const statuses = await listStatusesTeamsTeamIdStatusesGet(team.id); + const done = statuses.find((s) => s.category === 'done'); + + const current = await createCycleTeamsTeamIdCyclesPost(team.id, { + name: `Run ${RUN}`, + starts_at: FROM, + ends_at: TO, + }); + const next = await createCycleTeamsTeamIdCyclesPost(team.id, { + name: `Next ${RUN}`, + starts_at: NEXT_FROM, + ends_at: NEXT_TO, + }); + cycles.push(current.id, next.id); + + const finished = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Finished ${RUN}`, + cycle_id: current.id, + status_id: done?.id, + }); + const unfinished = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Unfinished ${RUN}`, + cycle_id: current.id, + }); + issues.push(finished.id, unfinished.id); + + const started = await startCycleCyclesCycleIdStartPost(current.id); + expect(started.state).toBe('active'); + + const completion = await completeCycleCyclesCycleIdCompletePost(current.id); + expect(completion.cycle.state).toBe('completed'); + // One issue was not done, and the app reports this count because + // "completed" otherwise reads as though it had been finished. + expect(completion.carried_over).toBe(1); + + const moved = await getIssueIssuesIssueIdGet(unfinished.id); + if (completion.carried_into_cycle_id) { + expect(moved.cycle_id).toBe(completion.carried_into_cycle_id); + } else { + expect(moved.cycle_id ?? null).toBeNull(); + } + + // The finished one stays where it was. + expect((await getIssueIssuesIssueIdGet(finished.id)).cycle_id).toBe(current.id); + }); + + it('filters the board by cycle, which is the link between the two', async () => { + const cycle = await createCycleTeamsTeamIdCyclesPost(team.id, { + name: `Filter ${RUN}`, + starts_at: FROM, + ends_at: TO, + }); + cycles.push(cycle.id); + const issue = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `In cycle ${RUN}`, + cycle_id: cycle.id, + }); + issues.push(issue.id); + + const page = await listIssuesTeamsTeamIdIssuesGet(team.id, { + cycle_id: cycle.id, + limit: 200, + }); + expect(page.items.map((i) => i.id)).toEqual([issue.id]); + }); + + it('lists cycles for the team', async () => { + const listed = await listCyclesTeamsTeamIdCyclesGet(team.id); + for (const id of cycles) expect(listed.map((c) => c.id)).toContain(id); + }); +}); diff --git a/src/board/board-screen.tsx b/src/board/board-screen.tsx index e3c1db4..fbeeaae 100644 --- a/src/board/board-screen.tsx +++ b/src/board/board-screen.tsx @@ -11,6 +11,7 @@ import { fromParams, isEmpty, toParams, toQueryParams, type BoardFilters } from import { IssueList } from '@/board/issue-list'; import { KanbanBoard } from '@/board/kanban-board'; import { useStatusChange } from '@/board/use-status-change'; +import { CycleBanner, CyclesSheet } from '@/cycles/cycles-screen'; import { useListViewsTeamsTeamIdViewsGet } from '@/api/generated/endpoints/views/views'; import { useAuth } from '@/auth/auth-context'; import { fromViewFilters } from '@/views/saved-views'; @@ -54,6 +55,7 @@ export function BoardScreen() { const [view, setView] = useState('board'); const [filterOpen, setFilterOpen] = useState(false); const [viewsOpen, setViewsOpen] = useState(false); + const [cyclesOpen, setCyclesOpen] = useState(false); const [switching, setSwitching] = useState(false); const [creating, setCreating] = useState(false); const [moving, setMoving] = useState(null); @@ -193,6 +195,8 @@ export function BoardScreen() {
+ setCyclesOpen(true)} /> + setFilterOpen(true)} /> {issuesQuery.isPending ? ( @@ -213,6 +217,12 @@ export function BoardScreen() { /> )} + setCyclesOpen(false)} + team={team} + /> + setViewsOpen(false)} diff --git a/src/cycles/cycles-screen.tsx b/src/cycles/cycles-screen.tsx new file mode 100644 index 0000000..875a633 --- /dev/null +++ b/src/cycles/cycles-screen.tsx @@ -0,0 +1,419 @@ +import { useState } from 'react'; +import { Alert as RNAlert, Pressable, ScrollView, View } from 'react-native'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + completeCycleCyclesCycleIdCompletePost, + createCycleTeamsTeamIdCyclesPost, + deleteCycleCyclesCycleIdDelete, + startCycleCyclesCycleIdStartPost, + updateCycleCyclesCycleIdPatch, + useListCyclesTeamsTeamIdCyclesGet, +} from '@/api/generated/endpoints/cycles/cycles'; +import type { CycleRead, TeamRead } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { Icon } from '@/ui/icon'; +import { Alert, AppText, Button, Card, Field, Loading } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * Cycles, per `docs/design/mobile/149-cycles.svg`. + * + * Progress is reported by the server, unsized issues included as their own + * count: a cycle whose points total looks complete while three issues were never + * sized is not finished, and saying so is the whole reason that field exists. + */ + +/** `YYYY-MM-DD` is what the API takes, and what a date field can produce. */ +function isoDate(value: string): string | null { + const trimmed = value.trim(); + if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return null; + const parsed = new Date(`${trimmed}T00:00:00Z`); + return Number.isNaN(parsed.getTime()) ? null : trimmed; +} + +function stateLabel(state: CycleRead['state']): string { + return state === 'active' ? 'Active' : state === 'upcoming' ? 'Upcoming' : 'Completed'; +} + +function CycleCard({ + cycle, + onManage, +}: { + cycle: CycleRead; + onManage: (cycle: CycleRead) => void; +}) { + const t = useTokens(); + const { progress } = cycle; + const done = progress.issues_total > 0 ? progress.issues_completed / progress.issues_total : 0; + + const color = + cycle.state === 'active' + ? t.status.progress + : cycle.state === 'upcoming' + ? t.status.todo + : t.status.done; + + return ( + onManage(cycle)} + > + + + + + {cycle.display_name} + + {stateLabel(cycle.state)} + + + + {cycle.starts_at.slice(0, 10)} → {cycle.ends_at.slice(0, 10)} + + + + + + + + {progress.issues_completed}/{progress.issues_total} issues ·{' '} + {progress.points_completed}/{progress.points_total} pts + {/* An unsized issue is not worth zero points, so the total is not the + whole story until this is nothing. */} + {progress.issues_unestimated > 0 + ? ` · ${progress.issues_unestimated} unsized` + : ''} + + + + ); +} + +export function CyclesSheet({ + visible, + onClose, + team, +}: { + visible: boolean; + onClose: () => void; + team: TeamRead; +}) { + const t = useTokens(); + const queryClient = useQueryClient(); + const cycles = useListCyclesTeamsTeamIdCyclesGet(team.id, { query: { enabled: visible } }); + + const [managing, setManaging] = useState(null); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const items = cycles.data ?? []; + + async function run(action: () => Promise, fallback: string) { + setError(null); + setBusy(true); + try { + await action(); + await queryClient.invalidateQueries({ queryKey: [`/teams/${team.id}/cycles`] }); + // A cycle boundary moves issues, so the board has to re-read too. + void queryClient.invalidateQueries({ queryKey: [`/teams/${team.id}/issues`] }); + } catch (err) { + setError(errorDetail(err, fallback)); + } finally { + setBusy(false); + } + } + + return ( + + + {error ? {error} : null} + + {cycles.isPending ? : null} + + {items.map((cycle) => ( + + ))} + + {items.length === 0 && !cycles.isPending ? ( + + No cycles yet. + + ) : null} + + + + + setCreating(false)} + onRun={run} + /> + + setManaging(null)} + onRun={run} + /> + + ); +} + +function NewCycleSheet({ + visible, + teamId, + busy, + onClose, + onRun, +}: { + visible: boolean; + teamId: number; + busy: boolean; + onClose: () => void; + onRun: (action: () => Promise, fallback: string) => Promise; +}) { + const [name, setName] = useState(''); + const [starts, setStarts] = useState(''); + const [ends, setEnds] = useState(''); + const [invalid, setInvalid] = useState(null); + + if (!visible) return null; + + return ( + + + {invalid ? {invalid} : null} + + + + + + + + + ); +} + +function ManageCycleSheet({ + cycle, + busy, + onClose, + onRun, +}: { + cycle: CycleRead | null; + busy: boolean; + onClose: () => void; + onRun: (action: () => Promise, fallback: string) => Promise; +}) { + const [name, setName] = useState(''); + if (!cycle) return null; + + return ( + + + + + + {cycle.state === 'upcoming' ? ( + + ) : null} + + {cycle.state === 'active' ? ( + + ) : null} + + + + + ); +} + +/** The current cycle, shown above the board while one is running. */ +export function CycleBanner({ team, onOpen }: { team: TeamRead; onOpen: () => void }) { + const t = useTokens(); + const cycles = useListCyclesTeamsTeamIdCyclesGet(team.id); + const active = (cycles.data ?? []).find((cycle) => cycle.state === 'active'); + if (!active) return null; + + const { progress } = active; + const done = + progress.issues_total > 0 ? progress.issues_completed / progress.issues_total : 0; + + return ( + + + + {active.display_name} + + + {progress.issues_completed}/{progress.issues_total} + {progress.issues_unestimated > 0 ? ` · ${progress.issues_unestimated} unsized` : ''} + + + + + + + + ); +} From a82659024490fb7984258c4cc93c6eaf732dc443 Mon Sep 17 00:00:00 2001 From: Waleed Khalid Date: Fri, 11 Sep 2026 22:25:57 +0500 Subject: [PATCH 12/18] Drop two unused bindings in the cycles sheet Co-Authored-By: Claude Opus 5 --- src/cycles/cycles-screen.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/cycles/cycles-screen.tsx b/src/cycles/cycles-screen.tsx index 875a633..03376b3 100644 --- a/src/cycles/cycles-screen.tsx +++ b/src/cycles/cycles-screen.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Alert as RNAlert, Pressable, ScrollView, View } from 'react-native'; +import { Alert as RNAlert, Pressable, View } from 'react-native'; import { useQueryClient } from '@tanstack/react-query'; import { @@ -114,7 +114,6 @@ export function CyclesSheet({ onClose: () => void; team: TeamRead; }) { - const t = useTokens(); const queryClient = useQueryClient(); const cycles = useListCyclesTeamsTeamIdCyclesGet(team.id, { query: { enabled: visible } }); From de44264a4c6bdeb0d19ee889e55ad5cfa719f340 Mon Sep 17 00:00:00 2001 From: Waleed Khalid Date: Fri, 11 Sep 2026 22:28:14 +0500 Subject: [PATCH 13/18] Add profile, security, notification and instance settings Closes #15. Password change and "sign out everywhere" both invalidate every token including this device's, and both hand back a replacement. Adopting it is what keeps you signed in to the account you just secured -- there is a test that pins the old token being dead and the new one working. The current-password field appears only once the email address is actually edited, because that is the single case the server demands it; asking for it unconditionally would imply the whole form needs it. The email toggle is disabled and explained where the instance has no SMTP configured. Leaving it live would mean a switch that silently does nothing. Switching instance clears the stored URL and the session, and that is the whole of it -- the base URL is resolved per request, so nothing is rebuilt or restarted. Username validation mirrors the backend's pattern so the form answers rather than a round trip. Co-Authored-By: Claude Opus 5 --- src/api/__tests__/live-settings.test.ts | 116 +++++++ src/settings/__tests__/username.test.ts | 24 ++ src/settings/username.ts | 12 + src/settings/you-screen.tsx | 438 +++++++++++++++++++++++- 4 files changed, 575 insertions(+), 15 deletions(-) create mode 100644 src/api/__tests__/live-settings.test.ts create mode 100644 src/settings/__tests__/username.test.ts create mode 100644 src/settings/username.ts diff --git a/src/api/__tests__/live-settings.test.ts b/src/api/__tests__/live-settings.test.ts new file mode 100644 index 0000000..6de427d --- /dev/null +++ b/src/api/__tests__/live-settings.test.ts @@ -0,0 +1,116 @@ +/** + * @jest-environment node + */ +import { + changeMyPasswordAuthMePasswordPost, + loginAuthLoginPost, + meAuthMeGet, + registerAuthRegisterPost, + signOutEverywhereRouteAuthMeSignOutEverywherePost, + updateMeAuthMePatch, +} from '@/api/generated/endpoints/auth/auth'; +import { + getNotificationSettingsNotificationsSettingsGet, + updateNotificationSettingsNotificationsSettingsPatch, +} from '@/api/generated/endpoints/notifications/notifications'; +import { setInstanceUrl } from '@/api/instance'; +import { getAccessToken, persistToken } from '@/auth/session'; + +/** + * Account settings against a real instance. + * + * Uses a throwaway account rather than the demo one, because these change a + * password and invalidate sessions -- doing that to the account every other + * suite signs in as would be antisocial. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); +const EMAIL = `mob-settings-${RUN}@example.com`; +const PASSWORD = 'password123'; + +describeLive('account settings against a live instance', () => { + beforeAll(async () => { + await setInstanceUrl(LIVE_URL!); + const token = await registerAuthRegisterPost({ + email: EMAIL, + password: PASSWORD, + full_name: 'Settings Tester', + }); + await persistToken(token.access_token); + }); + + it('edits the profile fields the form offers', async () => { + const updated = await updateMeAuthMePatch({ + full_name: 'Renamed Tester', + username: `mob${RUN}`, + avatar_color: '#14b8a6', + }); + + expect(updated.full_name).toBe('Renamed Tester'); + expect(updated.username).toBe(`mob${RUN}`); + expect(updated.avatar_color).toBe('#14b8a6'); + }); + + it('refuses an email change without the current password', async () => { + // Which is exactly why the field only appears once the address is edited. + await expect( + updateMeAuthMePatch({ email: `changed-${RUN}@example.com` }), + ).rejects.toMatchObject({ response: { status: 400 } }); + + const withPassword = await updateMeAuthMePatch({ + email: `changed-${RUN}@example.com`, + current_password: PASSWORD, + }); + expect(withPassword.email).toBe(`changed-${RUN}@example.com`); + }); + + it('reports whether the instance can actually send email', async () => { + const settings = await getNotificationSettingsNotificationsSettingsGet(); + expect(typeof settings.email_notifications).toBe('boolean'); + // The toggle is disabled and explained when this is false, rather than + // silently doing nothing. + expect(typeof settings.email_delivery_configured).toBe('boolean'); + + const toggled = await updateNotificationSettingsNotificationsSettingsPatch({ + email_notifications: !settings.email_notifications, + }); + expect(toggled.email_notifications).toBe(!settings.email_notifications); + }); + + it('hands back a working token when the password changes', async () => { + const before = getAccessToken(); + + const reissued = await changeMyPasswordAuthMePasswordPost({ + current_password: PASSWORD, + new_password: 'password456', + }); + expect(reissued.access_token).not.toBe(before); + + // The old token is dead -- which is why the app adopts the new one rather + // than leaving the user signed out of the account they just secured. + await persistToken(before); + await expect(meAuthMeGet()).rejects.toMatchObject({ response: { status: 401 } }); + + await persistToken(reissued.access_token); + expect((await meAuthMeGet()).email).toBe(`changed-${RUN}@example.com`); + }); + + it('signs other devices out while keeping this one', async () => { + // A second session, as if from another device. + const other = await loginAuthLoginPost({ + username: `changed-${RUN}@example.com`, + password: 'password456', + }); + + const reissued = await signOutEverywhereRouteAuthMeSignOutEverywherePost(); + await persistToken(reissued.access_token); + expect((await meAuthMeGet()).email).toBe(`changed-${RUN}@example.com`); + + await persistToken(other.access_token); + await expect(meAuthMeGet()).rejects.toMatchObject({ response: { status: 401 } }); + + await persistToken(reissued.access_token); + }); +}); diff --git a/src/settings/__tests__/username.test.ts b/src/settings/__tests__/username.test.ts new file mode 100644 index 0000000..00566e5 --- /dev/null +++ b/src/settings/__tests__/username.test.ts @@ -0,0 +1,24 @@ +import { isValidUsername } from '@/settings/username'; + +describe('isValidUsername', () => { + it('accepts what the backend accepts', () => { + expect(isValidUsername('ada')).toBe(true); + expect(isValidUsername('ada.lovelace')).toBe(true); + expect(isValidUsername('ada_2')).toBe(true); + expect(isValidUsername('a1')).toBe(true); + }); + + it('refuses what it would reject', () => { + // Must start with a letter or digit. + expect(isValidUsername('.ada')).toBe(false); + expect(isValidUsername('-ada')).toBe(false); + // Lowercase only, and no spaces or @. + expect(isValidUsername('Ada')).toBe(false); + expect(isValidUsername('ada lovelace')).toBe(false); + expect(isValidUsername('ada@example.com')).toBe(false); + // Two to thirty-nine characters. + expect(isValidUsername('a')).toBe(false); + expect(isValidUsername('a'.repeat(40))).toBe(false); + expect(isValidUsername('a'.repeat(39))).toBe(true); + }); +}); diff --git a/src/settings/username.ts b/src/settings/username.ts new file mode 100644 index 0000000..fe4597c --- /dev/null +++ b/src/settings/username.ts @@ -0,0 +1,12 @@ +/** + * The username rule, mirrored from `backend/lib_identity/usernames.py`. + * + * Checked before submitting so the form answers rather than the server -- a + * round trip to be told a dash is fine but a space is not reads as a fault in + * the app. + */ +const USERNAME_PATTERN = /^[a-z0-9][a-z0-9._-]{1,38}$/; + +export function isValidUsername(username: string): boolean { + return USERNAME_PATTERN.test(username); +} diff --git a/src/settings/you-screen.tsx b/src/settings/you-screen.tsx index edad7fe..e0f5768 100644 --- a/src/settings/you-screen.tsx +++ b/src/settings/you-screen.tsx @@ -1,24 +1,51 @@ -import { ScrollView, View } from 'react-native'; +import { useState } from 'react'; +import { Alert as RNAlert, Pressable, ScrollView, Switch, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { useQueryClient } from '@tanstack/react-query'; -import { instanceLabel } from '@/api/instance'; +import { + changeMyPasswordAuthMePasswordPost, + signOutEverywhereRouteAuthMeSignOutEverywherePost, + updateMeAuthMePatch, +} from '@/api/generated/endpoints/auth/auth'; +import { + updateNotificationSettingsNotificationsSettingsPatch, + useGetNotificationSettingsNotificationsSettingsGet, +} from '@/api/generated/endpoints/notifications/notifications'; +import { errorDetail } from '@/api/errors'; +import { instanceLabel, setInstanceUrl } from '@/api/instance'; import { useAuth } from '@/auth/auth-context'; +import { isValidUsername } from '@/settings/username'; import { useIsMultiPane } from '@/ui/layout'; -import { AppText, Avatar, Button, Card } from '@/ui/primitives'; +import { AppText, Alert, Avatar, Button, Card, Field } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; import { ThemePreferenceControl } from '@/ui/theme-control'; import { useTokens } from '@/ui/theme'; /** - * The You destination, per `docs/design/mobile/152-settings.svg`. + * Account settings, per `docs/design/mobile/152-settings.svg`. * - * Deliberately minimal -- issue #15 owns real account settings. What is here is - * what makes issue #1's acceptance criteria exercisable from the running app: - * the theme control, the connected instance, and sign out. + * The seven the backend picks from; see AVATAR_COLORS in lib_identity. Offered + * as swatches rather than a free colour because the server will only keep one + * of these. */ +const AVATAR_COLORS = [ + '#6366f1', + '#ec4899', + '#14b8a6', + '#f59e0b', + '#8b5cf6', + '#ef4444', + '#22c55e', +]; + +type Panel = 'profile' | 'security' | 'notifications' | 'instance' | null; + export function YouScreen() { const t = useTokens(); const multiPane = useIsMultiPane(); - const { user, instanceUrl, signOut } = useAuth(); + const { user, instanceUrl, signOut, setSession } = useAuth(); + const [panel, setPanel] = useState(null); return ( ) : null} + + ACCOUNT + + setPanel('profile')} /> + setPanel('security')} /> + setPanel('notifications')} /> + + + APPEARANCE @@ -49,13 +85,12 @@ export function YouScreen() { INSTANCE - - - {instanceUrl ? instanceLabel(instanceUrl) : 'Not connected'} - - - Switching instances arrives with the settings work. - + + setPanel('instance')} + /> @@ -63,6 +98,379 @@ export function YouScreen() { Sign out
+ + setPanel(null)} + onSaved={setSession} + /> + setPanel(null)} + onReissued={setSession} + /> + setPanel(null)} + /> + setPanel(null)} + onSwitch={signOut} + /> ); } + +function SettingsRow({ + label, + detail, + onPress, +}: { + label: string; + detail?: string; + onPress: () => void; +}) { + const t = useTokens(); + return ( + + + {label} + + {detail ? ( + + {detail} + + ) : null} + + ); +} + +function ProfilePanel({ + visible, + onClose, + onSaved, +}: { + visible: boolean; + onClose: () => void; + onSaved: (token: string, user: import('@/api/generated/models').UserMe) => void; +}) { + const t = useTokens(); + const queryClient = useQueryClient(); + const { user } = useAuth(); + + const [fullName, setFullName] = useState(''); + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [currentPassword, setCurrentPassword] = useState(''); + const [color, setColor] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + if (!visible || !user) return null; + + const chosenColor = color ?? user.avatar_color; + const emailChanged = email.trim() !== '' && email.trim() !== user.email; + + return ( + + + {error ? {error} : null} + + + {AVATAR_COLORS.map((swatch) => ( + setColor(swatch)} + style={{ + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: swatch, + borderWidth: chosenColor === swatch ? 3 : 0, + borderColor: t.brand[600], + }} + /> + ))} + + + + setUsername(value.toLowerCase())} + autoCapitalize="none" + autoCorrect={false} + hint="Lowercase letters, digits, dot, dash or underscore. 2 to 39 characters." + /> + + + {/* Only asked for when it is actually needed, which is the one case the + server demands it. */} + {emailChanged ? ( + + ) : null} + + + + + ); +} + +function SecurityPanel({ + visible, + onClose, + onReissued, +}: { + visible: boolean; + onClose: () => void; + onReissued: (token: string, user: import('@/api/generated/models').UserMe) => void; +}) { + const [current, setCurrent] = useState(''); + const [next, setNext] = useState(''); + const [confirm, setConfirm] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + if (!visible) return null; + + return ( + + + {error ? {error} : null} + + + + + + + + + + + ); +} + +function NotificationsPanel({ + visible, + onClose, +}: { + visible: boolean; + onClose: () => void; +}) { + const queryClient = useQueryClient(); + const settings = useGetNotificationSettingsNotificationsSettingsGet({ + query: { enabled: visible }, + }); + const [error, setError] = useState(null); + + if (!visible) return null; + const data = settings.data; + + return ( + + + {error ? {error} : null} + + + { + setError(null); + try { + await updateNotificationSettingsNotificationsSettingsPatch({ + email_notifications: value, + }); + await queryClient.invalidateQueries({ + queryKey: ['/notifications/settings'], + }); + } catch (err) { + setError(errorDetail(err, 'Could not change that setting.')); + } + }} + /> + + Email me about my issues + + + + {/* The toggle is meaningless where the instance cannot send email, and + silently doing nothing would be worse than saying so. */} + {data && !data.email_delivery_configured ? ( + + This instance has no email configured, so nothing would be sent. Ask + an administrator to set up SMTP. + + ) : null} + + + ); +} + +function InstancePanel({ + visible, + onClose, + onSwitch, +}: { + visible: boolean; + onClose: () => void; + onSwitch: () => void; +}) { + const { instanceUrl } = useAuth(); + if (!visible) return null; + + return ( + + + + Connected to {instanceUrl ? instanceLabel(instanceUrl) : 'nothing'}. + + + Switching signs you out of this instance. Your account here is not + affected. + + + + + ); +} From 357644e0f2e6580ced0ba40a865fd1495e8fbb5a Mon Sep 17 00:00:00 2001 From: Waleed Khalid Date: Fri, 11 Sep 2026 22:30:47 +0500 Subject: [PATCH 14/18] Add the notification inbox and unread badge Part of #10 -- the in-app half. Push is not built, and cannot be from here. The issue anticipated this: push "requires a backend addition for device token registration + push delivery". There is no /devices endpoint and nothing APNs- or FCM-shaped anywhere in the schema, so there is nowhere to send a token. The badge therefore polls once a minute and only while the app is in front -- a phone that polls in the background is a phone with a flat battery -- rather than pretending to be pushed. Recorded in the README rather than left to be discovered. Each of the four kinds gets its own sentence: "commented on" and "mentioned you in" are not the same event even though both are a comment. A notification is marked read on the way out rather than on arrival, because opening it is what says it was seen, and navigation still happens if that write fails -- getting to the issue matters more than the flag. Unread is carried by a marker rather than a row background, so the list does not change colour as things are read. Verified against a live instance with two accounts, which is the only way to show the acceptance criterion that your own actions notify nobody. Co-Authored-By: Claude Opus 5 --- README.md | 7 + src/api/__tests__/live-notifications.test.ts | 164 ++++++++++++ src/app/(app)/inbox.tsx | 6 +- src/notifications/inbox-screen.tsx | 252 +++++++++++++++++++ src/ui/__tests__/tab-bar.test.tsx | 13 +- src/ui/tab-bar.tsx | 36 +++ 6 files changed, 470 insertions(+), 8 deletions(-) create mode 100644 src/api/__tests__/live-notifications.test.ts create mode 100644 src/notifications/inbox-screen.tsx diff --git a/README.md b/README.md index d62a1c7..adf846b 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,13 @@ Known gaps, tracked rather than hidden: is impossible for arbitrary self-hosted hosts. `softtrack://invite/` works, and falls back to asking you to sign in first, since a custom-scheme link carries no instance. +- **Push notifications are not built, because the API has no way to register a + device.** Issue #10 anticipated this ("requires a backend addition for device + token registration + push delivery"). There is no `/devices` endpoint and + nothing APNs- or FCM-shaped anywhere in the schema, so the in-app inbox and + the unread badge are what ship; the badge polls once a minute while the app is + in front rather than pretending to be pushed. Real push also needs a dev build + and signing credentials, neither of which exists yet. - **Member counts cost one request per team.** `TeamRead` carries no count, so the teams list asks each team for its members. A `member_count` field upstream would remove the fan-out and help the web too. diff --git a/src/api/__tests__/live-notifications.test.ts b/src/api/__tests__/live-notifications.test.ts new file mode 100644 index 0000000..d5ad3d7 --- /dev/null +++ b/src/api/__tests__/live-notifications.test.ts @@ -0,0 +1,164 @@ +/** + * @jest-environment node + */ +import { + loginAuthLoginPost, + registerAuthRegisterPost, +} from '@/api/generated/endpoints/auth/auth'; +import { createCommentIssuesIssueIdCommentsPost } from '@/api/generated/endpoints/comments/comments'; +import { + createIssueTeamsTeamIdIssuesPost, + deleteIssueIssuesIssueIdDelete, + updateIssueIssuesIssueIdPatch, +} from '@/api/generated/endpoints/issues/issues'; +import { + listNotificationsNotificationsGet, + markAllReadNotificationsReadAllPost, + unreadCountNotificationsUnreadCountGet, + updateNotificationNotificationsNotificationIdPatch, +} from '@/api/generated/endpoints/notifications/notifications'; +import { createInviteTeamsTeamIdInvitesPost } from '@/api/generated/endpoints/invites/invites'; +import { acceptInviteInvitesTokenAcceptPost } from '@/api/generated/endpoints/invites/invites'; +import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; +import type { TeamRead } from '@/api/generated/models'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken } from '@/auth/session'; + +/** + * The inbox against a real instance. + * + * Needs two accounts: the acceptance criterion says no notifications for your + * own actions, which cannot be shown with one. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); +const DEMO_EMAIL = process.env.SOFTTRACK_LIVE_EMAIL ?? 'demo@softtrack.dev'; +const DEMO_PASSWORD = process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123'; +const OTHER_EMAIL = `mob-notify-${RUN}@example.com`; + +describeLive('notifications against a live instance', () => { + let team: TeamRead; + let demoToken: string; + let otherToken: string; + let otherUsername: string; + let otherId: number; + const issues: number[] = []; + + beforeAll(async () => { + await setInstanceUrl(LIVE_URL!); + const demo = await loginAuthLoginPost({ + username: DEMO_EMAIL, + password: DEMO_PASSWORD, + }); + demoToken = demo.access_token; + await persistToken(demoToken); + team = (await listMyTeamsTeamsGet())[0]; + + const other = await registerAuthRegisterPost({ + email: OTHER_EMAIL, + password: 'password123', + full_name: 'Notify Tester', + }); + otherToken = other.access_token; + otherUsername = other.user.username; + otherId = other.user.id; + + // The second account has to be on the team to be assignable or mentionable. + await persistToken(demoToken); + const invite = await createInviteTeamsTeamIdInvitesPost(team.id, { + email: OTHER_EMAIL, + role: 'member', + }); + await persistToken(otherToken); + await acceptInviteInvitesTokenAcceptPost(invite.token); + }); + + afterAll(async () => { + await persistToken(demoToken); + for (const id of issues) await deleteIssueIssuesIssueIdDelete(id).catch(() => undefined); + }); + + it('says nothing about your own actions', async () => { + await persistToken(otherToken); + await markAllReadNotificationsReadAllPost(); + const before = await unreadCountNotificationsUnreadCountGet(); + + // The other account acts on its own issue, entirely by itself. + const own = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Own action ${RUN}`, + assignee_id: otherId, + }); + issues.push(own.id); + await createCommentIssuesIssueIdCommentsPost(own.id, { body: 'talking to myself' }); + + const after = await unreadCountNotificationsUnreadCountGet(); + expect(after.unread).toBe(before.unread); + }); + + it('raises one when somebody assigns you', async () => { + await persistToken(demoToken); + const issue = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Assign ${RUN}`, + }); + issues.push(issue.id); + await updateIssueIssuesIssueIdPatch(issue.id, { assignee_id: otherId }); + + await persistToken(otherToken); + const page = await listNotificationsNotificationsGet({ unread_only: true, limit: 25 }); + const hit = page.items.find((n) => n.issue.id === issue.id); + expect(hit).toBeDefined(); + expect(hit?.kind).toBe('assigned'); + // Both are what the row renders. + expect(hit?.actor?.full_name).toEqual(expect.any(String)); + expect(hit?.issue.identifier).toContain(team.key); + }); + + it('raises one when somebody mentions you', async () => { + await persistToken(demoToken); + const issue = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Mention ${RUN}`, + }); + issues.push(issue.id); + await createCommentIssuesIssueIdCommentsPost(issue.id, { + body: `hey @${otherUsername} take a look`, + }); + + await persistToken(otherToken); + const page = await listNotificationsNotificationsGet({ unread_only: true, limit: 25 }); + const hit = page.items.find((n) => n.issue.id === issue.id); + expect(hit?.kind).toBe('mentioned'); + }); + + it('marks one read, and then all of them', async () => { + await persistToken(otherToken); + const unreadBefore = await unreadCountNotificationsUnreadCountGet(); + expect(unreadBefore.unread).toBeGreaterThan(0); + + const page = await listNotificationsNotificationsGet({ unread_only: true, limit: 1 }); + const one = page.items[0]; + await updateNotificationNotificationsNotificationIdPatch(one.id, { read: true }); + + const afterOne = await unreadCountNotificationsUnreadCountGet(); + expect(afterOne.unread).toBe(unreadBefore.unread - 1); + + await markAllReadNotificationsReadAllPost(); + expect((await unreadCountNotificationsUnreadCountGet()).unread).toBe(0); + // And the unread filter is genuinely a filter, not a styling choice. + const unreadOnly = await listNotificationsNotificationsGet({ + unread_only: true, + limit: 25, + }); + expect(unreadOnly.items).toEqual([]); + }); + + it('pages the full list', async () => { + await persistToken(otherToken); + const all = await listNotificationsNotificationsGet({ limit: 25 }); + expect(all.total).toBeGreaterThan(0); + const firstOnly = await listNotificationsNotificationsGet({ limit: 1, offset: 0 }); + expect(firstOnly.items).toHaveLength(1); + expect(firstOnly.total).toBe(all.total); + }); +}); diff --git a/src/app/(app)/inbox.tsx b/src/app/(app)/inbox.tsx index 59408ec..650da8f 100644 --- a/src/app/(app)/inbox.tsx +++ b/src/app/(app)/inbox.tsx @@ -1,5 +1 @@ -import { Placeholder } from '@/ui/placeholder'; - -export default function InboxRoute() { - return ; -} +export { InboxScreen as default } from '@/notifications/inbox-screen'; diff --git a/src/notifications/inbox-screen.tsx b/src/notifications/inbox-screen.tsx new file mode 100644 index 0000000..dcd74e3 --- /dev/null +++ b/src/notifications/inbox-screen.tsx @@ -0,0 +1,252 @@ +import { useState } from 'react'; +import { FlatList, Pressable, StyleSheet, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { router } from 'expo-router'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + markAllReadNotificationsReadAllPost, + updateNotificationNotificationsNotificationIdPatch, + useListNotificationsNotificationsGet, +} from '@/api/generated/endpoints/notifications/notifications'; +import type { NotificationKind, NotificationRead } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { href } from '@/ui/href'; +import { Icon } from '@/ui/icon'; +import { useIsMultiPane } from '@/ui/layout'; +import { Alert, AppText, Avatar, Card, Loading } from '@/ui/primitives'; +import { useTokens } from '@/ui/theme'; + +/** + * The inbox, per `docs/design/mobile/147-notifications.svg`. + * + * Four kinds, and each one needs a different sentence -- "commented on" and + * "mentioned you in" are not the same event even though both are a comment. + */ +const PAGE = 25; + +function sentenceFor(notification: NotificationRead): string { + const who = notification.actor?.full_name ?? 'Someone'; + const kind: NotificationKind = notification.kind; + switch (kind) { + case 'assigned': + return `${who} assigned you`; + case 'mentioned': + return `${who} mentioned you`; + case 'commented': + return `${who} commented`; + case 'status_changed': + return `${who} changed the status`; + default: + return who; + } +} + +export function InboxScreen() { + const t = useTokens(); + const multiPane = useIsMultiPane(); + const queryClient = useQueryClient(); + + const [unreadOnly, setUnreadOnly] = useState(false); + const [limit, setLimit] = useState(PAGE); + const [error, setError] = useState(null); + + const notifications = useListNotificationsNotificationsGet({ + unread_only: unreadOnly || undefined, + limit, + offset: 0, + }); + + const items = notifications.data?.items ?? []; + const total = notifications.data?.total ?? 0; + + async function refresh() { + await queryClient.invalidateQueries({ queryKey: ['/notifications'] }); + // The tab badge reads its own endpoint, so it has to be told too. + void queryClient.invalidateQueries({ queryKey: ['/notifications/unread-count'] }); + } + + async function open(notification: NotificationRead) { + // Marked read on the way out rather than on arrival: opening it is what + // says it has been seen. + if (!notification.read) { + try { + await updateNotificationNotificationsNotificationIdPatch(notification.id, { + read: true, + }); + await refresh(); + } catch { + // Navigating still matters more than the read flag. + } + } + router.push(href(`/issue/${notification.issue.id}`)); + } + + return ( + + + + Inbox + + + setUnreadOnly((current) => !current)} + style={{ + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: t.radius.pill, + backgroundColor: unreadOnly ? t.line.navActive : t.line.ghost, + }} + > + + Unread + + + + { + setError(null); + try { + await markAllReadNotificationsReadAllPost(); + await refresh(); + } catch (err) { + setError(errorDetail(err, 'Could not mark everything read.')); + } + }} + > + + + + + {notifications.isPending ? ( + + ) : ( + String(item.id)} + contentContainerStyle={{ padding: 16, gap: 8 }} + ListHeaderComponent={error ? {error} : null} + ListEmptyComponent={ + + + {unreadOnly ? 'Nothing unread' : 'Nothing yet'} + + + You are told when you are assigned, mentioned, or when work you + watch moves. + + + } + ListFooterComponent={ + total > items.length ? ( + setLimit((current) => current + PAGE)} + style={{ + paddingVertical: 12, + alignItems: 'center', + borderRadius: t.radius.control, + borderWidth: StyleSheet.hairlineWidth, + borderColor: t.line.hairline, + }} + > + + Show more + + + ) : null + } + renderItem={({ item }) => ( + void open(item)} + > + + {item.actor ? ( + + ) : ( + + )} + + + + {sentenceFor(item)} + + + {item.issue.identifier} · {item.issue.title} + + {item.excerpt ? ( + + {item.excerpt} + + ) : null} + + + {!item.read ? ( + + ) : null} + + + )} + refreshing={notifications.isFetching && !notifications.isPending} + onRefresh={refresh} + /> + )} + + ); +} diff --git a/src/ui/__tests__/tab-bar.test.tsx b/src/ui/__tests__/tab-bar.test.tsx index 6848221..4b127a9 100644 --- a/src/ui/__tests__/tab-bar.test.tsx +++ b/src/ui/__tests__/tab-bar.test.tsx @@ -2,6 +2,7 @@ import type * as React from 'react'; import { render } from '@testing-library/react-native'; import { StyleSheet } from 'react-native'; import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { RAIL_WIDTH, SoftTrackTabBar } from '@/ui/tab-bar'; import { ThemeProvider } from '@/ui/theme'; @@ -44,11 +45,17 @@ function tabBarProps(focusedIndex = 0): BarProps { } function renderBar(orientation: 'bar' | 'rail', focusedIndex = 0) { + // The bar polls the unread count for the inbox badge, so it needs a client. + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); return render( - - - + + + + + , ); } diff --git a/src/ui/tab-bar.tsx b/src/ui/tab-bar.tsx index 391b099..6c46d34 100644 --- a/src/ui/tab-bar.tsx +++ b/src/ui/tab-bar.tsx @@ -2,6 +2,7 @@ import { Pressable, StyleSheet, Text, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import type { BottomTabBarProps } from 'expo-router/js-tabs'; +import { useUnreadCountNotificationsUnreadCountGet } from '@/api/generated/endpoints/notifications/notifications'; import { Icon, type IconName } from '@/ui/icon'; import { useTokens } from '@/ui/theme'; @@ -37,6 +38,19 @@ export function SoftTrackTabBar({ const insets = useSafeAreaInsets(); const rail = orientation === 'rail'; + /** + * The unread badge. + * + * Polled rather than pushed, at the same minute the web uses, and only while + * the app is in front -- there is no realtime channel, and a phone that polls + * in the background is a phone with a flat battery. Real push needs a backend + * that can register device tokens; see the README. + */ + const unread = useUnreadCountNotificationsUnreadCountGet({ + query: { refetchInterval: 60_000, refetchIntervalInBackground: false }, + }); + const unreadCount = unread.data?.unread ?? 0; + return ( + + {route.name === 'inbox' && unreadCount > 0 ? ( + + + {unreadCount > 99 ? '99+' : unreadCount} + + + ) : null} Date: Fri, 11 Sep 2026 22:34:26 +0500 Subject: [PATCH 15/18] Add burndown, velocity, cumulative flow and created-vs-resolved Closes #13. Charts are drawn with react-native-svg, which is already here for the icon set, rather than adding a charting library: four fixed shapes need a fraction of what one provides, and tapping for a value is easier to get right when the hit areas are ours. Every number comes from the same event-history endpoints the web charts and none is recomputed here, which is what makes the figures match rather than merely agree. The burndown's ideal line included -- the client draws, it does not model. A fingertip covers several days at phone width, so a tap reads the nearest point rather than demanding an exact hit, and the readout row is reserved rather than conditional so the layout does not jump as a finger moves. The "history before tracking began cannot be reconstructed" notice is carried over from web, and every chart has an empty state, because a fresh instance has no history at all -- there is a test that the scales survive that rather than dividing by zero. Co-Authored-By: Claude Opus 5 --- src/api/__tests__/live-reports.test.ts | 104 ++++++++ src/board/board-screen.tsx | 24 ++ src/reports/__tests__/chart.test.ts | 41 +++ src/reports/chart.tsx | 270 +++++++++++++++++++ src/reports/reports-sheet.tsx | 345 +++++++++++++++++++++++++ src/ui/icon.tsx | 7 +- 6 files changed, 790 insertions(+), 1 deletion(-) create mode 100644 src/api/__tests__/live-reports.test.ts create mode 100644 src/reports/__tests__/chart.test.ts create mode 100644 src/reports/chart.tsx create mode 100644 src/reports/reports-sheet.tsx diff --git a/src/api/__tests__/live-reports.test.ts b/src/api/__tests__/live-reports.test.ts new file mode 100644 index 0000000..bf2932f --- /dev/null +++ b/src/api/__tests__/live-reports.test.ts @@ -0,0 +1,104 @@ +/** + * @jest-environment node + */ +import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth'; +import { listCyclesTeamsTeamIdCyclesGet } from '@/api/generated/endpoints/cycles/cycles'; +import { + cycleBurndownCyclesCycleIdBurndownGet, + teamCreatedVsResolvedTeamsTeamIdCreatedVsResolvedGet, + teamCumulativeFlowTeamsTeamIdCumulativeFlowGet, + teamVelocityTeamsTeamIdVelocityGet, +} from '@/api/generated/endpoints/reports/reports'; +import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; +import type { TeamRead } from '@/api/generated/models'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken } from '@/auth/session'; +import { makeScale } from '@/reports/chart'; + +/** + * The four reports against a real instance. + * + * The acceptance criterion for #13 is that the numbers match web exactly, which + * they do by construction -- every figure is read from these endpoints and none + * is recomputed on the client. What is worth checking is that the shapes the + * charts index into are what arrives, and that a chart can be scaled from them + * without dividing by zero on an empty instance. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +describeLive('reports against a live instance', () => { + let team: TeamRead; + + 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]; + }); + + it('returns a burndown with an ideal line to draw against', async () => { + const cycles = await listCyclesTeamsTeamIdCyclesGet(team.id); + if (cycles.length === 0) return; + + const burndown = await cycleBurndownCyclesCycleIdBurndownGet(cycles[0].id); + expect(burndown.cycle_id).toBe(cycles[0].id); + + for (const point of burndown.points) { + expect(point.day).toMatch(/^\d{4}-\d{2}-\d{2}/); + expect(typeof point.points_remaining).toBe('number'); + // Both lines come from the server; the client draws, it does not model. + expect(typeof point.ideal_remaining).toBe('number'); + expect(point.points_remaining).toBeLessThanOrEqual(point.points_total); + } + }); + + it('returns velocity as committed against delivered', async () => { + const velocity = await teamVelocityTeamsTeamIdVelocityGet(team.id); + expect(Array.isArray(velocity.cycles)).toBe(true); + for (const cycle of velocity.cycles) { + expect(typeof cycle.points_committed).toBe('number'); + expect(typeof cycle.points_completed).toBe('number'); + expect(cycle.cycle_name).toEqual(expect.any(String)); + } + }); + + it('returns cumulative flow keyed by the categories the chart stacks', async () => { + const flow = await teamCumulativeFlowTeamsTeamIdCumulativeFlowGet(team.id); + const known = ['backlog', 'unstarted', 'started', 'done', 'cancelled']; + + for (const day of flow.days) { + expect(day.day).toMatch(/^\d{4}-\d{2}-\d{2}/); + // Unknown keys would silently vanish from a stacked area, so the chart's + // fixed order has to cover everything that can arrive. + for (const key of Object.keys(day.counts)) expect(known).toContain(key); + } + }); + + it('returns created against resolved with a running open count', async () => { + const report = await teamCreatedVsResolvedTeamsTeamIdCreatedVsResolvedGet(team.id); + expect(typeof report.total_created).toBe('number'); + expect(typeof report.total_resolved).toBe('number'); + + for (const day of report.days) { + expect(typeof day.created).toBe('number'); + expect(typeof day.resolved).toBe('number'); + expect(typeof day.open_at_end_of_day).toBe('number'); + } + }); + + it('scales whatever the instance actually returns', async () => { + // An instance with no history is the common case for a fresh install, and + // the charts have to draw an axis rather than divide by zero. + const flow = await teamCumulativeFlowTeamsTeamIdCumulativeFlowGet(team.id); + const totals = flow.days.map((day) => + Object.values(day.counts).reduce((sum, n) => sum + (n ?? 0), 0), + ); + const scale = makeScale(300, flow.days.length, Math.max(...totals, 1)); + expect(Number.isFinite(scale.y(0))).toBe(true); + expect(Number.isFinite(scale.x(0))).toBe(true); + }); +}); diff --git a/src/board/board-screen.tsx b/src/board/board-screen.tsx index fbeeaae..deccf74 100644 --- a/src/board/board-screen.tsx +++ b/src/board/board-screen.tsx @@ -12,6 +12,7 @@ import { IssueList } from '@/board/issue-list'; import { KanbanBoard } from '@/board/kanban-board'; import { useStatusChange } from '@/board/use-status-change'; import { CycleBanner, CyclesSheet } from '@/cycles/cycles-screen'; +import { ReportsSheet } from '@/reports/reports-sheet'; import { useListViewsTeamsTeamIdViewsGet } from '@/api/generated/endpoints/views/views'; import { useAuth } from '@/auth/auth-context'; import { fromViewFilters } from '@/views/saved-views'; @@ -56,6 +57,7 @@ export function BoardScreen() { const [filterOpen, setFilterOpen] = useState(false); const [viewsOpen, setViewsOpen] = useState(false); const [cyclesOpen, setCyclesOpen] = useState(false); + const [reportsOpen, setReportsOpen] = useState(false); const [switching, setSwitching] = useState(false); const [creating, setCreating] = useState(false); const [moving, setMoving] = useState(null); @@ -158,6 +160,22 @@ export function BoardScreen() { + setReportsOpen(true)} + style={{ + width: 36, + height: 36, + borderRadius: t.radius.control, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: t.line.ghost, + }} + > + + + )} + setReportsOpen(false)} + team={team} + /> + setCyclesOpen(false)} diff --git a/src/reports/__tests__/chart.test.ts b/src/reports/__tests__/chart.test.ts new file mode 100644 index 0000000..215e2da --- /dev/null +++ b/src/reports/__tests__/chart.test.ts @@ -0,0 +1,41 @@ +import { linePath, makeScale } from '@/reports/chart'; + +describe('makeScale', () => { + const scale = makeScale(200, 5, 10); + + it('puts the first point on the axis and the last at the right edge', () => { + expect(scale.x(0)).toBeCloseTo(34, 5); + expect(scale.x(4)).toBeCloseTo(34 + scale.innerWidth, 5); + }); + + it('draws zero on the baseline and the max at the top', () => { + expect(scale.y(0)).toBeCloseTo(12 + scale.innerHeight, 5); + expect(scale.y(10)).toBeCloseTo(12, 5); + }); + + it('survives a chart whose values are all zero', () => { + // A cycle with nothing in it still has to draw an axis rather than divide + // by zero. + const flat = makeScale(200, 3, 0); + expect(Number.isFinite(flat.y(0))).toBe(true); + expect(flat.y(0)).toBeCloseTo(12 + flat.innerHeight, 5); + }); + + it('survives a single-point series', () => { + const single = makeScale(200, 1, 5); + expect(Number.isFinite(single.x(0))).toBe(true); + }); +}); + +describe('linePath', () => { + it('moves to the first point and lines to the rest', () => { + const scale = makeScale(200, 3, 10); + const path = linePath([10, 5, 0], scale); + expect(path.startsWith('M')).toBe(true); + expect(path.match(/L/g)).toHaveLength(2); + }); + + it('is empty for no data, rather than a malformed path', () => { + expect(linePath([], makeScale(200, 0, 1))).toBe(''); + }); +}); diff --git a/src/reports/chart.tsx b/src/reports/chart.tsx new file mode 100644 index 0000000..af871e4 --- /dev/null +++ b/src/reports/chart.tsx @@ -0,0 +1,270 @@ +import { useState, type ReactNode } from 'react'; +import { Pressable, View } from 'react-native'; +import Svg, { Circle, Line, Path, Rect } from 'react-native-svg'; + +import { AppText } from '@/ui/primitives'; +import { useTokens } from '@/ui/theme'; + +/** + * The chart primitives the four reports are drawn with. + * + * Hand-drawn in react-native-svg, which is already here for the icon set, rather + * than pulling in a charting library: four fixed chart shapes need a fraction of + * what one provides, and tapping for a value is easier to get right on a touch + * screen when the hit areas are ours. + */ +export const CHART_HEIGHT = 180; +const PADDING = { top: 12, right: 12, bottom: 22, left: 34 }; + +export type Scale = { + x: (index: number) => number; + y: (value: number) => number; + width: number; + height: number; + innerWidth: number; + innerHeight: number; +}; + +export function makeScale(width: number, count: number, max: number): Scale { + const innerWidth = Math.max(1, width - PADDING.left - PADDING.right); + const innerHeight = CHART_HEIGHT - PADDING.top - PADDING.bottom; + const steps = Math.max(1, count - 1); + const top = max <= 0 ? 1 : max; + + return { + x: (index) => PADDING.left + (index / steps) * innerWidth, + y: (value) => PADDING.top + innerHeight - (value / top) * innerHeight, + width, + height: CHART_HEIGHT, + innerWidth, + innerHeight, + }; +} + +export function linePath(values: number[], scale: Scale): string { + if (values.length === 0) return ''; + return values + .map((value, index) => `${index === 0 ? 'M' : 'L'}${scale.x(index)},${scale.y(value)}`) + .join(' '); +} + +/** + * A chart with a title, an optional note, and a tap-anywhere readout. + * + * `onProbe` receives the index nearest the touch, because a fingertip covers + * several days at this width and asking someone to hit an exact point would + * make the values unreachable. + */ +export function Chart({ + title, + subtitle, + count, + width, + onProbe, + readout, + children, +}: { + title: string; + subtitle?: string; + count: number; + width: number; + onProbe: (index: number | null) => void; + readout?: ReactNode; + children: ReactNode; +}) { + const t = useTokens(); + + return ( + + {title} + {subtitle ? {subtitle} : null} + + onProbe(null)} + onPress={(event) => { + const x = event.nativeEvent.locationX; + const steps = Math.max(1, count - 1); + const innerWidth = Math.max(1, width - PADDING.left - PADDING.right); + const ratio = (x - PADDING.left) / innerWidth; + onProbe(Math.max(0, Math.min(count - 1, Math.round(ratio * steps)))); + }} + style={{ + height: CHART_HEIGHT, + backgroundColor: t.surface.card, + borderRadius: t.radius.card, + }} + > + + {children} + + + + {/* Reserved rather than conditional, so the layout does not jump as a + finger moves across the chart. */} + {readout} + + ); +} + +export function Axes({ scale }: { scale: Scale }) { + const t = useTokens(); + return ( + <> + + + + ); +} + +export function Series({ + values, + scale, + color, + dashed = false, +}: { + values: number[]; + scale: Scale; + color: string; + dashed?: boolean; +}) { + return ( + + ); +} + +export function Marker({ + index, + value, + scale, + color, +}: { + index: number; + value: number; + scale: Scale; + color: string; +}) { + return ; +} + +export function Bars({ + values, + scale, + color, + offset = 0, + slots = 1, +}: { + values: number[]; + scale: Scale; + color: string; + offset?: number; + slots?: number; +}) { + const groupWidth = scale.innerWidth / Math.max(1, values.length); + const barWidth = Math.max(2, (groupWidth * 0.7) / slots); + const base = scale.y(0); + + return ( + <> + {values.map((value, index) => { + const top = scale.y(value); + const left = + PADDING.left + index * groupWidth + groupWidth * 0.15 + offset * barWidth; + return ( + + ); + })} + + ); +} + +/** Stacked areas, for the cumulative flow diagram. */ +export function StackedAreas({ + series, + scale, +}: { + series: { values: number[]; color: string }[]; + scale: Scale; +}) { + const running = series.length > 0 ? series[0].values.map(() => 0) : []; + + return ( + <> + {series.map((layer, layerIndex) => { + const lower = [...running]; + layer.values.forEach((value, index) => { + running[index] += value; + }); + const upper = [...running]; + + const forward = upper + .map((value, index) => `${index === 0 ? 'M' : 'L'}${scale.x(index)},${scale.y(value)}`) + .join(' '); + const back = lower + .map((value, index) => `L${scale.x(lower.length - 1 - index)},${scale.y(lower[lower.length - 1 - index])}`) + .join(' '); + + return ( + + ); + })} + + ); +} + +export function Legend({ items }: { items: { label: string; color: string }[] }) { + return ( + + {items.map((item) => ( + + + {item.label} + + ))} + + ); +} + +/** Hook for the shared "which point is being probed" state. */ +export function useProbe() { + const [index, setIndex] = useState(null); + return { index, setIndex }; +} diff --git a/src/reports/reports-sheet.tsx b/src/reports/reports-sheet.tsx new file mode 100644 index 0000000..a0f8f09 --- /dev/null +++ b/src/reports/reports-sheet.tsx @@ -0,0 +1,345 @@ +import { useState } from 'react'; +import { Pressable, useWindowDimensions, View } from 'react-native'; + +import { useListCyclesTeamsTeamIdCyclesGet } from '@/api/generated/endpoints/cycles/cycles'; +import { + useCycleBurndownCyclesCycleIdBurndownGet, + useTeamCreatedVsResolvedTeamsTeamIdCreatedVsResolvedGet, + useTeamCumulativeFlowTeamsTeamIdCumulativeFlowGet, + useTeamVelocityTeamsTeamIdVelocityGet, +} from '@/api/generated/endpoints/reports/reports'; +import type { StatusCategory, TeamRead } from '@/api/generated/models'; +import { + Axes, + Bars, + Chart, + Legend, + Marker, + makeScale, + Series, + StackedAreas, + useProbe, +} from '@/reports/chart'; +import { AppText, Card, Loading } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * The four reports, per `docs/design/mobile/150-reports.svg`. + * + * Every number comes from the same event-history endpoints the web charts, so + * the figures match rather than being recomputed here. Portrait-first: one chart + * per row, full width, with a tap-anywhere readout instead of a hover tooltip. + */ +const CATEGORY_ORDER: StatusCategory[] = [ + 'backlog', + 'unstarted', + 'started', + 'done', + 'cancelled', +]; + +/** `2026-01-06` -> `6 Jan`, which is all the axis has room for. */ +function shortDay(day: string): string { + const parsed = new Date(`${day}T00:00:00Z`); + if (Number.isNaN(parsed.getTime())) return day; + return `${parsed.getUTCDate()} ${parsed.toLocaleString('en', { month: 'short', timeZone: 'UTC' })}`; +} + +export function ReportsSheet({ + visible, + onClose, + team, +}: { + visible: boolean; + onClose: () => void; + team: TeamRead; +}) { + const t = useTokens(); + const { width: screenWidth } = useWindowDimensions(); + const width = Math.min(screenWidth, 520) - 72; + + const cycles = useListCyclesTeamsTeamIdCyclesGet(team.id, { query: { enabled: visible } }); + const [cycleId, setCycleId] = useState(null); + + // Whichever cycle is running, else the most recent one -- a burndown of + // nothing is not worth the screen. + const chosenCycle = + cycleId ?? + (cycles.data ?? []).find((cycle) => cycle.state === 'active')?.id ?? + (cycles.data ?? [])[0]?.id ?? + null; + + return ( + + + {/* The same notice the web shows: nothing can be drawn for the period + before the instance started recording history. */} + + Charts are drawn from recorded history. Anything that happened before + this instance began tracking cannot be reconstructed. + + + {cycles.isPending ? : null} + + + + {(cycles.data ?? []).length > 1 ? ( + + {(cycles.data ?? []).map((cycle) => { + const active = chosenCycle === cycle.id; + return ( + setCycleId(cycle.id)} + style={{ + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: t.radius.pill, + backgroundColor: active ? t.line.navActive : t.line.well, + }} + > + + {cycle.display_name} + + + ); + })} + + ) : null} + + + + + + + ); +} + +function BurndownChart({ cycleId, width }: { cycleId: number | null; width: number }) { + const t = useTokens(); + const probe = useProbe(); + const burndown = useCycleBurndownCyclesCycleIdBurndownGet(cycleId ?? 0, { + query: { enabled: cycleId !== null }, + }); + + const points = burndown.data?.points ?? []; + if (cycleId === null || points.length === 0) { + return ; + } + + const remaining = points.map((point) => point.points_remaining); + const ideal = points.map((point) => point.ideal_remaining); + const max = Math.max(...remaining, ...ideal, 1); + const scale = makeScale(width, points.length, max); + const at = probe.index !== null ? points[probe.index] : null; + + return ( + + {shortDay(at.day)} · {at.points_remaining} remaining of {at.points_total} + {' · ideal '} + {Math.round(at.ideal_remaining)} + + ) : ( + + ) + } + > + + + + {at && probe.index !== null ? ( + + ) : null} + + ); +} + +function VelocityChart({ teamId, width }: { teamId: number; width: number }) { + const t = useTokens(); + const probe = useProbe(); + const velocity = useTeamVelocityTeamsTeamIdVelocityGet(teamId); + + const cycles = velocity.data?.cycles ?? []; + if (cycles.length === 0) { + return ; + } + + const committed = cycles.map((cycle) => cycle.points_committed); + const completed = cycles.map((cycle) => cycle.points_completed); + const scale = makeScale(width, cycles.length, Math.max(...committed, ...completed, 1)); + const at = probe.index !== null ? cycles[probe.index] : null; + + return ( + + {at.cycle_name} · {at.points_completed} delivered of {at.points_committed}{' '} + committed + + ) : ( + + ) + } + > + + + + + ); +} + +function FlowChart({ teamId, width }: { teamId: number; width: number }) { + const t = useTokens(); + const probe = useProbe(); + const flow = useTeamCumulativeFlowTeamsTeamIdCumulativeFlowGet(teamId); + + const days = flow.data?.days ?? []; + if (days.length === 0) { + return ; + } + + const colorFor: Record = { + backlog: t.status.backlog, + unstarted: t.status.todo, + started: t.status.progress, + done: t.status.done, + cancelled: t.status.cancelled, + }; + + const series = CATEGORY_ORDER.map((category) => ({ + color: colorFor[category], + values: days.map((day) => day.counts[category] ?? 0), + })); + + const totals = days.map((day) => + CATEGORY_ORDER.reduce((sum, category) => sum + (day.counts[category] ?? 0), 0), + ); + const scale = makeScale(width, days.length, Math.max(...totals, 1)); + const at = probe.index !== null ? days[probe.index] : null; + + return ( + + {shortDay(at.day)} ·{' '} + {CATEGORY_ORDER.filter((category) => (at.counts[category] ?? 0) > 0) + .map((category) => `${category} ${at.counts[category]}`) + .join(', ')} + + ) : ( + ({ + label: category, + color: colorFor[category], + }))} + /> + ) + } + > + + + + ); +} + +function CreatedResolvedChart({ teamId, width }: { teamId: number; width: number }) { + const t = useTokens(); + const probe = useProbe(); + const report = useTeamCreatedVsResolvedTeamsTeamIdCreatedVsResolvedGet(teamId); + + const days = report.data?.days ?? []; + if (days.length === 0) { + return ; + } + + const created = days.map((day) => day.created); + const resolved = days.map((day) => day.resolved); + const scale = makeScale(width, days.length, Math.max(...created, ...resolved, 1)); + const at = probe.index !== null ? days[probe.index] : null; + + return ( + + {shortDay(at.day)} · {at.created} created, {at.resolved} resolved ·{' '} + {at.open_at_end_of_day} open + + ) : ( + + ) + } + > + + + + + ); +} + +function Empty({ title, reason }: { title: string; reason: string }) { + return ( + + {title} + + {reason} + + + ); +} diff --git a/src/ui/icon.tsx b/src/ui/icon.tsx index 5faf38d..9cca3fc 100644 --- a/src/ui/icon.tsx +++ b/src/ui/icon.tsx @@ -22,7 +22,8 @@ export type IconName = | 'check' | 'logout' | 'trash' - | 'plus'; + | 'plus' + | 'chart'; export function Icon({ name, @@ -113,6 +114,10 @@ export function Icon({ {name === 'check' ? : null} + {name === 'chart' ? ( + + ) : null} + {name === 'plus' ? : null} {name === 'trash' ? ( From 26e524d92ab7d31774325611675583a76f74997c Mon Sep 17 00:00:00 2001 From: Waleed Khalid Date: Fri, 11 Sep 2026 22:42:14 +0500 Subject: [PATCH 16/18] Add team administration: general, members, statuses, automation, repositories Closes #16 for everything the API exposes, with one deliberate exception noted below. Rules render as English -- "When an issue changes status and it is unassigned, assign it to Ada Lovelace." A rule is read far more often than it is written, usually to work out why an issue moved, and a grid of dropdowns does not answer that. Every lookup falls back by name, because a rule outlives the label it points at and a sentence with "undefined" in it is worse than one saying the label is gone. Reordering statuses sends every id at once, as the API asks: two people dragging columns would otherwise interleave into an order neither chose. It is driven by arrows rather than a drag -- the board already spends its drag on cards, and a second drag surface inside a sheet is both harder to hit and harder to verify. Deleting a status requires naming where its issues go, so the confirm offers the other columns rather than guessing. Guessing which column somebody's work lands in is not a decision to make for them. A webhook secret is not put on screen until it is asked for; these screens get shown to other people. Rule *authoring* is not here. A rule is a trigger plus six optional conditions plus seven optional actions, which is a form that belongs on a wider screen -- everything else about rules (reading, enabling, disabling, deleting, the run log) is. The panel says so and links out, as the issue allows for Jira import. Another correction from checking: demoting the last admin is 409, not 400. Co-Authored-By: Claude Opus 5 --- .claude/launch.json | 14 + README.md | 9 +- package-lock.json | 12 + package.json | 1 + src/api/__tests__/live-team-admin.test.ts | 169 +++++++++ src/team/admin/__tests__/automation.test.ts | 69 ++++ src/team/admin/automation-panel.tsx | 235 ++++++++++++ src/team/admin/members-panel.tsx | 249 +++++++++++++ src/team/admin/share.ts | 28 ++ src/team/admin/statuses-panel.tsx | 331 +++++++++++++++++ src/team/admin/team-admin-sheet.tsx | 379 ++++++++++++++++++++ src/team/teams-home.tsx | 14 + 12 files changed, 1506 insertions(+), 4 deletions(-) create mode 100644 .claude/launch.json create mode 100644 src/api/__tests__/live-team-admin.test.ts create mode 100644 src/team/admin/__tests__/automation.test.ts create mode 100644 src/team/admin/automation-panel.tsx create mode 100644 src/team/admin/members-panel.tsx create mode 100644 src/team/admin/share.ts create mode 100644 src/team/admin/statuses-panel.tsx create mode 100644 src/team/admin/team-admin-sheet.tsx diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..ffa1c6e --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,14 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "start", + "runtimeExecutable": "npm", + "runtimeArgs": [ + "run", + "start" + ], + "port": 3000 + } + ] +} diff --git a/README.md b/README.md index adf846b..79c6561 100644 --- a/README.md +++ b/README.md @@ -74,10 +74,11 @@ the real client modules against it, and the onboarding one writes real rows SOFTTRACK_LIVE_URL=http://localhost:8000 npm test ``` -Registration is throttled per IP and charged even on success, so running the -onboarding suite repeatedly will eventually 429. The counter lives in the API -process, so `docker compose restart backend` clears it without touching the -database. +The live suites between them register a handful of real accounts per run, and +registration is throttled per IP and charged even on success — so a few full runs +in a row will eventually 429. The counter lives in the API process, so +`docker compose restart backend && docker compose up -d` clears it without +touching the database. Note `experiments.typedRoutes` is a dev-time aid: the route union is written by `expo start`, not by `expo export`, so outside the dev server every path diff --git a/package-lock.json b/package-lock.json index 1db268b..76ba300 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "axios": "^1.20.0", "expo": "~57.0.22", "expo-blur": "~57.0.3", + "expo-clipboard": "~57.0.2", "expo-constants": "~57.0.18", "expo-document-picker": "~57.0.2", "expo-file-system": "~57.0.7", @@ -8094,6 +8095,17 @@ "react-native": "*" } }, + "node_modules/expo-clipboard": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-57.0.2.tgz", + "integrity": "sha512-VB4Au8X/RbvJKUtJk+87vdtveWSNEa5RzY3ooeO6VNF7Rd49RbQXZOu3/TfEW9p7mU8TCfyTvjyCiAUwSoSJ4w==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-constants": { "version": "57.0.18", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.18.tgz", diff --git a/package.json b/package.json index 760153d..8e335d1 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "axios": "^1.20.0", "expo": "~57.0.22", "expo-blur": "~57.0.3", + "expo-clipboard": "~57.0.2", "expo-constants": "~57.0.18", "expo-document-picker": "~57.0.2", "expo-file-system": "~57.0.7", diff --git a/src/api/__tests__/live-team-admin.test.ts b/src/api/__tests__/live-team-admin.test.ts new file mode 100644 index 0000000..79a08c1 --- /dev/null +++ b/src/api/__tests__/live-team-admin.test.ts @@ -0,0 +1,169 @@ +/** + * @jest-environment node + */ +import { + loginAuthLoginPost, + registerAuthRegisterPost, +} from '@/api/generated/endpoints/auth/auth'; +import { + acceptInviteInvitesTokenAcceptPost, + createInviteTeamsTeamIdInvitesPost, + listInvitesTeamsTeamIdInvitesGet, + revokeInviteTeamsTeamIdInvitesInviteIdDelete, +} from '@/api/generated/endpoints/invites/invites'; +import { + createIssueTeamsTeamIdIssuesPost, + deleteIssueIssuesIssueIdDelete, + getIssueIssuesIssueIdGet, +} from '@/api/generated/endpoints/issues/issues'; +import { + createStatusTeamsTeamIdStatusesPost, + deleteStatusStatusesStatusIdDelete, + listStatusesTeamsTeamIdStatusesGet, + reorderStatusesTeamsTeamIdStatusesOrderPut, +} from '@/api/generated/endpoints/statuses/statuses'; +import { + createTeamTeamsPost, + listTeamMembersTeamsTeamIdMembersGet, + removeTeamMemberTeamsTeamIdMembersUserIdDelete, + updateTeamMemberRoleTeamsTeamIdMembersUserIdPatch, + updateTeamTeamsTeamIdPatch, +} from '@/api/generated/endpoints/teams/teams'; +import type { TeamRead } from '@/api/generated/models'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken } from '@/auth/session'; + +/** + * Team administration against a real instance. + * + * Works on a team created for the run rather than the demo one, because these + * delete statuses and change roles. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); +const KEY = `T${RUN}`.replace(/[^A-Za-z]/g, 'X').toUpperCase().slice(0, 6); + +describeLive('team administration against a live instance', () => { + let team: TeamRead; + let adminToken: string; + let otherToken: string; + let otherId: number; + + beforeAll(async () => { + await setInstanceUrl(LIVE_URL!); + const admin = await loginAuthLoginPost({ + username: process.env.SOFTTRACK_LIVE_EMAIL ?? 'demo@softtrack.dev', + password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123', + }); + adminToken = admin.access_token; + await persistToken(adminToken); + + team = await createTeamTeamsPost({ name: `Admin ${RUN}`, key: KEY }); + + const other = await registerAuthRegisterPost({ + email: `mob-admin-${RUN}@example.com`, + password: 'password123', + full_name: 'Second Admin', + }); + otherToken = other.access_token; + otherId = other.user.id; + }); + + it('renames the team but never its key', async () => { + const updated = await updateTeamTeamsTeamIdPatch(team.id, { + name: `Renamed ${RUN}`, + description: 'Set from mobile', + }); + expect(updated.name).toBe(`Renamed ${RUN}`); + expect(updated.description).toBe('Set from mobile'); + // The key is permanent, which is why the field is read-only. + expect(updated.key).toBe(KEY); + }); + + it('invites, lists, and revokes', async () => { + const invite = await createInviteTeamsTeamIdInvitesPost(team.id, { + email: `revoke-${RUN}@example.com`, + role: 'member', + }); + expect((await listInvitesTeamsTeamIdInvitesGet(team.id)).map((i) => i.id)).toContain( + invite.id, + ); + + await revokeInviteTeamsTeamIdInvitesInviteIdDelete(team.id, invite.id); + expect((await listInvitesTeamsTeamIdInvitesGet(team.id)).map((i) => i.id)).not.toContain( + invite.id, + ); + }); + + it('refuses to leave the team without another admin', async () => { + // The guard the Manage sheet explains rather than discovers. + await expect( + removeTeamMemberTeamsTeamIdMembersUserIdDelete(team.id, otherId), + ).rejects.toMatchObject({ response: { status: 404 } }); + + const members = await listTeamMembersTeamsTeamIdMembersGet(team.id); + const me = members[0]; + await expect( + updateTeamMemberRoleTeamsTeamIdMembersUserIdPatch(team.id, me.user.id, { + role: 'member', + }), + // 409: demoting the last admin conflicts with the team still needing one, + // which is the guard the Manage sheet explains up front. + ).rejects.toMatchObject({ response: { status: 409 } }); + }); + + it('promotes a second member to admin', async () => { + const invite = await createInviteTeamsTeamIdInvitesPost(team.id, { + email: `mob-admin-${RUN}@example.com`, + role: 'member', + }); + await persistToken(otherToken); + await acceptInviteInvitesTokenAcceptPost(invite.token); + + await persistToken(adminToken); + const promoted = await updateTeamMemberRoleTeamsTeamIdMembersUserIdPatch( + team.id, + otherId, + { role: 'admin' }, + ); + expect(promoted.role).toBe('admin'); + }); + + it('creates, reorders and deletes a status, moving its issues', async () => { + const before = await listStatusesTeamsTeamIdStatusesGet(team.id); + const created = await createStatusTeamsTeamIdStatusesPost(team.id, { + name: `Review ${RUN}`, + category: 'started', + }); + + // Reordering sends every id at once, so two people cannot interleave into + // an order neither chose. + const reversed = [created.id, ...before.map((s) => s.id)]; + const reordered = await reorderStatusesTeamsTeamIdStatusesOrderPut(team.id, { + status_ids: reversed, + }); + expect(reordered.map((s) => s.id)).toEqual(reversed); + + const issue = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `In deleted status ${RUN}`, + status_id: created.id, + }); + + const destination = before[0]; + await deleteStatusStatusesStatusIdDelete(created.id, { move_to_id: destination.id }); + + // Where the issues go is required rather than guessed -- so they are + // exactly where they were sent. + expect((await getIssueIssuesIssueIdGet(issue.id)).status.id).toBe(destination.id); + await deleteIssueIssuesIssueIdDelete(issue.id).catch(() => undefined); + }); + + it('will not delete a status without saying where its issues go', async () => { + const statuses = await listStatusesTeamsTeamIdStatusesGet(team.id); + await expect( + deleteStatusStatusesStatusIdDelete(statuses[0].id, {} as never), + ).rejects.toMatchObject({ response: { status: 422 } }); + }); +}); diff --git a/src/team/admin/__tests__/automation.test.ts b/src/team/admin/__tests__/automation.test.ts new file mode 100644 index 0000000..3a50428 --- /dev/null +++ b/src/team/admin/__tests__/automation.test.ts @@ -0,0 +1,69 @@ +import { describeRule } from '@/team/admin/automation-panel'; + +const LOOK = { + status: (id: number) => (id === 1 ? 'In Progress' : 'a deleted status'), + label: (id: number) => (id === 2 ? 'bug' : 'a deleted label'), + project: (id: number) => (id === 3 ? 'Platform' : 'a deleted project'), + member: (id: number) => (id === 4 ? 'Ada Lovelace' : 'a former member'), + cycle: (id: number) => (id === 5 ? 'Cycle 14' : 'a deleted cycle'), +}; + +function rule(over: Record = {}) { + return { + id: 1, + team_id: 1, + name: 'Rule', + trigger: 'issue_created', + is_enabled: true, + conditions: {}, + actions: {}, + created_by: { id: 1, username: 'a', full_name: 'A', avatar_color: '#000' }, + created_at: '', + updated_at: '', + ...over, + } as never as Parameters[0]; +} + +describe('describeRule', () => { + it('reads as a sentence rather than a grid of fields', () => { + expect( + describeRule(rule({ actions: { set_priority: 'high' } }), LOOK), + ).toBe('When an issue is created, set its priority to High.'); + }); + + it('joins conditions with "and"', () => { + expect( + describeRule( + rule({ + trigger: 'status_changed', + conditions: { if_status_id: 1, if_unassigned: true }, + actions: { set_assignee_id: 4 }, + }), + LOOK, + ), + ).toBe( + 'When an issue changes status and it is in In Progress and it is unassigned, assign it to Ada Lovelace.', + ); + }); + + it('joins multiple actions', () => { + expect( + describeRule( + rule({ actions: { add_label_id: 2, move_to_active_cycle: true } }), + LOOK, + ), + ).toBe('When an issue is created, add the bug label, and move it into the active cycle.'); + }); + + it('names a row the team has since deleted rather than printing undefined', () => { + // A rule outlives the label it points at, and a sentence with "undefined" + // in it is worse than one that says the label is gone. + expect(describeRule(rule({ actions: { add_label_id: 99 } }), LOOK)).toContain( + 'a deleted label', + ); + }); + + it('says so when a rule does nothing', () => { + expect(describeRule(rule(), LOOK)).toBe('When an issue is created, do nothing.'); + }); +}); diff --git a/src/team/admin/automation-panel.tsx b/src/team/admin/automation-panel.tsx new file mode 100644 index 0000000..a13ea0c --- /dev/null +++ b/src/team/admin/automation-panel.tsx @@ -0,0 +1,235 @@ +import { useState } from 'react'; +import { Alert as RNAlert, Pressable, Switch, View } from 'react-native'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + deleteRuleAutomationRulesRuleIdDelete, + updateRuleAutomationRulesRuleIdPatch, + useListRulesTeamsTeamIdAutomationRulesGet, + useListRunsTeamsTeamIdAutomationRunsGet, +} from '@/api/generated/endpoints/automations/automations'; +import type { + AutomationRuleRead, + AutomationTrigger, + RuleActions, + RuleConditions, + TeamRead, +} from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { useTeamData } from '@/board/use-team-data'; +import { PRIORITY_LABEL } from '@/issues/issue-meta'; +import { Alert, AppText, Button, Card } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * Automation rules and their run log. + * + * Rules render as English rather than as a form of dropdowns. A rule is read + * far more often than it is written -- usually to work out why an issue moved -- + * and "When an issue is created, set its priority to High" answers that in a + * glance where a grid of selects does not. + */ +const TRIGGER_TEXT: Record = { + issue_created: 'an issue is created', + status_changed: 'an issue changes status', + issue_assigned: 'an issue is assigned', + comment_added: 'a comment is added', + cycle_completed: 'a cycle is completed', + branch_created: 'a branch is created', + pull_request_opened: 'a pull request is opened', + pull_request_merged: 'a pull request is merged', +}; + +type Lookup = { + status: (id: number) => string; + label: (id: number) => string; + project: (id: number) => string; + member: (id: number) => string; + cycle: (id: number) => string; +}; + +function conditionText(conditions: RuleConditions, look: Lookup): string[] { + const parts: string[] = []; + if (conditions.if_status_id) parts.push(`it is in ${look.status(conditions.if_status_id)}`); + if (conditions.if_priority) parts.push(`its priority is ${PRIORITY_LABEL[conditions.if_priority]}`); + if (conditions.if_label_id) parts.push(`it has the ${look.label(conditions.if_label_id)} label`); + if (conditions.if_project_id) parts.push(`it is in ${look.project(conditions.if_project_id)}`); + if (conditions.if_assignee_id) parts.push(`it is assigned to ${look.member(conditions.if_assignee_id)}`); + if (conditions.if_unassigned) parts.push('it is unassigned'); + return parts; +} + +function actionText(actions: RuleActions, look: Lookup): string[] { + const parts: string[] = []; + if (actions.set_status_id) parts.push(`move it to ${look.status(actions.set_status_id)}`); + if (actions.set_priority) parts.push(`set its priority to ${PRIORITY_LABEL[actions.set_priority]}`); + if (actions.set_assignee_id) parts.push(`assign it to ${look.member(actions.set_assignee_id)}`); + if (actions.add_label_id) parts.push(`add the ${look.label(actions.add_label_id)} label`); + if (actions.set_cycle_id) parts.push(`put it in ${look.cycle(actions.set_cycle_id)}`); + if (actions.move_to_active_cycle) parts.push('move it into the active cycle'); + if (actions.comment_body) parts.push('leave a comment'); + return parts; +} + +/** A rule as a sentence. Exported because the phrasing is worth testing. */ +export function describeRule(rule: AutomationRuleRead, look: Lookup): string { + const when = TRIGGER_TEXT[rule.trigger] ?? rule.trigger; + const ifs = conditionText(rule.conditions, look); + const dos = actionText(rule.actions, look); + + const condition = ifs.length > 0 ? ` and ${ifs.join(' and ')}` : ''; + const action = dos.length > 0 ? dos.join(', and ') : 'do nothing'; + return `When ${when}${condition}, ${action}.`; +} + +export function AutomationPanel({ + visible, + onClose, + team, + isAdmin, +}: { + visible: boolean; + onClose: () => void; + team: TeamRead; + isAdmin: boolean; +}) { + const t = useTokens(); + const queryClient = useQueryClient(); + const data = useTeamData(team); + + const rules = useListRulesTeamsTeamIdAutomationRulesGet(team.id, { + query: { enabled: visible }, + }); + const runs = useListRunsTeamsTeamIdAutomationRunsGet( + team.id, + { limit: 20 }, + { query: { enabled: visible } }, + ); + + const [error, setError] = useState(null); + + const look: Lookup = { + // A rule can outlive the row it names, so every lookup has a fallback + // rather than rendering "undefined" into a sentence. + status: (id) => data.statuses.find((s) => s.id === id)?.name ?? 'a deleted status', + label: (id) => data.labels.find((l) => l.id === id)?.name ?? 'a deleted label', + project: (id) => data.projects.find((p) => p.id === id)?.name ?? 'a deleted project', + member: (id) => + data.members.find((m) => m.user.id === id)?.user.full_name ?? 'a former member', + cycle: (id) => data.cycles.find((c) => c.id === id)?.display_name ?? 'a deleted cycle', + }; + + async function run(action: () => Promise, fallback: string) { + setError(null); + try { + await action(); + await queryClient.invalidateQueries({ + queryKey: [`/teams/${team.id}/automation-rules`], + }); + } catch (err) { + setError(errorDetail(err, fallback)); + } + } + + if (!visible) return null; + + return ( + + + {error ? {error} : null} + + {(rules.data ?? []).map((rule) => ( + + + + {rule.name} + + + void run( + () => + updateRuleAutomationRulesRuleIdPatch(rule.id, { + is_enabled: value, + }).then(() => undefined), + 'Could not change that rule.', + ) + } + /> + + + + {describeRule(rule, look)} + + + {isAdmin ? ( + + RNAlert.alert(`Delete ${rule.name}?`, 'This cannot be undone.', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: () => + void run( + () => + deleteRuleAutomationRulesRuleIdDelete(rule.id).then( + () => undefined, + ), + 'Could not delete that rule.', + ), + }, + ]) + } + > + + Delete + + + ) : null} + + ))} + + {(rules.data ?? []).length === 0 && !rules.isPending ? ( + + No automation rules yet. + + ) : null} + + {/* Rules can be read, enabled, disabled and deleted here. Authoring one + is a trigger plus six optional conditions plus seven optional + actions, which is a form that belongs on a wider screen -- the web + settings page has it. */} + {isAdmin ? ( + + New rules are written on the web settings page; everything else about + them is here. + + ) : null} + + + RECENT RUNS + {(runs.data?.items ?? []).map((entry) => ( + + + {entry.rule_name} + + + {entry.issue_identifier ? `${entry.issue_identifier} · ` : ''} + {entry.summary} + + + ))} + {(runs.data?.items ?? []).length === 0 && !runs.isPending ? ( + Nothing has run yet. + ) : null} + + + + ); +} diff --git a/src/team/admin/members-panel.tsx b/src/team/admin/members-panel.tsx new file mode 100644 index 0000000..a1d630a --- /dev/null +++ b/src/team/admin/members-panel.tsx @@ -0,0 +1,249 @@ +import { useState } from 'react'; +import { Alert as RNAlert, Pressable, View } from 'react-native'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + createInviteTeamsTeamIdInvitesPost, + revokeInviteTeamsTeamIdInvitesInviteIdDelete, + useListInvitesTeamsTeamIdInvitesGet, +} from '@/api/generated/endpoints/invites/invites'; +import { + removeTeamMemberTeamsTeamIdMembersUserIdDelete, + updateTeamMemberRoleTeamsTeamIdMembersUserIdPatch, + useListTeamMembersTeamsTeamIdMembersGet, +} from '@/api/generated/endpoints/teams/teams'; +import type { TeamRead, UserMe } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { getInstanceUrl } from '@/api/instance'; +import { shareInvite } from '@/team/admin/share'; +import { Alert, AppText, Avatar, Button, Card, Field, RoleChip } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * Members and invitations. + * + * Every guard here is the server's -- last-active-admin, self-demotion, admin + * only. The client mirrors the messages rather than the rules, so an action that + * would be refused says why instead of failing opaquely. + */ +export function MembersPanel({ + visible, + onClose, + team, + user, + isAdmin, +}: { + visible: boolean; + onClose: () => void; + team: TeamRead; + user: UserMe | null; + isAdmin: boolean; +}) { + const t = useTokens(); + const queryClient = useQueryClient(); + const members = useListTeamMembersTeamsTeamIdMembersGet(team.id, { + query: { enabled: visible }, + }); + const invites = useListInvitesTeamsTeamIdInvitesGet(team.id, { + query: { enabled: visible && isAdmin }, + }); + + const [email, setEmail] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const list = members.data ?? []; + const admins = list.filter((member) => member.role === 'admin'); + + async function run(action: () => Promise, fallback: string) { + setError(null); + setBusy(true); + try { + await action(); + await queryClient.invalidateQueries({ queryKey: [`/teams/${team.id}/members`] }); + await queryClient.invalidateQueries({ queryKey: [`/teams/${team.id}/invites`] }); + } catch (err) { + setError(errorDetail(err, fallback)); + } finally { + setBusy(false); + } + } + + if (!visible) return null; + + return ( + + + {error ? {error} : null} + + {list.map((member) => { + const isSelf = member.user.id === user?.id; + const isLastAdmin = member.role === 'admin' && admins.length === 1; + + return ( + + + + + {member.user.full_name} + {isSelf ? ' (you)' : ''} + + + @{member.user.username} + + + + + + {isAdmin ? ( + { + const options: Parameters[2] = [ + { text: 'Cancel', style: 'cancel' }, + ]; + + if (!isLastAdmin) { + options.push({ + text: member.role === 'admin' ? 'Make member' : 'Make admin', + onPress: () => + void run( + () => + updateTeamMemberRoleTeamsTeamIdMembersUserIdPatch( + team.id, + member.user.id, + { role: member.role === 'admin' ? 'member' : 'admin' }, + ).then(() => undefined), + 'Could not change that role.', + ), + }); + } + + options.push({ + text: isSelf ? 'Leave team' : 'Remove from team', + style: 'destructive', + onPress: () => + void run( + () => + removeTeamMemberTeamsTeamIdMembersUserIdDelete( + team.id, + member.user.id, + ).then(() => undefined), + isSelf ? 'Could not leave the team.' : 'Could not remove them.', + ), + }); + + RNAlert.alert( + member.user.full_name, + isLastAdmin + ? 'The last admin cannot be demoted — promote somebody else first.' + : undefined, + options, + ); + }} + > + + Manage + + + ) : null} + + ); + })} + + {isAdmin ? ( + + INVITE + + {(invites.data ?? []).map((invite) => ( + + + + {invite.email} + + Invited as {invite.role} + + + void shareInvite(invite.token, team.name, getInstanceUrl()) + } + > + + Share + + + + void run( + () => + revokeInviteTeamsTeamIdInvitesInviteIdDelete( + team.id, + invite.id, + ).then(() => undefined), + 'Could not revoke that invitation.', + ) + } + > + + Revoke + + + + ))} + + + + + ) : null} + + + ); +} diff --git a/src/team/admin/share.ts b/src/team/admin/share.ts new file mode 100644 index 0000000..3f6cea4 --- /dev/null +++ b/src/team/admin/share.ts @@ -0,0 +1,28 @@ +import { Share } from 'react-native'; + +/** + * Hand an invitation link to the system share sheet. + * + * This instance sends no email, so the link only reaches anybody if the person + * creating it can pass it on. The web URL is shared rather than the custom + * scheme: it opens for someone with no app installed, and the app can pick it + * up from there. + */ +export async function shareInvite( + token: string, + teamName: string, + instanceUrl: string | null, +): Promise { + const link = instanceUrl + ? `${instanceUrl}/invite/${token}` + : `softtrack://invite/${token}`; + + try { + await Share.share({ + message: `Join ${teamName} on SoftTrack: ${link}`, + url: link, + }); + } catch { + // Dismissing the share sheet is not a failure worth reporting. + } +} diff --git a/src/team/admin/statuses-panel.tsx b/src/team/admin/statuses-panel.tsx new file mode 100644 index 0000000..8297ab6 --- /dev/null +++ b/src/team/admin/statuses-panel.tsx @@ -0,0 +1,331 @@ +import { useState } from 'react'; +import { Alert as RNAlert, Pressable, View } from 'react-native'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + createStatusTeamsTeamIdStatusesPost, + deleteStatusStatusesStatusIdDelete, + reorderStatusesTeamsTeamIdStatusesOrderPut, + updateStatusStatusesStatusIdPatch, + useListStatusesTeamsTeamIdStatusesGet, +} from '@/api/generated/endpoints/statuses/statuses'; +import { StatusCategory, type StatusRead, type TeamRead } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { Icon } from '@/ui/icon'; +import { Alert, AppText, Button, Card, Field } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * The team's board columns. + * + * Reordering moves one row at a time with arrows rather than a drag. The board + * already spends its drag gesture on cards, and a second drag surface inside a + * sheet is both harder to hit and harder to verify -- arrows do the same job + * with a target a thumb can actually land on. + */ +const CATEGORY_ORDER: StatusCategory[] = [ + StatusCategory.backlog, + StatusCategory.unstarted, + StatusCategory.started, + StatusCategory.done, + StatusCategory.cancelled, +]; + +const CATEGORY_HINT: Record = { + backlog: 'Not committed to yet', + unstarted: 'Accepted, not begun', + started: 'Work in flight', + done: 'Finished', + cancelled: 'Closed without being delivered', +}; + +export function StatusesPanel({ + visible, + onClose, + team, + isAdmin, +}: { + visible: boolean; + onClose: () => void; + team: TeamRead; + isAdmin: boolean; +}) { + const t = useTokens(); + const queryClient = useQueryClient(); + const statuses = useListStatusesTeamsTeamIdStatusesGet(team.id, { + query: { enabled: visible }, + }); + + const [name, setName] = useState(''); + const [category, setCategory] = useState(StatusCategory.unstarted); + const [editing, setEditing] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const items = statuses.data ?? []; + + async function run(action: () => Promise, fallback: string) { + setError(null); + setBusy(true); + try { + await action(); + await queryClient.invalidateQueries({ queryKey: [`/teams/${team.id}/statuses`] }); + // Columns are what the board draws, so it has to re-read them. + void queryClient.invalidateQueries({ queryKey: [`/teams/${team.id}/issues`] }); + } catch (err) { + setError(errorDetail(err, fallback)); + } finally { + setBusy(false); + } + } + + /** Every id at once: two people reordering would otherwise interleave. */ + function move(index: number, delta: number) { + const next = [...items]; + const target = index + delta; + if (target < 0 || target >= next.length) return; + [next[index], next[target]] = [next[target], next[index]]; + void run( + () => + reorderStatusesTeamsTeamIdStatusesOrderPut(team.id, { + status_ids: next.map((status) => status.id), + }).then(() => undefined), + 'Could not reorder the columns.', + ); + } + + function confirmDelete(status: StatusRead) { + const others = items.filter((candidate) => candidate.id !== status.id); + if (others.length === 0) { + setError('A team needs at least one status.'); + return; + } + + // Where the issues go is required rather than defaulted: guessing which + // column somebody's work lands in is not a decision to make for them. + RNAlert.alert( + `Delete ${status.name}?`, + 'Its issues have to go somewhere. Choose a column.', + [ + { text: 'Cancel', style: 'cancel' }, + ...others.slice(0, 4).map((destination) => ({ + text: `Move to ${destination.name}`, + onPress: () => + void run( + () => + deleteStatusStatusesStatusIdDelete(status.id, { + move_to_id: destination.id, + }).then(() => undefined), + 'Could not delete that status.', + ), + })), + ], + ); + } + + if (!visible) return null; + + return ( + + + {error ? {error} : null} + + {items.map((status, index) => ( + + + + + {status.name} + + {CATEGORY_HINT[status.category]} + + + {isAdmin ? ( + <> + move(index, -1)} + style={{ padding: 6, opacity: index === 0 ? 0.3 : 1 }} + > + ↑ + + move(index, 1)} + style={{ padding: 6, opacity: index === items.length - 1 ? 0.3 : 1 }} + > + ↓ + + setEditing(status)} + style={{ padding: 6 }} + > + + + + ) : null} + + ))} + + {isAdmin ? ( + + NEW STATUS + + + + {CATEGORY_ORDER.map((option) => { + const active = category === option; + return ( + setCategory(option)} + style={{ + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: t.radius.pill, + backgroundColor: active ? t.line.navActive : t.line.well, + }} + > + + {option} + + + ); + })} + + {CATEGORY_HINT[category]} + + + + ) : null} + + + {editing ? ( + setEditing(null)} + onRun={run} + onDelete={() => { + const target = editing; + setEditing(null); + confirmDelete(target); + }} + /> + ) : null} + + ); +} + +function EditStatusSheet({ + status, + onClose, + onRun, + onDelete, +}: { + status: StatusRead; + onClose: () => void; + onRun: (action: () => Promise, fallback: string) => Promise; + onDelete: () => void; +}) { + const t = useTokens(); + const [name, setName] = useState(''); + const [category, setCategory] = useState(status.category); + + return ( + + + + + + {CATEGORY_ORDER.map((option) => { + const active = category === option; + return ( + setCategory(option)} + style={{ + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: t.radius.pill, + backgroundColor: active ? t.line.navActive : t.line.well, + }} + > + + {option} + + + ); + })} + + + + + + + + ); +} diff --git a/src/team/admin/team-admin-sheet.tsx b/src/team/admin/team-admin-sheet.tsx new file mode 100644 index 0000000..27bcfeb --- /dev/null +++ b/src/team/admin/team-admin-sheet.tsx @@ -0,0 +1,379 @@ +import { useState } from 'react'; +import { Alert as RNAlert, Linking, Pressable, Share, View } from 'react-native'; +import * as Clipboard from 'expo-clipboard'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + deleteRepositoryRepositoriesRepositoryIdDelete, + rotateSecretRepositoriesRepositoryIdRotatePost, + useListRepositoriesTeamsTeamIdRepositoriesGet, +} from '@/api/generated/endpoints/integrations/integrations'; +import { updateTeamTeamsTeamIdPatch } from '@/api/generated/endpoints/teams/teams'; +import { useListTeamMembersTeamsTeamIdMembersGet } from '@/api/generated/endpoints/teams/teams'; +import type { TeamRead } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { getInstanceUrl } from '@/api/instance'; +import { useAuth } from '@/auth/auth-context'; +import { AutomationPanel } from '@/team/admin/automation-panel'; +import { MembersPanel } from '@/team/admin/members-panel'; +import { StatusesPanel } from '@/team/admin/statuses-panel'; +import { Alert, AppText, Button, Card, Field } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * Team administration, per `docs/design/mobile/153-team-admin.svg`. + * + * A menu of panels rather than one long form: five unrelated areas on one phone + * screen would be a scroll with no landmarks. + */ +type Panel = 'general' | 'members' | 'statuses' | 'automation' | 'repositories' | null; + +export function TeamAdminSheet({ + visible, + onClose, + team, +}: { + visible: boolean; + onClose: () => void; + team: TeamRead; +}) { + const t = useTokens(); + const { user } = useAuth(); + const [panel, setPanel] = useState(null); + + const members = useListTeamMembersTeamsTeamIdMembersGet(team.id, { + query: { enabled: visible }, + }); + // The server enforces every admin rule; this only decides what to offer, so + // nothing is presented that would be refused. + const isAdmin = + (members.data ?? []).find((member) => member.user.id === user?.id)?.role === 'admin'; + + if (!visible) return null; + + return ( + + + {( + [ + ['general', 'General'], + ['members', 'Members and invitations'], + ['statuses', 'Statuses'], + ['automation', 'Automation'], + ['repositories', 'Repositories'], + ] as const + ).map(([key, label]) => ( + setPanel(key)} + style={{ + paddingVertical: 14, + paddingHorizontal: 10, + borderRadius: t.radius.control, + }} + > + {label} + + ))} + + {!isAdmin ? ( + + You are a member of this team, so some settings are read-only. + + ) : null} + + {/* Importing from Jira is a long, file-driven flow that stays on the + web settings page, as the issue allows. */} + { + const base = getInstanceUrl(); + if (base) void Linking.openURL(`${base}/settings/teams/${team.key}/general`); + }} + style={{ paddingVertical: 14, paddingHorizontal: 10 }} + > + + Import from Jira (opens the web app) + + + + + setPanel(null)} + team={team} + isAdmin={isAdmin} + /> + setPanel(null)} + team={team} + user={user} + isAdmin={isAdmin} + /> + setPanel(null)} + team={team} + isAdmin={isAdmin} + /> + setPanel(null)} + team={team} + isAdmin={isAdmin} + /> + setPanel(null)} + team={team} + isAdmin={isAdmin} + /> + + ); +} + +function GeneralPanel({ + visible, + onClose, + team, + isAdmin, +}: { + visible: boolean; + onClose: () => void; + team: TeamRead; + isAdmin: boolean; +}) { + const queryClient = useQueryClient(); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + if (!visible) return null; + + return ( + + + {error ? {error} : null} + + + + + + {isAdmin ? ( + + ) : null} + + + ); +} + +function RepositoriesPanel({ + visible, + onClose, + team, + isAdmin, +}: { + visible: boolean; + onClose: () => void; + team: TeamRead; + isAdmin: boolean; +}) { + const t = useTokens(); + const queryClient = useQueryClient(); + const repositories = useListRepositoriesTeamsTeamIdRepositoriesGet(team.id, { + query: { enabled: visible }, + }); + const [error, setError] = useState(null); + const [revealed, setRevealed] = useState(null); + + async function run(action: () => Promise, fallback: string) { + setError(null); + try { + await action(); + await queryClient.invalidateQueries({ + queryKey: [`/teams/${team.id}/repositories`], + }); + } catch (err) { + setError(errorDetail(err, fallback)); + } + } + + if (!visible) return null; + const items = repositories.data ?? []; + + return ( + + + {error ? {error} : null} + + {items.map((repository) => ( + + {repository.full_name} + + {repository.provider} ·{' '} + {repository.last_delivery_at + ? `last delivery ${repository.last_delivery_at.slice(0, 10)}` + : 'nothing delivered yet'} + + + + void Clipboard.setStringAsync(repository.webhook_url)} + > + + Copy payload URL + + + + {/* A secret is not put on screen until it is asked for -- these + screens get shown to other people. */} + { + if (revealed === repository.id) { + void Clipboard.setStringAsync(repository.secret); + } else { + setRevealed(repository.id); + } + }} + > + + {revealed === repository.id ? 'Copy secret' : 'Reveal secret'} + + + + {isAdmin ? ( + + RNAlert.alert( + 'Rotate the secret?', + 'Deliveries stop until the new secret is set on the repository.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Rotate', + style: 'destructive', + onPress: () => + void run( + () => + rotateSecretRepositoriesRepositoryIdRotatePost( + repository.id, + ).then(() => undefined), + 'Could not rotate the secret.', + ), + }, + ], + ) + } + > + + Rotate secret + + + ) : null} + + {isAdmin ? ( + + RNAlert.alert( + `Disconnect ${repository.full_name}?`, + 'Branches and pull requests stop being linked to issues.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Disconnect', + style: 'destructive', + onPress: () => + void run( + () => + deleteRepositoryRepositoriesRepositoryIdDelete( + repository.id, + ).then(() => undefined), + 'Could not disconnect that repository.', + ), + }, + ], + ) + } + > + + Disconnect + + + ) : null} + + void Share.share({ message: repository.webhook_url })} + > + + Share URL + + + + + {revealed === repository.id ? ( + + {repository.secret} + + ) : null} + + ))} + + {items.length === 0 && !repositories.isPending ? ( + + No repositories connected. + + ) : null} + + + ); +} diff --git a/src/team/teams-home.tsx b/src/team/teams-home.tsx index 7ba43a3..1651a21 100644 --- a/src/team/teams-home.tsx +++ b/src/team/teams-home.tsx @@ -6,6 +6,7 @@ import { useQueryClient } from '@tanstack/react-query'; import type { TeamRead } from '@/api/generated/models'; import { InvitesBanner } from '@/team/invites-banner'; import { NewTeamSheet } from '@/team/new-team-sheet'; +import { TeamAdminSheet } from '@/team/admin/team-admin-sheet'; import { useTeams } from '@/team/team-context'; import { useMemberCounts } from '@/team/use-member-counts'; import { Icon } from '@/ui/icon'; @@ -28,11 +29,13 @@ function TeamRow({ memberCount, active, onPress, + onLongPress, }: { team: TeamRead; memberCount: number | undefined; active: boolean; onPress: () => void; + onLongPress: () => void; }) { const t = useTokens(); return ( @@ -41,6 +44,7 @@ function TeamRow({ accessibilityLabel={team.name} accessibilityState={active ? { selected: true } : {}} onPress={onPress} + onLongPress={onLongPress} > (null); const [refreshing, setRefreshing] = useState(false); const filtered = useMemo(() => { @@ -132,6 +137,7 @@ export function TeamsHome() { memberCount={counts[item.id]} active={item.key === teamKey} onPress={() => setTeamKey(item.key)} + onLongPress={() => setAdministering(item)} /> )} ListHeaderComponent={ @@ -189,6 +195,14 @@ export function TeamsHome() { /> )} + {administering ? ( + setAdministering(null)} + team={administering} + /> + ) : null} + setCreating(false)} /> ); From 3ef40914a17d6ecf488c07ab61b6c94814f125ab Mon Sep 17 00:00:00 2001 From: Waleed Khalid Date: Fri, 11 Sep 2026 22:44:40 +0500 Subject: [PATCH 17/18] Add the site admin user directory Closes #17. Only offered to an account that actually has site admin, so there is nothing to discover that the server would then refuse. The two guards -- you cannot deactivate or demote yourself, and the last active site admin is protected -- are mirrored in the sheet, not to enforce them but so the action is explained before it is attempted rather than merely refused after. The last-admin count is a hint from one page; the server has the real answer and is still the one that decides. Deactivated accounts stay in the list, dimmed, because reactivating them is the whole reason they are listed. Setting a password exists because nothing here sends email, so the hint says to pass it on out of band rather than implying a reset link is on its way. Verified against a live instance, including that a deactivated account is refused at sign-in with its own 403 rather than "incorrect password", and that both self-guards hold. Co-Authored-By: Claude Opus 5 --- src/admin/site-admin-sheet.tsx | 256 ++++++++++++++++++++++ src/api/__tests__/live-site-admin.test.ts | 118 ++++++++++ src/settings/you-screen.tsx | 19 +- 3 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 src/admin/site-admin-sheet.tsx create mode 100644 src/api/__tests__/live-site-admin.test.ts diff --git a/src/admin/site-admin-sheet.tsx b/src/admin/site-admin-sheet.tsx new file mode 100644 index 0000000..e9e8096 --- /dev/null +++ b/src/admin/site-admin-sheet.tsx @@ -0,0 +1,256 @@ +import { useState } from 'react'; +import { Alert as RNAlert, Pressable, View } from 'react-native'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + resetPasswordAdminUsersUserIdResetPasswordPost, + updateUserAdminUsersUserIdPatch, + useListUsersAdminUsersGet, +} from '@/api/generated/endpoints/admin/admin'; +import type { AdminUserRead, UserMe } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { AppText, Alert, Avatar, Button, Card, Field } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useDebouncedValue } from '@/ui/use-debounced-value'; +import { useTokens } from '@/ui/theme'; + +/** + * The user directory, per `docs/design/mobile/154-site-admin.svg`. + * + * Site admins only; the server refuses everyone else, and the entry point is + * hidden for them so there is nothing to discover that would be denied. + * + * Every guard is the server's -- you cannot deactivate or demote yourself, and + * the last active site admin is protected. Those two are checked here as well, + * not to enforce them but so the action is explained rather than merely refused. + */ +const PAGE = 25; + +export function SiteAdminSheet({ + visible, + onClose, + user, +}: { + visible: boolean; + onClose: () => void; + user: UserMe | null; +}) { + const t = useTokens(); + const queryClient = useQueryClient(); + + const [query, setQuery] = useState(''); + const [limit, setLimit] = useState(PAGE); + const [error, setError] = useState(null); + const [resetting, setResetting] = useState(null); + + const settled = useDebouncedValue(query.trim(), 300); + const users = useListUsersAdminUsersGet( + { q: settled || undefined, limit, offset: 0 }, + { query: { enabled: visible } }, + ); + + const items = users.data?.items ?? []; + const total = users.data?.total ?? 0; + const activeAdmins = items.filter((row) => row.is_site_admin && row.is_active).length; + + async function run(action: () => Promise, fallback: string) { + setError(null); + try { + await action(); + await queryClient.invalidateQueries({ queryKey: ['/admin/users'] }); + } catch (err) { + setError(errorDetail(err, fallback)); + } + } + + function manage(row: AdminUserRead) { + const isSelf = row.id === user?.id; + // The page shows one page at a time, so this count is only a hint -- the + // server has the real answer and will refuse regardless. + const lastAdmin = row.is_site_admin && row.is_active && activeAdmins <= 1; + + const options: Parameters[2] = [ + { text: 'Cancel', style: 'cancel' }, + ]; + + if (!isSelf && !lastAdmin) { + options.push({ + text: row.is_site_admin ? 'Revoke site admin' : 'Grant site admin', + onPress: () => + void run( + () => + updateUserAdminUsersUserIdPatch(row.id, { + is_site_admin: !row.is_site_admin, + }).then(() => undefined), + 'Could not change that role.', + ), + }); + } + + options.push({ + text: 'Set a password', + onPress: () => setResetting(row), + }); + + if (!isSelf && !lastAdmin) { + options.push({ + text: row.is_active ? 'Deactivate' : 'Reactivate', + style: row.is_active ? 'destructive' : 'default', + onPress: () => + void run( + () => + updateUserAdminUsersUserIdPatch(row.id, { + is_active: !row.is_active, + }).then(() => undefined), + 'Could not change that account.', + ), + }); + } + + RNAlert.alert( + row.full_name, + isSelf + ? 'You cannot deactivate or demote your own account.' + : lastAdmin + ? 'This is the last active site admin — promote somebody else first.' + : row.email, + options, + ); + } + + if (!visible) return null; + + return ( + + + {error ? {error} : null} + + + + {items.map((row) => ( + manage(row)} + > + + + + + {row.full_name} + {row.id === user?.id ? ' (you)' : ''} + + + {row.email} · {row.team_count} team{row.team_count === 1 ? '' : 's'} + + + + + {row.is_site_admin ? ( + + Site admin + + ) : null} + {!row.is_active ? ( + + Deactivated + + ) : null} + + + + ))} + + {items.length === 0 && !users.isPending ? ( + No account matches that. + ) : null} + + {total > items.length ? ( + + ) : null} + + + {resetting ? ( + setResetting(null)} + onRun={run} + /> + ) : null} + + ); +} + +/** + * Setting a password for somebody locked out. + * + * The admin types it and passes it on out of band -- there is no email here to + * send a reset link through, which is exactly why this exists. + */ +function SetPasswordSheet({ + row, + onClose, + onRun, +}: { + row: AdminUserRead; + onClose: () => void; + onRun: (action: () => Promise, fallback: string) => Promise; +}) { + const [password, setPassword] = useState(''); + const [invalid, setInvalid] = useState(null); + + return ( + + + {invalid ? {invalid} : null} + + + + + + + ); +} diff --git a/src/api/__tests__/live-site-admin.test.ts b/src/api/__tests__/live-site-admin.test.ts new file mode 100644 index 0000000..e91e4a1 --- /dev/null +++ b/src/api/__tests__/live-site-admin.test.ts @@ -0,0 +1,118 @@ +/** + * @jest-environment node + */ +import { + listUsersAdminUsersGet, + resetPasswordAdminUsersUserIdResetPasswordPost, + updateUserAdminUsersUserIdPatch, +} from '@/api/generated/endpoints/admin/admin'; +import { + loginAuthLoginPost, + meAuthMeGet, + registerAuthRegisterPost, +} from '@/api/generated/endpoints/auth/auth'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken } from '@/auth/session'; + +/** + * The user directory against a real instance. + * + * The demo account is a site admin, which is what makes this testable at all. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); +const SUBJECT_EMAIL = `mob-subject-${RUN}@example.com`; + +describeLive('site administration against a live instance', () => { + let adminToken: string; + let adminId: number; + let subjectId: number; + + beforeAll(async () => { + await setInstanceUrl(LIVE_URL!); + const admin = await loginAuthLoginPost({ + username: process.env.SOFTTRACK_LIVE_EMAIL ?? 'demo@softtrack.dev', + password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123', + }); + adminToken = admin.access_token; + adminId = admin.user.id; + expect(admin.user.is_site_admin).toBe(true); + + const subject = await registerAuthRegisterPost({ + email: SUBJECT_EMAIL, + password: 'password123', + full_name: 'Directory Subject', + }); + subjectId = subject.user.id; + await persistToken(adminToken); + }); + + it('searches the directory', async () => { + const page = await listUsersAdminUsersGet({ q: `mob-subject-${RUN}`, limit: 25 }); + const found = page.items.find((row) => row.id === subjectId); + expect(found).toBeDefined(); + // Every field the row renders. + expect(found?.email).toBe(SUBJECT_EMAIL); + expect(found?.is_active).toBe(true); + expect(found?.is_site_admin).toBe(false); + expect(typeof found?.team_count).toBe('number'); + }); + + it('grants and revokes site admin', async () => { + const granted = await updateUserAdminUsersUserIdPatch(subjectId, { + is_site_admin: true, + }); + expect(granted.is_site_admin).toBe(true); + + const revoked = await updateUserAdminUsersUserIdPatch(subjectId, { + is_site_admin: false, + }); + expect(revoked.is_site_admin).toBe(false); + }); + + it('deactivates and reactivates, and a deactivated account cannot sign in', async () => { + await updateUserAdminUsersUserIdPatch(subjectId, { is_active: false }); + + // 403 with its own message, which is what login surfaces rather than + // "incorrect password". + await expect( + loginAuthLoginPost({ username: SUBJECT_EMAIL, password: 'password123' }), + ).rejects.toMatchObject({ response: { status: 403 } }); + + const reactivated = await updateUserAdminUsersUserIdPatch(subjectId, { + is_active: true, + }); + expect(reactivated.is_active).toBe(true); + }); + + it('sets a password for somebody locked out', async () => { + // There is no email here to send a reset link through, which is exactly why + // this exists. + await resetPasswordAdminUsersUserIdResetPasswordPost(subjectId, { + new_password: 'brand-new-password', + }); + + const signedIn = await loginAuthLoginPost({ + username: SUBJECT_EMAIL, + password: 'brand-new-password', + }); + expect(signedIn.user.id).toBe(subjectId); + await persistToken(adminToken); + }); + + it('refuses to let an admin deactivate or demote themselves', async () => { + // Both guards the sheet explains up front rather than discovering. + await expect( + updateUserAdminUsersUserIdPatch(adminId, { is_active: false }), + ).rejects.toMatchObject({ response: { status: 400 } }); + + await expect( + updateUserAdminUsersUserIdPatch(adminId, { is_site_admin: false }), + ).rejects.toMatchObject({ response: { status: 400 } }); + + // Still a working admin afterwards. + expect((await meAuthMeGet()).is_site_admin).toBe(true); + }); +}); diff --git a/src/settings/you-screen.tsx b/src/settings/you-screen.tsx index e0f5768..11cd09d 100644 --- a/src/settings/you-screen.tsx +++ b/src/settings/you-screen.tsx @@ -15,6 +15,7 @@ import { import { errorDetail } from '@/api/errors'; import { instanceLabel, setInstanceUrl } from '@/api/instance'; import { useAuth } from '@/auth/auth-context'; +import { SiteAdminSheet } from '@/admin/site-admin-sheet'; import { isValidUsername } from '@/settings/username'; import { useIsMultiPane } from '@/ui/layout'; import { AppText, Alert, Avatar, Button, Card, Field } from '@/ui/primitives'; @@ -39,7 +40,7 @@ const AVATAR_COLORS = [ '#22c55e', ]; -type Panel = 'profile' | 'security' | 'notifications' | 'instance' | null; +type Panel = 'profile' | 'security' | 'notifications' | 'instance' | 'admin' | null; export function YouScreen() { const t = useTokens(); @@ -78,6 +79,17 @@ export function YouScreen() { + {/* Only for an account that actually has it; there is nothing to + discover here that the server would then refuse. */} + {user?.is_site_admin ? ( + + ADMINISTRATION + + setPanel('admin')} /> + + + ) : null} + APPEARANCE @@ -113,6 +125,11 @@ export function YouScreen() { visible={panel === 'notifications'} onClose={() => setPanel(null)} /> + setPanel(null)} + user={user} + /> setPanel(null)} From 0e92562d23de887ecc98af1e7c0a0ef69a5bc572 Mon Sep 17 00:00:00 2001 From: Waleed Khalid Date: Fri, 11 Sep 2026 22:49:19 +0500 Subject: [PATCH 18/18] Add offline reading, a mutation queue and sync status Closes #18. React Query already knows how to pause a mutation and replay it, so the work is telling it the truth about a phone. Its online default listens for a browser event that does not exist here, so NetInfo drives onlineManager instead -- without that, a phone in a tunnel fires requests that time out one by one rather than queueing anything. It watches isInternetReachable rather than isConnected, because a captive portal is "connected" and is exactly the case that hangs. Queued mutations are registered by key rather than passed inline. A persisted mutation is only data, and setMutationDefaults is what turns it back into something runnable after the app has been killed -- which is the difference between a queue and a list of regrets. A queued move captures the issue's updated_at when it is made, and refuses on replay if somebody else moved the card meanwhile. Applying it blindly would undo their work. Moving it to where it was already going is not a conflict, so that case still applies. Conflicts are surfaced, not resolved: the server's version and the queued one are both somebody's intent, and choosing between them is not a decision to make on their behalf. The banner stays up while queued work is still going out after a connection returns. "Back online" with three unsent changes is not the same as up to date. Only three mutations queue -- moving a card, commenting, creating an issue. They are the ones worth doing without a connection; everything else asks for one. Co-Authored-By: Claude Opus 5 --- README.md | 15 +- package-lock.json | 57 ++++++ package.json | 3 + src/api/__tests__/live-notifications.test.ts | 6 +- src/api/query-client.ts | 4 + src/app/_layout.tsx | 22 +- src/board/board-screen.tsx | 6 + src/board/use-status-change.ts | 31 ++- src/offline/__tests__/queue.test.ts | 90 ++++++++ src/offline/connectivity.ts | 32 +++ src/offline/persistence.ts | 19 ++ src/offline/queue.ts | 126 ++++++++++++ src/offline/sync-status.tsx | 203 +++++++++++++++++++ src/team/admin/automation-panel.tsx | 2 +- src/team/admin/team-admin-sheet.tsx | 6 +- 15 files changed, 602 insertions(+), 20 deletions(-) create mode 100644 src/offline/__tests__/queue.test.ts create mode 100644 src/offline/connectivity.ts create mode 100644 src/offline/persistence.ts create mode 100644 src/offline/queue.ts create mode 100644 src/offline/sync-status.tsx diff --git a/README.md b/README.md index 79c6561..5994ddf 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ src/auth/ session store, auth context, deep-link capture src/team/ team context, switcher, creation, invitations src/board/ board and list views, filters, drag-to-move src/issues/ priority and status metadata +src/offline/ connectivity, cache persistence, the mutation queue src/ui/ design tokens, theme, primitives, navigation chrome openapi/ the vendored API contract codegen reads ``` @@ -97,11 +98,12 @@ The mockups reuse the web app's design tokens (brand `#6342db`, tinted neutrals, ## Status -Onboarding works end to end: sign in to any instance, register, accept an -invitation, create and switch teams, and work a team's board -- columns from the -team's own statuses, six server-side filters held in the URL, board and list -views, and drag or tap to move a card. Search and Inbox are placeholders -pointing at their issues. +Every tracked issue is implemented except push notifications, which have no +backend to talk to. Sign in to any instance, register, accept invitations, +manage teams, work a board, create and edit issues with sub-issues and links, +comment in GitHub-flavored markdown, attach photos and files, search, run cycles, +read the four reports, save views, administer a team or the whole instance, and +keep working with no connection. Known gaps, tracked rather than hidden: @@ -125,6 +127,9 @@ Known gaps, tracked rather than hidden: - **Member counts cost one request per team.** `TeamRead` carries no count, so the teams list asks each team for its members. A `member_count` field upstream would remove the fan-out and help the web too. +- **Offline is verified as logic, not as behaviour.** The queue, the conflict + rule and the cache are covered by tests, but nobody has watched a phone lose + signal, move a card and come back. That needs a device. - **No aurora or glass blur yet.** The web's translucent panels are approximated with opaque surfaces; `backdrop-filter` has no React Native equivalent and per-surface blur is expensive on Android. diff --git a/package-lock.json b/package-lock.json index 76ba300..4860f5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,10 @@ "version": "0.1.0", "dependencies": { "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-community/netinfo": "12.0.1", + "@tanstack/query-async-storage-persister": "^5.102.8", "@tanstack/react-query": "^5.102.8", + "@tanstack/react-query-persist-client": "^5.102.8", "axios": "^1.20.0", "expo": "~57.0.22", "expo-blur": "~57.0.3", @@ -3657,6 +3660,16 @@ "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, + "node_modules/@react-native-community/netinfo": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-12.0.1.tgz", + "integrity": "sha512-P/3caXIvfYSJG8AWJVefukg+ZGRPs+M4Lp3pNJtgcTYoJxCjWrKQGNnCkj/Cz//zWa/avGed0i/wzm0T8vV2IQ==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": ">=0.59" + } + }, "node_modules/@react-native-masked-view/masked-view": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz", @@ -4218,6 +4231,20 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@tanstack/query-async-storage-persister": { + "version": "5.102.8", + "resolved": "https://registry.npmjs.org/@tanstack/query-async-storage-persister/-/query-async-storage-persister-5.102.8.tgz", + "integrity": "sha512-9smqFJWyhRo+0l3NqaGFo6CQVr+3jpKOVZBQXI/LL+GoTIGhhxq1wTZegeI63dy0oResZx/EyazGzDjW8harbg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.102.8", + "@tanstack/query-persist-client-core": "5.102.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/query-core": { "version": "5.102.8", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.8.tgz", @@ -4228,6 +4255,19 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/query-persist-client-core": { + "version": "5.102.8", + "resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.102.8.tgz", + "integrity": "sha512-t3v4/D6ejo/BrPzM5gm/AT3c4CWPCxFa+0cIjAeHAeanGPG6rCjFS1hZnPZH/rf55qe3qYYgx5U6iBZMY1PQjA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.102.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/react-query": { "version": "5.102.8", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.8.tgz", @@ -4244,6 +4284,23 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-query-persist-client": { + "version": "5.102.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-5.102.8.tgz", + "integrity": "sha512-WOZReC+m9klekPtG7eeZ3txJeMA9lg2R0RytSG3yXcZgJItQAVIyoobJdbdIQtlpgcU2tSzsOEBtJacs2vlCOA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-persist-client-core": "5.102.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.102.8", + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/react-native": { "version": "14.0.1", "resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-14.0.1.tgz", diff --git a/package.json b/package.json index 8e335d1..df5300e 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,10 @@ }, "dependencies": { "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-community/netinfo": "12.0.1", + "@tanstack/query-async-storage-persister": "^5.102.8", "@tanstack/react-query": "^5.102.8", + "@tanstack/react-query-persist-client": "^5.102.8", "axios": "^1.20.0", "expo": "~57.0.22", "expo-blur": "~57.0.3", diff --git a/src/api/__tests__/live-notifications.test.ts b/src/api/__tests__/live-notifications.test.ts index d5ad3d7..f3d1ef2 100644 --- a/src/api/__tests__/live-notifications.test.ts +++ b/src/api/__tests__/live-notifications.test.ts @@ -17,8 +17,10 @@ import { unreadCountNotificationsUnreadCountGet, updateNotificationNotificationsNotificationIdPatch, } from '@/api/generated/endpoints/notifications/notifications'; -import { createInviteTeamsTeamIdInvitesPost } from '@/api/generated/endpoints/invites/invites'; -import { acceptInviteInvitesTokenAcceptPost } from '@/api/generated/endpoints/invites/invites'; +import { + acceptInviteInvitesTokenAcceptPost, + createInviteTeamsTeamIdInvitesPost, +} from '@/api/generated/endpoints/invites/invites'; import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; import type { TeamRead } from '@/api/generated/models'; import { setInstanceUrl } from '@/api/instance'; diff --git a/src/api/query-client.ts b/src/api/query-client.ts index 5b40782..297d4e0 100644 --- a/src/api/query-client.ts +++ b/src/api/query-client.ts @@ -13,6 +13,10 @@ export const queryClient = new QueryClient({ queries: { retry: 1, refetchOnWindowFocus: false, + // Long enough that a persisted cache is still served after a restart -- + // without it everything would be discarded as stale on the way in, which + // is exactly the case offline reading exists for. + gcTime: 7 * 24 * 60 * 60 * 1000, }, }, }); diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 118f657..28018fd 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -6,10 +6,13 @@ import { Stack } from 'expo-router'; import * as SplashScreen from 'expo-splash-screen'; import * as SystemUI from 'expo-system-ui'; import { StatusBar } from 'expo-status-bar'; -import { QueryClientProvider } from '@tanstack/react-query'; +import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; import { hydrateInstanceUrl } from '@/api/instance'; import { queryClient } from '@/api/query-client'; +import { startConnectivityWatch } from '@/offline/connectivity'; +import { MAX_AGE_MS, persister } from '@/offline/persistence'; +import { registerQueuedMutations } from '@/offline/queue'; import { AuthProvider, useAuth } from '@/auth/auth-context'; import { hydrateSession } from '@/auth/session'; import { useDeepLinkCapture } from '@/auth/use-deep-link-capture'; @@ -149,6 +152,13 @@ function Gate() { export default function RootLayout() { const [hydrated, setHydrated] = useState(null); + useEffect(() => { + // Mutation functions have to be registered by key before a persisted queue + // is restored, or a paused mutation comes back as data with nothing to run. + registerQueuedMutations(queryClient); + return startConnectivityWatch(); + }, []); + useEffect(() => { // One pass over persisted state before anything renders: the stored theme, // the instance URL, and the token -- each into its module-level mirror so @@ -173,13 +183,19 @@ export default function RootLayout() { - + void queryClient.resumePausedMutations()} + > - + diff --git a/src/board/board-screen.tsx b/src/board/board-screen.tsx index deccf74..08fbe15 100644 --- a/src/board/board-screen.tsx +++ b/src/board/board-screen.tsx @@ -13,6 +13,7 @@ import { KanbanBoard } from '@/board/kanban-board'; import { useStatusChange } from '@/board/use-status-change'; import { CycleBanner, CyclesSheet } from '@/cycles/cycles-screen'; import { ReportsSheet } from '@/reports/reports-sheet'; +import { OfflineBanner, SyncQueueSheet } from '@/offline/sync-status'; import { useListViewsTeamsTeamIdViewsGet } from '@/api/generated/endpoints/views/views'; import { useAuth } from '@/auth/auth-context'; import { fromViewFilters } from '@/views/saved-views'; @@ -58,6 +59,7 @@ export function BoardScreen() { const [viewsOpen, setViewsOpen] = useState(false); const [cyclesOpen, setCyclesOpen] = useState(false); const [reportsOpen, setReportsOpen] = useState(false); + const [syncOpen, setSyncOpen] = useState(false); const [switching, setSwitching] = useState(false); const [creating, setCreating] = useState(false); const [moving, setMoving] = useState(null); @@ -213,6 +215,8 @@ export function BoardScreen() { + setSyncOpen(true)} /> + setCyclesOpen(true)} /> setFilterOpen(true)} /> @@ -235,6 +239,8 @@ export function BoardScreen() { /> )} + setSyncOpen(false)} /> + setReportsOpen(false)} diff --git a/src/board/use-status-change.ts b/src/board/use-status-change.ts index e7a02db..ca78b4f 100644 --- a/src/board/use-status-change.ts +++ b/src/board/use-status-change.ts @@ -1,10 +1,8 @@ import { useCallback } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { - getListIssuesTeamsTeamIdIssuesGetQueryKey, - updateIssueIssuesIssueIdPatch, -} from '@/api/generated/endpoints/issues/issues'; +import { getListIssuesTeamsTeamIdIssuesGetQueryKey } from '@/api/generated/endpoints/issues/issues'; +import { QUEUED, type MoveIssueVariables } from '@/offline/queue'; import type { ListIssuesTeamsTeamIdIssuesGetParams, PageIssueRead, @@ -22,12 +20,20 @@ import type { * * `params` has to match what the board is showing, because it is part of the * query key being patched. + * + * The write itself goes through a keyed mutation rather than a bare call, so + * React Query pauses it when there is no network and replays it on reconnect -- + * a card moved on a train stays moved, and reaches the server when the train + * comes out of the tunnel. */ export function useStatusChange( team: TeamRead | undefined, params: ListIssuesTeamsTeamIdIssuesGetParams, ) { const queryClient = useQueryClient(); + const move = useMutation({ + mutationKey: QUEUED.moveIssue, + }); return useCallback( async (issueId: number, status: StatusRead): Promise => { @@ -47,8 +53,17 @@ export function useStatusChange( : old, ); + const before = previous?.items.find((item) => item.id === issueId); + try { - await updateIssueIssuesIssueIdPatch(issueId, { status_id: status.id }); + await move.mutateAsync({ + issueId, + status, + // Captured now so a replay can tell "nothing moved" from "somebody + // else moved it while we were away". + seenAt: before?.updated_at ?? '', + identifier: before?.identifier ?? `#${issueId}`, + }); // Moving a card moves its points between columns, and may close out a // cycle's remaining work. void queryClient.invalidateQueries({ queryKey: [`/teams/${team.id}/estimates`] }); @@ -56,10 +71,12 @@ export function useStatusChange( void queryClient.invalidateQueries({ queryKey: [`/issues/${issueId}`] }); return true; } catch { + // A paused mutation does not reject, so reaching here means it was + // genuinely refused -- put the card back where it was. queryClient.setQueryData(queryKey, previous); return false; } }, - [queryClient, team, params], + [queryClient, team, params, move], ); } diff --git a/src/offline/__tests__/queue.test.ts b/src/offline/__tests__/queue.test.ts new file mode 100644 index 0000000..f84d80f --- /dev/null +++ b/src/offline/__tests__/queue.test.ts @@ -0,0 +1,90 @@ +import { QueryClient } from '@tanstack/react-query'; + +import { describeQueued, MoveConflict, QUEUED, registerQueuedMutations } from '@/offline/queue'; + +jest.mock('@/api/generated/endpoints/issues/issues', () => ({ + getIssueIssuesIssueIdGet: jest.fn(), + updateIssueIssuesIssueIdPatch: jest.fn(), + createIssueTeamsTeamIdIssuesPost: jest.fn(), +})); +jest.mock('@/api/generated/endpoints/comments/comments', () => ({ + createCommentIssuesIssueIdCommentsPost: jest.fn(), +})); + +const issues = jest.requireMock('@/api/generated/endpoints/issues/issues'); + +const TODO = { id: 1, name: 'Todo', category: 'unstarted', color: '#000', team_id: 1, position: 1 }; +const DONE = { id: 2, name: 'Done', category: 'done', color: '#0f0', team_id: 1, position: 2 }; + +/** Run the registered move mutation the way a resumed queue would. */ +async function runMove(variables: Record) { + const client = new QueryClient(); + registerQueuedMutations(client); + const defaults = client.getMutationDefaults(QUEUED.moveIssue); + return (defaults.mutationFn as (v: unknown) => Promise)(variables); +} + +beforeEach(() => jest.clearAllMocks()); + +describe('the queued move', () => { + it('applies when nothing changed while offline', async () => { + issues.getIssueIssuesIssueIdGet.mockResolvedValue({ + updated_at: 'T1', + status: TODO, + }); + issues.updateIssueIssuesIssueIdPatch.mockResolvedValue({ status: DONE }); + + await runMove({ issueId: 7, status: DONE, seenAt: 'T1', identifier: 'ENG-7' }); + + expect(issues.updateIssueIssuesIssueIdPatch).toHaveBeenCalledWith(7, { status_id: 2 }); + }); + + it('refuses when somebody else moved it in the meantime', async () => { + // Replaying blindly here would undo their work, which is the whole reason + // the timestamp is captured when the move is queued. + issues.getIssueIssuesIssueIdGet.mockResolvedValue({ + updated_at: 'T2', + status: { ...TODO, id: 3, name: 'In Review' }, + }); + + await expect( + runMove({ issueId: 7, status: DONE, seenAt: 'T1', identifier: 'ENG-7' }), + ).rejects.toBeInstanceOf(MoveConflict); + expect(issues.updateIssueIssuesIssueIdPatch).not.toHaveBeenCalled(); + }); + + it('is not a conflict when somebody made the same move', async () => { + // The issue changed, but it is already where this move wanted it -- there + // is nothing to argue about. + issues.getIssueIssuesIssueIdGet.mockResolvedValue({ updated_at: 'T2', status: DONE }); + issues.updateIssueIssuesIssueIdPatch.mockResolvedValue({ status: DONE }); + + await runMove({ issueId: 7, status: DONE, seenAt: 'T1', identifier: 'ENG-7' }); + expect(issues.updateIssueIssuesIssueIdPatch).toHaveBeenCalled(); + }); + + it('names both sides in the conflict message', async () => { + const conflict = new MoveConflict('ENG-7', 'Done', 'In Review'); + expect(conflict.message).toContain('ENG-7'); + expect(conflict.message).toContain('In Review'); + expect(conflict.message).toContain('Done'); + }); +}); + +describe('describeQueued', () => { + it('says what each waiting change will do', () => { + expect( + describeQueued(QUEUED.moveIssue, { identifier: 'ENG-7', status: DONE }), + ).toBe('Move ENG-7 to Done'); + expect(describeQueued(QUEUED.addComment, { identifier: 'ENG-7' })).toBe( + 'Comment on ENG-7', + ); + expect(describeQueued(QUEUED.createIssue, { title: 'Fix it' })).toBe( + 'New issue “Fix it”', + ); + }); + + it('falls back rather than rendering a key', () => { + expect(describeQueued(['something', 'else'], {})).toBe('Pending change'); + }); +}); diff --git a/src/offline/connectivity.ts b/src/offline/connectivity.ts new file mode 100644 index 0000000..8dc8cb9 --- /dev/null +++ b/src/offline/connectivity.ts @@ -0,0 +1,32 @@ +import NetInfo from '@react-native-community/netinfo'; +import { onlineManager, focusManager } from '@tanstack/react-query'; +import { AppState, type AppStateStatus } from 'react-native'; + +/** + * Telling React Query whether there is a network. + * + * Its browser default listens for `window.online`, which does not exist here -- + * without this, a phone in a tunnel keeps firing requests that time out one by + * one instead of pausing them, and nothing is ever queued. + * + * `isInternetReachable` rather than `isConnected`: a captive portal or a Wi-Fi + * network with no route out is still "connected", and is exactly the case where + * a request will hang rather than fail fast. + */ +export function startConnectivityWatch(): () => void { + const unsubscribeNet = NetInfo.addEventListener((state) => { + const reachable = state.isInternetReachable ?? state.isConnected ?? false; + onlineManager.setOnline(Boolean(reachable)); + }); + + // React Query's focus default is also browser-shaped; on a phone, coming back + // to the app is the equivalent event. + const subscription = AppState.addEventListener('change', (status: AppStateStatus) => { + focusManager.setFocused(status === 'active'); + }); + + return () => { + unsubscribeNet(); + subscription.remove(); + }; +} diff --git a/src/offline/persistence.ts b/src/offline/persistence.ts new file mode 100644 index 0000000..fd1569e --- /dev/null +++ b/src/offline/persistence.ts @@ -0,0 +1,19 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'; + +/** + * The cache that survives the app being closed. + * + * What this buys is offline *reading*: boards, issues and comments that were + * loaded recently are still there with no network. A week is long enough that a + * phone opened on Monday still shows Friday's board, and short enough that + * nothing ancient is presented as current -- the banner says what it is. + */ +export const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; + +export const persister = createAsyncStoragePersister({ + storage: AsyncStorage, + key: 'softtrack.cache', + // Writes are debounced so a fast-scrolling board does not thrash storage. + throttleTime: 1000, +}); diff --git a/src/offline/queue.ts b/src/offline/queue.ts new file mode 100644 index 0000000..d3be5c9 --- /dev/null +++ b/src/offline/queue.ts @@ -0,0 +1,126 @@ +import type { QueryClient } from '@tanstack/react-query'; + +import { createCommentIssuesIssueIdCommentsPost } from '@/api/generated/endpoints/comments/comments'; +import { + createIssueTeamsTeamIdIssuesPost, + getIssueIssuesIssueIdGet, + updateIssueIssuesIssueIdPatch, +} from '@/api/generated/endpoints/issues/issues'; +import type { IssueRead, StatusRead } from '@/api/generated/models'; + +/** + * The mutations that survive going offline. + * + * React Query pauses a mutation while `onlineManager` says there is no network + * and replays it on reconnect. For that to survive the app being killed, the + * mutation function has to be registered by key rather than passed inline -- + * a persisted mutation is only data, and `setMutationDefaults` is what turns it + * back into something runnable. + * + * Only these three are queued. They are the ones the issue names, and the ones + * where doing the work offline is genuinely useful: moving a card, saying + * something, and capturing a thought. Everything else asks for a connection. + */ +export const QUEUED = { + moveIssue: ['offline', 'moveIssue'] as const, + addComment: ['offline', 'addComment'] as const, + createIssue: ['offline', 'createIssue'] as const, +}; + +export type MoveIssueVariables = { + issueId: number; + status: StatusRead; + /** The issue's updated_at when the move was queued, for conflict detection. */ + seenAt: string; + identifier: string; +}; + +export type AddCommentVariables = { + issueId: number; + body: string; + identifier: string; +}; + +export type CreateIssueVariables = { + teamId: number; + title: string; + description?: string; +}; + +/** + * A queued move whose issue changed while we were away. + * + * Thrown rather than silently applied: somebody else moving the card to Done + * while this phone was in a tunnel is exactly the case where replaying blindly + * would undo their work. + */ +export class MoveConflict extends Error { + constructor( + readonly identifier: string, + readonly intended: string, + readonly actual: string, + ) { + super( + `${identifier} moved to ${actual} while you were offline, so it was not moved to ${intended}.`, + ); + this.name = 'MoveConflict'; + } +} + +export function registerQueuedMutations(queryClient: QueryClient): void { + queryClient.setMutationDefaults(QUEUED.moveIssue, { + mutationFn: async (variables: MoveIssueVariables) => { + const current = await getIssueIssuesIssueIdGet(variables.issueId); + + // Unchanged since it was queued: the move is still the one intended. + if (current.updated_at !== variables.seenAt && current.status.id !== variables.status.id) { + throw new MoveConflict( + variables.identifier, + variables.status.name, + current.status.name, + ); + } + + return updateIssueIssuesIssueIdPatch(variables.issueId, { + status_id: variables.status.id, + }); + }, + }); + + queryClient.setMutationDefaults(QUEUED.addComment, { + mutationFn: (variables: AddCommentVariables) => + createCommentIssuesIssueIdCommentsPost(variables.issueId, { body: variables.body }), + }); + + queryClient.setMutationDefaults(QUEUED.createIssue, { + mutationFn: (variables: CreateIssueVariables) => + createIssueTeamsTeamIdIssuesPost(variables.teamId, { + title: variables.title, + description: variables.description, + }), + }); +} + +/** What a queued mutation is called in the sync panel. */ +export function describeQueued(mutationKey: unknown, variables: unknown): string { + const key = Array.isArray(mutationKey) ? mutationKey.join('/') : ''; + + if (key === QUEUED.moveIssue.join('/')) { + const move = variables as MoveIssueVariables; + return `Move ${move.identifier} to ${move.status.name}`; + } + if (key === QUEUED.addComment.join('/')) { + const comment = variables as AddCommentVariables; + return `Comment on ${comment.identifier}`; + } + if (key === QUEUED.createIssue.join('/')) { + const issue = variables as CreateIssueVariables; + return `New issue “${issue.title}”`; + } + return 'Pending change'; +} + +/** The issue as it should look while a queued move waits. */ +export function withQueuedStatus(issue: IssueRead, status: StatusRead): IssueRead { + return { ...issue, status }; +} diff --git a/src/offline/sync-status.tsx b/src/offline/sync-status.tsx new file mode 100644 index 0000000..43822df --- /dev/null +++ b/src/offline/sync-status.tsx @@ -0,0 +1,203 @@ +import { useEffect, useState } from 'react'; +import { Pressable, View } from 'react-native'; +import { + onlineManager, + useIsMutating, + useMutationState, + useQueryClient, +} from '@tanstack/react-query'; + +import { describeQueued, MoveConflict } from '@/offline/queue'; +import { AppText, Button, Card } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** Whether there is a network, as React Query currently understands it. */ +export function useIsOnline(): boolean { + const [online, setOnline] = useState(() => onlineManager.isOnline()); + useEffect(() => onlineManager.subscribe(setOnline), []); + return online; +} + +/** + * The offline banner, per `docs/design/mobile/155-offline.svg`. + * + * Shown while there is no network, and also while queued work is still going + * out after one returns -- "back online" with three unsent changes is not the + * same as being up to date, and saying so is the difference between trusting the + * app and refreshing it nervously. + */ +export function OfflineBanner({ onOpenQueue }: { onOpenQueue: () => void }) { + const t = useTokens(); + const online = useIsOnline(); + const pending = useMutationState({ + filters: { status: 'pending' }, + }).length; + const paused = useMutationState({ + filters: { predicate: (mutation) => mutation.state.isPaused }, + }).length; + + const queued = paused + pending; + if (online && queued === 0) return null; + + const tone = online ? t.status.progress : t.priority.medium; + + return ( + + + + {online + ? `Syncing ${queued} change${queued === 1 ? '' : 's'}…` + : queued > 0 + ? `Offline · ${queued} change${queued === 1 ? '' : 's'} waiting` + : 'Offline · showing what was last loaded'} + + {queued > 0 ? ( + + View + + ) : null} + + ); +} + +/** + * The queue, and anything that could not be applied. + * + * A conflict is shown rather than resolved: the server's version and the one + * that was queued are both somebody's intent, and picking between them is not a + * decision to make on their behalf. + */ +export function SyncQueueSheet({ + visible, + onClose, +}: { + visible: boolean; + onClose: () => void; +}) { + const t = useTokens(); + const queryClient = useQueryClient(); + const online = useIsOnline(); + const inFlight = useIsMutating(); + + const all = useMutationState({ + select: (mutation) => ({ + key: mutation.options.mutationKey, + variables: mutation.state.variables, + isPaused: mutation.state.isPaused, + status: mutation.state.status, + error: mutation.state.error, + }), + }); + + const waiting = all.filter((entry) => entry.isPaused || entry.status === 'pending'); + const failed = all.filter((entry) => entry.status === 'error'); + + if (!visible) return null; + + return ( + + + + {online + ? inFlight > 0 + ? 'Sending queued changes…' + : 'Up to date.' + : 'No connection. Changes are kept and sent when one returns.'} + + + {waiting.length > 0 ? ( + + WAITING + {waiting.map((entry, index) => ( + + + + {describeQueued(entry.key, entry.variables)} + + + ))} + + ) : null} + + {failed.length > 0 ? ( + + NEEDS ATTENTION + {failed.map((entry, index) => ( + + + {describeQueued(entry.key, entry.variables)} + + + {entry.error instanceof MoveConflict + ? entry.error.message + : 'Could not be applied.'} + + {entry.error instanceof MoveConflict ? ( + + Open the issue to decide what it should be. + + ) : null} + + ))} + + ) : null} + + {waiting.length === 0 && failed.length === 0 ? ( + Nothing waiting. + ) : null} + + {online && waiting.length > 0 ? ( + + ) : null} + + {failed.length > 0 ? ( + + ) : null} + + + ); +} diff --git a/src/team/admin/automation-panel.tsx b/src/team/admin/automation-panel.tsx index a13ea0c..587f538 100644 --- a/src/team/admin/automation-panel.tsx +++ b/src/team/admin/automation-panel.tsx @@ -18,7 +18,7 @@ import type { import { errorDetail } from '@/api/errors'; import { useTeamData } from '@/board/use-team-data'; import { PRIORITY_LABEL } from '@/issues/issue-meta'; -import { Alert, AppText, Button, Card } from '@/ui/primitives'; +import { Alert, AppText, Card } from '@/ui/primitives'; import { Sheet } from '@/ui/sheet'; import { useTokens } from '@/ui/theme'; diff --git a/src/team/admin/team-admin-sheet.tsx b/src/team/admin/team-admin-sheet.tsx index 27bcfeb..59c0c94 100644 --- a/src/team/admin/team-admin-sheet.tsx +++ b/src/team/admin/team-admin-sheet.tsx @@ -8,8 +8,10 @@ import { rotateSecretRepositoriesRepositoryIdRotatePost, useListRepositoriesTeamsTeamIdRepositoriesGet, } from '@/api/generated/endpoints/integrations/integrations'; -import { updateTeamTeamsTeamIdPatch } from '@/api/generated/endpoints/teams/teams'; -import { useListTeamMembersTeamsTeamIdMembersGet } from '@/api/generated/endpoints/teams/teams'; +import { + updateTeamTeamsTeamIdPatch, + useListTeamMembersTeamsTeamIdMembersGet, +} from '@/api/generated/endpoints/teams/teams'; import type { TeamRead } from '@/api/generated/models'; import { errorDetail } from '@/api/errors'; import { getInstanceUrl } from '@/api/instance';