Skip to content

SubHandler Pattern

Türkçe

SubHandler, bir parent entity'nin detay sayfasında child entity'leri tab olarak gösteren ve child'a tıklandığında yeni bir seviyeye "drill-down" yapan navigasyon kalıbıdır. Örnek: İş Ortağı → Şubeler → Sözleşmeler şeklinde 3 seviye derine inebilirsiniz.

Concept

SubHandlers enable nested entity navigation within the CRUD framework. When viewing a parent entity, related child entities appear as tabs. Clicking a child row drills down into it, creating a breadcrumb-style navigation stack.

Business Partner (VIEW)
├── [Master Tab] Partner details form
├── [Branches Tab] List of branches → click → drill-down
├── [Contacts Tab] List of contacts → click → drill-down
└── [Contracts Tab] List of contracts → click → drill-down
         └── Contract (VIEW) ← new navigation level
             ├── [Master Tab] Contract details
             └── [Items Tab] Contract items → click → drill-down

URL reflects the navigation stack:

/business-partners/a7x9k2m                              ← Partner VIEW
/business-partners/a7x9k2m/branches/b3k8m2x             ← Branch drill-down
/business-partners/a7x9k2m/branches/b3k8m2x/edit        ← Branch EDIT

Configuration

SubHandlers Section

subHandlers: {
    enabled: true,
    renderIn: ["VIEW"],                // Show tabs only in VIEW mode
    layout: "TABVIEW_INTEGRATED",      // Layout style
    masterTabTitle: "subhandler.bp.masterTab",  // First tab label
    items: [
        {
            key: "branches",
            handler: "branches",       // Config registry key
            title: "subhandler.bp.branches",
            icon: "Building2",
            order: 1,
            parentFields: [
                { source: "id", target: "businessPartnerId" }
            ],
            lazyLoad: true,
            allowOwnerCrud: true,
        },
        {
            key: "contacts",
            handler: "contacts",
            title: "subhandler.bp.contacts",
            icon: "Users",
            order: 2,
            parentFields: [
                { source: "id", target: "businessPartnerId" }
            ],
        },
    ],
}

SubHandlerConfig Interface

interface SubHandlerConfig {
    key: string                        // Unique identifier
    handler: string                    // Config registry basePath
    title: string                      // Tab title (i18n key)
    icon?: string                      // Lucide icon name
    order: number                      // Tab sort order
    parentFields: SubHandlerParentField[]  // Parent → child mapping
    badge?: {                          // Count badge on tab
        source: string
        property: string
    }
    lazyLoad?: boolean                 // Load on tab focus only
    initialAction?: CrudMode           // Initial mode when opened
    visibleWhen?: SubHandlerVisibleWhen  // Conditional visibility
    defaultFilter?: Record<string, unknown>  // Extra filter applied
    allowOwnerCrud?: boolean           // Owner bypass for child CRUD
    allowAdminCrud?: boolean           // Admin bypass for child CRUD
    useParentRbac?: boolean            // Use parent's RBAC module
}

Parent-Child Field Mapping

The parentFields array defines how parent entity data flows into child queries:

interface SubHandlerParentField {
    source: string   // Field name on parent entity
    target: string   // Field name on child filter/entity
}

Example:

// Parent: BusinessPartner { id: 123, code: "BP-001" }
parentFields: [
    { source: "id", target: "businessPartnerId" }
]
// → Child filter includes: { businessPartnerId: 123 }
// → Child create includes: { businessPartnerId: 123 }

Nested target fields are supported:

parentFields: [
    { source: "accountNo", target: "account.accountNo" }
]
// → Builds: { account: { accountNo: "ACC001" } }

Türkçe

parentFields hem LIST sorgusunda (filtre olarak) hem de CREATE/EDIT işleminde (kayda otomatik eklenir) kullanılır. Child entity'nin parent referansı böyle korunur.


Store (Zustand)

Drill-down state is managed by a Zustand store:

// src/app/store/navigation-stack-store.ts

interface NavigationLevel {
    key: string                         // Unique React key
    config: HandlerConfig               // Child handler config
    entityId: number | string | null    // Entity ID (null for CREATE)
    entityLabel: string                 // Breadcrumb display text
    entityTypeLabel: string             // Type label (e.g., "Branches")
    mode: CrudMode                      // VIEW | EDIT | CREATE
    parentFilter: Record<string, unknown>  // Filter from parent
    subHandlerItem: SubHandlerConfig | null
    activeTabIndex: number              // Active SubHandler tab
}

Operations:

Method Description
pushLevel(level) Navigate down (drill-down into child)
popLevel() Navigate back (return to parent)
popToLevel(index) Jump to specific breadcrumb level
updateActiveLevel(updates) Change mode, entity, or tab at current level
replaceRoot(level) Set root level (deep link reconstruction)

Drill-Down Flow

1. User views BusinessPartner (root level)
   Stack: [{ config: bpConfig, entityId: 123, mode: VIEW }]

2. User clicks "View" on Branch row in SubHandler tab
   → pushLevel({ config: branchConfig, entityId: 456, mode: VIEW,
                  parentFilter: { businessPartnerId: 123 } })
   Stack: [root, { config: branchConfig, entityId: 456, mode: VIEW }]

3. User clicks Edit on Branch
   → updateActiveLevel({ mode: EDIT })
   Stack: [root, { config: branchConfig, entityId: 456, mode: EDIT }]

4. User saves and goes back
   → popLevel()
   Stack: [root]  ← Back to BusinessPartner VIEW

When a user refreshes (F5) or shares a multi-level URL, CrudPage reconstructs the stack:

URL: /business-partners/a7x9k2m/branches/b3k8m2x

→ Parse: parent=business-partners/a7x9k2m, child=branches/b3k8m2x
→ Load parent config → push root level
→ Load child config → push drill-down level
→ Render DrillDownFormView at level 1

Component Architecture

CrudFormView
├── FormSections (parent entity form)
├── SubHandlerPanel
│   ├── Tab: Master (parent form)
│   ├── Tab: Branches (SubHandlerTab)
│   │   ├── Own useCrudHandler (separate state)
│   │   ├── FilterPanel (child filters)
│   │   ├── DataTable (child list)
│   │   └── Row click → DrillDownContext.onDrillDown()
│   └── Tab: Contacts (SubHandlerTab)
│       └── ...
└── Sidebar

DrillDownFormView (rendered when stack.length > 1)
├── Own useCrudHandler (separate state per level)
├── Loads entity on mount
├── Full CRUD operations within level
├── SubHandlerPanel (recursive — child can have sub-handlers too)
└── Back button → popLevel()

DrillDown Context

Provided by CrudFormView, consumed by SubHandlerTab:

interface DrillDownContextValue {
    enabled: boolean
    onDrillDown: (
        childConfig: HandlerConfig,
        entityId: number | string | null,
        entityLabel: string,
        subHandlerItem: SubHandlerConfig,
        parentFilter: Record<string, unknown>,
        mode: CrudMode,
    ) => void
}

When a row is clicked in a SubHandler tab, onDrillDown pushes a new level to the stack.


Layouts

Layout Description
TABVIEW_INTEGRATED Master form as Tab 0, each SubHandler as Tab 1+
TABVIEW_BOTTOM SubHandler tabs rendered below master form
ACCORDION Each SubHandler as a collapsible accordion section
SIDE SubHandler in a side panel (future)

Conditional Visibility

SubHandler tabs can be conditionally hidden based on parent entity state:

visibleWhen: {
    field: "status",
    operator: "eq",
    value: "ACTIVE",
}
Operator Description
eq Field equals value
neq Field does not equal value
truthy Field is truthy (not null/undefined/false/"")
falsy Field is falsy

SubHandler Permissions

Each SubHandler tab has its own RBAC checks:

// Default: use child handler's own moduleName
const rbacModule = item.useParentRbac
    ? parentConfig.crud.moduleName     // Inherit parent RBAC
    : childConfig.crud.moduleName      // Own RBAC

const rbac = useRBAC(rbacModule)

// Owner/admin override
const ownerOverride = item.allowOwnerCrud && user?.isAccountOwner
const adminOverride = item.allowAdminCrud && user?.isAccountAdmin

const canCreate = rbac.canCreate || ownerOverride || adminOverride
const canEdit   = rbac.canEdit   || ownerOverride || adminOverride
const canDelete = rbac.canDelete || ownerOverride || adminOverride

Türkçe

useParentRbac: true ayarı, child entity için ayrı rol tanımlamak gerekmediğinde kullanılır. Örneğin "Contract Items" için ayrı rol yerine "Contract" rolü yeterli olabilir.


Example: Complete Parent-Child Setup

Parent: business-partner.config.ts

subHandlers: {
    enabled: true,
    renderIn: ["VIEW"],
    layout: "TABVIEW_INTEGRATED",
    items: [
        {
            key: "branches",
            handler: "branches",         // ← points to branch config
            title: "subhandler.bp.branches",
            order: 1,
            parentFields: [
                { source: "id", target: "businessPartnerId" }
            ],
        },
    ],
}

Child: branch.config.ts

crud: {
    basePath: "branches",
    moduleName: "ICOSYS_BPM.BranchDef",
    // ...standard CRUD config
}

Data flow:

Parent entity: { id: 123, legalName: "ACME Corp" }
    parentFields: source="id" → target="businessPartnerId"
Child list request:  POST /api/branch/list
                     { filter: { businessPartnerId: 123 } }

Child create:        POST /api/branch
                     { name: "HQ", businessPartnerId: 123, ... }