feat(mobile): phone styling for tours list, trip settings and admin cards

The tours list in the places browser now uses the same rows as places: a
grade or type tile, the day plan's figure badges and a day badge or plus on
the right. A shared "Planned on desktop" badge replaces the grey hint box
there and sits under the road trip's empty stage.

Trip settings and trip edit open phone sheets instead of the desktop
dialogs; the edit sheet gains the day count of a dateless trip and the
reminder. Questions are asked in phone sheets, not the browser.

Admin on the phone: the paddle regions and school holiday cards are phone
cards on their shared hooks, long addon descriptions fold to two lines,
the addon header keeps the wordmark inside its sentence, the group bands
drop the dash and the permissions actions get a row of their own. The
Tours addon gets its catalog name and description in every locale.

The journey map on the phone shows every layer without the switch bar.
This commit is contained in:
Maurice
2026-10-22 21:26:41 +02:00
parent bb3c0151d5
commit 49afee161d
151 changed files with 2660 additions and 899 deletions
@@ -60,7 +60,7 @@ vi.mock('../../../../src/mobile/screens/admin/MAdminStoragePanel', () => ({
}));
// The addon manager is reduced to the two controls MAdmin wires itself.
vi.mock('../../../../src/components/Admin/paddle/PaddleRegionsPanel', () => ({
vi.mock('../../../../src/mobile/screens/admin/MAdminPaddleRegionsPanel', () => ({
default: () => <div data-testid="paddle-regions-panel" />,
}));
vi.mock('../../../../src/mobile/screens/admin/MAdminAddonManager', () => ({
@@ -118,9 +118,10 @@ describe('MAdminAddonManager', () => {
render(<MAdminAddonManager />);
await screen.findByText('Lists');
expect(screen.getByText('Trip — Available as a tab within each trip')).toBeInTheDocument();
// The band names the type and says what it means on a line of its own, no dash between them.
expect(screen.getByText('Available as a tab within each trip')).toBeInTheDocument();
expect(screen.getByText('Packing lists and to-do tasks for your trips')).toBeInTheDocument();
expect(screen.getByText('Trip')).toBeInTheDocument();
expect(screen.getAllByText('Trip')).toHaveLength(2);
expect(screen.getByRole('switch', { name: 'Lists' })).toHaveAttribute('aria-checked', 'true');
});
@@ -275,7 +276,7 @@ describe('MAdminAddonManager', () => {
render(<MAdminAddonManager />);
await screen.findByText('Immich');
expect(screen.getByText('Global — Available as a standalone section in the main navigation')).toBeInTheDocument();
expect(screen.getByText('Available as a standalone section in the main navigation')).toBeInTheDocument();
expect(screen.getByText('Synology Photos')).toBeInTheDocument();
expect(screen.getByText('Unsplash')).toBeInTheDocument();
expect(screen.getByRole('switch', { name: 'Immich' })).toHaveAttribute('aria-checked', 'true');
@@ -499,7 +500,7 @@ describe('MAdminAddonManager', () => {
render(<MAdminAddonManager />);
await screen.findByText('AI Parsing');
expect(screen.getByText('Integration — Backend services and API integrations with no dedicated page')).toBeInTheDocument();
expect(screen.getByText('Backend services and API integrations with no dedicated page')).toBeInTheDocument();
expect(screen.queryByText('Connection')).not.toBeInTheDocument();
});
@@ -0,0 +1,167 @@
// FE-MOB-APAD-001 to FE-MOB-APAD-010: the paddle regions card of the phone admin, drawn from a
// controlled usePaddleRegionsAdmin (the hook has its own test).
import type { PaddleRegion, PaddleRegionPending } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import type { usePaddleRegionsAdmin } from '../../../../src/components/Admin/paddle/usePaddleRegionsAdmin';
import MAdminPaddleRegionsPanel from '../../../../src/mobile/screens/admin/MAdminPaddleRegionsPanel';
import { fireEvent, render, screen, within } from '../../../helpers/render';
type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
const hook = vi.hoisted(() => ({ usePaddleRegionsAdmin: vi.fn<() => Admin>() }));
vi.mock('../../../../src/components/Admin/paddle/usePaddleRegionsAdmin', () => ({
usePaddleRegionsAdmin: hook.usePaddleRegionsAdmin,
}));
function region(overrides: Partial<PaddleRegion> = {}): PaddleRegion {
return {
id: 'oberpfalz',
name: 'Oberpfalz',
status: 'ready',
origin: 'admin',
source_timestamp: '2026-10-03T12:00:00Z',
imported_at: '2026-10-04T08:00:00Z',
feature_count: 1234,
error: null,
progress: null,
network: null,
...overrides,
};
}
const REQUEST: PaddleRegionPending = {
id: 'germany/bayern',
name: 'Bayern',
reason: 'too_large',
bytes: 700 * 1024 * 1024,
requested_by: 'alice',
requested_at: '2026-10-05T12:00:00Z',
declined: false,
} as PaddleRegionPending;
function show(overrides: Partial<Admin> = {}, search: Partial<Admin['search']> = {}) {
const state: Admin = {
toursOn: true,
offline: false,
regions: [],
requests: [],
autoLoad: { settings: null, saving: false, save: vi.fn(async () => {}) },
loading: false,
error: null,
retry: vi.fn(),
added: new Set<string>(),
busyId: null,
add: vi.fn(async () => {}),
refresh: vi.fn(async () => {}),
approve: vi.fn(async () => {}),
decline: vi.fn(async () => {}),
removing: null,
askRemove: vi.fn(),
cancelRemove: vi.fn(),
confirmRemove: vi.fn(),
...overrides,
search: { query: '', setQuery: vi.fn(), hits: null, searching: false, error: null, ...search },
};
hook.usePaddleRegionsAdmin.mockReturnValue(state);
render(<MAdminPaddleRegionsPanel />);
return state;
}
describe('MAdminPaddleRegionsPanel', () => {
it('FE-MOB-APAD-001: shows nothing while the Tours addon is off', () => {
show({ toursOn: false });
expect(screen.queryByText('Paddle water data')).not.toBeInTheDocument();
});
it('FE-MOB-APAD-002: a region shows its status, its objects and its actions', () => {
const state = show({ regions: [region({ network: { water_km: 812 } as PaddleRegion['network'] })] });
const row = screen.getByTestId('paddle-region-row');
expect(within(row).getByText('Oberpfalz')).toBeInTheDocument();
expect(within(row).getByText('Ready')).toBeInTheDocument();
expect(within(row).getByText('1234 objects')).toBeInTheDocument();
expect(within(row).getByText('812 km of water')).toBeInTheDocument();
fireEvent.click(within(row).getByRole('button', { name: 'Read again Oberpfalz' }));
expect(state.refresh).toHaveBeenCalledWith('oberpfalz');
fireEvent.click(within(row).getByRole('button', { name: 'Remove region Oberpfalz' }));
expect(state.askRemove).toHaveBeenCalledWith(expect.objectContaining({ id: 'oberpfalz' }));
});
it('FE-MOB-APAD-003: a failed read says why and that the old data stays', () => {
show({ regions: [region({ status: 'failed', error: 'Download broke off.' })] });
expect(screen.getByText('Failed')).toBeInTheDocument();
expect(screen.getByText(/Download broke off\. The data read before stays in use\./)).toBeInTheDocument();
});
it('FE-MOB-APAD-004: the mode and the size are switched in place', () => {
const save = vi.fn(async () => {});
show({ autoLoad: { settings: { mode: 'auto', max_mb: 500 }, saving: false, save } as Admin['autoLoad'] });
const modes = screen.getByRole('radiogroup', { name: 'Load regions automatically' });
expect(within(modes).getByRole('radio', { name: /Automatic/ })).toHaveAttribute('aria-checked', 'true');
fireEvent.click(within(modes).getByRole('radio', { name: /On request/ }));
expect(save).toHaveBeenCalledWith({ mode: 'ask' });
const sizes = screen.getByRole('group', { name: 'Largest region loaded automatically' });
fireEvent.click(within(sizes).getByRole('button', { name: /1 GB/ }));
expect(save).toHaveBeenCalledWith({ max_mb: 1000 });
});
it('FE-MOB-APAD-005: no size choice outside the automatic mode', () => {
show({
autoLoad: {
settings: { mode: 'off', max_mb: 500 },
saving: false,
save: vi.fn(),
} as unknown as Admin['autoLoad'],
});
expect(screen.queryByRole('group', { name: 'Largest region loaded automatically' })).not.toBeInTheDocument();
});
it('FE-MOB-APAD-006: a requested region is loaded or declined', () => {
const state = show({ requests: [REQUEST] });
const row = screen.getByTestId('paddle-region-request');
expect(within(row).getByText('Bayern')).toBeInTheDocument();
fireEvent.click(within(row).getByRole('button', { name: /Load/ }));
expect(state.approve).toHaveBeenCalledWith('germany/bayern');
fireEvent.click(within(row).getByRole('button', { name: 'Decline' }));
expect(state.decline).toHaveBeenCalledWith('germany/bayern');
});
it('FE-MOB-APAD-007: a search hit is added, an added one says so', () => {
const state = show(
{ added: new Set(['netherlands/limburg']) },
{
query: 'Limburg',
hits: [
{ id: 'belgium/limburg', name: 'Limburg', path: ['Europe', 'Belgium'] },
{ id: 'netherlands/limburg', name: 'Limburg NL', path: ['Europe', 'Netherlands'] },
],
}
);
const hits = screen.getAllByTestId('paddle-region-hit');
expect(within(hits[0]).getByText('Belgium')).toBeInTheDocument();
fireEvent.click(within(hits[0]).getByRole('button', { name: /Limburg/ }));
expect(state.add).toHaveBeenCalledWith('belgium/limburg');
expect(within(hits[1]).getByText('Added')).toBeInTheDocument();
});
it('FE-MOB-APAD-008: typing searches, and a search without hits says so', () => {
const state = show({}, { hits: [] });
fireEvent.change(screen.getByRole('searchbox', { name: 'Add a region' }), { target: { value: 'Atlantis' } });
expect(state.search.setQuery).toHaveBeenCalledWith('Atlantis');
expect(screen.getByText('No region by that name')).toBeInTheDocument();
});
it('FE-MOB-APAD-009: a failed read of the list says so and can be retried', () => {
const state = show({ error: 'Server down' });
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect(state.retry).toHaveBeenCalled();
});
it('FE-MOB-APAD-010: removing asks in a phone sheet first', () => {
const state = show({ regions: [region()], removing: region() });
const question = screen.getByRole('dialog', { name: 'Remove region' });
fireEvent.click(within(question).getByRole('button', { name: 'Remove' }));
expect(state.confirmRemove).toHaveBeenCalled();
});
});
@@ -0,0 +1,124 @@
// FE-MOB-ASCH-001 to FE-MOB-ASCH-008: the school holiday catalog of the phone admin, drawn from a
// controlled useSchoolHolidayCatalog (the hook has its own test).
import type { SchoolHolidayRegionDetail } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import type { useSchoolHolidayCatalog } from '../../../../src/components/Admin/useSchoolHolidayCatalog';
import MAdminSchoolHolidayCatalog from '../../../../src/mobile/screens/admin/MAdminSchoolHolidayCatalog';
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render';
type Catalog = ReturnType<typeof useSchoolHolidayCatalog>;
const hook = vi.hoisted(() => ({ useSchoolHolidayCatalog: vi.fn<() => Catalog>() }));
vi.mock('../../../../src/components/Admin/useSchoolHolidayCatalog', () => ({
useSchoolHolidayCatalog: hook.useSchoolHolidayCatalog,
}));
const REGION: SchoolHolidayRegionDetail = {
id: 5,
country: 'DE',
name: 'Bayern',
code: 'BY',
revision: 2,
holidays: [{ name: 'Herbstferien', startDate: '2026-11-02', endDate: '2026-11-06' }],
};
function show(overrides: Partial<Catalog> = {}) {
const state = {
catalog: {
countries: [{ code: 'DE', name: 'Deutschland' }],
regions: [{ id: 5, country: 'DE', name: 'Bayern', code: 'BY', revision: 2 }],
},
loading: false,
busy: false,
error: '',
offline: false,
editor: null,
setEditor: vi.fn(),
refresh: vi.fn(async () => true),
createCountry: vi.fn(async () => true),
deleteCountry: vi.fn(async () => true),
openRegion: vi.fn(async () => true),
saveRegion: vi.fn(async () => true),
deleteRegion: vi.fn(async () => true),
...overrides,
} as unknown as Catalog;
hook.useSchoolHolidayCatalog.mockReturnValue(state);
render(<MAdminSchoolHolidayCatalog />);
return state;
}
describe('MAdminSchoolHolidayCatalog', () => {
it('FE-MOB-ASCH-001: lists the regions of the chosen country and opens one', () => {
const state = show();
expect(screen.getByText('School holidays')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Bayern' }));
expect(state.openRegion).toHaveBeenCalledWith(5);
});
it('FE-MOB-ASCH-002: a country with regions cannot be deleted, and says why', () => {
show();
expect(screen.getByRole('button', { name: 'Delete country' })).toBeDisabled();
expect(screen.getByText('Remove regions first. Regions used by calendars cannot be deleted.')).toBeInTheDocument();
});
it('FE-MOB-ASCH-003: a region is deleted after the phone question', () => {
const state = show();
fireEvent.click(screen.getByRole('button', { name: 'Delete Bayern' }));
const question = screen.getByRole('dialog', { name: 'Delete' });
fireEvent.click(within(question).getByRole('button', { name: 'Delete' }));
expect(state.deleteRegion).toHaveBeenCalledWith(5, 2);
});
it('FE-MOB-ASCH-004: an empty country says so and offers a new region', () => {
const state = show({
catalog: { countries: [{ code: 'AT', name: 'Österreich' }], regions: [] } as Catalog['catalog'],
});
expect(screen.getByText('No regions yet.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Add region' }));
expect(state.setEditor).toHaveBeenCalledWith(expect.objectContaining({ id: 0, country: 'AT', holidays: [] }));
});
it('FE-MOB-ASCH-005: a country is added in its own sheet, with a two-letter code', async () => {
const state = show({ catalog: { countries: [], regions: [] } as Catalog['catalog'] });
fireEvent.click(screen.getByRole('button', { name: 'Add country' }));
const sheet = screen.getByRole('dialog', { name: 'Add country' });
const save = within(sheet).getByRole('button', { name: 'Save' });
expect(save).toBeDisabled();
const [name, code] = within(sheet).getAllByRole('textbox');
fireEvent.change(name, { target: { value: 'France' } });
fireEvent.change(code, { target: { value: 'fr' } });
expect(code).toHaveValue('FR');
fireEvent.click(save);
await waitFor(() => expect(state.createCountry).toHaveBeenCalledWith({ code: 'FR', name: 'France' }));
});
it('FE-MOB-ASCH-006: the region sheet saves its periods as the request the server takes', () => {
const state = show({ editor: REGION });
const sheet = screen.getByRole('dialog', { name: 'Bayern' });
fireEvent.click(within(sheet).getByRole('button', { name: 'Add holiday period' }));
expect(within(sheet).getAllByPlaceholderText('Holiday name')).toHaveLength(2);
fireEvent.click(within(sheet).getByRole('button', { name: 'Delete 2' }));
fireEvent.click(within(sheet).getByRole('button', { name: 'Save' }));
expect(state.saveRegion).toHaveBeenCalledWith('DE', 5, {
name: 'Bayern',
revision: 2,
holidays: [{ name: 'Herbstferien', startDate: '2026-11-02', endDate: '2026-11-06' }],
});
});
it('FE-MOB-ASCH-007: an incomplete period is refused before it is sent', () => {
const state = show({ editor: REGION });
const sheet = screen.getByRole('dialog', { name: 'Bayern' });
fireEvent.click(within(sheet).getByRole('button', { name: 'Add holiday period' }));
fireEvent.click(within(sheet).getByRole('button', { name: 'Save' }));
expect(within(sheet).getByRole('alert')).toHaveTextContent('Check the names and dates.');
expect(state.saveRegion).not.toHaveBeenCalled();
});
it('FE-MOB-ASCH-008: a failed read says so and can be tried again', () => {
const state = show({ error: 'Server down' });
expect(screen.getByRole('alert')).toHaveTextContent('Server down');
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(state.refresh).toHaveBeenCalled();
});
});
@@ -178,6 +178,7 @@ describe('MNewTripSheet', () => {
start_date: null,
end_date: null,
currency: 'USD',
reminder_days: 0,
day_count: 7,
}));
expect(onClose).toHaveBeenCalled();
@@ -664,3 +665,66 @@ describe('MNewTripSheet', () => {
spy.mockRestore();
});
});
describe('MNewTripSheet day count and reminder', () => {
it('FE-MOB-NTSH-090: a dateless trip sends a changed day count, an unchanged one not', async () => {
const onSave = vi.fn(async (_data: TripCreateRequest) => undefined);
const trip = buildDashTrip({ start_date: null, end_date: null, day_count: 5 } as Partial<DashboardTrip>);
const { unmount } = render(<MNewTripSheet open trip={trip} onClose={vi.fn()} onSave={onSave} />);
fireEvent.click(screen.getByRole('button', { name: 'Update' }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave.mock.calls[0][0]).not.toHaveProperty('day_count');
unmount();
render(<MNewTripSheet open trip={trip} onClose={vi.fn()} onSave={onSave} />);
fireEvent.change(screen.getByDisplayValue('5'), { target: { value: '9' } });
fireEvent.click(screen.getByRole('button', { name: 'Update' }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(2));
expect(onSave.mock.calls[1][0]).toMatchObject({ day_count: 9 });
});
it('FE-MOB-NTSH-091: an empty day count is refused', async () => {
const onSave = vi.fn(async (_data: TripCreateRequest) => undefined);
render(<MNewTripSheet open trip={buildDashTrip({ start_date: null, end_date: null, day_count: 5 } as Partial<DashboardTrip>)} onClose={vi.fn()} onSave={onSave} />);
fireEvent.change(screen.getByDisplayValue('5'), { target: { value: '' } });
fireEvent.click(screen.getByRole('button', { name: 'Update' }));
expect(await screen.findByText('Number of days is required')).toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
});
it('FE-MOB-NTSH-092: the owner picks a reminder, a preset or a number of their own', async () => {
useAuthStore.setState({ tripRemindersEnabled: true });
const onSave = vi.fn(async (_data: TripCreateRequest) => undefined);
render(<MNewTripSheet open trip={buildDashTrip({ reminder_days: 3 } as Partial<DashboardTrip>)} onClose={vi.fn()} onSave={onSave} />);
expect(screen.getByRole('button', { name: '3 days' })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: 'Custom' }));
const days = screen.getByRole('spinbutton', { name: 'days before departure' });
expect(days).toHaveValue(7);
fireEvent.change(days, { target: { value: '12' } });
fireEvent.click(screen.getByRole('button', { name: 'Update' }));
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ reminder_days: 12 })));
fireEvent.click(screen.getByRole('button', { name: 'None' }));
expect(screen.queryByRole('spinbutton', { name: 'days before departure' })).not.toBeInTheDocument();
});
it('FE-MOB-NTSH-093: with reminders off the owner is told where to switch them on', () => {
useAuthStore.setState({ tripRemindersEnabled: false });
render(<MNewTripSheet open trip={buildDashTrip()} onClose={vi.fn()} onSave={vi.fn()} />);
expect(screen.getByText(/Trip reminders are disabled/)).toBeInTheDocument();
});
it('FE-MOB-NTSH-094: someone else\'s trip offers no reminder and sends none', async () => {
useAuthStore.setState({ tripRemindersEnabled: true, user: buildUser({ id: 9, role: 'user' }) });
const onSave = vi.fn(async (_data: TripCreateRequest) => undefined);
render(<MNewTripSheet open trip={buildDashTrip()} onClose={vi.fn()} onSave={onSave} />);
expect(screen.queryByRole('button', { name: 'Custom' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Update' }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave.mock.calls[0][0]).not.toHaveProperty('reminder_days');
});
});
@@ -272,7 +272,7 @@ describe('MRoadtripTab', () => {
renderTab(planner({ roadtripRoutes: routes({ days: [] }) }))
expect(screen.getByText('roadtrip.empty.title')).toBeInTheDocument()
expect(screen.getByText('mobileTrip.rtPlanOnDesktop')).toBeInTheDocument()
expect(screen.getByText('mobileTrip.rtDriveOnTwoPlaces')).toBeInTheDocument()
expect(screen.queryByText('mobileTrip.rtStart')).toBeNull()
expect(screen.queryByText(/roadtrip\.day\.stopCount/)).toBeNull()
})
@@ -282,7 +282,7 @@ describe('MRoadtripTab', () => {
expect(screen.getByText('roadtrip.summary.partial')).toBeInTheDocument()
expect(screen.queryByText('roadtrip.empty.title')).toBeNull()
expect(screen.queryByText('mobileTrip.rtPlanOnDesktop')).toBeNull()
expect(screen.queryByText('mobileTrip.rtDriveOnTwoPlaces')).toBeNull()
})
it('FE-MOB-RTTAB-053: with the drive routed and no day picked it asks for a day, not for a route', () => {
@@ -294,7 +294,7 @@ describe('MRoadtripTab', () => {
expect(screen.getByText('mobileTrip.rtNoDay')).toBeInTheDocument()
expect(screen.getByText('mobileTrip.rtNoDayHint')).toBeInTheDocument()
expect(screen.queryByText('roadtrip.empty.title')).toBeNull()
expect(screen.queryByText('mobileTrip.rtPlanOnDesktop')).toBeNull()
expect(screen.queryByText('mobileTrip.rtDriveOnTwoPlaces')).toBeNull()
expect(screen.queryByText('mobileTrip.rtStart')).toBeNull()
// A picked day the routing has nothing for is still the empty stage: that one is
@@ -0,0 +1,228 @@
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import MTripSettingsSheet from '../../../../src/mobile/screens/trip/sheets/MTripSettingsSheet';
import { useAuthStore } from '../../../../src/store/authStore';
import { usePermissionsStore } from '../../../../src/store/permissionsStore';
import { useTripStore } from '../../../../src/store/tripStore';
import { buildTrip, buildUser } from '../../../helpers/factories';
import { server } from '../../../helpers/msw/server';
import { render, screen, waitFor, within } from '../../../helpers/render';
import { resetAllStores, seedStore } from '../../../helpers/store';
// FE-MOB-TSET-001 to FE-MOB-TSET-008: the phone's trip settings, on useTripShare.
const owner = buildUser({ id: 1, username: 'owner' });
const alice = buildUser({ id: 2, username: 'alice' });
interface Row {
id: number;
username: string;
avatar_url?: string | null;
is_guest?: boolean;
}
function roster(members: Row[]) {
server.use(
http.get('/api/trips/1/members', () =>
HttpResponse.json({
owner: { id: owner.id, username: owner.username, avatar_url: null },
members,
current_user_id: owner.id,
})
)
);
}
function renderSheet(props: Partial<Parameters<typeof MTripSettingsSheet>[0]> = {}) {
const onClose = vi.fn();
const onMembersChanged = vi.fn();
render(
<MTripSettingsSheet
open
onClose={onClose}
tripId={1}
tripTitle="Japan 2026"
onMembersChanged={onMembersChanged}
{...props}
/>
);
return { onClose, onMembersChanged };
}
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: owner, isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, title: 'Japan 2026', user_id: owner.id }) });
roster([]);
server.use(
http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: null })),
http.get('/api/trips/1/invite-link', () => HttpResponse.json({ token: null })),
http.get('/api/auth/users', () => HttpResponse.json({ users: [alice] }))
);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('MTripSettingsSheet', () => {
it('FE-MOB-TSET-001: names the trip in its header and the owner with their badges', async () => {
renderSheet();
const sheet = screen.getByRole('dialog', { name: 'Trip settings' });
expect(within(sheet).getByText('Japan 2026')).toBeInTheDocument();
expect(await within(sheet).findByText('owner')).toBeInTheDocument();
expect(within(sheet).getByText('you')).toBeInTheDocument();
expect(within(sheet).getByText('Owner')).toBeInTheDocument();
});
it('FE-MOB-TSET-002: asks in a sheet of its own before a member goes, and only then removes', async () => {
const user = userEvent.setup();
roster([{ id: alice.id, username: 'alice', avatar_url: null }]);
let deletes = 0;
server.use(
http.delete('/api/trips/1/members/:userId', () => {
deletes++;
return HttpResponse.json({ success: true });
})
);
const { onMembersChanged } = renderSheet();
await user.click(await screen.findByRole('button', { name: 'Remove access' }));
const question = await screen.findByRole('dialog', { name: 'Remove access' });
expect(within(question).getByText('Remove access for this user?')).toBeInTheDocument();
expect(deletes).toBe(0);
await user.click(within(question).getByRole('button', { name: 'Remove access' }));
await waitFor(() => expect(deletes).toBe(1));
await waitFor(() => expect(onMembersChanged).toHaveBeenCalled());
});
it('FE-MOB-TSET-003: a question that is cancelled changes nothing', async () => {
const user = userEvent.setup();
roster([{ id: alice.id, username: 'alice', avatar_url: null }]);
let transfers = 0;
server.use(
http.post('/api/trips/1/transfer', () => {
transfers++;
return HttpResponse.json({ success: true });
})
);
renderSheet();
await user.click(await screen.findByRole('button', { name: 'Make owner' }));
const question = await screen.findByRole('dialog', { name: 'Make owner' });
await user.click(within(question).getByRole('button', { name: 'Cancel' }));
expect(transfers).toBe(0);
});
it('FE-MOB-TSET-004: a guest is added, renamed and removed', async () => {
const user = userEvent.setup();
roster([{ id: 3, username: 'Grandma', avatar_url: null, is_guest: true }]);
let created: string | null = null;
let renamed: string | null = null;
let deleted = false;
server.use(
http.post('/api/trips/1/guests', async ({ request }) => {
created = ((await request.json()) as { name: string }).name;
return HttpResponse.json({ success: true });
}),
http.put('/api/trips/1/guests/:userId', async ({ request }) => {
renamed = ((await request.json()) as { name: string }).name;
return HttpResponse.json({ success: true });
}),
http.delete('/api/trips/1/guests/:userId', () => {
deleted = true;
return HttpResponse.json({ success: true });
})
);
renderSheet();
expect(await screen.findByText('Grandma')).toBeInTheDocument();
await user.type(screen.getByPlaceholderText('Guest name'), 'Grandpa');
await user.click(screen.getByRole('button', { name: 'Add guest' }));
await waitFor(() => expect(created).toBe('Grandpa'));
await user.click(screen.getByRole('button', { name: 'Rename' }));
const input = screen.getByDisplayValue('Grandma');
await user.clear(input);
await user.type(input, 'Granny{Enter}');
await waitFor(() => expect(renamed).toBe('Granny'));
const removeGuest = screen.getAllByRole('button', { name: 'Remove access' });
await user.click(removeGuest[removeGuest.length - 1]);
await user.click(
within(await screen.findByRole('dialog', { name: 'Remove access' })).getByRole('button', {
name: 'Remove access',
})
);
await waitFor(() => expect(deleted).toBe(true));
});
it('FE-MOB-TSET-005: the public link toggles say whether they are on (#2604)', async () => {
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
server.use(
http.get('/api/trips/1/share-link', () =>
HttpResponse.json({
token: 'abc123',
share_map: true,
share_bookings: true,
share_packing: false,
share_budget: false,
share_collab: false,
})
)
);
renderSheet();
expect(await screen.findByRole('button', { name: 'Bookings' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Costs' })).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByDisplayValue(/\/shared\/abc123$/)).toBeInTheDocument();
});
it('FE-MOB-TSET-006: a public link is created with what it should show', async () => {
const user = userEvent.setup();
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
let body: Record<string, boolean> | null = null;
server.use(
http.post('/api/trips/1/share-link', async ({ request }) => {
body = (await request.json()) as Record<string, boolean>;
return HttpResponse.json({ token: 'fresh' });
})
);
renderSheet();
await user.click(await screen.findByRole('button', { name: 'Packing' }));
await user.click(screen.getByRole('button', { name: 'Create link' }));
await waitFor(() => expect(body).toMatchObject({ share_map: true, share_packing: true }));
expect(await screen.findByDisplayValue(/\/shared\/fresh$/)).toBeInTheDocument();
});
it('FE-MOB-TSET-007: the invite link is created and switched off again', async () => {
const user = userEvent.setup();
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
let removed = false;
server.use(
http.post('/api/trips/1/invite-link', () => HttpResponse.json({ token: 'join1' })),
http.delete('/api/trips/1/invite-link', () => {
removed = true;
return HttpResponse.json({ success: true });
})
);
renderSheet();
await user.click(await screen.findByRole('button', { name: 'Create invite link' }));
expect(await screen.findByDisplayValue(/\/join\/join1$/)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Disable' }));
await waitFor(() => expect(removed).toBe(true));
});
it('FE-MOB-TSET-008: closes through its close button', async () => {
const user = userEvent.setup();
const { onClose } = renderSheet();
await user.click(
within(screen.getByRole('dialog', { name: 'Trip settings' })).getByRole('button', { name: 'Close' })
);
expect(onClose).toHaveBeenCalled();
});
});
@@ -11,9 +11,8 @@ import { render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-SHOST-030 to FE-MOB-SHOST-031 (001 to 029 and 032 live in MTripSheets.test.tsx)
//
// The phone has no share sheet of its own: the Mehr sheet opens the desktop
// TripMembersModal through this host. Every other sheet is stubbed away so the real
// modal is what the phone user gets (#2478).
// The Mehr sheet opens the phone's own MTripSettingsSheet through this host. Every
// other sheet is stubbed away so the real sheet is what the phone user gets (#2478).
vi.mock('../../../../src/mobile/screens/trip/sheets/MPlaceSheet', () => ({ default: () => null }))
vi.mock('../../../../src/mobile/screens/trip/sheets/MDaySheet', () => ({ default: () => null }))
@@ -39,7 +38,7 @@ vi.mock('../../../../src/mobile/screens/settings/MConfirmSheet', () => ({ defaul
vi.mock('../../../../src/components/Planner/BookingImportModal', () => ({ default: () => null }))
vi.mock('../../../../src/components/Planner/AirTrailImportModal', () => ({ default: () => null }))
vi.mock('../../../../src/components/Planner/TransitJourneyModal', () => ({ default: () => null }))
vi.mock('../../../../src/components/Trips/TripFormModal', () => ({ default: () => null }))
vi.mock('../../../../src/mobile/screens/dashboard/MNewTripSheet', () => ({ default: () => null }))
import MTripSheets from '../../../../src/mobile/screens/trip/sheets/MTripSheets'
@@ -133,9 +133,9 @@ vi.mock('../../../../src/components/Planner/AirTrailImportModal', () => ({
),
}))
vi.mock('../../../../src/components/Trips/TripFormModal', () => ({
default: ({ isOpen, onClose, onSave, trip, onCoverUpdate }: {
isOpen: boolean; onClose: () => void
vi.mock('../../../../src/mobile/screens/dashboard/MNewTripSheet', () => ({
default: ({ open: isOpen, onClose, onSave, trip, onCoverUpdate }: {
open: boolean; onClose: () => void
onSave: (data: Record<string, unknown>) => Promise<void>
trip?: Trip | null
onCoverUpdate: (id: number, coverUrl: string) => void
@@ -148,9 +148,9 @@ vi.mock('../../../../src/components/Trips/TripFormModal', () => ({
),
}))
vi.mock('../../../../src/components/Trips/TripMembersModal', () => ({
default: ({ isOpen, onClose, tripTitle, onMembersChanged }: {
isOpen: boolean; onClose: () => void; tripTitle?: string; onMembersChanged: () => void
vi.mock('../../../../src/mobile/screens/trip/sheets/MTripSettingsSheet', () => ({
default: ({ open: isOpen, onClose, tripTitle, onMembersChanged }: {
open: boolean; onClose: () => void; tripTitle?: string; onMembersChanged: () => void
}) => (
<div data-testid="stub-members" data-open={String(isOpen)} data-title={tripTitle ?? 'none'}>
<button type="button" onClick={onMembersChanged}>members changed</button>