From 177ea9793a6f476727c5570429c73b62d72e295a Mon Sep 17 00:00:00 2001 From: Waleed Khalid Date: Fri, 11 Sep 2026 22:23:18 +0500 Subject: [PATCH] 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 ( + + + + + + + + + + + + + + + ); +}