ui: smooth new issue submit state

This commit is contained in:
Dotta
2026-03-10 21:06:16 -05:00
parent d3ac8722be
commit 5f76d03913

View File

@@ -34,6 +34,7 @@ import {
Tag, Tag,
Calendar, Calendar,
Paperclip, Paperclip,
Loader2,
} from "lucide-react"; } from "lucide-react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { extractProviderIdWithFallback } from "../lib/model-utils"; import { extractProviderIdWithFallback } from "../lib/model-utils";
@@ -420,7 +421,7 @@ export function NewIssueDialog() {
} }
function handleSubmit() { function handleSubmit() {
if (!effectiveCompanyId || !title.trim()) return; if (!effectiveCompanyId || !title.trim() || createIssue.isPending) return;
const assigneeAdapterOverrides = buildAssigneeAdapterOverrides({ const assigneeAdapterOverrides = buildAssigneeAdapterOverrides({
adapterType: assigneeAdapterType, adapterType: assigneeAdapterType,
modelOverride: assigneeModelOverride, modelOverride: assigneeModelOverride,
@@ -516,6 +517,11 @@ export function NewIssueDialog() {
})), })),
[orderedProjects], [orderedProjects],
); );
const savedDraft = loadDraft();
const hasSavedDraft = Boolean(savedDraft?.title.trim() || savedDraft?.description.trim());
const canDiscardDraft = hasDraft || hasSavedDraft;
const createIssueErrorMessage =
createIssue.error instanceof Error ? createIssue.error.message : "Failed to create issue. Try again.";
const handleProjectChange = useCallback((nextProjectId: string) => { const handleProjectChange = useCallback((nextProjectId: string) => {
setProjectId(nextProjectId); setProjectId(nextProjectId);
@@ -563,7 +569,7 @@ export function NewIssueDialog() {
<Dialog <Dialog
open={newIssueOpen} open={newIssueOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) closeNewIssue(); if (!open && !createIssue.isPending) closeNewIssue();
}} }}
> >
<DialogContent <DialogContent
@@ -576,7 +582,16 @@ export function NewIssueDialog() {
: "sm:max-w-lg" : "sm:max-w-lg"
)} )}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onEscapeKeyDown={(event) => {
if (createIssue.isPending) {
event.preventDefault();
}
}}
onPointerDownOutside={(event) => { onPointerDownOutside={(event) => {
if (createIssue.isPending) {
event.preventDefault();
return;
}
// Radix Dialog's modal DismissableLayer calls preventDefault() on // Radix Dialog's modal DismissableLayer calls preventDefault() on
// pointerdown events that originate outside the Dialog DOM tree. // pointerdown events that originate outside the Dialog DOM tree.
// Popover portals render at the body level (outside the Dialog), so // Popover portals render at the body level (outside the Dialog), so
@@ -654,6 +669,7 @@ export function NewIssueDialog() {
size="icon-xs" size="icon-xs"
className="text-muted-foreground" className="text-muted-foreground"
onClick={() => setExpanded(!expanded)} onClick={() => setExpanded(!expanded)}
disabled={createIssue.isPending}
> >
{expanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />} {expanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</Button> </Button>
@@ -662,6 +678,7 @@ export function NewIssueDialog() {
size="icon-xs" size="icon-xs"
className="text-muted-foreground" className="text-muted-foreground"
onClick={() => closeNewIssue()} onClick={() => closeNewIssue()}
disabled={createIssue.isPending}
> >
<span className="text-lg leading-none">&times;</span> <span className="text-lg leading-none">&times;</span>
</Button> </Button>
@@ -680,6 +697,7 @@ export function NewIssueDialog() {
e.target.style.height = "auto"; e.target.style.height = "auto";
e.target.style.height = `${e.target.scrollHeight}px`; e.target.style.height = `${e.target.scrollHeight}px`;
}} }}
readOnly={createIssue.isPending}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && !e.metaKey && !e.ctrlKey) { if (e.key === "Enter" && !e.metaKey && !e.ctrlKey) {
e.preventDefault(); e.preventDefault();
@@ -998,17 +1016,36 @@ export function NewIssueDialog() {
size="sm" size="sm"
className="text-muted-foreground" className="text-muted-foreground"
onClick={discardDraft} onClick={discardDraft}
disabled={!hasDraft && !loadDraft()} disabled={createIssue.isPending || !canDiscardDraft}
> >
Discard Draft Discard Draft
</Button> </Button>
<Button <div className="flex items-center gap-3">
size="sm" <div className="min-h-5 text-right">
disabled={!title.trim() || createIssue.isPending} {createIssue.isPending ? (
onClick={handleSubmit} <span className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
> <Loader2 className="h-3 w-3 animate-spin" />
{createIssue.isPending ? "Creating..." : "Create Issue"} Creating issue...
</Button> </span>
) : createIssue.isError ? (
<span className="text-xs text-destructive">{createIssueErrorMessage}</span>
) : canDiscardDraft ? (
<span className="text-xs text-muted-foreground">Draft autosaves locally</span>
) : null}
</div>
<Button
size="sm"
className="min-w-[8.5rem] disabled:opacity-100"
disabled={!title.trim() || createIssue.isPending}
onClick={handleSubmit}
aria-busy={createIssue.isPending}
>
<span className="inline-flex items-center justify-center gap-1.5">
{createIssue.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
<span>{createIssue.isPending ? "Creating..." : "Create Issue"}</span>
</span>
</Button>
</div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>