Appearance
Multi-Level Category Cascade
Date: 2026-02-25 Status: Draft
Problem
Backend now returns part categories with up to 4 levels of nesting (e.g. Запчасти > Для автомобилей > Двигатель > Блок цилиндров). The current frontend hardcodes exactly 2 levels (parent + child). Need dynamic N-level cascading dropdowns everywhere categories are used.
API Format
Backend returns a flat array with parent_id — no changes to schema or endpoints:
json
{ "id": 110, "name": "Двигатель", "parent_id": 105, "category_type": "part", ... }Max depth observed: 4 levels for part type. Condition and attribute categories remain flat (no nesting).
Design Decisions
- Cascading dropdowns (like YMM select) — each level appears after selecting the previous
- User can stop at any level — not required to reach leaf
- Full chain sent to backend — all selected category IDs in
category_ids[],is_primaryon deepest - Homepage unchanged — root category cards link to catalog
- Catalog — cascading filter replaces single dropdown
New Feature: features/category-select/
Structure
features/category-select/
├── composables/
│ └── useCategoryCascade.ts
└── ui/
└── CategoryCascadeSelect.vueuseCategoryCascade(categories: Ref<Category[]>)
Composable that manages dynamic cascading selection:
Input: Flat array of categories (already filtered by category_type === 'part')
Internal state:
childrenMap: Map<number | null, Category[]>— parent_id → sorted childrenselections: Ref<(number | undefined)[]>— selected ID per level
Computed:
levels: Array of{ options: Category[], selected: number | undefined }[]— what to rendercategoryChain: All selected IDs from root to deepest (forform.category_ids)primaryCategoryId: Deepest selected ID (forprimary_category_id)
Methods:
selectAt(level: number, categoryId: number | undefined)— select category at level, clear deeper levels, add next level if children existsetChain(ids: number[])— restore selection from existing chain (for edit mode)reset()— clear all selections
Logic:
1. Build childrenMap from flat array (group by parent_id, sort by sort_order)
2. Level 0 = childrenMap.get(null) — root categories
3. When user selects at level N:
a. Set selections[N] = categoryId
b. Truncate selections to length N+1
c. If childrenMap.has(categoryId) → selections grows, next level appears
4. levels computed = for each selection, get options from childrenMapCategoryCascadeSelect.vue
Props:
categories: Category[]— flat list of part categoriesmodelValue: number[]— selected chain of IDsplaceholders?: string[]— optional per-level placeholders (falls back to generic)
Emits:
update:modelValue— new chain
Template:
html
<div class="flex flex-col gap-3">
<USelectMenu
v-for="(level, index) in levels"
:key="index"
:model-value="level.selected"
:items="level.options.map(c => ({ label: c.name, value: c.id }))"
:placeholder="placeholders?.[index] ?? t('listing.selectCategory')"
value-key="value"
label-key="label"
class="w-full"
@update:model-value="selectAt(index, $event)"
/>
</div>Changes to Existing Files
1. Product Form — features/product-form/
composables/useProductForm.ts:
- No changes to composable itself — it already handles
form.category_idsas array - The UI will now set the full chain instead of one entry
ui/ProductFormPage.vue:
- Remove:
selectedParentCategoryId,selectedChildCategoryId,parentCategories,childCategories(), watchers - Add:
selectedCategoryChain: Ref<number[]>synced withform.category_ids - Replace 2 hardcoded
USelectMenuwith:
html
<CategoryCascadeSelect
:categories="partCategories"
v-model="selectedCategoryChain"
/>Watch
selectedCategoryChain→ updateform.category_ids:tswatch(selectedCategoryChain, (chain) => { const last = chain[chain.length - 1] form.category_ids = chain.map(id => ({ category_id: id, is_primary: id === last, })) })Edit mode restore: build chain from product's part categories by walking parent_id up to root, then reverse
2. Catalog — pages/catalog/index.vue
- Replace single
USelectMenuwith<CategoryCascadeSelect> filters.category_id= deepest selected (last in chain)- On mount: if
?category_idquery param exists, set it as initial selection (root level)
3. Product Detail — pages/product/[id].vue
- Replace single category name display with breadcrumb chain
- Build chain: from product's part categories, walk parent_id to build ordered path
- Display:
Категория1 > Категория2 > Категория3 - Each level clickable →
/catalog?category_id={id}
4. Admin Product Detail — pages/admin/products/[id].vue
- Same breadcrumb display as product detail
What Does NOT Change
- Category schema (
entities/category/model/category.schema.ts) — same flat structure withparent_id - Admin references (
features/admin-references/) — already supports hierarchy display - Condition / Attribute categories — remain flat, radio buttons
- API endpoints — no changes
- Homepage — root category cards, no cascade
buildRequestBody()— already correct, sendsform.category_ids.map(c => c.category_id)
i18n Keys to Add
json
{
"listing.selectLevel": "Выберите подкатегорию"
}Existing keys listing.category, listing.selectCategory, listing.subcategory are reused.
Helper: Build Category Chain from Product
Utility function needed in multiple places (product detail, admin, edit form):
ts
function buildCategoryChain(
productCategories: ProductCategory[],
allCategories: Category[],
type: CategoryType = 'part',
): Category[] {
const catMap = new Map(allCategories.map(c => [c.id, c]))
const partCatIds = new Set(
productCategories
.map(pc => pc.category_id)
.filter(id => catMap.get(id)?.category_type === type)
)
// Find the deepest category (one whose id is not anyone's parent_id)
const leaf = [...partCatIds].find(id => {
return ![...partCatIds].some(otherId => catMap.get(otherId)?.parent_id === id)
})
if (!leaf) return []
// Walk up from leaf to root
const chain: Category[] = []
let current = catMap.get(leaf)
while (current) {
chain.unshift(current)
current = current.parent_id ? catMap.get(current.parent_id) : undefined
}
return chain
}This lives in features/category-select/composables/useCategoryCascade.ts as an exported utility.
Testing
useCategoryCascade— unit tests:- Builds correct levels from flat data
- Selecting at level N clears deeper levels
categoryChainreturns full pathsetChainrestores from existing IDs- Works with 1, 2, 3, 4 levels of depth
- Handles categories with no children (leaf selection)
- Integration: product form correctly sends chain, edit mode restores chain