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.input(z.null()).render(() => <div>Create</div>),
  updateStatus: commandBuilder.input(projectBulkSchema).render(() => <div>Status</div>),
  archive: commandBuilder.input(projectBulkSchema).render(() => <ArchiveCommand ... />),
  restore: commandBuilder.input(projectBulkSchema).render(() => <RestoreCommand ... />),
  delete: commandBuilder.input(projectBulkSchema).render(() => <DeleteCommand ... />),
});

Select At Usage Site

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

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

<ResponsiveCommandMenu
  data={project}
  commands={projectCommands.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.

const rowSchema = z.looseObject({ id: z.string() });
const bulkSchema = z.union([rowSchema.transform((item) => [item]), rowSchema.array()]);

export const customerCommands = defineCommands({
  delete: commandBuilder.input(bulkSchema).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.

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
    .input(z.object({ projectId: z.string() }))
    .render(({ data, meta }) => (
      <CreateTimeEntryCommand
        defaultValues={data}
        fieldVisibility={{ projectId: meta.fieldVisibility.projectId ?? true }}
      />
    )),
});

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

Context-Specific Action Sets

Use different subsets for list/detail/child screens.

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

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

Visibility And Permissions Together

Use permissions for coarse access and visibility for runtime eligibility.

archive: commandBuilder
  .input(projectBulkSchema)
  .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 validated 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 z from "zod";
import { orpc } from "@/server/router/client";

const createProjectSchema = z.object({ customerId: z.string() });

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>
  );
}

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

Render it where the action belongs:

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

Which Approach To Choose

  • Inline .pick() at call site: default and preferred
  • .omit(): when a screen uses almost everything except a small subset
  • Custom local constant in component: acceptable if reused multiple times in the same file only

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

On this page