mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user