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.
Navigation Stack¶
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
Deep Link Support¶
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:
| 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
Data flow: