refactor(admin): share the category manager logic between desktop and phone

useCategoryAdmin now holds the category list, the create and edit form, the colour presets and the delete. The phone sheet still shows a busy delete and closes itself on success through the trackDelete option.
This commit is contained in:
Maurice
2026-10-09 10:09:06 +02:00
parent 0de89b86fa
commit 7c006b6445
4 changed files with 308 additions and 180 deletions
@@ -1,6 +1,4 @@
import { useState, useEffect, useRef, useId } from 'react'
import { categoriesApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { useId } from 'react'
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'
@@ -8,98 +6,16 @@ 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'
import { getApiErrorMessage } from '../../types'
const PRESET_COLORS = [
'#6366f1', '#8b5cf6', '#ec4899', '#ef4444', '#f97316',
'#f59e0b', '#10b981', '#06b6d4', '#3b82f6', '#84cc16',
'#6b7280', '#1f2937',
]
// The colour a new category is stored with: data the server keeps, not a theme colour.
const DEFAULT_COLOR = '#6366f1' // theme-lint-disable: a stored category colour, not styling
const ICON_NAMES = Object.keys(CATEGORY_ICON_MAP)
import { ICON_NAMES, PRESET_COLORS, useCategoryAdmin } from './useCategoryAdmin'
export default function CategoryManager() {
const [categories, setCategories] = useState([])
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState(null)
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 {
categories, showForm, editingId, form, setForm, isSaving, isLoading, deleteId, setDeleteId, colorInputRef,
handleStartEdit, handleStartCreate, handleCancel, handleSave, handleDelete, isPresetColor,
} = useCategoryAdmin()
const { t } = useTranslation()
const labelId = useId()
useEffect(() => { void loadCategories() }, [])
const loadCategories = async () => {
setIsLoading(true)
try {
const data = await categoriesApi.list()
setCategories(data.categories || [])
} catch (err: unknown) {
toast.error(t('categories.toast.loadError'))
} finally {
setIsLoading(false)
}
}
const handleStartEdit = (cat) => {
setEditingId(cat.id)
setForm({ name: cat.name, color: cat.color || DEFAULT_COLOR, icon: cat.icon || 'MapPin' })
setShowForm(false)
}
const handleStartCreate = () => {
setEditingId(null)
setForm({ name: '', color: DEFAULT_COLOR, icon: 'MapPin' })
setShowForm(true)
}
const handleCancel = () => {
setShowForm(false)
setEditingId(null)
}
// The Save button carries disabled={… || !form.name.trim()}, so the name is set here.
const handleSave = async () => {
setIsSaving(true)
try {
if (editingId) {
const result = await categoriesApi.update(editingId, form)
setCategories(prev => prev.map(c => c.id === editingId ? result.category : c))
setEditingId(null)
toast.success(t('categories.toast.updated'))
} else {
const result = await categoriesApi.create(form)
setCategories(prev => [...prev, result.category])
setShowForm(false)
toast.success(t('categories.toast.created'))
}
setForm({ name: '', color: DEFAULT_COLOR, icon: 'MapPin' })
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('categories.toast.saveError')))
} finally {
setIsSaving(false)
}
}
const handleDelete = async (id) => {
try {
await categoriesApi.delete(id)
setCategories(prev => prev.filter(c => c.id !== id))
toast.success(t('categories.toast.deleted'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('categories.toast.deleteError')))
}
}
const isPresetColor = PRESET_COLORS.includes(form.color)
const PreviewIcon = getCategoryIcon(form.icon)
const formOpen = showForm || editingId !== null
const saveDisabled = isSaving || !form.name.trim()
@@ -0,0 +1,143 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { categoriesApi } from '../../api/client';
import { DEFAULT_COLOR, useCategoryAdmin } from './useCategoryAdmin';
// FE-HOOK-CATADMIN-001 to FE-HOOK-CATADMIN-010
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
const BEACH = { id: 1, name: 'Beach', color: '#10b981', icon: 'Umbrella' };
const MUSEUM = { id: 2, name: 'Museum', color: '', icon: '' };
beforeEach(() => {
toast.success.mockReset();
toast.error.mockReset();
vi.spyOn(categoriesApi, 'list').mockResolvedValue({ categories: [BEACH, MUSEUM] });
vi.spyOn(categoriesApi, 'create').mockResolvedValue({
category: { id: 3, name: 'Bar', color: '#ef4444', icon: 'Wine' },
});
vi.spyOn(categoriesApi, 'update').mockResolvedValue({ category: { ...BEACH, name: 'Shore' } });
vi.spyOn(categoriesApi, 'delete').mockResolvedValue({ success: true });
});
afterEach(() => vi.restoreAllMocks());
async function loaded(trackDelete?: boolean) {
const hook = renderHook(() => useCategoryAdmin(trackDelete === undefined ? undefined : { trackDelete }));
await waitFor(() => expect(hook.result.current.isLoading).toBe(false));
return hook;
}
describe('useCategoryAdmin', () => {
it('FE-HOOK-CATADMIN-001: loads the categories once on mount', async () => {
const { result, rerender } = await loaded();
rerender();
expect(categoriesApi.list).toHaveBeenCalledTimes(1);
expect(result.current.categories.map((c) => c.id)).toEqual([1, 2]);
});
it('FE-HOOK-CATADMIN-002: a failed load toasts the load error and leaves the list empty', async () => {
vi.mocked(categoriesApi.list).mockRejectedValue(new Error('down'));
const { result } = await loaded();
expect(toast.error).toHaveBeenCalledWith('categories.toast.loadError');
expect(result.current.categories).toEqual([]);
});
it('FE-HOOK-CATADMIN-003: start edit fills the form, falling back to the default colour and icon', async () => {
const { result } = await loaded();
act(() => result.current.handleStartEdit(MUSEUM));
expect(result.current.editingId).toBe(2);
expect(result.current.showForm).toBe(false);
expect(result.current.form).toEqual({ name: 'Museum', color: DEFAULT_COLOR, icon: 'MapPin' });
expect(result.current.isPresetColor).toBe(true);
});
it('FE-HOOK-CATADMIN-004: start create opens an empty form; cancel closes create and edit', async () => {
const { result } = await loaded();
act(() => result.current.handleStartEdit(BEACH));
act(() => result.current.handleStartCreate());
expect(result.current.editingId).toBeNull();
expect(result.current.showForm).toBe(true);
expect(result.current.form).toEqual({ name: '', color: DEFAULT_COLOR, icon: 'MapPin' });
act(() => result.current.handleCancel());
expect(result.current.showForm).toBe(false);
expect(result.current.editingId).toBeNull();
});
it('FE-HOOK-CATADMIN-005: saving a new category appends it, closes the form and resets it', async () => {
const { result } = await loaded();
act(() => result.current.handleStartCreate());
act(() => result.current.setForm({ name: 'Bar', color: '#123456', icon: 'Wine' }));
expect(result.current.isPresetColor).toBe(false);
await act(() => result.current.handleSave());
expect(categoriesApi.create).toHaveBeenCalledWith({ name: 'Bar', color: '#123456', icon: 'Wine' });
expect(result.current.categories.map((c) => c.id)).toEqual([1, 2, 3]);
expect(result.current.showForm).toBe(false);
expect(result.current.form).toEqual({ name: '', color: DEFAULT_COLOR, icon: 'MapPin' });
expect(toast.success).toHaveBeenCalledWith('categories.toast.created');
});
it('FE-HOOK-CATADMIN-006: saving an edit replaces the row in place and leaves edit mode', async () => {
const { result } = await loaded();
act(() => result.current.handleStartEdit(BEACH));
await act(() => result.current.handleSave());
expect(categoriesApi.update).toHaveBeenCalledWith(1, { name: 'Beach', color: '#10b981', icon: 'Umbrella' });
expect(result.current.categories[0].name).toBe('Shore');
expect(result.current.editingId).toBeNull();
expect(toast.success).toHaveBeenCalledWith('categories.toast.updated');
});
it('FE-HOOK-CATADMIN-007: a failed save toasts the server message and keeps the form', async () => {
vi.mocked(categoriesApi.create).mockRejectedValue({ response: { data: { error: 'Name taken' } } });
const { result } = await loaded();
act(() => result.current.handleStartCreate());
act(() => result.current.setForm({ name: 'Bar', color: DEFAULT_COLOR, icon: 'MapPin' }));
await act(() => result.current.handleSave());
expect(toast.error).toHaveBeenCalledWith('Name taken');
expect(result.current.showForm).toBe(true);
expect(result.current.form.name).toBe('Bar');
expect(result.current.isSaving).toBe(false);
});
it('FE-HOOK-CATADMIN-008: the desktop delete removes the row and leaves the pending id to the dialog', async () => {
const { result } = await loaded();
act(() => result.current.setDeleteId(1));
await act(() => result.current.handleDelete(1));
expect(result.current.categories.map((c) => c.id)).toEqual([2]);
expect(toast.success).toHaveBeenCalledWith('categories.toast.deleted');
expect(result.current.deleteId).toBe(1);
expect(result.current.isDeleting).toBe(false);
});
it('FE-HOOK-CATADMIN-009: the phone delete shows busy while it runs and clears the pending id after', async () => {
let finish!: (v: unknown) => void;
vi.mocked(categoriesApi.delete).mockReturnValue(new Promise((r) => (finish = r)));
const { result } = await loaded(true);
act(() => result.current.setDeleteId(1));
let pending!: Promise<void>;
act(() => {
pending = result.current.handleDelete(1);
});
expect(result.current.isDeleting).toBe(true);
await act(async () => {
finish({ success: true });
await pending;
});
expect(result.current.isDeleting).toBe(false);
expect(result.current.deleteId).toBeNull();
});
it('FE-HOOK-CATADMIN-010: a failed phone delete keeps the row and the pending id', async () => {
vi.mocked(categoriesApi.delete).mockRejectedValue({ response: { data: { error: 'In use' } } });
const { result } = await loaded(true);
act(() => result.current.setDeleteId(1));
await act(() => result.current.handleDelete(1));
expect(toast.error).toHaveBeenCalledWith('In use');
expect(result.current.categories.map((c) => c.id)).toEqual([1, 2]);
expect(result.current.deleteId).toBe(1);
expect(result.current.isDeleting).toBe(false);
});
});
@@ -0,0 +1,154 @@
import { useEffect, useEffectEvent, useRef, useState } from 'react';
import { categoriesApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { type Category, getApiErrorMessage } from '../../types';
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons';
import { useToast } from '../shared/Toast';
export const PRESET_COLORS = [
'#6366f1',
'#8b5cf6',
'#ec4899',
'#ef4444',
'#f97316',
'#f59e0b',
'#10b981',
'#06b6d4',
'#3b82f6',
'#84cc16',
'#6b7280',
'#1f2937',
];
// The colour a new category is stored with: data the server keeps, not a theme colour.
export const DEFAULT_COLOR = '#6366f1'; // theme-lint-disable: a stored category colour, not styling
export const ICON_NAMES = Object.keys(CATEGORY_ICON_MAP);
interface CategoryForm {
name: string;
color: string;
icon: string;
}
interface Options {
/**
* The phone sheet stays open while the delete runs (busy spinner) and closes itself
* once it succeeded; the desktop dialog closes on confirm and tracks nothing.
*/
trackDelete?: boolean;
}
/**
* The place categories editor behind both admin shells: the list loaded on mount, one
* form shared by create and edit, and the delete waiting for its confirmation. The
* desktop dialog and the phone inline form render their own markup over it.
*/
export function useCategoryAdmin({ trackDelete = false }: Options = {}) {
const [categories, setCategories] = useState<Category[]>([]);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<CategoryForm>({ 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<number | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const colorInputRef = useRef<HTMLInputElement>(null);
const toast = useToast();
const { t } = useTranslation();
const loadCategories = async () => {
setIsLoading(true);
try {
const data = await categoriesApi.list();
setCategories(data.categories || []);
} catch {
toast.error(t('categories.toast.loadError'));
} finally {
setIsLoading(false);
}
};
const loadOnMount = useEffectEvent(() => {
void loadCategories();
});
useEffect(() => {
loadOnMount();
}, []);
const handleStartEdit = (cat: Category) => {
setEditingId(cat.id);
setForm({ name: cat.name, color: cat.color || DEFAULT_COLOR, icon: cat.icon || 'MapPin' });
setShowForm(false);
};
const handleStartCreate = () => {
setEditingId(null);
setForm({ name: '', color: DEFAULT_COLOR, icon: 'MapPin' });
setShowForm(true);
};
const handleCancel = () => {
setShowForm(false);
setEditingId(null);
};
// The Save button carries disabled={… || !form.name.trim()}, so the name is set here.
const handleSave = async () => {
setIsSaving(true);
try {
if (editingId) {
const result = await categoriesApi.update(editingId, form);
setCategories((prev) => prev.map((c) => (c.id === editingId ? result.category : c)));
setEditingId(null);
toast.success(t('categories.toast.updated'));
} else {
const result = await categoriesApi.create(form);
setCategories((prev) => [...prev, result.category]);
setShowForm(false);
toast.success(t('categories.toast.created'));
}
setForm({ name: '', color: DEFAULT_COLOR, icon: 'MapPin' });
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('categories.toast.saveError')));
} finally {
setIsSaving(false);
}
};
const handleDelete = async (id: number) => {
if (trackDelete) setIsDeleting(true);
try {
await categoriesApi.delete(id);
setCategories((prev) => prev.filter((c) => c.id !== id));
toast.success(t('categories.toast.deleted'));
if (trackDelete) setDeleteId(null);
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('categories.toast.deleteError')));
} finally {
if (trackDelete) setIsDeleting(false);
}
};
return {
categories,
showForm,
editingId,
form,
setForm,
isSaving,
isLoading,
deleteId,
setDeleteId,
isDeleting,
colorInputRef,
handleStartEdit,
handleStartCreate,
handleCancel,
handleSave,
handleDelete,
isPresetColor: PRESET_COLORS.includes(form.color),
};
}
@@ -1,102 +1,17 @@
import { useState, useEffect, useRef } from 'react'
import { Plus, Edit2, Trash2, Pipette } from 'lucide-react'
import { categoriesApi } from '../../../api/client'
import { useToast } from '../../../components/shared/Toast'
import { ICON_NAMES, PRESET_COLORS, useCategoryAdmin } from '../../../components/Admin/useCategoryAdmin'
import { CATEGORY_ICON_MAP, ICON_LABELS, getCategoryIcon } from '../../../components/shared/categoryIcons'
import { useTranslation } from '../../../i18n'
import { getApiErrorMessage } from '../../../types'
import MConfirmSheet from '../settings/MConfirmSheet'
import { MAdminButton, MAdminCard, MAdminCardHead } from './MAdminUi'
const PRESET_COLORS = [
'#6366f1', '#8b5cf6', '#ec4899', '#ef4444', '#f97316',
'#f59e0b', '#10b981', '#06b6d4', '#3b82f6', '#84cc16',
'#6b7280', '#1f2937',
]
const ICON_NAMES = Object.keys(CATEGORY_ICON_MAP)
export default function MAdminCategoryManager() {
const [categories, setCategories] = useState([])
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState(null)
const [form, setForm] = useState({ name: '', color: '#6366f1', icon: 'MapPin' })
const [isSaving, setIsSaving] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [deleteId, setDeleteId] = useState(null)
const [isDeleting, setIsDeleting] = useState(false)
const colorInputRef = useRef(null)
const toast = useToast()
const {
categories, showForm, editingId, form, setForm, isSaving, isLoading, deleteId, setDeleteId, isDeleting,
colorInputRef, handleStartEdit, handleStartCreate, handleCancel, handleSave, handleDelete, isPresetColor,
} = useCategoryAdmin({ trackDelete: true })
const { t } = useTranslation()
useEffect(() => { void loadCategories() }, [])
const loadCategories = async () => {
setIsLoading(true)
try {
const data = await categoriesApi.list()
setCategories(data.categories || [])
} catch (err: unknown) {
toast.error(t('categories.toast.loadError'))
} finally {
setIsLoading(false)
}
}
const handleStartEdit = (cat) => {
setEditingId(cat.id)
setForm({ name: cat.name, color: cat.color || '#6366f1', icon: cat.icon || 'MapPin' })
setShowForm(false)
}
const handleStartCreate = () => {
setEditingId(null)
setForm({ name: '', color: '#6366f1', icon: 'MapPin' })
setShowForm(true)
}
const handleCancel = () => {
setShowForm(false)
setEditingId(null)
}
const handleSave = async () => {
setIsSaving(true)
try {
if (editingId) {
const result = await categoriesApi.update(editingId, form)
setCategories(prev => prev.map(c => c.id === editingId ? result.category : c))
setEditingId(null)
toast.success(t('categories.toast.updated'))
} else {
const result = await categoriesApi.create(form)
setCategories(prev => [...prev, result.category])
setShowForm(false)
toast.success(t('categories.toast.created'))
}
setForm({ name: '', color: '#6366f1', icon: 'MapPin' })
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('categories.toast.saveError')))
} finally {
setIsSaving(false)
}
}
const handleDelete = async (id) => {
setIsDeleting(true)
try {
await categoriesApi.delete(id)
setCategories(prev => prev.filter(c => c.id !== id))
toast.success(t('categories.toast.deleted'))
setDeleteId(null)
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('categories.toast.deleteError')))
} finally {
setIsDeleting(false)
}
}
const isPresetColor = PRESET_COLORS.includes(form.color)
const PreviewIcon = getCategoryIcon(form.icon)
const categoryForm = (