---
type: guide
title: Admin — Categories
description: How an admin lists, creates, edits, and deletes challenge categories from the /admin/categories page, including system-row protection and challenge-attached protection.
tags: [guide, admin, categories, tester]
timestamp: 2026-07-22T18:37:00Z
---
# When this view is available
The Categories admin page renders at `/admin/categories` for users with
`role === 'admin'`. It is reached from the [Admin Shell](/guides/admin-shell.md)
side-nav (categories are also embedded inside the General settings page).
| Layer | File | Check |
|------------------|----------------------------------------------------------------------------|------------------------------------------------------|
| Client route | `frontend/src/app/app.routes.ts` | `/admin/categories` child of `adminGuard`. |
| Client component | `frontend/src/app/features/admin/categories/categories.component.ts` | `AdminCategoriesComponent.ngOnInit` fetches the list. |
| Client modals | `frontend/src/app/features/admin/categories/category-form-modal.component.ts` | Create / edit modal. |
| | `frontend/src/app/features/admin/categories/category-delete-modal.component.ts` | Delete confirmation modal. |
| Server route | `backend/src/modules/admin/admin-categories.controller.ts` | `GET/POST /api/v1/admin/categories`, `PUT/DELETE /:id`. |
# How to access (tester steps)
1. Sign in as an admin user.
2. Open **Admin area** → **Categories** in the side-nav, or visit
`/admin/categories` directly. (The page also renders below the
General settings form at `/admin/general`.)
3. The page shows `Loading categories...` (`data-testid="cat-loading"`)
while the list request is in flight, then renders one row per
category sorted alphabetically by (lowercased) abbreviation.
# Visual elements
| Element | Selector | Purpose |
|------------------------|--------------------------------------------|---------|
| Page section | `[data-testid="admin-categories"]` | Root container. |
| Add button (`+`) | `[data-testid="cat-add"]` | Opens the create modal. |
| Loading | `[data-testid="cat-loading"]` | "Loading categories..." placeholder. |
| Load error | `[data-testid="cat-error"]` | Red error text. |
| List | `[data-testid="cat-list"]` | `
` of category rows. |
| Row | `[data-testid="cat-row-{ABBR}"]` | One `
` per category. |
| Edit button | `[data-testid="cat-edit-{ABBR}"]` | Opens the edit modal. Disabled for system rows (the abbreviation input becomes `readonly`). |
| Delete button | `[data-testid="cat-delete-{ABBR}"]` | Opens the delete confirmation modal. |
| Form modal | `[data-testid="cat-form-modal"]` | Create/edit dialog (`cat-form-backdrop` is the click-outside dismiss layer). |
| Delete modal | `[data-testid="cat-delete-modal"]` | Confirmation dialog (`cat-delete-backdrop` is the dismiss layer). |
| Delete error | `[data-testid="cat-delete-error"]` | Inline error message after a failed delete. |
# Form modal fields
| Label | `data-testid` | Notes |
|-------------------------------|----------------|-------|
| Name | `cf-name` | Required, max 120 chars. |
| Abbreviation (uppercase) | `cf-abbr` | Required, 2–6 chars; server upper-cases on save. `readonly` when editing a system row. |
| Description | `cf-desc` | Optional, max 2000 chars. |
| Icon (file picker) | `cf-icon` | Optional image; uploaded to `POST /api/v1/uploads/category-icon` and resized/normalized server-side. |
| Cancel / OK | `cf-cancel`, `cf-ok` | OK disabled while form invalid or already submitting. |
# Expected behavior
## List
* The list is sorted by `LOWER(abbreviation)` ascending. Server returns
rows already sorted; the client re-sorts defensively in
`AdminCategoriesComponent.load()`.
* System rows (`isSystem === true`) render with the edit/delete actions
still visible, but the abbreviation field is locked when editing, and
the delete confirmation modal hides the "OK" button (see "Delete
behavior" below).
## Create
1. Click `cat-add` → `cat-form-modal` opens in create mode.
2. Fill name, abbreviation, description. Optionally pick an icon file
(preview appears immediately).
3. Click `cf-ok`. The component calls
`AdminService.createCategory({...})`, then — if an icon file was
selected — `uploadCategoryIcon(id, file)` and finally
`updateCategory(id, { iconPath: publicUrl })` to persist the icon
URL.
4. On success the modal closes and the list refreshes.
## Edit
1. Click `cat-edit-{ABBR}` → modal opens in edit mode, pre-filled with
the row's values.
2. For system rows, the abbreviation field is `readonly`.
3. On save the component issues `updateCategory(id, {...})`. If an icon
file is selected, the new icon is uploaded first and the returned
`publicUrl` replaces `iconPath` in the same update.
### Edit prefill mechanics
When the parent sets `open=true` + `mode='edit'` + a `category`, a
constructor `effect()` runs `syncCategoryForm(form, mode, category)`
which:
* writes `name`, `abbreviation`, `description` via per-control
`setValue(..., { emitEvent: false })`,
* marks every control pristine + untouched so validators do not flash
errors on a freshly opened modal,
* returns `{ abbreviationReadonly: category.isSystem === true,
iconPreview: category.iconPath || null }` which the effect pushes
into `abbreviationReadonly` and `iconPreview` signals and clears
`iconFile`,
* and triggers `ChangeDetectorRef.markForCheck()` so the OnPush view
repaints the freshly-patched `` / `