mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
bee500880e
Finishes the plural work the previous wip commit started. t() picks a count string's form by the language's own Intl.PluralRules, and every locale now spells out the forms its language reaches with whole numbers, about 160 count strings in all: one, few and many in Russian, Ukrainian and Polish, one and few in Czech and Slovak, five forms in Arabic, one in the others, none in Japanese, Korean, Chinese, Thai, Vietnamese and Indonesian. Each language was translated and then reviewed on its own. English no longer reads "1 files", "1 updates available" or "photo(s)". Count labels shown next to a number now take that number too: the atlas and admin statistics, the trip cards, journey and shared trip pills, packing templates, receipts, storage usage, the Studio export note. A heading that shared its key with a counted label keeps a key of its own (the boarding pass heading, the Bookings tab), so languages that put a noun after a number in the singular keep a plural heading. The plugin hosts chip passed its count through .replace() after t() and never got a form; three toasts sent the count as text. Mail and push texts for shared photos and suppressed storage failures pick their form in code through pluralForm. A whole number sent as text counts too, which is how in-app notification params arrive, and the plural rule follows the language of the strings on screen while another one still loads. The parity check knows which forms each language needs and refuses missing and impossible ones. It also reads keys that contain a colon now: it skipped them, which hid 58 missing plugin permission texts in Catalan and 35 in Vietnamese, both translated. The client parity test covers every language of the registry instead of a hand-kept list of 19, and the placeholder spec lets a form spell its number out only where its category is that one number. Brazilian Portuguese error screens were in Breton, and an Arabic dashboard label held Hebrew text.
678 lines
27 KiB
TypeScript
678 lines
27 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.
|
|
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' }));
|
|
|
|
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 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' }));
|
|
|
|
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 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();
|
|
});
|
|
});
|