Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions src/api/__tests__/live-views.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
60 changes: 58 additions & 2 deletions src/board/board-screen.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -49,10 +53,37 @@ export function BoardScreen() {

const [view, setView] = useState<BoardView>('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<IssueRead | null>(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<number | null>(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,
Expand Down Expand Up @@ -125,6 +156,22 @@ export function BoardScreen() {

<Segmented value={view} onChange={setView} />

<Pressable
accessibilityRole="button"
accessibilityLabel="Saved views"
onPress={() => setViewsOpen(true)}
style={{
width: 36,
height: 36,
borderRadius: t.radius.control,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: t.line.ghost,
}}
>
<Icon name="list" size={16} color={t.neutral[600]} />
</Pressable>

<Pressable
accessibilityRole="button"
accessibilityLabel="Filter"
Expand Down Expand Up @@ -166,6 +213,15 @@ export function BoardScreen() {
/>
)}

<ViewsSheet
visible={viewsOpen}
onClose={() => setViewsOpen(false)}
team={team}
user={user}
filters={filters}
onApply={setFilters}
/>

<FilterSheet
visible={filterOpen}
onClose={() => setFilterOpen(false)}
Expand Down
35 changes: 35 additions & 0 deletions src/views/__tests__/saved-views.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
36 changes: 36 additions & 0 deletions src/views/saved-views.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading
Loading