We are Coltor Apps ― a software development agency behind this OSS. Need a reliable tech partner?

Let’s talk

Guides

Entity hierarchies

About this guide

This short guide demonstrates how to create hierarchies of entities. Specifically, we'll create a section entity that can contain text fields.

Entity definitions

We'll start by defining entity definitions for text fields and sections.

import { createEntity } from "@coltorapps/builder";

import { labelAttribute } from "./label-attribute";

export const textFieldEntity = createEntity({
  attributes: { label: labelAttribute },
  validate(value) {
    if (typeof value !== "string") {
      throw new Error("Must be a string");
    }

    return value;
  },
  parentRequired: true,
});

export const sectionEntity = createEntity({
  attributes: { title: labelAttribute },
  childrenAllowed: true,
});

We've added parentRequired: true to the text field entity to ensure that it's always a child of a parent entity. The childrenAllowed: true flag on the section entity allows it to have child entities.

Builder definition

Now let's define a form builder. We'll also type-safely constrain sections to only have text fields as children, and text fields to only be placed inside sections. This is entirely optional and is done here solely to demonstrate what's possible.

import { createBuilder } from "@coltorapps/builder";

import { sectionEntity, textFieldEntity } from "./entities";

export const formBuilder = createBuilder({
  entities: {
    textField: textFieldEntity,
    section: sectionEntity,
  },
  entitiesExtensions: {
    textField: {
      allowedParents: ["section"],
    },
    section: {
      childrenAllowed: ["textField"],
    },
  },
});

Entity components

Let's create builder and interpreter components for our text field:

import {
  type EntityAttributesValues,
  type EntityValue,
} from "@coltorapps/builder";
import {
  useEntityAttributesValues,
  useEntityError,
  useEntityValue,
  type BuilderEntityComponentProps,
  type InterpreterEntityComponentProps,
} from "@coltorapps/builder-react";

import { type textFieldEntity } from "./entities";

interface TextFieldProps
  extends EntityAttributesValues<typeof textFieldEntity> {
  id: string;
  value?: EntityValue<typeof textFieldEntity>;
  onChange?: (value: EntityValue<typeof textFieldEntity>) => void;
}

function TextField(props: TextFieldProps) {
  return (
    <div>
      <label htmlFor={props.id} aria-required={props.required}>
        {props.label.trim() ? props.label : "Label"}
      </label>
      <input
        id={props.id}
        name={props.id}
        value={props.value ?? ""}
        onChange={(e) => props.onChange?.(e.target.value)}
        placeholder={props.placeholder}
        required={props.required}
      />
    </div>
  );
}

export function BuilderTextFieldEntity(
  props: BuilderEntityComponentProps<typeof textFieldEntity>,
) {
  const attributes = useEntityAttributesValues(props.entity);

  return <TextField id={props.entity.id} {...attributes} />;
}

export function InterpreterTextFieldEntity(
  props: InterpreterEntityComponentProps<typeof textFieldEntity>,
) {
  const value = useEntityValue(props.entity);

  const error = useEntityError(props.entity);

  return (
    <div>
      <TextField
        id={props.entity.id}
        value={value}
        onChange={(value) => props.entity.setValue(value)}
        {...props.entity.attributes}
      />
      {error ? <p className="text-red-500">{String(error)}</p> : null}
    </div>
  );
}

Now let's create builder and interpreter components for our section entity, which will render its title and children. The builder component will also include a button to add entities during the building phase.

import { type ReactNode } from "react";

import {
  type EntityAttributesValues,
  type EntityValue,
} from "@coltorapps/builder";
import {
  useAttributeValue,
  type BuilderEntityComponentProps,
  type InterpreterEntityComponentProps,
} from "@coltorapps/builder-react";

import { type sectionEntity } from "./entities";
import { type formBuilder } from "./form-builder";

function Section(props: { children?: ReactNode; title: string }) {
  return (
    <section>
      <h3>{props.title}</h3>
      <div className="p-4">{props.children}</div>
    </section>
  );
}

export function BuilderSectionEntity(
  props: BuilderEntityComponentProps<typeof sectionEntity, typeof formBuilder>,
) {
  const title = useAttributeValue(props.entity.attributes.title);

  return (
    <Section title={title}>
      <props.RenderChildren />
      <button
        type="button"
        onClick={() =>
          props.builderStore.addEntity({
            type: "textField",
            attributes: {
              label: "",
            },
          })
        }
      >
        Add text field to section
      </button>
    </Section>
  );
}

export function InterpreterSectionEntity(
  props: InterpreterEntityComponentProps<typeof sectionEntity>,
) {
  return (
    <Section title={props.entity.attributes.title}>
      <props.RenderChildren />
    </Section>
  );
}

Notice how we've passed the type of our form builder to the builder entity component of the section using BuilderEntityComponentProps<typeof sectionEntity, typeof formBuilder>? This constrains the component to be used only with builder stores created from formBuilder. It ensures type safety when calling props.builderStore.addEntity, giving you full autocomplete for all available entity types and their attributes.

<props.RenderChildren /> essentially acts as a slot. It simply renders all child entities.

Builder component

We can now implement our form builder component, which includes a button for adding new sections.

import { BuilderEntities, useBuilderStore } from "@coltorapps/builder-react";

import { BuilderSectionEntity, BuilderTextFieldEntity } from "./components";
import { formBuilder } from "./form-builder";

const components = {
  textField: BuilderTextFieldEntity,
  section: BuilderSectionEntity,
};

export default function FormBuilderPage() {
  const builderStore = useBuilderStore(formBuilder);

  return (
    <div>
      <BuilderEntities builderStore={builderStore} components={components} />
      <button
        type="button"
        onClick={() =>
          builderStore.addEntity({
            type: "section",
            attributes: { title: "Cool Section" },
          })
        }
      >
        Add section
      </button>
    </div>
  );
}

Each section, during the building phase, will also include an "Add text field to section" button.

Previous
Form validation approaches

Canary Branch