172 lines
5.6 KiB
TypeScript
172 lines
5.6 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import * as React from "react";
|
||
|
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||
|
|
import { Loader2, Plus, X } from "lucide-react";
|
||
|
|
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { api } from "@/lib/trpc";
|
||
|
|
import type { ConvoyClassification } from "@tasks/shared/convoy-lifecycle";
|
||
|
|
|
||
|
|
const CLASSIFICATIONS: ConvoyClassification[] = [
|
||
|
|
"feature",
|
||
|
|
"hotfix",
|
||
|
|
"docs-only",
|
||
|
|
"infra-only",
|
||
|
|
"server-only",
|
||
|
|
"config-only",
|
||
|
|
];
|
||
|
|
|
||
|
|
type FormState = {
|
||
|
|
slug: string;
|
||
|
|
classification: ConvoyClassification;
|
||
|
|
successMetric: string;
|
||
|
|
ideaMarkdown: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
const EMPTY: FormState = {
|
||
|
|
slug: "",
|
||
|
|
classification: "feature",
|
||
|
|
successMetric: "",
|
||
|
|
ideaMarkdown: "",
|
||
|
|
};
|
||
|
|
|
||
|
|
export function CreateConvoyDialog({
|
||
|
|
workspaceSlug,
|
||
|
|
open,
|
||
|
|
onOpenChange,
|
||
|
|
onCreated,
|
||
|
|
}: {
|
||
|
|
workspaceSlug: string;
|
||
|
|
open: boolean;
|
||
|
|
onOpenChange: (open: boolean) => void;
|
||
|
|
onCreated: (convoyId: string) => void;
|
||
|
|
}) {
|
||
|
|
const utils = api.useUtils();
|
||
|
|
const [form, setForm] = React.useState<FormState>(EMPTY);
|
||
|
|
|
||
|
|
const createMut = api.convoys.create.useMutation({
|
||
|
|
onSuccess: (result) => {
|
||
|
|
void utils.convoys.kanban.invalidate();
|
||
|
|
onCreated(result.convoyId);
|
||
|
|
onOpenChange(false);
|
||
|
|
setForm(EMPTY);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
return (
|
||
|
|
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
||
|
|
<DialogPrimitive.Portal>
|
||
|
|
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50" />
|
||
|
|
<DialogPrimitive.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-6 shadow-lg">
|
||
|
|
<div className="mb-4 flex items-center justify-between">
|
||
|
|
<DialogPrimitive.Title className="text-lg font-semibold">
|
||
|
|
New convoy
|
||
|
|
</DialogPrimitive.Title>
|
||
|
|
<DialogPrimitive.Close asChild>
|
||
|
|
<button type="button" className="rounded p-1 hover:bg-muted" aria-label="Close">
|
||
|
|
<X className="size-4" />
|
||
|
|
</button>
|
||
|
|
</DialogPrimitive.Close>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-3">
|
||
|
|
<div>
|
||
|
|
<label className="mb-1 block text-sm font-medium" htmlFor="convoy-slug">
|
||
|
|
Slug
|
||
|
|
</label>
|
||
|
|
<Input
|
||
|
|
id="convoy-slug"
|
||
|
|
placeholder="my-feature"
|
||
|
|
value={form.slug}
|
||
|
|
onChange={(e) => setForm((f) => ({ ...f, slug: e.target.value }))}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label className="mb-1 block text-sm font-medium" htmlFor="convoy-class">
|
||
|
|
Classification
|
||
|
|
</label>
|
||
|
|
<select
|
||
|
|
id="convoy-class"
|
||
|
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||
|
|
value={form.classification}
|
||
|
|
onChange={(e) =>
|
||
|
|
setForm((f) => ({
|
||
|
|
...f,
|
||
|
|
classification: e.target.value as ConvoyClassification,
|
||
|
|
}))
|
||
|
|
}
|
||
|
|
>
|
||
|
|
{CLASSIFICATIONS.map((c) => (
|
||
|
|
<option key={c} value={c}>
|
||
|
|
{c}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label className="mb-1 block text-sm font-medium" htmlFor="convoy-metric">
|
||
|
|
Success metric
|
||
|
|
</label>
|
||
|
|
<Input
|
||
|
|
id="convoy-metric"
|
||
|
|
value={form.successMetric}
|
||
|
|
onChange={(e) => setForm((f) => ({ ...f, successMetric: e.target.value }))}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label className="mb-1 block text-sm font-medium" htmlFor="convoy-idea">
|
||
|
|
Idea / plan markdown
|
||
|
|
</label>
|
||
|
|
<textarea
|
||
|
|
id="convoy-idea"
|
||
|
|
className="min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||
|
|
value={form.ideaMarkdown}
|
||
|
|
onChange={(e) => setForm((f) => ({ ...f, ideaMarkdown: e.target.value }))}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="flex justify-end gap-2 pt-2">
|
||
|
|
<DialogPrimitive.Close asChild>
|
||
|
|
<Button type="button" variant="outline">
|
||
|
|
Cancel
|
||
|
|
</Button>
|
||
|
|
</DialogPrimitive.Close>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
disabled={
|
||
|
|
createMut.isPending ||
|
||
|
|
!form.slug.trim() ||
|
||
|
|
!form.successMetric.trim() ||
|
||
|
|
!form.ideaMarkdown.trim()
|
||
|
|
}
|
||
|
|
onClick={() =>
|
||
|
|
createMut.mutate({
|
||
|
|
workspace: workspaceSlug,
|
||
|
|
slug: form.slug.trim(),
|
||
|
|
classification: form.classification,
|
||
|
|
successMetric: form.successMetric.trim(),
|
||
|
|
ideaMarkdown: form.ideaMarkdown.trim(),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
>
|
||
|
|
{createMut.isPending ? (
|
||
|
|
<Loader2 className="size-4 animate-spin" />
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
<Plus className="mr-1 size-4" />
|
||
|
|
Create
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
{createMut.error && (
|
||
|
|
<p className="text-xs text-destructive">{createMut.error.message}</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</DialogPrimitive.Content>
|
||
|
|
</DialogPrimitive.Portal>
|
||
|
|
</DialogPrimitive.Root>
|
||
|
|
);
|
||
|
|
}
|