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 inapp/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 aniframeblock, or a list of choices for asingle-selectblock).- 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 thelabelandhelpText.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 theblock_optionstable.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:
- Displaying the block's label, help text, and options.
- Providing the correct UI for user interaction.
- Reading and writing cell data based on the block's
sheet_idandcolumn_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:
-
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
OutputSheetAndColumncomponent. 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). Adateblock will be bound to adatecolumn. - This binding—the
sheet_idandcolumn_id—is saved as part of the block's definition.
-
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_idandcolumn_id, along with the currentworkflow_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.
-
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, andworkflow_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
typeis mapped to acolumnTypeinapp/constants/blocks.ts(e.g.,stringblock maps to atextcolumn,dateblock to adatecolumn). 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) oritems(for multiple selections). - Configuration: Uses
SelectBlockConfig.tsx,PollBlockConfig.tsx, orTaskBlockConfig.tsx. A key feature is the use ofBlockOptions.tsxto 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 (
contentorurlfields). - Configuration: They have specialized, simpler configuration components like
ContentBlockConfig.tsx,VideoBlockConfig.tsx, andMediaGalleryBlockConfig.tsx. The media/resource blocks have UI for uploading files, which are stored ascontent_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 theembedded_block_idto 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:
- Sheet Metadata: Information about the sheet itself (e.g., name, ID).
- Columns: An array of all column definitions for the sheet. Each column object includes its
id,name, and crucially, itstype(e.g.,text,date,item). - 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. - Cells: An array of all non-empty cells in the sheet. Each cell object contains its
valuealong with therow_idandcolumn_idthat define its position.
Grid Assembly and Cell Rendering
The frontend UI, typically using a data grid component, takes this payload and assembles the view:
- Column Definition: The
columnsarray is used to create the grid's headers. The order of columns is respected. - Row Creation: The
rowsarray is used to create the rows of the grid. - Cell Population: The application then iterates through the
cellsarray. For each cell, it uses therow_idandcolumn_idto find the corresponding location in the grid and populates it with the cell'svalue.
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(fromsingle-selectorpollblocks): The stored value is an object like{"id": "...", "label": "Option A"}. The renderer extracts and displays thelabelproperty.items(frommulti-select): The value is an array ofitemobjects, 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(fromcheckbox): The value is{"label": "...", "checked": true}. This is rendered as a visual checkbox component (checked or unchecked).checks(fromchecklistortodo): 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.
- Open
app/constants/blocks.ts. - Add an entry to the
BLOCK_TYPES_AND_LABELSarray. 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'
}
]- Add the new type to the
BlockTypeValuesobject to make it available as a typed constant.
// app/constants/blocks.ts
export const BlockTypeValues = {
// ... existing values
SIGNATURE: 'signature'
} as const- 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
signaturecolumn type.- Add your new column type string to the
COLUMN_TYPESarray. - Add the new type to the
ColumnTypeunion type for TypeScript support.
- Add your new column type string to the
// app/constants/blocks.ts
export const COLUMN_TYPES = [
// ... existing types
'signature'
]
export type ColumnType =
// ... existing types
'signature'- 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.
- Create a new file in
app/components/blocks/, for example,SignatureBlock.tsx. - This component will receive props like
block,cells,workflowRunId, andcanRespond. - It should read its current value from the
cellsprop (using thefindCellForBlockutility) and handle user interaction. - When the user provides input, the component should call the
POST /api/cells/findOrCreateendpoint to save the data. It's best practice to use a debounced function or anonBlurevent 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.
);
}- 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.
- Open
app/components/renderers/BlocksRenderer.tsx. - Import your new
SignatureBlockcomponent. - Add a
casefor your new block type (signature) inside theswitchstatement within therenderBlockfunction.
// 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.
- Create a new file in
app/components/renderers/components/blockConfig/, for example,SignatureBlockConfig.tsx. - 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.
- Open
app/components/renderers/components/BlockConfig.tsx. - Import your new
SignatureBlockConfigcomponent. - Add a
.with()clause for your new block type to thematchstatement. 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.
- Create a new file in
app/components/sheet/cell-renderers/, for example,AgCellSignature.tsx. - This component will receive
ICellRendererParamsfrom AG Grid and should render the cell'svalue. 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' />
)
}- Wire up the cell renderer selector. Open
app/components/sheet/cell-renderers/index.tsx, import your new component, and add a case to thematchstatement for your newcolumnType.
// 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.
- Open
app/util/checkRequiredFieldsCompletion.ts. - Navigate to the
checkCellValueBasedOnBlockTypefunction. - Add an
else ifcase for your newblockType. The logic should returntrueif thevalueis considered valid/complete, andfalseotherwise. 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.