Eddy Dev Handbook
Architecture

Block System Architecture

The architecture of Blocks, the fundamental building blocks for user interaction and data capture within workflows.

Block Architecture

Source: This page is based on docs/BLOCK_ARCHITECTURE.md

This document outlines the architecture of "Blocks" within Project Eddy. Blocks are the fundamental building blocks for user interaction and data capture within workflows and handbooks.

1. Core Concepts

A Block is a self-contained component that serves a specific purpose, such as displaying content, asking for user input, or facilitating a discussion.

Block Definition

Each block is defined by several key properties:

  • type: A unique string identifier (e.g., string, single-select, content). The available types are defined in app/constants/blocks.ts.
  • label: The primary text displayed to the user as the block's title or question.
  • helpText: A rich-text description or context to guide the user.
  • options: A set of configurable properties specific to the block's type (e.g., the URL for an iframe block, or a list of choices for a single-select block).
  • Data Binding (sheet_id, column_id): For input-based blocks, this defines where the user's response is stored. This is a crucial concept linking the UI (Block) to the data layer (Sheet/Column).

Block Configuration

When an administrator adds or edits a block in the workflow builder, a configuration panel is displayed. The main router for this is BlockConfig.tsx, which uses a match statement to render the appropriate configuration component for the given block type.

Many configuration components share common elements:

  • Header.tsx: A shared component for setting the label and helpText.
  • OutputSheetAndColumn.tsx: A critical shared component for most input blocks. It allows the builder to select an existing Sheet and Column or create new ones on the fly to store the block's output data.
  • BlockOptions.tsx: Used for blocks that require a list of user-defined choices (e.g., poll, single-select, multi-select). It handles creating, editing, reordering, and deleting options, which are stored in the block_options table.
  • Required.tsx: A simple checkbox to mark the block's input as mandatory.

Block Rendering

When a user participates in a workflow run, blocks are rendered using the BlocksRenderer.tsx component. Similar to the configuration, a switch statement maps the block type to a specific presentational component (e.g., <InputBlock>, <SelectBlock>, <ContentBlock>). These components are responsible for:

  1. Displaying the block's label, help text, and options.
  2. Providing the correct UI for user interaction.
  3. Reading and writing cell data based on the block's sheet_id and column_id.

2. Data Model: The Relationship Between Blocks and Sheets

The power of input blocks comes from their ability to store data in a structured way. This is achieved by linking the UI element (the Block) to a specific location in the data layer (the Sheet). Think of Blocks as the "form fields" and Sheets as the "database tables".

This relationship is built on four key components:

  • Sheet: The primary data container, analogous to a spreadsheet or a database table. It is composed of columns and rows.
  • Column: A field definition within a Sheet, like a column in a spreadsheet. Each column has a specific type (e.g., text, date, number) that dictates the kind of data it can store.
  • Row: A single record within a Sheet. In the context of a workflow, a new row is typically created for each unique Workflow Run, linking that specific instance of the workflow to a record in the sheet.
  • Cell: The intersection of a Row and a Column. This is where the actual data is stored. Each piece of information a user submits through a block is saved in a cell.

The Connection Flow in Practice

Here is a step-by-step example of how these components work together:

  1. Configuration (The "Binding"):

    • An admin is building a workflow and adds a "Date" block to capture a project's start date.
    • In the block's configuration panel, they use the OutputSheetAndColumn component. They select an existing "Projects" sheet or create a new one. Then, they select an existing "Start Date" column or create a new one.
    • The system ensures data integrity by mapping the block type to an appropriate column type, as defined in app/constants/blocks.ts (BLOCK_TYPE_TO_COLUMN_TYPE). A date block will be bound to a date column.
    • This binding—the sheet_id and column_id—is saved as part of the block's definition.
  2. Execution (The "Write" Operation):

    • A user begins this workflow, which creates a new Workflow Run.
    • The system also creates a corresponding Row in the "Projects" Sheet and links it to this specific workflow run.
    • The user interacts with the "Date" block and selects a date.
    • On submission, the application makes an API call to POST /api/cells/findOrCreate.
    • This endpoint uses the block's sheet_id and column_id, along with the current workflow_run_id (to find the correct row), to pinpoint the exact Cell where the selected date should be stored. The data is then written to the database.
  3. Rendering (The "Read" Operation):

    • If the user navigates away and comes back to the page with the "Date" block, the rendering component needs to display the previously saved value.
    • It fetches the cell data from the backend (e.g., via POST /api/cells/index) using the same coordinates: sheet_id, column_id, and workflow_run_id.
    • The value from the cell is then used to populate the date picker, showing the user their saved entry.

This architecture decouples the user interface from the data storage, allowing for flexible and powerful data collection that can be viewed, analyzed, and reused outside of the original workflow run.

3. Block Categories & Data Flow

Blocks can be grouped into several logical categories based on their function and configuration.

A. Input Blocks

These blocks are designed to capture user data. They almost always use the OutputSheetAndColumn component to bind their output to the data layer.

  • Types: string, textarea, number, email, phone, date, url, attachment.
  • Data Mapping: The block's type is mapped to a columnType in app/constants/blocks.ts (e.g., string block maps to a text column, date block to a date column). This ensures data integrity in the sheet.
  • Configuration: Primarily uses InputBlockConfig.tsx.

B. Choice Blocks

These blocks present the user with a predefined set of options to choose from.

  • Types: single-select, multi-select, poll, checkbox, checklist, todo.
  • Data Mapping: The selected option(s) are stored in the bound column. The column type is typically item (for single selection) or items (for multiple selections).
  • Configuration: Uses SelectBlockConfig.tsx, PollBlockConfig.tsx, or TaskBlockConfig.tsx. A key feature is the use of BlockOptions.tsx to manage the choices.

C. Content & Media Blocks

These blocks are for displaying static or dynamic content to the user and do not typically save data to a sheet column.

  • Types: content, iframe, video, resource, image-gallery.
  • Data Flow: The content (rich text, URL, file references) is stored directly in the block's own configuration (content or url fields).
  • Configuration: They have specialized, simpler configuration components like ContentBlockConfig.tsx, VideoBlockConfig.tsx, and MediaGalleryBlockConfig.tsx. The media/resource blocks have UI for uploading files, which are stored as content_attachments.

D. Specialized Blocks

These blocks have unique functionality that goes beyond simple data capture or content display.

  • discussion: Creates a discussion thread tied to the block within a specific workflow run. Does not write to a sheet column in the same way as input blocks.
  • review: Displays the output of another block within the same workflow for review. Its configuration (ReviewBlockConfig.tsx) involves selecting the embedded_block_id to review.
  • AI Blocks (minerva-*): These blocks take another column as an input, process its content via an AI service, and save the result to their own output column. Their configuration (AIBlockConfig.tsx) requires mapping both an input and an output column.

4. Rendering Block Data in Sheets

While the "Read Operation" described in the Data Model section covers how a block's value is displayed to a user within a workflow run, the collected data is also designed to be viewed in aggregate in a tabular "Sheet View". This provides a powerful way to see all the data from multiple workflow runs in one place, similar to a spreadsheet.

The process of rendering this data involves fetching the constituent parts of a sheet and assembling them into a grid on the frontend.

Data Fetching

When a user navigates to a specific sheet page (e.g., /workspaces/{groupId}/sheets/{sheetId}), the application makes an API call to an endpoint like POST /api/sheets/data/sheet. This endpoint gathers and returns a comprehensive JSON payload containing:

  1. Sheet Metadata: Information about the sheet itself (e.g., name, ID).
  2. Columns: An array of all column definitions for the sheet. Each column object includes its id, name, and crucially, its type (e.g., text, date, item).
  3. Rows: An array of all row records associated with the sheet. Each row corresponds to a unique record, often linked to a specific workflow_run_id.
  4. Cells: An array of all non-empty cells in the sheet. Each cell object contains its value along with the row_id and column_id that define its position.

Grid Assembly and Cell Rendering

The frontend UI, typically using a data grid component, takes this payload and assembles the view:

  1. Column Definition: The columns array is used to create the grid's headers. The order of columns is respected.
  2. Row Creation: The rows array is used to create the rows of the grid.
  3. Cell Population: The application then iterates through the cells array. For each cell, it uses the row_id and column_id to find the corresponding location in the grid and populates it with the cell's value.

A critical aspect of this process is that the rendering of a cell's value is dependent on the type of its parent column. The raw value stored in the database is a JSON string, which is parsed and formatted for display.

Here are some examples of how different column types are handled:

  • text, number, email: The raw value is displayed directly as a string or number.
  • date: The date string is parsed and formatted into a human-readable format (e.g., "July 26, 2024").
  • item (from single-select or poll blocks): The stored value is an object like {"id": "...", "label": "Option A"}. The renderer extracts and displays the label property.
  • items (from multi-select): The value is an array of item objects, e.g., [{"id": "...", "label": "Choice 1"}, {"id": "...", "label": "Choice 3"}]. This is often rendered as a collection of styled "pills" or a comma-separated list of the labels.
  • check (from checkbox): The value is {"label": "...", "checked": true}. This is rendered as a visual checkbox component (checked or unchecked).
  • checks (from checklist or todo): The value is an array of objects like [{"id": "...", "label": "Task 1", "checked": true}]. This might be rendered as a list of tasks with checkboxes, or a progress indicator like "1/3 completed".
  • attachments: The value is an array of file metadata objects. The renderer displays this as clickable links or file icons that allow the user to view or download the attached files.

This type-aware rendering ensures that the data collected from various interactive block types is presented in a clear, intuitive, and contextually appropriate way in the final sheet view.

5. API Endpoints

The lifecycle of blocks is managed through several key API endpoints, documented in API_ENDPOINTS.md:

  • POST /api/blocks/create: Creates a new block within a section.
  • POST /api/blocks/update: Updates a block's properties. This endpoint is powerful and can also trigger the creation of new sheets or columns if specified in the payload.
  • POST /api/blocks/delete: Archives a block.
  • POST /api/block_options/*: A set of endpoints for CRUD operations on the options associated with choice-based blocks.

6. How to Add a New Block

This section provides a step-by-step guide for developers to add a new block type to the Eddy system. Let's assume we are creating a new "Signature" block with the type signature.

Step 1: Define Block & Column Types

First, you need to register the new block type and its associated data (column) type within the application's constants.

  1. Open app/constants/blocks.ts.
  2. Add an entry to the BLOCK_TYPES_AND_LABELS array. This makes the block available in the workflow builder's "Add a block" dropdown.
// app/constants/blocks.ts

export const BLOCK_TYPES_AND_LABELS = [
  // ... existing block types
  {
    type: 'signature',
    label: 'Signature Pad'
  }
]
  1. Add the new type to the BlockTypeValues object to make it available as a typed constant.
// app/constants/blocks.ts

export const BlockTypeValues = {
  // ... existing values
  SIGNATURE: 'signature'
} as const
  1. If your block captures user input, it needs a corresponding column type. If an existing column type isn't suitable, create a new one. For a signature, we'll store the image data as a data URL in a new signature column type.
    • Add your new column type string to the COLUMN_TYPES array.
    • Add the new type to the ColumnType union type for TypeScript support.
// app/constants/blocks.ts

export const COLUMN_TYPES = [
  // ... existing types
  'signature'
]

export type ColumnType =
  // ... existing types
  'signature'
  1. Finally, map your new block type to its column type in BLOCK_TYPE_TO_COLUMN_TYPE. This ensures the system creates the correct column type when a user binds this block to a new column in the builder.
// app/constants/blocks.ts

export const BLOCK_TYPE_TO_COLUMN_TYPE = [
  // ... existing mappings
  {
    blockType: 'signature',
    columnType: 'signature'
  }
]

Step 2: Create the Rendering Component (for Workflows)

This is the component that end-users interact with during a workflow run.

  1. Create a new file in app/components/blocks/, for example, SignatureBlock.tsx.
  2. This component will receive props like block, cells, workflowRunId, and canRespond.
  3. It should read its current value from the cells prop (using the findCellForBlock utility) and handle user interaction.
  4. When the user provides input, the component should call the POST /api/cells/findOrCreate endpoint to save the data. It's best practice to use a debounced function or an onBlur event to trigger the save.
// app/components/blocks/SignatureBlock.tsx (Example)

import findCellForBlock from '../../util/findCellForBlock'
import { useMutation }...

export default function SignatureBlock({ block, cells, workflowRunId, ... }) {
  const cell = findCellForBlock(cells, block);
  const [signatureData, setSignatureData] = useState(cell?.value);

  const mutation = useMutation(async (value) => {
    // ... API call to /api/cells/findOrCreate with the signature data
  });

  const handleSaveSignature = (dataUrl) => {
    setSignatureData(dataUrl);
    mutation.mutate(dataUrl);
  };

  return (
    // ... JSX for the signature pad component
    // It should display the existing `signatureData`
    // and call `handleSaveSignature` on new input.
  );
}
  1. Export the new component from app/components/blocks/index.tsx.

Step 3: Wire Up the Workflow Block Renderer

Now, you must tell the main renderer how to display your new block within a workflow.

  1. Open app/components/renderers/BlocksRenderer.tsx.
  2. Import your new SignatureBlock component.
  3. Add a case for your new block type (signature) inside the switch statement within the renderBlock function.
// app/components/renderers/BlocksRenderer.tsx

// ... imports
import { SignatureBlock } from '../blocks'; // Assuming it's exported from index

export const renderBlock = ({ block, ... }) => {
  switch (block.type) {
    // ... other cases
    case 'signature':
      return <SignatureBlock block={block} ... />;
    default:
      return <Text>...</Text>;
  }
}

Step 4: Create the Configuration Component

This is the UI that an admin uses in the workflow builder to set up the block.

  1. Create a new file in app/components/renderers/components/blockConfig/, for example, SignatureBlockConfig.tsx.
  2. This component will typically compose several shared configuration components. For a standard input block, this is often sufficient.
// app/components/renderers/components/blockConfig/SignatureBlockConfig.tsx
import Header from './Header'
import OutputSheetAndColumn from './OutputSheetAndColumn'
import Required from './Required'

export default function SignatureBlockConfig({
  block,
  sheetId,
  columnId,
  columnName
}) {
  return (
    <>
      <Header
        sheetId={sheetId}
        columnId={columnId}
        columnName={columnName}
      />
      <OutputSheetAndColumn
        block={block}
        sheetId={sheetId}
        columnId={columnId}
        columnName={columnName}
      />
      <Required />
    </>
  )
}

Step 5: Wire Up the Block Configuration

Connect your new configuration UI to the builder.

  1. Open app/components/renderers/components/BlockConfig.tsx.
  2. Import your new SignatureBlockConfig component.
  3. Add a .with() clause for your new block type to the match statement. If its configuration is identical to other input blocks, you can group it with them.
// app/components/renderers/components/BlockConfig.tsx

// ... imports
import SignatureBlockConfig from './blockConfig/SignatureBlockConfig';

export default function BlockConfig({ block, ... }) {
  // ...
  return (
    // ...
    {match<BlockType>(block.type)
      // ... other .with() clauses
      .with('signature', () => (
        <SignatureBlockConfig
          block={block}
          sheetId={sheetId}
          columnId={columnId}
          columnName={columnName}
        />
      ))
      .otherwise(() => (
        <InputBlockConfig ... />
      ))}
  );
}

Step 6: Create the Sheet Cell Renderer

To ensure the data from your new block is displayed correctly in the Sheet view, you need to create a cell renderer.

  1. Create a new file in app/components/sheet/cell-renderers/, for example, AgCellSignature.tsx.
  2. This component will receive ICellRendererParams from AG Grid and should render the cell's value. For our signature, this would likely be an <img> tag.
// app/components/sheet/cell-renderers/AgCellSignature.tsx (Example)
import { ICellRendererParams } from '@ag-grid-community/core'

import { Image } from '@chakra-ui/react'

export default function AgCellSignature({
  value
}: ICellRendererParams<any, string>) {
  if (!value) return null
  return (
    <Image src={value} alt='Signature' boxSize='40px' objectFit='contain' />
  )
}
  1. Wire up the cell renderer selector. Open app/components/sheet/cell-renderers/index.tsx, import your new component, and add a case to the match statement for your new columnType.
// app/components/sheet/cell-renderers/index.tsx
// ... imports
import AgCellSignature from './AgCellSignature'

export default function cellRendererSelector({
  value,
  colDef
}: ICellRendererParams) {
  const columnType = colDef?.type || (colDef?.refData?.type as CellTypeT)
  // ...
  return (
    match(columnType)
      // ... other .with() clauses
      .with('signature', () => ({ component: AgCellSignature }))
      .otherwise(() => ({ component: defaultRenderer }))
  )
}

Step 7: Add Required Field Validation Logic

If your block can be marked as "required", you must add logic to check if it has been completed before a user can proceed.

  1. Open app/util/checkRequiredFieldsCompletion.ts.
  2. Navigate to the checkCellValueBasedOnBlockType function.
  3. Add an else if case for your new blockType. The logic should return true if the value is considered valid/complete, and false otherwise. For a signature, a non-empty string is sufficient.
// app/util/checkRequiredFieldsCompletion.ts

function checkCellValueBasedOnBlockType(value: any, blockType: string) {
  // ... other block types
  } else if (blockType === 'url') {
    return !!value && value.length > 0 && validateURL(value)
  } else if (blockType === 'signature') {
    // A signature data URL will be a long string, so checking for existence is enough.
    return !!value && value.length > 0
  } else {
    return false
  }
}

After completing these steps, the new "Signature Pad" block will be a fully integrated part of the system, available for use in workflows, rendering correctly in sheets, and behaving consistently with other block types.

On this page