diff --git a/client/src/components/Admin/CategoryManager.tsx b/client/src/components/Admin/CategoryManager.tsx index 9966aef92..a4edae032 100644 --- a/client/src/components/Admin/CategoryManager.tsx +++ b/client/src/components/Admin/CategoryManager.tsx @@ -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() diff --git a/client/src/components/Admin/useCategoryAdmin.test.ts b/client/src/components/Admin/useCategoryAdmin.test.ts new file mode 100644 index 000000000..e3cd563ae --- /dev/null +++ b/client/src/components/Admin/useCategoryAdmin.test.ts @@ -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; + 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); + }); +}); diff --git a/client/src/components/Admin/useCategoryAdmin.ts b/client/src/components/Admin/useCategoryAdmin.ts new file mode 100644 index 000000000..f54f7d96b --- /dev/null +++ b/client/src/components/Admin/useCategoryAdmin.ts @@ -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([]); + 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 [isDeleting, setIsDeleting] = useState(false); + const colorInputRef = useRef(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), + }; +} diff --git a/client/src/mobile/screens/admin/MAdminCategoryManager.tsx b/client/src/mobile/screens/admin/MAdminCategoryManager.tsx index 856d73cac..e01e315e5 100644 --- a/client/src/mobile/screens/admin/MAdminCategoryManager.tsx +++ b/client/src/mobile/screens/admin/MAdminCategoryManager.tsx @@ -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 = (