Skip to content

Block Grouping — Feasibility & Design Spec

Date: 2026-04-08 GitHub Issue: #1805 Status: Design approved, ready for implementation planning

Summary

Add a GROUP block type that wraps child blocks in a named <div> with a CSS class. Groups have size, position, and flex alignment. This enables scroll-sections (double midscroll), shared animation (future), and custom CSS targeting.

MVP Scope

Included

  • GROUP block type with groupName, size, position, flex alignment
  • Multi-select in block list (Cmd/Ctrl+click)
  • Group/Ungroup via context menu and keyboard shortcut (Cmd+G)
  • Collapsible group rendering in block list with drag in/out
  • CreativeGroupBlock.vue in engine — renders wrapper div with CSS class from groupName
  • Config panel: name, width, height, top, left, justify-content, align-items

Not included (future)

  • Nesting (groups inside groups) — test if trivial during implementation
  • Group-level animation / childAnimation / stagger
  • Background color, overflow, opacity, border, etc.
  • Scrub-on-input (blocked by theming branch)

Data Model

New block type

blocks.ts constants:

typescript
GROUP: 'groupProperties'

types.ts:

typescript
interface GroupProperties extends BlockBase {
  blockType: 'groupProperties'
  groupName: string              // User-chosen name → sanitized CSS class
  width: string                  // Default: '100%'
  height: string                 // Default: '100%'
  top: string                    // Default: '0'
  left: string                   // Default: '0'
  justifyContent: string         // Default: 'flex-start'
  alignItems: string             // Default: 'flex-start'
  allowedSubBlocks: ['*']        // All block types allowed as children
  subBlocksReorderable: true
}

Parent/child relationship

Uses the existing parent field on BlockBase and the nested property storage pattern (same as form inputs inside formProperties). Children are stored as properties of the group block object, not as top-level entries:

creativeBlocks: {
  "groupProperties-1": {
    groupName: "section-1", order: 0, ...,
    "textProperties-1":  { parent: "groupProperties-1", order: 0, ... },
    "graphicProperties-1": { parent: "groupProperties-1", order: 1, ... },
  },
  "buttonProperties-1": { order: 0, ... }   // ungrouped, top-level
}

This matches how the engine's getSubBlocks() function discovers children (filters object properties with blockName field) and how the builder's BlocksList renders nested sub-blocks.

No backend changes — stored in creativeBlob JSON like all other blocks.

Engine Rendering

New component: CreativeGroupBlock.vue

html
<div :class="[blockClassNames.wrap, sanitizedGroupName]"
     :style="groupStyles">
  <!-- Reuse CreativeVisualElements with a group filter prop, or inline the same pattern -->
  <creative-text-block v-for="block in childTextBlocks" :block="block" />
  <creative-graphic-block v-for="block in childGraphicBlocks" :block="block" />
  <creative-button-block v-for="block in childButtonBlocks" :block="block" />
  <creative-html-block v-for="block in childHtmlBlocks" :block="block" />
</div>
  • sanitizedGroupName: groupName sanitized to valid CSS class (lowercase, hyphens, no spaces)
  • groupStyles: computed with position: relative, width, height, top, left, display: flex, justifyContent, alignItems
  • Children filtered from creativeBlocks where block.parent === this.blockName
  • Ordered by child order field

Changes to existing rendering

CreativeBody.vue: Add group blocks to the rendering loop alongside conversation, video, slider, form blocks. Add // #include when blocks have "groupProperties" guard so group code is tree-shaken from creatives that don't use groups.

CreativeVisualElements.vue: Add a group-specific filter to the visualElements computed. The filter must check if block.parent matches a group block specifically (e.g., isGroupChild(block)), not just any parent — because parent is already used by other block relationships (form inputs, slider sub-blocks, expandable blocks). Blocks with a group parent are skipped here and rendered inside their group instead.

GROUP is NOT a visual element: Do not add GROUP to the VISUAL_ELEMENTS constant. Groups are containers, not visual elements.

Child positioning: Children inside a group are positioned relative to the group div (since the group has position: relative). When ungrouping, child position values remain unchanged — the user may need to adjust them manually. This is the expected Figma-like behavior.

Key principle: blocks with a group parent render inside the group div, not at the top level. Ungrouped blocks render as they do today.

Builder UI

Multi-select (new capability)

  • Cmd/Ctrl + click to toggle block selection in block list
  • New store state: selectedBlocks: string[] — coexists with existing selectedBlockPath (single selection for config panel). selectedBlocks is for multi-select actions only; selectedBlockPath continues to drive which config panel is shown.
  • Selected blocks get visual highlight
  • Useful beyond grouping (future: bulk delete, bulk hide, etc.)

Group action

  • Select 2+ blocks → right-click → "Group" (or Cmd+G)
  • Creates groupProperties-N with default name "Group N"
  • Sets parent on all selected blocks
  • Group defaults to 100% width/height of creative

Ungroup action

  • Right-click group → "Ungroup"
  • Removes parent from all children
  • Deletes the group block

Block list rendering

▾ Group 1              ← collapsible, draggable
    Text Block 1       ← indented, draggable within group
    Graphic Block 1
▾ Group 2
    Text Block 2
    Button Block 1
  Video Block          ← ungrouped, draggable as today
  • Drag blocks in/out of groups to change parent
  • Drag entire group to change z-order
  • Collapse/expand with arrow icon (same pattern as form blocks today)

Config panel

When a group is selected, show config with:

  • Name — text input, updates groupName and CSS class
  • Size — width/height (standard InputField)
  • Position — top/left (standard InputField)
  • Alignment — justifyContent/alignItems (OptionList or InputSelect)

Changes Per Repo

Application-Frontend

  • src/constants/blocks.ts — add GROUP constant
  • src/pages/Chatbots/components/BuilderVisuals/Blocks/data/types.ts — add GroupProperties type
  • src/store/modules/blocks.ts — multi-select state, group/ungroup mutations, filter grouped blocks from top-level
  • src/pages/Chatbots/components/BuilderVisuals/Blocks/BlocksList.vue — multi-select UI, group context menu, collapsible group rendering
  • src/pages/Chatbots/components/BuilderVisuals/Blocks/BlockItem.vue — multi-select click handling, group drag targets
  • New config component for group blocks (name, size, position, alignment sections)

Creative-Engine

  • New CreativeGroupBlock.vue component
  • src/components/conversationflow/CreativeBody.vue — render group blocks in loop
  • src/components/creative/VisualElements/CreativeVisualElements.vue — skip blocks with group parent
  • src/utils/blockUtils.ts — group class name generation if needed

Application-Backend

  • No changes

Nesting (stretch goal)

If implementation is trivial, test groups inside groups. The parent field already supports any block as parent. Main considerations:

  • Recursive child filtering in engine
  • Indentation depth in block list UI
  • Config panel shows correct parent context

Risk Assessment

Low risk: The parent field, allowedSubBlocks, subBlocksReorderable, and sub-block rendering patterns all exist. Group is conceptually "a block without visual content that wraps children in a div."

Medium risk: Multi-select is new UI behavior. Drag-in/out of groups needs careful UX for drop zones.

Lessons Learned (post-implementation)

  • selectedBlockPath goes stale when blocks move between containers. Any mutation that relocates a block (groupBlocks, ungroupBlocks, moveBlockToGroup, moveBlockOutOfGroup) must rewrite selectedBlockPath to match the block's new location. Config components retain the stale path otherwise.
  • updateBlockValue uses lodash.set, which silently creates missing keys. A stale path like 'buttonProperties-1.isStatePreview' (after the button was moved into a group) will produce a ghost top-level buttonProperties-1: { isStatePreview: false }. On save+reload, setCreativeBlocks then runs defaultsDeep and fills in the full default shape — visible as a duplicate block with empty displayName and the default parent. Guard: reject paths whose root key isn't in creativeBlocks.
  • fixBlockNameMismatches must cover every sub-block container. It already handled formProperties and sliderProperties; groups needed the same treatment.
  • i18n key lookup uses blockType, not blockName. Keys are visuals.blocks.buttonProperties, not visuals.blocks.buttonProperties-1. BlockItem.vue name fallback must use block.blockType.
  • generateVisualElementName display-name switch must list every type. Missing cases (VIDEO, CONVERSATION, SLIDER, FORM, AR, GROUP) produced empty display names.

No risk: Backend is unchanged — group blocks are just another entry in the creativeBlob JSON.

Internal documentation