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 ( + + + + + + + + + + + + + + + ); +}