# Version history

> The saved versions of a record with a diff and a button to restore one.

- Block: [versions](https://bettersupabase.com/blocks/versions.md)
- Kind: component
- Data: loaded on the server through the block's client
- Page: https://bettersupabase.com/recipes/version-history

## Install the block

```bash
npx better-supabase add versions
```

## Web (Next.js)

Add the shadcn components:

```bash
npx shadcn add alert alert-dialog badge button card dialog empty skeleton
```

From `apps/examples/nextjs/src/features/customers/components/version-history.tsx` (https://github.com/ScaleDockHQ/better-supabase/blob/main/apps/examples/nextjs/src/features/customers/components/version-history.tsx):

```tsx
/**
 * Saved versions of the customer's name and KvK number, newest first. Each
 * one compares with the version before it and restores as a new version.
 */
export function VersionHistory({
  customerId,
  versions,
  canSave,
  canRestore,
}: {
  customerId: string;
  versions: Loaded<readonly VersionRow[]>;
  canSave: boolean;
  canRestore: boolean;
}) {
  const t = useExtracted("customers");

  return (
    <Card size="sm" data-recipe="version-history">
      <CardHeader>
        <CardTitle>{t("Version history")}</CardTitle>
        <CardDescription>
          {t("Snapshots of the name and KvK number.")}
        </CardDescription>
        {canSave && versions.kind === "ok" && versions.data.length > 0 ? (
          <CardAction>
            <SaveVersionButton customerId={customerId} />
          </CardAction>
        ) : null}
      </CardHeader>
      <CardContent>
        {versions.kind === "error" ? (
          <RecordLoadError title={t("The versions didn't load")} />
        ) : versions.data.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <HistoryIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No versions yet")}</EmptyTitle>
              <EmptyDescription>
                {t(
                  "Save a version to keep the customer's details as they are now.",
                )}
              </EmptyDescription>
            </EmptyHeader>
            {canSave ? (
              <EmptyContent>
                <SaveVersionButton customerId={customerId} />
              </EmptyContent>
            ) : null}
          </Empty>
        ) : (
          <VersionList
            customerId={customerId}
            versions={versions.data}
            canRestore={canRestore}
          />
        )}
      </CardContent>
    </Card>
  );
}

function VersionList({
  customerId,
  versions,
  canRestore,
}: {
  customerId: string;
  versions: readonly VersionRow[];
  canRestore: boolean;
}) {
  const t = useExtracted("customers");
  const format = useFormatter();

  const source = {
    manual: t("Saved"),
    auto: t("Automatic"),
    restore: t("Restored"),
  } as const satisfies Record<VersionRow["source"], string>;

  return (
    <ol className="flex flex-col divide-y" data-testid="versions">
      {versions.map((version, index) => {
        const previous = versions[index + 1];

        return (
          <li
            key={version.id}
            className="flex items-center justify-between gap-2 py-2"
          >
            <div className="flex min-w-0 flex-col gap-1">
              <div className="flex items-center gap-2">
                <span className="text-sm font-medium">
                  {t("Version {number}", { number: String(version.number) })}
                </span>
                <Badge variant="secondary">{source[version.source]}</Badge>
              </div>
              <span className="truncate text-xs text-muted-foreground">
                {version.label ? `${version.label} · ` : ""}
                {format.dateTime(version.createdAt, {
                  dateStyle: "medium",
                  timeStyle: "short",
                })}
              </span>
            </div>
            <div className="flex items-center gap-1">
              {previous ? (
                <CompareDialog
                  fromId={previous.id}
                  toId={version.id}
                  fromNumber={previous.number}
                  toNumber={version.number}
                />
              ) : null}
              {canRestore && index > 0 ? (
                <RestoreButton
                  customerId={customerId}
                  versionId={version.id}
                  number={version.number}
                />
              ) : null}
            </div>
          </li>
        );
      })}
    </ol>
  );
}

function SaveVersionButton({ customerId }: { customerId: string }) {
  const t = useExtracted("customers");
  const errorMessage = useErrorMessage();

  const save = useAction(saveCustomerVersion, {
    onSuccess: () => {
      toast.success(t("Version saved"));
    },
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  return (
    <Button
      size="sm"
      variant="outline"
      disabled={save.pending}
      onClick={() => {
        void save.run({ customerId });
      }}
    >
      <HistoryIcon />
      {t("Save a version")}
    </Button>
  );
}

function CompareDialog({
  fromId,
  toId,
  fromNumber,
  toNumber,
}: {
  fromId: string;
  toId: string;
  fromNumber: number;
  toNumber: number;
}) {
  const t = useExtracted("customers");
  const compare = useAction(compareCustomerVersions);

  const load = () => {
    void compare.run({ fromId, toId });
  };

  return (
    <Dialog
      onOpenChange={(open) => {
        if (open) {
          load();
        }
      }}
    >
      <DialogTrigger render={<Button size="sm" variant="ghost" />}>
        {t("Compare")}
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>
            {t("Version {from} to {to}", {
              from: String(fromNumber),
              to: String(toNumber),
            })}
          </DialogTitle>
          <DialogDescription>
            {t("What changed between the two versions.")}
          </DialogDescription>
        </DialogHeader>
        {compare.error ? (
          <Alert variant="destructive">
            <TriangleAlertIcon />
            <AlertTitle>{t("The comparison didn't load")}</AlertTitle>
            <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
            <AlertAction>
              <Button size="sm" variant="outline" onClick={load}>
                {t("Retry")}
              </Button>
            </AlertAction>
          </Alert>
        ) : compare.data === undefined || compare.pending ? (
          <div className="flex flex-col gap-2">
            <Skeleton className="h-8" />
            <Skeleton className="h-8" />
          </div>
        ) : compare.data.length === 0 ? (
          <p className="text-sm text-muted-foreground">
            {t("The two versions are the same.")}
          </p>
        ) : (
          <ul className="flex flex-col divide-y" data-testid="version-diff">
            {compare.data.map((operation) => (
              <li
                key={`${operation.op}:${operation.path}`}
                className="flex items-center gap-2 py-2 text-sm"
              >
                <ChangeBadge operation={operation} />
                <code className="text-xs">{operation.path}</code>
                {"value" in operation ? (
                  <span className="truncate">
                    {JSON.stringify(operation.value)}
                  </span>
                ) : null}
              </li>
            ))}
          </ul>
        )}
        <DialogFooter>
          <DialogClose render={<Button />}>{t("Done")}</DialogClose>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

function ChangeBadge({ operation }: { operation: JsonPatchOperation }) {
  const t = useExtracted("customers");

  switch (operation.op) {
    case "add": {
      return <Badge variant="success-light">{t("Added")}</Badge>;
    }

    case "remove": {
      return <Badge variant="destructive-light">{t("Removed")}</Badge>;
    }

    case "replace": {
      return <Badge variant="info-light">{t("Changed")}</Badge>;
    }

    case "move":
    case "copy":
    case "test": {
      return <Badge variant="secondary">{operation.op}</Badge>;
    }

    default: {
      const unhandled: never = operation;

      return unhandled;
    }
  }
}

function RestoreButton({
  customerId,
  versionId,
  number,
}: {
  customerId: string;
  versionId: string;
  number: number;
}) {
  const t = useExtracted("customers");
  const errorMessage = useErrorMessage();

  const restore = useAction(restoreCustomerVersion, {
    onSuccess: () => {
      toast.success(t("Version restored"));
    },
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  return (
    <AlertDialog>
      <AlertDialogTrigger
        render={<Button size="sm" variant="ghost" disabled={restore.pending} />}
      >
        {t("Restore")}
      </AlertDialogTrigger>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>
            {t("Restore version {number}?", { number: String(number) })}
          </AlertDialogTitle>
          <AlertDialogDescription>
            {t(
              "The customer's name and KvK number go back to this version. The current details stay in the history.",
            )}
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel>{t("Cancel")}</AlertDialogCancel>
          <AlertDialogAction
            onClick={() => {
              void restore.run({ customerId, versionId });
            }}
          >
            {t("Restore")}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}

/** The version history card while its rows load. */
export function VersionHistorySkeleton() {
  return <RecordCardSkeleton rows={3} />;
}
```

## Prompt

Paste this into a coding agent to build the recipe in your app.

### Web

```text
Build "Version history" in a React web app that uses shadcn/ui components. The saved versions of a record with a diff and a button to restore one.

It uses the better-supabase `versions` block (Versions). It loads its data on the server through the block's client.

Steps:

1. If the app doesn't have the block yet, add the SQL with `npx better-supabase add versions` and apply the migration.
2. Add the shadcn components the recipe uses: `npx shadcn add alert alert-dialog badge button card dialog empty skeleton`.
3. The recipe needs no glue: it calls the block's client on the server.
4. Write the component. Load the data on the server with the block's client, starting from the recipe's snippet (`apps/examples/nextjs/src/features/customers/components/version-history.tsx`). Adapt it to the app's existing components, styling and data loading instead of copying it as is.
5. Check the result against the recipe and the block's docs: it typechecks, it reads the same block data, and it handles the loading and empty states the snippet handles.

Read the full code first:

- Recipe: https://bettersupabase.com/recipes/version-history.md
- Block docs: https://bettersupabase.com/docs/blocks/versions
```
