Files
TREK/client/tests/unit/mobile/admin/MAdminPackingTemplateManager.test.tsx
T
Maurice 31859651d7 feat(help): rewrite the help center for v5 with a Tours section, new pictures in every language, and fix the tour planner bugs it surfaced
Every help screen checked against the v5 redesign. Tours is documented for
the first time, as a screen of its own with twelve screens under it, and
notifications get help of their own. All pictures are taken again on a
realistic demo trip, with short loops where a still cannot show the motion.
The English is plain and every language is written naturally rather than
word for word.

The switcher shows two levels at most, a screen's overview lists its
sub-screens as cards above its guides, each wiki page is linked once, and
the help sidebar is wider.

Fixes found along the way: Save to Collection keeps a tour's route, a track
dropped on the places column opens the Tours import, tour Share on desktop,
the unsaved tour draft lives on the account, a route click no longer adds
an end point, Trip places leaves the trip's tours out, Escape closes a
stage's day menu, confirms before destructive deletes, DWD warnings sharing
one id kept apart, a booking's date moves with it, and smaller fixes across
notifications, bookings, the day plan, stays, Atlas, Collections, Vacay and
the admin screens.
2026-10-22 22:29:35 +02:00

690 lines
28 KiB
TypeScript

// FE-MOB-APKG-001 to FE-MOB-APKG-028
import { describe, it, expect, beforeEach } from 'vitest';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { render, screen, fireEvent, waitFor, within } from '../../../helpers/render';
import { server } from '../../../helpers/msw/server';
import { resetAllStores } from '../../../helpers/store';
import { ToastContainer } from '../../../../src/components/shared/Toast';
import MAdminPackingTemplateManager from '../../../../src/mobile/screens/admin/MAdminPackingTemplateManager';
const tmpl1 = { id: 1, name: 'Beach Trip', item_count: 5, category_count: 2, created_by_name: 'admin' };
const tmpl2 = { id: 2, name: 'City Break', item_count: 3, category_count: 1, created_by_name: 'admin' };
const cat1 = { id: 10, template_id: 1, name: 'Clothing', sort_order: 0 };
const item1 = { id: 100, category_id: 10, name: 'T-shirt', sort_order: 0 };
const item2 = { id: 101, category_id: 10, name: 'Shorts', sort_order: 1 };
function list(templates: unknown[]) {
return http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates }));
}
function detail(id: number, categories: unknown[], items: unknown[]) {
return http.get(`/api/admin/packing-templates/${id}`, () => HttpResponse.json({ categories, items }));
}
function renderManager() {
return render(<><ToastContainer /><MAdminPackingTemplateManager /></>);
}
// Template rows nest the name two levels below the row that holds the icon buttons.
/** Accepts the confirm sheet a template or category delete opens. */
async function confirmSheet(user: ReturnType<typeof userEvent.setup>) {
const sheet = await screen.findByRole('dialog');
await user.click(within(sheet).getByRole('button', { name: 'Delete' }));
}
function templateRow(name: string): HTMLElement {
return screen.getByText(name).parentElement!.parentElement as HTMLElement;
}
function categoryHeader(name: string): HTMLElement {
return screen.getByText(name).closest('div') as HTMLElement;
}
function itemRow(name: string): HTMLElement {
return screen.getByText(name).closest('div') as HTMLElement;
}
beforeEach(() => {
resetAllStores();
});
describe('MAdminPackingTemplateManager', () => {
it('FE-MOB-APKG-001: shows the spinner while templates load, then the empty state', async () => {
server.use(
http.get('/api/admin/packing-templates', async () => {
await new Promise(r => setTimeout(r, 60));
return HttpResponse.json({ templates: [] });
}),
);
renderManager();
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
expect(screen.getByText('Packing Templates')).toBeInTheDocument();
expect(screen.getByText('Create reusable packing lists for your trips')).toBeInTheDocument();
await screen.findByText('No templates created yet');
expect(document.querySelector('.animate-spin')).not.toBeInTheDocument();
});
it('FE-MOB-APKG-002: renders each template with its category and item counts', async () => {
server.use(list([tmpl1, tmpl2]));
renderManager();
await screen.findByText('Beach Trip');
expect(screen.getByText('City Break')).toBeInTheDocument();
expect(screen.getByText('2 categories, 5 items')).toBeInTheDocument();
expect(screen.getByText('1 category, 3 items')).toBeInTheDocument();
});
it('FE-MOB-APKG-003: a failing list request surfaces the load error toast', async () => {
server.use(http.get('/api/admin/packing-templates', () => HttpResponse.error()));
renderManager();
await screen.findByText('Failed to load templates');
expect(screen.getByText('No templates created yet')).toBeInTheDocument();
});
it('FE-MOB-APKG-004: creating a template prepends it, expands it and toasts', async () => {
const user = userEvent.setup();
let posted: unknown = null;
server.use(
list([tmpl1]),
http.post('/api/admin/packing-templates', async ({ request }) => {
posted = await request.json();
return HttpResponse.json({ template: { id: 99, name: 'Winter Trip' } });
}),
detail(99, [], []),
);
renderManager();
await screen.findByText('Beach Trip');
await user.click(screen.getByRole('button', { name: 'New Template' }));
await user.type(screen.getByPlaceholderText('Template name (e.g. Beach Holiday)'), ' Winter Trip {Enter}');
await waitFor(() => expect(posted).toEqual({ name: 'Winter Trip' }));
await screen.findByText('Template created');
expect(screen.getByText('0 categories, 0 items')).toBeInTheDocument();
// The new template is expanded right away, so its category editor is visible.
expect(screen.getByRole('button', { name: 'Add category' })).toBeInTheDocument();
expect(screen.queryByPlaceholderText('Template name (e.g. Beach Holiday)')).not.toBeInTheDocument();
});
it('FE-MOB-APKG-005: saving a blank template name is a no-op', async () => {
const user = userEvent.setup();
let postCalls = 0;
server.use(
http.post('/api/admin/packing-templates', () => {
postCalls += 1;
return HttpResponse.json({ template: { id: 5, name: '' } });
}),
);
renderManager();
await screen.findByText('No templates created yet');
await user.click(screen.getByRole('button', { name: 'New Template' }));
await user.type(screen.getByPlaceholderText('Template name (e.g. Beach Holiday)'), ' ');
await user.click(screen.getByRole('button', { name: 'Save' }));
expect(postCalls).toBe(0);
expect(screen.getByPlaceholderText('Template name (e.g. Beach Holiday)')).toBeInTheDocument();
});
it('FE-MOB-APKG-006: a failing create shows the create error toast', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/admin/packing-templates', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
);
renderManager();
await screen.findByText('No templates created yet');
await user.click(screen.getByRole('button', { name: 'New Template' }));
await user.type(screen.getByPlaceholderText('Template name (e.g. Beach Holiday)'), 'Boom{Enter}');
expect(await screen.findByText('Failed to create template')).toBeInTheDocument();
});
it('FE-MOB-APKG-007: Escape and the cancel button both close the create field', async () => {
const user = userEvent.setup();
renderManager();
await screen.findByText('No templates created yet');
await user.click(screen.getByRole('button', { name: 'New Template' }));
await user.type(screen.getByPlaceholderText('Template name (e.g. Beach Holiday)'), 'Draft{Escape}');
await waitFor(() =>
expect(screen.queryByPlaceholderText('Template name (e.g. Beach Holiday)')).not.toBeInTheDocument(),
);
await user.click(screen.getByRole('button', { name: 'New Template' }));
await user.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() =>
expect(screen.queryByPlaceholderText('Template name (e.g. Beach Holiday)')).not.toBeInTheDocument(),
);
});
it('FE-MOB-APKG-008: expanding loads categories and items, collapsing hides them again', async () => {
const user = userEvent.setup();
server.use(list([tmpl1]), detail(1, [cat1], [item1, item2]));
renderManager();
await screen.findByText('Beach Trip');
await user.click(screen.getByRole('button', { name: 'Expand' }));
await screen.findByText('Clothing');
expect(screen.getByText('T-shirt')).toBeInTheDocument();
expect(screen.getByText('Shorts')).toBeInTheDocument();
// The category header counts its items.
expect(within(categoryHeader('Clothing')).getByText('2')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Collapse' }));
await waitFor(() => expect(screen.queryByText('Clothing')).not.toBeInTheDocument());
expect(screen.queryByText('T-shirt')).not.toBeInTheDocument();
});
it('FE-MOB-APKG-009: a failing detail request toasts and leaves the template empty', async () => {
const user = userEvent.setup();
server.use(
list([tmpl1]),
http.get('/api/admin/packing-templates/1', () => HttpResponse.error()),
);
renderManager();
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('Failed to load templates');
expect(screen.getByRole('button', { name: 'Add category' })).toBeInTheDocument();
});
it('FE-MOB-APKG-010: deleting a template removes it and collapses the expanded panel', async () => {
const user = userEvent.setup();
let deleted = false;
server.use(
list([tmpl1, tmpl2]),
detail(1, [cat1], [item1]),
http.delete('/api/admin/packing-templates/1', () => {
deleted = true;
return HttpResponse.json({ success: true });
}),
);
renderManager();
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('Clothing');
await user.click(within(templateRow('Beach Trip')).getByRole('button', { name: 'Delete' }));
expect(deleted).toBe(false);
await confirmSheet(user);
await waitFor(() => expect(deleted).toBe(true));
await waitFor(() => expect(screen.queryByText('Beach Trip')).not.toBeInTheDocument());
expect(screen.queryByText('Clothing')).not.toBeInTheDocument();
expect(screen.getByText('City Break')).toBeInTheDocument();
await screen.findByText('Template deleted');
});
it('FE-MOB-APKG-011: a failing template delete keeps the row and toasts', async () => {
const user = userEvent.setup();
server.use(
list([tmpl1]),
http.delete('/api/admin/packing-templates/1', () => HttpResponse.error()),
);
renderManager();
await screen.findByText('Beach Trip');
await user.click(within(templateRow('Beach Trip')).getByRole('button', { name: 'Delete' }));
await confirmSheet(user);
await screen.findByText('Failed to delete template');
expect(screen.getByText('Beach Trip')).toBeInTheDocument();
});
it('FE-MOB-APKG-012: renaming a template persists the trimmed name', async () => {
const user = userEvent.setup();
let body: unknown = null;
server.use(
list([tmpl1]),
http.put('/api/admin/packing-templates/1', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ success: true });
}),
);
renderManager();
await screen.findByText('Beach Trip');
await user.click(within(templateRow('Beach Trip')).getByRole('button', { name: 'Edit' }));
const input = screen.getByDisplayValue('Beach Trip');
await user.clear(input);
await user.type(input, 'Summer Packing{Enter}');
await waitFor(() => expect(body).toEqual({ name: 'Summer Packing' }));
await screen.findByText('Summer Packing');
});
it('FE-MOB-APKG-013: blurring an emptied template name cancels the rename', async () => {
const user = userEvent.setup();
let putCalls = 0;
server.use(
list([tmpl1]),
http.put('/api/admin/packing-templates/1', () => {
putCalls += 1;
return HttpResponse.json({ success: true });
}),
);
renderManager();
await screen.findByText('Beach Trip');
await user.click(within(templateRow('Beach Trip')).getByRole('button', { name: 'Edit' }));
const input = screen.getByDisplayValue('Beach Trip');
await user.clear(input);
fireEvent.blur(input);
await waitFor(() => expect(screen.getByText('Beach Trip')).toBeInTheDocument());
expect(putCalls).toBe(0);
});
it('FE-MOB-APKG-014: Escape cancels the rename and a failing rename toasts', async () => {
const user = userEvent.setup();
let putCalls = 0;
server.use(
list([tmpl1]),
http.put('/api/admin/packing-templates/1', () => {
putCalls += 1;
return HttpResponse.error();
}),
);
renderManager();
await screen.findByText('Beach Trip');
await user.click(within(templateRow('Beach Trip')).getByRole('button', { name: 'Edit' }));
await user.type(screen.getByDisplayValue('Beach Trip'), '{Escape}');
await waitFor(() => expect(screen.queryByDisplayValue('Beach Trip')).not.toBeInTheDocument());
expect(putCalls).toBe(0);
await user.click(within(templateRow('Beach Trip')).getByRole('button', { name: 'Edit' }));
await user.type(screen.getByDisplayValue('Beach Trip'), ' 2{Enter}');
await screen.findByText('Failed to save');
expect(putCalls).toBe(1);
});
it('FE-MOB-APKG-015: adding a category appends it to the expanded template', async () => {
const user = userEvent.setup();
let body: unknown = null;
server.use(
list([tmpl1]),
detail(1, [], []),
http.post('/api/admin/packing-templates/1/categories', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ category: { id: 20, template_id: 1, name: 'Electronics', sort_order: 1 } });
}),
);
renderManager();
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await user.click(await screen.findByRole('button', { name: 'Add category' }));
await user.type(screen.getByPlaceholderText('Category name (e.g. Clothing)'), 'Electronics{Enter}');
await waitFor(() => expect(body).toEqual({ name: 'Electronics' }));
await screen.findByText('Electronics');
expect(screen.queryByPlaceholderText('Category name (e.g. Clothing)')).not.toBeInTheDocument();
});
it('FE-MOB-APKG-016: a blank category name is ignored and Escape closes the field', async () => {
const user = userEvent.setup();
let postCalls = 0;
server.use(
list([tmpl1]),
detail(1, [], []),
http.post('/api/admin/packing-templates/1/categories', () => {
postCalls += 1;
return HttpResponse.json({ category: { id: 20, template_id: 1, name: 'x', sort_order: 1 } });
}),
);
renderManager();
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await user.click(await screen.findByRole('button', { name: 'Add category' }));
await user.click(screen.getByRole('button', { name: 'Save' }));
expect(postCalls).toBe(0);
await user.type(screen.getByPlaceholderText('Category name (e.g. Clothing)'), 'Draft{Escape}');
await waitFor(() =>
expect(screen.queryByPlaceholderText('Category name (e.g. Clothing)')).not.toBeInTheDocument(),
);
// Reopening starts from an empty field and the cancel button closes it too.
await user.click(screen.getByRole('button', { name: 'Add category' }));
expect(screen.getByPlaceholderText('Category name (e.g. Clothing)')).toHaveValue('');
await user.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() =>
expect(screen.queryByPlaceholderText('Category name (e.g. Clothing)')).not.toBeInTheDocument(),
);
expect(postCalls).toBe(0);
});
it('FE-MOB-APKG-017: a failing category create toasts the save error', async () => {
const user = userEvent.setup();
server.use(
list([tmpl1]),
detail(1, [], []),
http.post('/api/admin/packing-templates/1/categories', () => HttpResponse.error()),
);
renderManager();
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await user.click(await screen.findByRole('button', { name: 'Add category' }));
await user.type(screen.getByPlaceholderText('Category name (e.g. Clothing)'), 'Electronics{Enter}');
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
});
it('FE-MOB-APKG-018: renaming a category updates its header', async () => {
const user = userEvent.setup();
let body: unknown = null;
server.use(
list([tmpl1]),
detail(1, [cat1], []),
http.put('/api/admin/packing-templates/1/categories/10', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ success: true });
}),
);
renderManager();
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('Clothing');
await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Edit' }));
const input = screen.getByDisplayValue('Clothing');
await user.clear(input);
await user.type(input, 'Shoes{Enter}');
await waitFor(() => expect(body).toEqual({ name: 'Shoes' }));
await screen.findByText('Shoes');
});
it('FE-MOB-APKG-019: Escape and an emptied field both cancel the category rename', async () => {
const user = userEvent.setup();
let putCalls = 0;
server.use(
list([tmpl1]),
detail(1, [cat1], []),
http.put('/api/admin/packing-templates/1/categories/10', () => {
putCalls += 1;
return HttpResponse.json({ success: true });
}),
);
renderManager();
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('Clothing');
await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Edit' }));
await user.type(screen.getByDisplayValue('Clothing'), '{Escape}');
await waitFor(() => expect(screen.queryByDisplayValue('Clothing')).not.toBeInTheDocument());
await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Edit' }));
const input = screen.getByDisplayValue('Clothing');
await user.clear(input);
fireEvent.blur(input);
await waitFor(() => expect(screen.getByText('Clothing')).toBeInTheDocument());
expect(putCalls).toBe(0);
});
it('FE-MOB-APKG-029: a failing category rename toasts and keeps the old name', async () => {
const user = userEvent.setup();
server.use(
list([tmpl1]),
detail(1, [cat1], []),
http.put('/api/admin/packing-templates/1/categories/10', () => HttpResponse.error()),
);
renderManager();
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('Clothing');
await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Edit' }));
await user.type(screen.getByDisplayValue('Clothing'), ' XL{Enter}');
await screen.findByText('Failed to save');
expect(screen.getByDisplayValue('Clothing XL')).toBeInTheDocument();
});
it('FE-MOB-APKG-020: deleting a category drops its items too', async () => {
const user = userEvent.setup();
let deleted = false;
server.use(
list([tmpl1]),
detail(1, [cat1], [item1, item2]),
http.delete('/api/admin/packing-templates/1/categories/10', () => {
deleted = true;
return HttpResponse.json({ success: true });
}),
);
renderManager();
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('Clothing');
await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Delete' }));
expect(deleted).toBe(false);
await confirmSheet(user);
await waitFor(() => expect(deleted).toBe(true));
await waitFor(() => expect(screen.queryByText('Clothing')).not.toBeInTheDocument());
expect(screen.queryByText('T-shirt')).not.toBeInTheDocument();
expect(screen.queryByText('Shorts')).not.toBeInTheDocument();
});
it('FE-MOB-APKG-021: a failing category delete toasts and keeps the category', async () => {
const user = userEvent.setup();
server.use(
list([tmpl1]),
detail(1, [cat1], [item1]),
http.delete('/api/admin/packing-templates/1/categories/10', () => HttpResponse.error()),
);
renderManager();
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('Clothing');
await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Delete' }));
await confirmSheet(user);
await screen.findByText('Failed to delete');
expect(screen.getByText('Clothing')).toBeInTheDocument();
});
it('FE-MOB-APKG-022: adding an item posts to the category and clears the field', async () => {
const user = userEvent.setup();
let body: unknown = null;
server.use(
list([tmpl1]),
detail(1, [cat1], []),
http.post('/api/admin/packing-templates/1/categories/10/items', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ item: { id: 102, category_id: 10, name: 'Sandals', sort_order: 2 } });
}),
);
renderManager();
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('Clothing');
await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Item name' }));
await user.type(screen.getByPlaceholderText('Item name'), 'Sandals{Enter}');
await waitFor(() => expect(body).toEqual({ name: 'Sandals' }));
await screen.findByText('Sandals');
expect(screen.getByPlaceholderText('Item name')).toHaveValue('');
});
it('FE-MOB-APKG-023: the add-item confirm button is disabled while the field is empty', async () => {
const user = userEvent.setup();
server.use(
list([tmpl1]),
detail(1, [cat1], []),
http.post('/api/admin/packing-templates/1/categories/10/items', () =>
HttpResponse.json({ item: { id: 103, category_id: 10, name: 'Hat', sort_order: 3 } }),
),
);
renderManager();
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('Clothing');
await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Item name' }));
const addRow = screen.getByPlaceholderText('Item name').closest('div') as HTMLElement;
const confirm = within(addRow).getByRole('button', { name: 'Item name' });
expect(confirm).toBeDisabled();
await user.type(screen.getByPlaceholderText('Item name'), 'Hat');
expect(confirm).toBeEnabled();
await user.click(confirm);
await screen.findByText('Hat');
});
it('FE-MOB-APKG-024: Escape and cancel close the add-item field without posting', async () => {
const user = userEvent.setup();
let postCalls = 0;
server.use(
list([tmpl1]),
detail(1, [cat1], []),
http.post('/api/admin/packing-templates/1/categories/10/items', () => {
postCalls += 1;
return HttpResponse.json({ item: { id: 104, category_id: 10, name: 'x', sort_order: 4 } });
}),
);
renderManager();
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('Clothing');
const plus = within(categoryHeader('Clothing')).getByRole('button', { name: 'Item name' });
await user.click(plus);
await user.type(screen.getByPlaceholderText('Item name'), 'Draft{Escape}');
await waitFor(() => expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument());
await user.click(plus);
await user.type(screen.getByPlaceholderText('Item name'), 'Draft');
const addRow = screen.getByPlaceholderText('Item name').closest('div') as HTMLElement;
await user.click(within(addRow).getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument());
// The plus button toggles the field shut when pressed a second time.
await user.click(plus);
expect(screen.getByPlaceholderText('Item name')).toBeInTheDocument();
await user.click(plus);
await waitFor(() => expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument());
expect(postCalls).toBe(0);
});
it('FE-MOB-APKG-025: a failing item create toasts the save error', async () => {
const user = userEvent.setup();
server.use(
list([tmpl1]),
detail(1, [cat1], []),
http.post('/api/admin/packing-templates/1/categories/10/items', () => HttpResponse.error()),
);
renderManager();
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('Clothing');
await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Item name' }));
await user.type(screen.getByPlaceholderText('Item name'), 'Sandals{Enter}');
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
});
it('FE-MOB-APKG-026: renaming an item persists via the save button', async () => {
const user = userEvent.setup();
let body: unknown = null;
server.use(
list([tmpl1]),
detail(1, [cat1], [item1]),
http.put('/api/admin/packing-templates/1/items/100', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ success: true });
}),
);
renderManager();
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('T-shirt');
await user.click(within(itemRow('T-shirt')).getByRole('button', { name: 'Edit' }));
const input = screen.getByDisplayValue('T-shirt');
await user.clear(input);
await user.type(input, 'Tank Top');
await user.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(body).toEqual({ name: 'Tank Top' }));
await screen.findByText('Tank Top');
});
it('FE-MOB-APKG-027: item rename cancels on Escape, on cancel and on an empty name', async () => {
const user = userEvent.setup();
let putCalls = 0;
server.use(
list([tmpl1]),
detail(1, [cat1], [item1]),
http.put('/api/admin/packing-templates/1/items/100', () => {
putCalls += 1;
return HttpResponse.error();
}),
);
renderManager();
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('T-shirt');
await user.click(within(itemRow('T-shirt')).getByRole('button', { name: 'Edit' }));
await user.type(screen.getByDisplayValue('T-shirt'), '{Escape}');
await waitFor(() => expect(screen.queryByDisplayValue('T-shirt')).not.toBeInTheDocument());
await user.click(within(itemRow('T-shirt')).getByRole('button', { name: 'Edit' }));
await user.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByDisplayValue('T-shirt')).not.toBeInTheDocument());
await user.click(within(itemRow('T-shirt')).getByRole('button', { name: 'Edit' }));
const input = screen.getByDisplayValue('T-shirt');
await user.clear(input);
await user.type(input, '{Enter}');
await waitFor(() => expect(screen.getByText('T-shirt')).toBeInTheDocument());
expect(putCalls).toBe(0);
// A rejected rename keeps the old name and toasts.
await user.click(within(itemRow('T-shirt')).getByRole('button', { name: 'Edit' }));
await user.type(screen.getByDisplayValue('T-shirt'), ' XL{Enter}');
await screen.findByText('Failed to save');
expect(putCalls).toBe(1);
});
it('FE-MOB-APKG-028: deleting an item removes only that item, failures toast', async () => {
const user = userEvent.setup();
server.use(
list([tmpl1]),
detail(1, [cat1], [item1, item2]),
http.delete('/api/admin/packing-templates/1/items/100', () => HttpResponse.json({ success: true })),
http.delete('/api/admin/packing-templates/1/items/101', () => HttpResponse.error()),
);
renderManager();
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('T-shirt');
await user.click(within(itemRow('T-shirt')).getByRole('button', { name: 'Delete' }));
await waitFor(() => expect(screen.queryByText('T-shirt')).not.toBeInTheDocument());
expect(screen.getByText('Shorts')).toBeInTheDocument();
await user.click(within(itemRow('Shorts')).getByRole('button', { name: 'Delete' }));
await screen.findByText('Failed to delete');
expect(screen.getByText('Shorts')).toBeInTheDocument();
});
});