Creating a Cell Plugin

Build a custom property type for Table View.

A cell plugin defines the data model for one property type. It must render a cell value; add an editor when users should be able to change that value.

Create a Tag plugin

Define the plugin

import type {
  CellEditorProps,
  CellPlugin,
  CellValueProps,
} from "@notion-kit/table-hook/plugins";
import { createCompareFn } from "@notion-kit/table-hook/plugins";

type TagData = string[];
type TagConfig = { options: string[] };
type TagPlugin = CellPlugin<"tag", TagData, TagConfig>;

const TagIcon = <span aria-hidden>🏷️</span>;

function TagValue({ data }: Pick<CellValueProps<TagData, TagConfig>, "data">) {
  return <span>{data.join(", ") || "—"}</span>;
}

function TagEditor({
  data,
  config,
  onChange,
}: Pick<CellEditorProps<TagData, TagConfig>, "data" | "config" | "onChange">) {
  return (
    <div>
      {config.options.map((tag) => {
        const selected = data.includes(tag);

        return (
          <button
            key={tag}
            type="button"
            aria-pressed={selected}
            onClick={() =>
              onChange(
                selected
                  ? data.filter((value) => value !== tag)
                  : [...data, tag],
              )
            }
          >
            {tag}
          </button>
        );
      })}
    </div>
  );
}

export const tagPlugin: TagPlugin = {
  id: "tag",
  meta: {
    name: "Tag",
    desc: "A multi-select tag property",
    icon: TagIcon,
  },
  default: {
    name: "Tags",
    icon: TagIcon,
    config: { options: [] },
    data: [],
  },
  fromValue: (value) =>
    typeof value === "string"
      ? value
          .split(",")
          .map((tag) => tag.trim())
          .filter(Boolean)
      : [],
  toValue: (data) => data.join(", "),
  toTextValue: (data) => data.join(", "),
  compare: createCompareFn<TagPlugin>((a, b) =>
    a.join(", ").localeCompare(b.join(", ")),
  ),
  renderCellValue: (props) => <TagValue {...props} />,
  renderCellEditor: (props) => ({
    presentation: "popover",
    content: <TagEditor {...props} />,
  }),
};

renderCellEditor receives a scope. It is { kind: "cell" } for a single cell and { kind: "bulk" } when the bulk-edit bar invokes the same editor for selected rows. The example does not need to branch on the scope, so it supports both paths automatically.

Register the plugin

Add the plugin to the list passed to TableView, then include a matching property in the table data.

import { DEFAULT_PLUGINS, TableView } from "@notion-kit/table-view";
import type { ColumnDefs, Row } from "@notion-kit/table-view";

import { tagPlugin } from "./tag-plugin";

const plugins = [...DEFAULT_PLUGINS, tagPlugin];

const properties: ColumnDefs<typeof plugins> = [
  { id: "name", name: "Name", type: "title" },
  {
    id: "tags",
    name: "Tags",
    type: "tag",
    config: { options: ["Bug", "Feature"] },
  },
];

const now = Date.now();
const data: Row<typeof plugins>[] = [
  {
    id: "task-1",
    createdAt: now,
    lastEditedAt: now,
    properties: {
      name: { id: "task-1-name", value: "Ship Table View" },
      tags: { id: "task-1-tags", value: ["Feature"] },
    },
  },
];

export function DatabaseView() {
  return (
    <TableView
      plugins={plugins}
      defaultProperties={properties}
      defaultData={data}
    />
  );
}

Add renderConfigMenu when users need to edit the property configuration, and add sorting, grouping, or counting methods only when the property needs those capabilities.