Tulip Logo IconTulip
Commands

Command Builder

Create command definitions with types, tags, permission, visibility, disabled state, and render.

Use commandBuilder (or createCommandBuilder<TMeta>()) to define commands in a strict chain.

import { commandBuilder, createCommandBuilder, defineCommands } from "@tulip-systems/core/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 .tags(...), .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>().tags("single").render(fn)
  4. .$type<T>().visibleWhen(fn).render(fn)
  5. .$type<T>().disabledWhen(fn).render(fn)
  6. .$type<T>().transform(fn).render(fn)
  7. .$type<T>().permission(permission).visibleWhen(fn).disabledWhen(fn).render(fn)

render is always the final step. You can call permission, visibleWhen, disabledWhen, tags, and transform in any order before render.

.$type<T>()

Sets the data type the command works with. This is a compile-time annotation — no runtime validation is performed.

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

const commands = defineCommands({
  edit: commandBuilder
    .$type<ProjectData>()
    .render(({ data }) => <div>{data.name}</div>),
});

Use .$type<T>() early in the chain to get type-safe access to data in visibleWhen, disabledWhen, transform, and render.

.transform(fn)

Transforms input data before it reaches visibleWhen, disabledWhen, and render. Accepts a lightweight function — no schema validation.

Use this to normalize single + bulk actions into one array shape:

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

const commands = defineCommands({
  delete: commandBuilder
    .$type<ProjectInput>()
    .transform((data) => (Array.isArray(data) ? data : [data]))
    .render(({ data }) => <div>{data.length}</div>), // data is ProjectArray
});

When .transform() is used, the menu component receives the pre-transform type as data, while visibleWhen, disabledWhen, and render receive the post-transform type. This keeps menus typed against the union input while commands work with a clean normalized shape.

.tags(...tags)

Assigns literal tags to the command for filtering and organization. The const inference preserves the exact string values at compile time, enabling type-safe tag filtering via commands.tags.every(), commands.tags.some(), etc.

const commands = defineCommands({
  edit: commandBuilder
    .$type<{ id: string }>()
    .tags("single", "table", "safe")
    .render(({ data }) => <EditCommand data={data} />),

  delete: commandBuilder
    .$type<{ id: string }[]>()
    .tags("single", "bulk", "danger")
    .render(({ data }) => <DeleteCommand data={data} />),
});

Tags are purely descriptive and do not affect command behavior. They exist to enable contextual filtering at the menu level.

ensureArray

ensureArray is a small helper exported from @tulip-systems/core/commands for the common single-or-bulk normalization case.

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

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

const commands = defineCommands({
  archive: commandBuilder
    .$type<ProjectInput>()
    .transform(ensureArray)
    .render(({ data }) => <div>{data.length}</div>),
});

Prefer ensureArray when the transform does not need any custom branching beyond T | T[] -> T[].

.permission(permission)

Attaches authorization requirements to the command.

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

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

Keep permission logic in the command definition so every surface behaves consistently.

Permission checks are applied by the command renderer around the rendered command content. Visibility and disabled rules are evaluated before this permission wrapper.

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

Controls whether the command is rendered for the current context.

const commands = defineCommands({
  archive: commandBuilder
    .$type<{ id: string; isDeleted: boolean }[]>()
    .visibleWhen(({ data }) => data.every((item) => item.isDeleted === false))
    .render(() => <div>Archive</div>),
});

Visibility conditions can return a boolean or boolean array. Arrays must pass every(Boolean).

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.

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

Disabled conditions can return a boolean or boolean array. Arrays must pass every(Boolean).

When disabled, command triggers stay visible but are not interactive.

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

Returns the UI for the command.

  • data: the typed data (post-transform if .transform() was used)
  • meta: optional contextual meta passed by the menu
  • ui: the current surface ("inline", "dropdown", "context", "table", "custom")
const commands = defineCommands({
  create: 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 }>();

const timeEntryCommands = defineCommands({
  create: 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={timeEntryCommands.keys.pick({ create: true }).toArray()}
/>

defineCommands({...})

Wraps command definitions in a registry with helper methods:

  • commands.keys.pick({...}) / commands.keys.omit({...}) — key-based selection
  • commands.tags.every(...) / commands.tags.some(...) / commands.tags.exact(...) / commands.tags.without(...) — tag-based filtering
  • commands.extend({...}) — extend with additional commands
  • commands.toArray() — terminal: returns the array of command definitions
export const taskCommands = defineCommands({
  create: commandBuilder.$type<null>().render(() => <div>Create</div>),
  delete: commandBuilder.$type<{ id: string }[]>().render(() => <div>Delete</div>),
});

Use selections where commands are rendered:

commands={taskCommands.keys.pick({ create: true }).toArray()}

Key selections can use object flags or arrays:

taskCommands.keys.pick({ create: true, delete: true }).toArray();
taskCommands.keys.pick(["create", "delete"]).toArray();
taskCommands.keys.omit({ delete: true }).toArray();
taskCommands.keys.omit(["delete"]).toArray();

Tag-based filtering:

taskCommands.tags.every("single").toArray();
taskCommands.tags.some("single", "bulk").toArray();
taskCommands.tags.every("single").without("danger").toArray();

Use .extend({...}) when a feature needs a local override or extra command while keeping the base registry available.

Deprecated: commands.pick(...) and commands.omit(...)

The top-level .pick() and .omit() methods on registries are deprecated. Use commands.keys.pick(...) and commands.keys.omit(...) instead.

On this page