Tulip Logo IconTulip
Commands

Examples

Practical command patterns using atomic command values and local composition.

This page shows the recommended command architecture used in Tulip apps.

Atomic commands per feature

Export one command per action.

// app/admin/projects/_config/commands.tsx
export const projectCreateCommand = commandBuilder
  .$type<null>()
  .render(() => <CreateProjectCommand />);

export const projectUpdateStatusCommand = commandBuilder
  .$type<{ id: string } | { id: string }[]>()
  .transform(ensureArray)
  .render(({ data }) => <UpdateProjectStatusCommand projects={data} />);

export const projectArchiveCommand = commandBuilder
  .$type<{ id: string } | { id: string }[]>()
  .transform(ensureArray)
  .render(({ data }) => <ArchiveProjectCommand projects={data} />);

export const projectDeleteCommand = commandBuilder
  .$type<{ id: string } | { id: string }[]>()
  .transform(ensureArray)
  .render(({ data }) => <DeleteProjectCommand projects={data} />);

Local composition at usage site

Compose the command list where it is rendered.

<ResponsiveCommandMenu
  data={project}
  commands={[
    projectUpdateStatusCommand,
    projectArchiveCommand,
    projectDeleteCommand,
  ]}
/>

Create action in a toolbar:

<InlineCommandMenu data={null} commands={[projectCreateCommand]} />

Single and bulk with one command

Use .transform(ensureArray) when one command should support row actions and selection actions.

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

export const customerDeleteCommand = commandBuilder
  .$type<CustomerInput>()
  .transform(ensureArray)
  .render(({ data }) => (
    <DeleteCommand variables={{ ids: data.map((item) => item.id) }} mutation={...} />
  ));

The same command can then be used in both places:

<DropdownCommandMenu data={row} commands={[customerDeleteCommand]} />

<FloatingCommandMenu data={selectedRows} commands={[customerDeleteCommand]} state="open" />

Meta-aware commands

Use createCommandBuilder<TMeta>() when commands need extra view context.

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

export const timeEntryCreateCommand = 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={[timeEntryCreateCommand]}
/>

Full create command example

import { commandBuilder } from "@tulip-systems/commands";
import {
  CommandFormDialog,
  CommandFormDialogCancel,
  CommandFormDialogContent,
  CommandFormDialogFields,
  CommandFormDialogFooter,
  CommandFormDialogHeader,
  CommandFormDialogSubmit,
  CommandFormDialogTitle,
  CommandFormDialogTrigger,
  CommandLabel,
} from "@tulip-systems/commands/client";
import { Input } from "@tulip-systems/ui/input";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
} from "@tulip-systems/ui/react-hook-form";
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>
  );
}

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

Render it where the action belongs:

<InlineCommandMenu
  data={{ customerId: customer.id }}
  commands={[projectCreateCommand]}
/>

On this page