How an admin lists, creates, edits, and deletes challenge categories from the /admin/categories page, including system-row protection and challenge-attached protection.
guide
admin
categories
tester
2026-07-22T19:18: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
side-nav (categories are also embedded inside the General settings page).
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.)
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"]
<ul> of category rows.
Row
[data-testid="cat-row-{ABBR}"]
One <li> 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 error
[data-testid="cf-error"]
Inline error rendered inside the form modal after a failed create/update/upload.
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, already submitting, or while the parent is performing the async save (bound saving input).
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
Click cat-add → cat-form-modal opens in create mode.
Fill name, abbreviation, description. Optionally pick an icon file
(preview appears immediately).
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.
On success the modal closes and the list refreshes.
Edit
Click cat-edit-{ABBR} → modal opens in edit mode, pre-filled with
the row's values.
For system rows, the abbreviation field is readonly. For user
(non-system) rows the abbreviation is editable and is uppercased
server-side on save (the existing category row is re-sorted
alphabetically by the defensive client sort after a successful
update).
On save the component issues updateCategory(id, {...}) with
name, abbreviation, description, and (if an icon file was
chosen) iconPath. When an icon file is selected, the new icon is
uploaded first via POST /api/v1/uploads/category-icon and the
returned publicUrl replaces iconPath in the same update.
On either an upload rejection (HTTP 400) or an update rejection
(HTTP 400/404/409), the modal stays open, the OK button is disabled
while the request is in flight via the bound saving flag, and the
server message is rendered inside the modal at
data-testid="cf-error". The list is not refreshed and any
previously-valid icon on disk is preserved (the upload endpoint
does not overwrite when Sharp decode fails).
Edit prefill mechanics
When the parent sets open=true + mode='edit' + a category, a
constructor effect() runs syncCategoryForm(form, mode, category)
which:
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 <input> / <textarea> values.
Switching back to mode='create' with category=null re-invokes the
helper, which resets every control to '' and unlocks the abbreviation
input. syncCategoryForm is exported from the modal file so it can be
exercised in isolation by
tests/frontend/admin-categories-form-modal.spec.ts.
Save error and loading state
CategoryFormModalComponent is purely presentational and does not own
HTTP state. It exposes two additional signal inputs:
errorMessage: string | null — when truthy, an inline error paragraph
is rendered at data-testid="cf-error" showing the supplied message.
saving: boolean — when true, the OK button is disabled in addition
to the existing form.invalid and submitting guards.
The parent AdminCategoriesComponent owns both signals:
formError — set from the server's error.message (or a generic
fallback) when create/update/upload fails, and cleared every time the
modal is opened or a submission begins.
saving — set to true at the start of onFormSubmit and reset to
false in finally, so the modal can never get stuck in a
"submitting" state if the request rejects.
This separation matters because deleteError (rendered by the delete
modal at data-testid="cat-delete-error") is a different state machine
and must not be reused for create/update failures.
Delete behavior
System rows: the delete modal renders
"This is a system category and cannot be deleted." and the OK
button is hidden. canConfirm() returns false. If somehow the
request is dispatched, the backend returns 403 SYSTEM_PROTECTED
and the UI shows System categories cannot be deleted..
Rows with attached challenges: the backend returns
409 CATEGORY_HAS_CHALLENGES with { count } in details. The UI
maps this to Cannot delete: category has N challenge(s) attached.
and renders it in cat-delete-error.
Normal row: the modal confirms the name + abbreviation, OK
dispatches DELETE /api/v1/admin/categories/:id, and the list
refreshes.
list (sorted by LOWER(abbreviation)), create (uppercase + dup-check), update (system-abbr-immutable), remove (system-protected, challenge-count check).
8
backend/src/modules/admin/dto/categories.dto.ts
zod schemas with length constraints; param validator for :id.
9
backend/src/modules/uploads/uploads.controller.ts
POST /api/v1/uploads/category-icon (multipart) — also admin-only.
Notes
Abbreviations are uppercased server-side before persistence and
uniqueness check (DB enforces uniqueness via the
uq_category_abbreviation index — see
Challenge Tables).
System rows are seeded by the
UpdateSystemCategoryKeys1700000000300 migration and identified by
a non-null system_key column.
The icon upload pipeline normalizes the image to a fixed size; the
returned publicUrl is stored in category.icon_path.
The Categories component is embedded inside the General settings
page, so changes to a category are visible on either route without
an extra refresh.
See also
Admin Shell — side-nav layout and the General settings page that embeds categories.