Tulip Logo IconTulip
Commands

Examples

Real implementation patterns and different approaches across the app.

This page shows practical command architecture patterns used across Tulip apps.

Single Registry Per Domain Module

Define one command registry per feature and reuse everywhere.

// app/admin/projects/_config/commands.tsx
export const projectCommands = defineCommands({
  create: commandBuilder.$type<null>().render(() => <div>Create</div>),
  updateStatus: commandBuilder.$type<{ id: string }[]>().render(() => <div>Status</div>),
  archive: commandBuilder.$type<{ id: string }[]>().render(() => <ArchiveCommand ... />),
  restore: commandBuilder.$type<{ id: string }[]>().render(() => <RestoreCommand ... />),
  delete: commandBuilder.$type<{ id: string }[]>().render(() => <DeleteCommand ... />),
});

Select At Usage Site

Use .keys.pick({...}).toArray() directly in menu props.

<InlineCommandMenu data={null} commands={projectCommands.keys.pick({ create: true }).toArray()} />

<ResponsiveCommandMenu
  data={project}
  commands={projectCommands.keys.pick({ updateStatus: true, archive: true, restore: true, delete: true }).toArray()}
/>

Why this approach:

  • selection intent is visible where actions are rendered
  • no extra alias constants to maintain
  • easier to compare across pages

Bulk And Single Normalization

Accept both row actions and bulk selections in one command with .transform().

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

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

export const customerCommands = defineCommands({
  delete: commandBuilder
    .$type<ProjectInput>()
    .transform(ensureArray)
    .render(({ data }) => (
      <DeleteCommand variables={{ ids: data.map((item) => item.id) }} mutation={...} />
    )),
});

This keeps table row actions and bulk action bars on the same command implementation without repeating the same array normalization in every command.

Meta-Driven Command Behavior

Use createCommandBuilder<TMeta>() when a command needs view context.

const timeBuilder = createCommandBuilder<{ fieldVisibility: { projectId?: boolean } }>();

export const timeEntryCommands = defineCommands({
  create: timeBuilder
    .$type<{ projectId: string }>()
    .render(({ data, meta }) => (
      <CreateTimeEntryCommand
        defaultValues={data}
        fieldVisibility={{ projectId: meta.fieldVisibility.projectId ?? true }}
      />
    )),
});

<InlineCommandMenu
  data={{ projectId: "p_1" }}
  meta={{ fieldVisibility: { projectId: false } }}
  commands={timeEntryCommands.keys.pick({ create: true }).toArray()}
/>

Context-Specific Action Sets

Use different subsets for list/detail/child screens.

// list page
commands={taskCommands.keys.pick({ updateStatus: true, updateAssignee: true, delete: true }).toArray()}

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

Visibility And Permissions Together

Use permissions for coarse access and visibility for runtime eligibility.

archive: commandBuilder
  .$type<{ id: string; deletedAt: string | null }[]>()
  .permission({ project: ["archive"] })
  .visibleWhen(({ data }) => data.every((item) => item.deletedAt === null))
  .render(({ data }) => <ArchiveCommand variables={{ ids: data.map((i) => i.id) }} mutation={...} />)

Full Create Command Example

This pattern keeps form state inside the command component and lets the registry pass only typed command data.

import { commandBuilder, defineCommands } from "@tulip-systems/core/commands";
import {
  CommandFormDialog,
  CommandFormDialogCancel,
  CommandFormDialogContent,
  CommandFormDialogFields,
  CommandFormDialogFooter,
  CommandFormDialogHeader,
  CommandFormDialogSubmit,
  CommandFormDialogTitle,
  CommandFormDialogTrigger,
  CommandLabel,
} from "@tulip-systems/core/commands/client";
import { Input } from "@tulip-systems/core/components";
import { Form, FormControl, FormField, FormItem, FormLabel } from "@tulip-systems/core/components/client";
import { PlusIcon } from "lucide-react";
import { useForm } from "react-hook-form";
import { orpc } from "@/server/router/client";

type CreateProjectInput = {
  name: string;
  customerId: string;
};

function CreateProjectCommand({ customerId }: { customerId: string }) {
  const form = useForm<CreateProjectInput>({ defaultValues: { name: "", customerId } });

  return (
    <CommandFormDialog>
      <CommandFormDialogTrigger label="Create project">
        <PlusIcon className="w-4" />
        <CommandLabel />
      </CommandFormDialogTrigger>

      <Form {...form}>
        <CommandFormDialogContent
          variables={(values) => values}
          mutation={orpc.projects.create.mutationOptions()}
        >
          <CommandFormDialogHeader>
            <CommandFormDialogTitle>Create project</CommandFormDialogTitle>
          </CommandFormDialogHeader>

          <CommandFormDialogFields>
            <FormField
              control={form.control}
              name="name"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Name</FormLabel>
                  <FormControl>
                    <Input {...field} />
                  </FormControl>
                </FormItem>
              )}
            />
          </CommandFormDialogFields>

          <CommandFormDialogFooter>
            <CommandFormDialogCancel>Cancel</CommandFormDialogCancel>
            <CommandFormDialogSubmit>Create</CommandFormDialogSubmit>
          </CommandFormDialogFooter>
        </CommandFormDialogContent>
      </Form>
    </CommandFormDialog>
  );
}

type CreateProjectData = { customerId: string };

export const projectCommands = defineCommands({
  create: commandBuilder
    .$type<CreateProjectData>()
    .permission({ project: ["create"] })
    .render(({ data }) => <CreateProjectCommand customerId={data.customerId} />),
});

Render it where the action belongs:

<InlineCommandMenu
  data={{ customerId: customer.id }}
  commands={projectCommands.keys.pick({ create: true }).toArray()}
/>

Which Approach To Choose

  • commands.keys.pick() at call site: default and preferred
  • commands.keys.omit(): when a screen uses almost everything except a small subset
  • commands.tags.every/some/without(): when commands need context-based selection
  • Custom local constant in component: acceptable if reused multiple times in the same file only

Avoid global alias exports that only mirror commands.keys.pick(...) unless there is a strong reuse reason.

On this page