Gallery

Database schema studio

Loading…
Source code
$ npx @flitterjs/cli@latest add flow-diagram
import {
  Border,
  BorderRadius,
  BoxDecoration,
  Center,
  Column,
  Container,
  CrossAxisAlignment,
  EdgeInsets,
  Expanded,
  GestureDetector,
  LayoutBuilder,
  InputDecoration,
  MainAxisSize,
  Positioned,
  Row,
  SizedBox,
  Stack,
  StackFit,
  State,
  StatefulWidget,
  Text,
  TextField,
  TextStyle,
  type Widget,
} from "flitterjs";
import {
  FlowDiagramController,
  type Connection,
  type FlowEdge,
  type FlowNode,
  type HandleSpec,
} from "@flitterjs/diagram";
import FlowDiagram, { defaultEdge } from "@/components/diagram/flow-diagram";

type Theme = "light" | "dark";
type Field = { name: string; type: string; badge?: "PK" | "FK" };
const tables: Record<string, Field[]> = {
  customers: [
    { name: "id", type: "uuid", badge: "PK" },
    { name: "email", type: "text" },
    { name: "name", type: "text" },
    { name: "created_at", type: "timestamp" },
  ],
  orders: [
    { name: "id", type: "uuid", badge: "PK" },
    { name: "customer_id", type: "uuid", badge: "FK" },
    { name: "total", type: "numeric" },
    { name: "status", type: "text" },
  ],
  products: [
    { name: "id", type: "uuid", badge: "PK" },
    { name: "sku", type: "text" },
    { name: "name", type: "text" },
    { name: "price", type: "numeric" },
  ],
  order_items: [
    { name: "id", type: "uuid", badge: "PK" },
    { name: "order_id", type: "uuid", badge: "FK" },
    { name: "product_id", type: "uuid", badge: "FK" },
    { name: "quantity", type: "integer" },
  ],
};
const accents: Record<string, string> = {
  customers: "#6b9df5",
  orders: "#41b59a",
  products: "#bb91ec",
  order_items: "#dcab56",
};

class Schema extends StatefulWidget {
  constructor(readonly theme: Theme) {
    super(theme);
  }
  createState() {
    return new SchemaState();
  }
}
class SchemaState extends State<Schema> {
  flow = new FlowDiagramController();
  query = "";
  selected = "orders";
  nodes: FlowNode[] = Object.keys(tables).map((id, i) => ({
    id,
    type: "table",
    position: { x: (i % 2) * 360, y: Math.floor(i / 2) * 245 },
    width: 280,
    height: 174,
    data: { label: id },
    selected: id === "orders",
  }));
  edges: FlowEdge[] = [
    {
      id: "orders_customer",
      source: "orders",
      sourceHandle: "customer_id",
      target: "customers",
      targetHandle: "id",
    },
    {
      id: "items_order",
      source: "order_items",
      sourceHandle: "order_id",
      target: "orders",
      targetHandle: "id",
    },
    {
      id: "items_product",
      source: "order_items",
      sourceHandle: "product_id",
      target: "products",
      targetHandle: "id",
    },
  ].map((e) => ({ ...e, type: "smoothstep", label: "N : 1" }));
  get c() {
    return this.widget.theme === "dark"
      ? {
          bg: "#141719",
          card: "#1c2023",
          inset: "#252a2e",
          ink: "#edf0f2",
          mute: "#a0a8b1",
          line: "#33393e",
          accent: "#5dd6b6",
        }
      : {
          bg: "#f4f6f8",
          card: "#ffffff",
          inset: "#eef2f5",
          ink: "#202b35",
          mute: "#647381",
          line: "#dce2e8",
          accent: "#167e68",
        };
  }
  text(value: string, size = 12, color = this.c.ink, mono = false) {
    return Text(value, {
      style: new TextStyle({
        fontFamily: mono ? "ui-monospace, monospace" : "system-ui",
        fontSize: Math.max(13, size),
        height: 1.25,
        color,
      }),
    });
  }
  button(label: string, action: () => void, active = false) {
    return GestureDetector({
      onMouseDown: (e) => e.stopPropagation(),
      onClick: (e) => {
        e.stopPropagation();
        action();
      },
      child: Container({
        padding: EdgeInsets.symmetric({ horizontal: 12, vertical: 9 }),
        decoration: new BoxDecoration({
          color: active ? this.c.inset : this.c.card,
          borderRadius: BorderRadius.circular(5),
        }),
        child: this.text(label, 14, active ? this.c.accent : this.c.ink),
      }),
    });
  }
  focus(id: string) {
    this.flow.selectNodes([id]);
    this.setState(() => {
      this.selected = id;
    });
    void this.flow.fitView({
      nodes: [
        { id },
        ...this.flow.getIncomers({ id }),
        ...this.flow.getOutgoers({ id }),
      ],
      padding: 0.18,
      maxZoom: 1,
      duration: 200,
    });
  }
  handles(node: FlowNode): HandleSpec[] {
    return tables[node.id].flatMap((field) =>
      (["target", "source"] as const).map((type) => ({
        id: field.name,
        type,
        position: type === "source" ? "left" : "right",
        placement: "inline",
        width: 12,
        height: 12,
        connectable:
          type === "target" ? field.badge === "PK" : field.badge === "FK",
      })),
    );
  }
  compatible = (connection: Connection) => {
    const source = tables[connection.source]?.find(
      (f) => f.name === connection.sourceHandle,
    );
    const target = tables[connection.target]?.find(
      (f) => f.name === connection.targetHandle,
    );
    return (
      connection.source !== connection.target &&
      source?.badge === "FK" &&
      target?.badge === "PK" &&
      source.type === target.type
    );
  };
  sidebar(): Widget {
    const matches = Object.keys(tables).filter((id) =>
      id.includes(this.query.toLowerCase()),
    );
    return Container({
      width: 184,
      color: this.c.card,
      padding: EdgeInsets.all(14),
      child: Column({
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          TextField(this.query, {
            key: "schema-search",
            ariaLabel: "Search tables",
            width: 156,
            height: 32,
            decoration: new InputDecoration({
              hintText: "Search tables",
              hintStyle: new TextStyle({ color: this.c.mute, fontSize: 14 }),
              border: Border.all({ color: this.c.line, width: 1 }),
              contentPadding: EdgeInsets.all(8),
            }),
            style: new TextStyle({
              fontFamily: "system-ui",
              color: this.c.ink,
              fontSize: 14,
            }),
            onChanged: (value) =>
              this.setState(() => {
                this.query = value;
              }),
          }),
          SizedBox({ height: 20 }),
          ...matches.map((id) =>
            this.button(id, () => this.focus(id), this.selected === id),
          ),
          ...(matches.length
            ? []
            : [this.text("No tables found", 12, this.c.mute)]),
        ],
      }),
    });
  }
  build(): Widget {
    const c = this.c;
    return FlowDiagram({
      controller: this.flow,
      data: { nodes: this.nodes, edges: this.edges },
      viewport: { minZoom: 0.15, fitView: { padding: 0.12, maxZoom: 1 } },
      interaction: {
        drag: { snap: { grid: [10, 10] } },
        connect: { validate: this.compatible },
      },
      can: (gesture) => gesture !== "delete",
      on: {
        nodeClick: (_event, node) =>
          this.setState(() => {
            this.selected = node.id;
          }),
      },
      config: {
        colorMode: this.widget.theme,
        colors: { background: c.bg, edgeStroke: c.mute },
        edge: { strokeWidth: 1.5 },
        edgeLabel: { fontSize: 13 },
        minimap: { visible: false },
      },
      nodeOptions: { table: { handles: (node) => this.handles(node) } },
      custom: {
        // The complete editor shell is a widget, including the explorer and footer.
        layout: ({
          background,
          viewport,
          selectionBox,
          toolbars,
          contextMenu,
          controls,
        }) =>
          LayoutBuilder({
            builder: (_context, size) =>
              Column({
                children: [
                  Container({
                    height: 46,
                    color: c.card,
                    padding: EdgeInsets.symmetric({ horizontal: 16 }),
                    child: Row({
                      children: [
                        Expanded({ child: this.text("Commerce / Schema", 16) }),
                      ],
                    }),
                  }),
                  Expanded({
                    child: Row({
                      crossAxisAlignment: CrossAxisAlignment.stretch,
                      children: [
                        ...(size.maxWidth >= 760
                          ? [
                              this.sidebar(),
                              Container({ width: 1, color: c.line }),
                            ]
                          : []),
                        Expanded({
                          child: Stack({
                            fit: StackFit.expand,
                            children: [
                              background,
                              viewport,
                              selectionBox,
                              toolbars,
                              contextMenu,
                            ],
                          }),
                        }),
                      ],
                    }),
                  }),
                  Container({
                    height: 42,
                    color: c.card,
                    padding: EdgeInsets.symmetric({ horizontal: 12 }),
                    child: Row({
                      children: [
                        Expanded({ child: SizedBox.shrink() }),
                        controls,
                      ],
                    }),
                  }),
                ],
              }),
          }),
        controls: ({ zoomIn, zoomOut, fitView }) =>
          Row({
            mainAxisSize: MainAxisSize.min,
            children: [zoomOut, zoomIn, fitView],
          }),
        handle: ({
          handle,
          isConnectable,
          isConnectionCandidate,
          isValidCandidate,
        }) =>
          SizedBox({
            width: 12,
            height: 12,
            child: Center({
              child: Container({
                width: isConnectable ? 6 : 3,
                height: isConnectable ? 6 : 3,
                decoration: new BoxDecoration({
                  shape: "circle",
                  color: isConnectionCandidate
                    ? isValidCandidate
                      ? "#22c55e"
                      : "#ef4444"
                    : isConnectable
                      ? c.accent
                      : c.line,
                }),
              }),
            }),
          }),
        node: ({ node, handles, isSelected }) =>
          Container({
            decoration: new BoxDecoration({
              color: c.card,
              borderRadius: BorderRadius.circular(7),
              border: Border.all({
                color: isSelected ? c.accent : c.line,
                width: isSelected ? 2 : 1,
              }),
            }),
            child: Column({
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                Container({ height: 3, color: accents[node.id] }),
                Container({
                  height: 47,
                  padding: EdgeInsets.symmetric({ horizontal: 14 }),
                  child: Row({
                    children: [
                      Expanded({ child: this.text(node.id, 14, c.ink, true) }),
                      this.text("public", 11, c.mute),
                    ],
                  }),
                }),
                ...tables[node.id].map((field) =>
                  Container({
                    height: 30,
                    padding: EdgeInsets.symmetric({ horizontal: 6 }),
                    child: Row({
                      children: [
                        handles.find(
                          (h) => h.id === field.name && h.type === "source",
                        )!.handle,
                        SizedBox({
                          width: 27,
                          child: this.text(
                            field.badge ?? "",
                            10,
                            field.badge === "PK" ? c.accent : c.mute,
                          ),
                        }),
                        Expanded({
                          child: this.text(field.name, 12, c.ink, true),
                        }),
                        this.text(field.type, 11, c.mute, true),
                        SizedBox({ width: 7 }),
                        handles.find(
                          (h) => h.id === field.name && h.type === "target",
                        )!.handle,
                      ],
                    }),
                  }),
                ),
              ],
            }),
          }),
        edge: (args, context) =>
          defaultEdge(
            {
              ...args,
              edge: {
                ...args.edge,
                style: {
                  stroke: args.isSelected ? c.accent : c.mute,
                  strokeWidth: args.isSelected ? 2 : 1.2,
                },
              },
            },
            context,
          ),
      },
    });
  }
  dispose() {
    this.flow.dispose();
    super.dispose();
  }
}
export default function SchemaVisualizer(theme: Theme = "light"): Widget {
  return new Schema(theme);
}

A node becomes a table with row-level ports. The layout becomes an editor with an explorer and a persistent toolbar.

Select a table, search in the explorer, then drag a foreign-key port to a matching primary key.

Demo data; execution and deployment are simulated.

Related