Tulip Logo IconTulip
Commands

Command Builder

Define atomic command values with types, permissions, transforms, visibility, disabled state, and render.

Use commandBuilder (or createCommandBuilder<TMeta>()) to define a single reusable command value.

import { commandBuilder, createCommandBuilder, ensureArray } from "@tulip-systems/commands";

Builder flow

Every command ends with .render(fn). The builder starts with an unknown data type. Use .$type<T>() to lock in the data shape, then optionally chain .permission(...), .transform(...), .visibleWhen(...), and .disabledWhen(...) before calling .render(...).

Supported chains:

  1. .$type<T>().render(fn)
  2. .$type<T>().permission(permission).render(fn)
  3. .$type<T>().visibleWhen(fn).render(fn)
  4. .$type<T>().disabledWhen(fn).render(fn)
  5. .$type<T>().transform(fn).render(fn)
  6. .$type<T>().permission(permission).visibleWhen(fn).disabledWhen(fn).render(fn)

.$type<T>()

Sets the data type the command works with. This is a compile-time annotation only.

type ProjectData = { id: string; name: string };

export const projectEditCommand = commandBuilder
  .$type<ProjectData>()
  .render(({ data }) => <div>{data.name}</div>);

.transform(fn)

Transforms input data before it reaches visibleWhen, disabledWhen, and render.

Use this to normalize single-item and multi-item actions into one array shape:

type ProjectInput = { id: string } | { id: string }[];

export const projectDeleteCommand = commandBuilder
  .$type<ProjectInput>()
  .transform((data) => (Array.isArray(data) ? data : [data]))
  .render(({ data }) => <div>{data.length}</div>);

ensureArray

ensureArray is a small helper for the common T | T[] -> T[] case.

import { commandBuilder, ensureArray } from "@tulip-systems/commands";

type ProjectInput = { id: string } | { id: string }[];

export const projectArchiveCommand = commandBuilder
  .$type<ProjectInput>()
  .transform(ensureArray)
  .render(({ data }) => <div>{data.length}</div>);

.permission(permission)

Attaches authorization requirements to the command.

export const customerCreateCommand = commandBuilder
  .$type<null>()
  .permission({ customer: ["create"] })
  .render(() => <div>Create</div>);

Permission is also a render gate: if the current user does not satisfy the permission, the command does not render.

.visibleWhen(({ data, meta }) => ...)

Controls whether the command is rendered for the current context.

export const projectArchiveCommand = commandBuilder
  .$type<{ id: string; deletedAt: string | null }[]>()
  .visibleWhen(({ data }) => data.every((item) => item.deletedAt === null))
  .render(() => <div>Archive</div>);

Use visibility for runtime eligibility, such as archived state, readonly rows, or whether all selected records support the action.

.disabledWhen(({ data, meta }) => ...)

Controls whether a command is rendered but disabled.

export const projectArchiveCommand = commandBuilder
  .$type<{ id: string; locked: boolean }[]>()
  .visibleWhen(({ data }) => data.length > 0)
  .disabledWhen(({ data }) => data.some((item) => item.locked))
  .render(() => <div>Archive</div>);

.render(({ data, meta, ui }) => ...)

Returns the UI for the command.

  • data: typed command data
  • meta: optional contextual meta passed by the menu
  • ui: current surface ("inline", "dropdown", "context", "table", "custom")
export const projectCreateCommand = commandBuilder
  .$type<null>()
  .render(({ ui }) => <div>Rendered in: {ui}</div>);

createCommandBuilder<TMeta>()

Use this when commands need extra context that is not in data.

import type { VisibilityState } from "@tanstack/react-table";

const builder = createCommandBuilder<{ fieldVisibility: VisibilityState }>();

export const timeEntryCreateCommand = builder
  .$type<{ taskId: string }>()
  .render(({ data, meta }) => (
    <div>{meta.fieldVisibility.taskId ? data.taskId : "hidden"}</div>
  ));

Pass meta at render site:

<InlineCommandMenu
  data={{ taskId: "task_1" }}
  meta={{ fieldVisibility: { taskId: false } }}
  commands={[timeEntryCreateCommand]}
/>

Export atomic command values from *_config/commands.tsx:

export const customerCreateCommand = commandBuilder
  .$type<null>()
  .permission({ customer: ["create"] })
  .render(() => <CreateCustomerCommand />);

export const customerArchiveCommand = commandBuilder
  .$type<{ id: string } | { id: string }[]>()
  .transform(ensureArray)
  .permission({ customer: ["archive"] })
  .render(({ data }) => <ArchiveCommand ids={data.map((item) => item.id)} />);

Compose arrays where commands are rendered:

commands={[customerArchiveCommand]}

commands={[
  customerArchiveCommand,
  customerRestoreCommand,
  customerDeleteCommand,
]}

On this page