Skip to content

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

  1. Cascading dropdowns (like YMM select) — each level appears after selecting the previous
  2. User can stop at any level — not required to reach leaf
  3. Full chain sent to backend — all selected category IDs in category_ids[], is_primary on deepest
  4. Homepage unchanged — root category cards link to catalog
  5. Catalog — cascading filter replaces single dropdown

New Feature: features/category-select/

Structure

features/category-select/
├── composables/
│   └── useCategoryCascade.ts
└── ui/
    └── CategoryCascadeSelect.vue

useCategoryCascade(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 children
  • selections: Ref<(number | undefined)[]> — selected ID per level

Computed:

  • levels: Array of { options: Category[], selected: number | undefined }[] — what to render
  • categoryChain: All selected IDs from root to deepest (for form.category_ids)
  • primaryCategoryId: Deepest selected ID (for primary_category_id)

Methods:

  • selectAt(level: number, categoryId: number | undefined) — select category at level, clear deeper levels, add next level if children exist
  • setChain(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 childrenMap

CategoryCascadeSelect.vue

Props:

  • categories: Category[] — flat list of part categories
  • modelValue: number[] — selected chain of IDs
  • placeholders?: 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_ids as 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 with form.category_ids
  • Replace 2 hardcoded USelectMenu with:
html
<CategoryCascadeSelect
  :categories="partCategories"
  v-model="selectedCategoryChain"
/>
  • Watch selectedCategoryChain → update form.category_ids:

    ts
    watch(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 USelectMenu with <CategoryCascadeSelect>
  • filters.category_id = deepest selected (last in chain)
  • On mount: if ?category_id query 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 with parent_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, sends form.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
    • categoryChain returns full path
    • setChain restores 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