Skip to content

Block Ordering and Reordering System - Complete Architecture Documentation

Overview

This document provides a comprehensive analysis of how block ordering, removal, and reordering works in the Cavai Application-Frontend, with special focus on the problematic areas in form blocks.

Core Concepts

Block Types

There are three categories of blocks:

  1. Visual Elements (defined in VISUAL_ELEMENTS array)

    • textProperties, graphicProperties, htmlProperties, buttonProperties
    • Can have multiple instances with dynamic keys (e.g., textProperties-1, textProperties-2)
    • Use generateVisualElementName() to create unique blockNames
  2. Specialized Blocks (NOT in VISUAL_ELEMENTS)

    • sliderProperties, formProperties, conversationProperties, etc.
    • Usually only one instance per creative
    • Use static keys (e.g., formProperties, not formProperties-1)
  3. Sub-Blocks (nested within parent blocks)

    • Slider sub-blocks: graphicProperties-X, textProperties-X nested in sliderProperties
    • Form sub-blocks: formInputProperties-X, formSubmitButtonProperties nested in formProperties
    • Stored as properties on parent object (NOT in arrays)

Block Identification

Each block has these key properties:

typescript
{
  blockName: string,      // Unique identifier (e.g., "formInputProperties-3")
  blockType: string,      // Type identifier (e.g., "formInputProperties")
  displayName: string,    // User-facing name (e.g., "Email #1")
  order: number,          // Position in rendering order
  parent: string,         // Parent block's blockName
  typeIndex?: number      // For form inputs: per-type numbering (e.g., 1st email, 2nd email)
}

Storage Architecture

Top-Level Blocks

Stored directly in state.creativeBlocks:

json
{
  "creativeBlocks": {
    "baseProperties": { ... },
    "formProperties": { ... },
    "textProperties-1": { ... },
    "textProperties-2": { ... }
  }
}

Sub-Blocks (Nested Storage)

Stored as properties within parent blocks:

json
{
  "formProperties": {
    "blockName": "formProperties",
    "blockType": "formProperties",
    "order": 1,
    "formInputProperties-1": {
      "blockName": "formInputProperties-1",
      "blockType": "formInputProperties",
      "type": "text",
      "order": 0,
      "parent": "formProperties"
    },
    "formInputProperties-2": {
      "blockName": "formInputProperties-2",
      "blockType": "formInputProperties",
      "type": "email",
      "order": 1,
      "parent": "formProperties"
    }
  }
}

CRITICAL: Sub-blocks are stored as object properties, NOT in arrays. The old inputs array pattern was removed.

Key Functions

1. getSubBlocks(block)

Location: /src/pages/Chatbots/components/BuilderVisuals/Blocks/utils.ts

Purpose: Extract all sub-blocks from a parent block

Implementation:

typescript
export const getSubBlocks = (block) => {
  // Collect subblocks stored as direct object properties
  const propSubBlocks = Object.values(block).filter((value: BlockBase) => value?.blockName)
  
  // Legacy: Also check for array-based storage (being phased out)
  const arraySubBlocks: BlockBase[] = []
  if (Array.isArray((block as any).inputs)) {
    arraySubBlocks.push(...((block as any).inputs as any[]).filter((v: any) => v && v.blockName))
  }
  
  // Merge and dedupe by blockName
  const merged = [...propSubBlocks, ...arraySubBlocks]
  const uniqueByName = Object.values(
    merged.reduce((acc, cur) => {
      acc[cur.blockName] = cur
      return acc
    }, {})
  )
  
  // Sort by order DESCENDING (highest order first)
  return uniqueByName.sort((a, b) => (Number(b.order) || 0) - (Number(a.order) || 0))
}

Important: Returns blocks sorted by order in DESCENDING order (highest first).

2. getBlockPath(candidateBlockName, creativeBlocks)

Location: /src/pages/Chatbots/components/BuilderVisuals/Blocks/utils.ts

Purpose: Find the path to a block (either top-level or nested)

Returns:

  • Top-level block: "formProperties"
  • Nested block: "formProperties.formInputProperties-3"

Implementation:

typescript
export const getBlockPath = (candidateBlockName, creativeBlocks) => {
  // Case 1: Top-level block
  if (Object.values(creativeBlocks).map(block => block.blockName).includes(candidateBlockName)) {
    return candidateBlockName
  }
  
  // Case 2: Nested sub-block
  let allSubBlocks = []
  getSubBlocks(creativeBlocks).forEach(block => {
    allSubBlocks = allSubBlocks.concat(getSubBlocks(block))
  })
  
  for (const subBlock of allSubBlocks) {
    if (subBlock.blockName === candidateBlockName) {
      return [subBlock.parent, candidateBlockName].join('.')
    }
  }
  
  return null
}

3. generateVisualElementName(creativeBlocks, blockType)

Location: /src/store/modules/blocks.ts

Purpose: Generate unique blockName and displayName for new blocks

Implementation:

typescript
const generateVisualElementName = (creativeBlocks, blockType) => {
  const id = uniqueBlockId(creativeBlocks, blockType)
  const blockName = blockType === 'sliderProperties' 
    ? blockType 
    : `${blockType}-${id}`
  
  const getDisplayName = () => {
    switch (blockType) {
      case BLOCKS.TEXT: return 'Text'
      case BLOCKS.GRAPHIC: return 'Graphic'
      case BLOCKS.HTML: return 'HTML'
      case BLOCKS.BUTTON: return 'Button'
      default: return ''
    }
  }
  
  return { blockName, displayName: `${getDisplayName()} #${id}` }
}

4. uniqueBlockId(blocks, newBlockType)

Location: /src/pages/Chatbots/components/BuilderVisuals/Blocks/utils.ts

Purpose: Find next available ID for a block type

Algorithm:

  1. Find all existing blocks of this type recursively
  2. Extract numeric IDs from blockNames (e.g., formInputProperties-33)
  3. Sort IDs
  4. Look for first gap in sequence (e.g., if have [1, 2, 4], return 3)
  5. If no gaps, return max + 1

This is why blockNames can become mismatched after reordering!

Form Input Specific Functions

5. calculateFormInputTypeIndex(parentBlock, inputType)

Location: /src/store/modules/blocks.ts

Purpose: Calculate the next typeIndex for a specific input type

Implementation:

typescript
const calculateFormInputTypeIndex = (parentBlock, inputType) => {
  const existingInputs = getSubBlocks(parentBlock).filter(block =>
    block.blockType === 'formInputProperties' && (block.type || 'text') === inputType
  )
  
  // Sort by order to match renumberFormInputDisplayNames logic
  existingInputs.sort((a, b) => {
    const orderA = typeof a.order === 'number' ? a.order : 0
    const orderB = typeof b.order === 'number' ? b.order : 0
    return orderA - orderB
  })
  
  return existingInputs.length + 1
}

6. renumberFormInputDisplayNames(formBlock)

Location: /src/store/modules/blocks.ts

Purpose: Renumber all form inputs by type after add/remove/reorder

Implementation:

typescript
const renumberFormInputDisplayNames = (formBlock) => {
  const formInputs = getSubBlocks(formBlock).filter(block =>
    block.blockType === 'formInputProperties'
  )
  
  // Sort by order ASCENDING
  formInputs.sort((a, b) => {
    const orderA = typeof a.order === 'number' ? a.order : 0
    const orderB = typeof b.order === 'number' ? b.order : 0
    return orderA - orderB
  })
  
  // Group by type and renumber each type
  const typeCounters = {}
  
  formInputs.forEach(input => {
    const inputType = input.type || 'text'
    
    if (!typeCounters[inputType]) {
      typeCounters[inputType] = 0
    }
    
    typeCounters[inputType]++
    input.typeIndex = typeCounters[inputType]
    
    // Only update displayName/label if they match default pattern
    if (isDefaultDisplayName(input.displayName, inputType)) {
      const newDisplayName = generateFormInputDisplayName(inputType, typeCounters[inputType])
      input.displayName = newDisplayName
      
      if (isDefaultDisplayName(input.label, inputType)) {
        input.label = newDisplayName
      }
    }
  })
}

Key Point: This function updates typeIndex, displayName, and label but does NOT update blockName.

Mutations (State Changes)

1. addBlock

Location: /src/store/modules/blocks.ts line 520

Flow for Form Inputs:

typescript
// 1. Check if adding form input
if (blockTypeToCreate === BLOCKS.FORM_INPUT) {
  const parentBlock = state.creativeBlocks[targetBlockPath]
  
  // 2. Create form input block with unique blockName
  const formInputBlock = createFormInputBlock(
    state,
    parentBlock,
    targetBlockPath,
    inputType,
    additionalProperties
  )
  
  // 3. Add to parent as property
  const formParent = { ...state.creativeBlocks.formProperties }
  formParent[formInputBlock.blockName] = formInputBlock
  
  // 4. Set order based on ALL existing sub-blocks
  const allSubBlocks = getSubBlocks(formParent)
  formInputBlock.order = allSubBlocks.length
  
  // 5. Renumber all form inputs
  renumberFormInputDisplayNames(formParent)
  
  // 6. Update state
  state.creativeBlocks = {
    ...state.creativeBlocks,
    formProperties: formParent
  }
}

2. removeBlock

Location: /src/store/modules/blocks.ts line 676

Flow:

typescript
removeBlock(state, name) {
  const creativeBlocks = cloneDeep(state.creativeBlocks)
  const path = getBlockPath(name, creativeBlocks)
  
  // If nested path like "formProperties.formInputProperties-3"
  if (path.includes('.')) {
    const [parentKey, childKey] = path.split('.')
    const parent = creativeBlocks[parentKey] || {}
    
    // Remove the subblock object from parent
    if (parent && parent[childKey]) {
      delete parent[childKey]
    }
    
    // Renumber remaining form inputs
    if (parent.blockType === 'formProperties') {
      renumberFormInputDisplayNames(parent)
    }
    
    creativeBlocks[parentKey] = parent
  }
  
  // Remove the block
  unset(creativeBlocks, path)
  state.creativeBlocks = creativeBlocks
}

PROBLEM: After deletion, renumberFormInputDisplayNames() updates typeIndex but NOT blockName. This creates a mismatch.

3. updateBlockOrder

Location: /src/store/modules/blocks.ts line 764

Flow:

typescript
updateBlockOrder(state, { block, newIndex }) {
  const oldIndex = block.order
  if (newIndex === oldIndex) return
  
  const creativeBlocks = cloneDeep(state.creativeBlocks)
  const path = getBlockPath(block.blockName, state.creativeBlocks)
  const isSubBlock = path.includes('.')
  
  // Get all blocks at this level
  const blocks = getSubBlocks(isSubBlock ? creativeBlocks[block.parent] : creativeBlocks)
  const orderByBlockNames = orderBy(blocks, ['order']).map(item => item.blockName)
  
  // Reorder: remove from old position, insert at new position
  orderByBlockNames.splice(oldIndex, 1)
  orderByBlockNames.splice(newIndex, 0, block.blockName)
  
  // Apply new indices
  orderByBlockNames.forEach((blockName, index) => {
    const pathComponents = [isSubBlock ? block.parent : undefined, blockName, 'order']
    const subPath = compact(pathComponents).join('.')
    set(creativeBlocks, subPath, index)
  })
  
  // If reordering form inputs, renumber
  if (isSubBlock && block.blockType === 'formInputProperties') {
    const parentBlock = creativeBlocks[block.parent]
    if (parentBlock) {
      renumberFormInputDisplayNames(parentBlock)
    }
  }
  
  state.creativeBlocks = creativeBlocks
}

PROBLEM: After reordering, renumberFormInputDisplayNames() updates typeIndex but NOT blockName. This creates mismatches.

The Root Problem

Issue: blockName vs typeIndex Mismatch

What happens:

  1. You create form inputs in order:

    • formInputProperties-1 (text, typeIndex: 1, order: 0)
    • formInputProperties-2 (email, typeIndex: 1, order: 1)
    • formInputProperties-3 (phone, typeIndex: 1, order: 2)
  2. You reorder them (move phone to position 0):

    • formInputProperties-3 (phone, typeIndex: 1, order: 0) ← order changed
    • formInputProperties-1 (text, typeIndex: 1, order: 1) ← order changed
    • formInputProperties-2 (email, typeIndex: 1, order: 2) ← order changed
  3. renumberFormInputDisplayNames() runs and updates typeIndex based on NEW order:

    • formInputProperties-3 (phone, typeIndex: 1, order: 0) ← typeIndex stays 1
    • formInputProperties-1 (text, typeIndex: 1, order: 1) ← typeIndex stays 1
    • formInputProperties-2 (email, typeIndex: 1, order: 2) ← typeIndex stays 1
  4. You delete the phone input (formInputProperties-3):

    • formInputProperties-1 (text, order: 0)
    • formInputProperties-2 (email, order: 1)
  5. renumberFormInputDisplayNames() runs again:

    • formInputProperties-1 (text, typeIndex: 1, order: 0)
    • formInputProperties-2 (email, typeIndex: 1, order: 1)
  6. You add a new date input:

    • uniqueBlockId() sees gaps: [1, 2] and returns 3
    • New block gets blockName: "formInputProperties-3"
    • But it's a DATE input, not the original PHONE input!

Result: formInputProperties-3 is now a date input, but the JSON shows it as formInputProperties-4 in your example because of complex reordering.

Why This Happens

The system has TWO separate numbering systems:

  1. blockName suffix (e.g., -1, -2, -3)

    • Generated by uniqueBlockId() which finds gaps in existing IDs
    • NEVER changes after creation
    • Used as object property key
  2. typeIndex (e.g., 1st email, 2nd email)

    • Calculated by renumberFormInputDisplayNames() based on current order
    • CHANGES on every add/remove/reorder
    • Used for display names and CSS classes

These two systems are completely independent and can diverge!

Example from Your JSON

Looking at your JSON:

json
"formInputProperties-2": {
  "blockType": "formInputProperties",
  "type": "phone",
  "order": 3,
  "blockName": "formInputProperties-3"  // ← MISMATCH!
}

The property key is formInputProperties-2 but blockName is formInputProperties-3. This is the core problem.

Why It's Overcomplicated

Multiple Sources of Truth

  1. Object property keys (e.g., formProperties["formInputProperties-2"])
  2. blockName property (e.g., blockName: "formInputProperties-3")
  3. order property (e.g., order: 3)
  4. typeIndex property (e.g., typeIndex: 1)

These should all be in sync but aren't.

Inconsistent Sorting

  • getSubBlocks() sorts DESCENDING by order (highest first)
  • renumberFormInputDisplayNames() sorts ASCENDING by order (lowest first)
  • updateBlockOrder() uses lodash orderBy() which sorts ASCENDING

Gap-Filling Logic

uniqueBlockId() tries to reuse deleted IDs, which causes:

  • Deleted formInputProperties-3 (phone)
  • New input gets formInputProperties-3 (date)
  • But they're completely different inputs!

Dual Update Pattern

When reordering/removing:

  1. Update order property
  2. Call renumberFormInputDisplayNames() which updates typeIndex, displayName, label
  3. But NOT blockName

This creates divergence.

Current Workarounds

1. uniquefyFormBlockNames()

Location: /src/store/modules/blocks.ts line 255

Used when duplicating form blocks to ensure unique names:

typescript
const uniquefyFormBlockNames = (creativeBlocks, block) => {
  const uniqueCreativeBlocks = {}
  const allBlocksClone = cloneDeep({ ...creativeBlocks, ...block })
  getSubBlocks(allBlocksClone).forEach(subBlock => {
    uniqueCreativeBlocks[subBlock.blockName] = subBlock
  })
  
  const subBlockNameDictionary = {}
  
  // Iterate through sub-blocks and generate new unique names
  getSubBlocks(block).forEach(subBlock => {
    delete block[subBlock.blockName]
    const { blockName } = generateVisualElementName(uniqueCreativeBlocks, subBlock.blockType)
    
    subBlockNameDictionary[subBlock.blockName] = blockName
    subBlock.blockName = blockName
    
    uniqueCreativeBlocks[blockName] = subBlock
    block[blockName] = subBlock
  })
  
  // Renumber form input display names
  renumberFormInputDisplayNames(block)
  
  return block
}

Problem: This works for duplication but doesn't fix the reordering issue.

2. Renumbering After Every Operation

The code calls renumberFormInputDisplayNames() after:

  • Adding form input
  • Removing form input
  • Reordering form input
  • Duplicating form block

But this only updates typeIndex, not blockName.

Comparison with Slider Blocks

Slider blocks have similar architecture but work better because:

  1. Slider sub-blocks ARE visual elements

    • They're in the VISUAL_ELEMENTS array
    • They can have multiple instances
    • Their blockNames are meant to be dynamic
  2. No typeIndex complexity

    • Slider blocks don't have per-type numbering
    • Just simple order-based rendering
  3. Simpler renaming

    • uniquefySlideBlockNames() regenerates ALL blockNames on duplication
    • No partial updates like form inputs

Summary of Issues

  1. blockName never updates after creation - causes property key vs blockName mismatch
  2. Gap-filling reuses IDs - deleted phone input's ID gets reused for date input
  3. Inconsistent sorting - different functions sort differently
  4. Multiple numbering systems - blockName suffix vs typeIndex
  5. Form inputs NOT in VISUAL_ELEMENTS - can't use standard visual element patterns
  6. Object property keys vs blockName - two ways to identify same block

Next Steps

See the accompanying block-ordering-refactor-plan.md for proposed solutions.

Internal documentation