feat(settings): confirmations in the shared dialog, token colours on About, scope picker in the new look

This commit is contained in:
Maurice
2026-10-01 16:19:41 +02:00
parent 56dfd56bab
commit cf3818cb66
17 changed files with 337 additions and 191 deletions
@@ -19,6 +19,13 @@ beforeEach(() => {
seedStore(useAuthStore, { user: buildUser({ role: 'admin' }), isAuthenticated: true });
});
/** Answers the confirm dialog: its Delete is the last one in the document (portal). */
async function confirmDelete(user: ReturnType<typeof userEvent.setup>) {
await screen.findByText('Delete category? Places in this category will not be deleted.');
const deletes = screen.getAllByRole('button', { name: 'Delete' });
await user.click(deletes[deletes.length - 1]);
}
describe('CategoryManager', () => {
it('FE-COMP-CAT-001: renders without crashing', () => {
render(<CategoryManager />);
@@ -116,15 +123,15 @@ describe('CategoryManager', () => {
return HttpResponse.json({ success: true });
})
);
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<><ToastContainer /><CategoryManager /></>);
await screen.findByText('Parks');
// Delete button is icon-only (Trash2, no title) — find the second action button
const buttons = screen.getAllByRole('button');
const actionBtns = buttons.filter(b => !b.textContent?.includes('New Category'));
await user.click(actionBtns[1]);
await user.click(screen.getByRole('button', { name: 'Delete' }));
// Nothing goes before the confirm dialog is answered
expect(await screen.findByText('Delete category? Places in this category will not be deleted.')).toBeInTheDocument();
expect(deleteCalled).toBe(false);
await confirmDelete(user);
await waitFor(() => expect(deleteCalled).toBe(true));
vi.restoreAllMocks();
await waitFor(() => expect(screen.queryByText('Parks')).not.toBeInTheDocument());
});
it('FE-COMP-CAT-010: shows subtitle text', async () => {
@@ -209,23 +216,23 @@ describe('CategoryManager', () => {
expect(screen.getByDisplayValue('Parks')).toBeInTheDocument();
});
it('FE-COMP-CAT-016: declining the delete confirm keeps the category', async () => {
it('FE-COMP-CAT-016: cancelling the delete confirm keeps the category', async () => {
const user = userEvent.setup();
let deleteCalled = false;
server.use(
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })),
http.delete('/api/categories/9', () => { deleteCalled = true; return HttpResponse.json({ success: true }); }),
);
vi.spyOn(window, 'confirm').mockReturnValue(false);
render(<CategoryManager />);
await screen.findByText('Parks');
const actionBtns = screen.getAllByRole('button').filter(b => !b.textContent?.includes('New Category'));
await user.click(actionBtns[1]);
await user.click(screen.getByRole('button', { name: 'Delete' }));
await screen.findByText('Delete category? Places in this category will not be deleted.');
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.queryByText('Delete category? Places in this category will not be deleted.')).not.toBeInTheDocument();
expect(deleteCalled).toBe(false);
expect(screen.getByText('Parks')).toBeInTheDocument();
vi.restoreAllMocks();
});
it('FE-COMP-CAT-017: a failing delete toasts and keeps the row', async () => {
@@ -234,16 +241,14 @@ describe('CategoryManager', () => {
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })),
http.delete('/api/categories/9', () => HttpResponse.json({ error: 'category in use' }, { status: 409 })),
);
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<><ToastContainer /><CategoryManager /></>);
await screen.findByText('Parks');
const actionBtns = screen.getAllByRole('button').filter(b => !b.textContent?.includes('New Category'));
await user.click(actionBtns[1]);
await user.click(screen.getByRole('button', { name: 'Delete' }));
await confirmDelete(user);
expect(await screen.findByText('category in use')).toBeInTheDocument();
expect(screen.getByText('Parks')).toBeInTheDocument();
vi.restoreAllMocks();
});
it('FE-COMP-CAT-018: picking an icon and a preset colour updates the live preview', async () => {
@@ -293,7 +298,7 @@ describe('CategoryManager', () => {
await user.click(screen.getByText('New Category'));
expect(screen.getByPlaceholderText('Category name')).toHaveValue('');
const row = screen.getByText('Hotels').closest('.p-3') as HTMLElement;
const row = screen.getByText('Hotels').closest('[data-category-row]') as HTMLElement;
await user.click(within(row).getAllByRole('button')[0]);
// Only the inline edit form remains, pre-filled with the row's name
@@ -4,6 +4,7 @@ import { useToast } from '../shared/Toast'
import { Plus, Pencil, Trash2, Pipette, Tags, Loader2 } from 'lucide-react'
import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { Tooltip } from '../shared/Tooltip'
import ConfirmDialog from '../shared/ConfirmDialog'
import { SettingRows, SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON } from '../Settings/settingsKit'
import { CATEGORY_ICON_MAP, ICON_LABELS, getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n'
@@ -27,6 +28,8 @@ export default function CategoryManager() {
const [form, setForm] = useState({ name: '', color: DEFAULT_COLOR, icon: 'MapPin' })
const [isSaving, setIsSaving] = useState(false)
const [isLoading, setIsLoading] = useState(true)
// The category whose delete waits for the answer in the confirm dialog.
const [deleteId, setDeleteId] = useState(null)
const colorInputRef = useRef(null)
const toast = useToast()
const { t } = useTranslation()
@@ -87,7 +90,6 @@ export default function CategoryManager() {
}
const handleDelete = async (id) => {
if (!confirm(t('categories.confirm.delete'))) return
try {
await categoriesApi.delete(id)
setCategories(prev => prev.filter(c => c.id !== id))
@@ -236,7 +238,7 @@ export default function CategoryManager() {
{categories.map(cat => {
const Icon = getCategoryIcon(cat.icon)
return (
<div key={cat.id} className={`group flex items-center gap-3 p-3 ${editingId === cat.id ? 'bg-surface-secondary' : ''}`}>
<div key={cat.id} data-category-row={cat.id} className={`group flex items-center gap-3 px-3.5 py-3 ${editingId === cat.id ? 'bg-surface-secondary' : ''}`}>
<span className="grid h-9 w-9 flex-none place-items-center rounded-[10px]"
style={{ backgroundColor: `${cat.color}20` }}>
<Icon size={17} strokeWidth={1.8} color={cat.color} />
@@ -255,7 +257,7 @@ export default function CategoryManager() {
</button>
</Tooltip>
<Tooltip label={t('common.delete')}>
<button type="button" onClick={() => handleDelete(cat.id)} aria-label={t('common.delete')} className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`}>
<button type="button" onClick={() => setDeleteId(cat.id)} aria-label={t('common.delete')} className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`}>
<Trash2 size={14} strokeWidth={2} />
</button>
</Tooltip>
@@ -266,6 +268,14 @@ export default function CategoryManager() {
</SettingRows>
)}
{formOpen && categoryDialog}
<ConfirmDialog
isOpen={deleteId !== null}
onClose={() => setDeleteId(null)}
onConfirm={() => { if (deleteId !== null) void handleDelete(deleteId) }}
message={t('categories.confirm.delete')}
confirmLabel={t('common.delete')}
danger
/>
</SettingsCard>
)
}