feat: add New Goal dialog and Sub Goal button
- Added NewGoalDialog component with title, description (markdown), status, level, and parent goal selection - Integrated dialog into DialogContext with parentId defaults support - Added "+ New Goal" button on /goals page (both empty state and header) - Added "+ Sub Goal" button on goal detail sub-goals tab that pre-fills the parent goal Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import { PropertiesPanel } from "./PropertiesPanel";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
import { NewIssueDialog } from "./NewIssueDialog";
|
||||
import { NewProjectDialog } from "./NewProjectDialog";
|
||||
import { NewGoalDialog } from "./NewGoalDialog";
|
||||
import { NewAgentDialog } from "./NewAgentDialog";
|
||||
import { OnboardingWizard } from "./OnboardingWizard";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
@@ -84,6 +85,7 @@ export function Layout() {
|
||||
<CommandPalette />
|
||||
<NewIssueDialog />
|
||||
<NewProjectDialog />
|
||||
<NewGoalDialog />
|
||||
<NewAgentDialog />
|
||||
<OnboardingWizard />
|
||||
</div>
|
||||
|
||||
282
ui/src/components/NewGoalDialog.tsx
Normal file
282
ui/src/components/NewGoalDialog.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { GOAL_STATUSES, GOAL_LEVELS } from "@paperclip/shared";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { goalsApi } from "../api/goals";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Target,
|
||||
Layers,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
|
||||
const levelLabels: Record<string, string> = {
|
||||
company: "Company",
|
||||
team: "Team",
|
||||
agent: "Agent",
|
||||
task: "Task",
|
||||
};
|
||||
|
||||
export function NewGoalDialog() {
|
||||
const { newGoalOpen, newGoalDefaults, closeNewGoal } = useDialog();
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [status, setStatus] = useState("planned");
|
||||
const [level, setLevel] = useState("task");
|
||||
const [parentId, setParentId] = useState("");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
const [levelOpen, setLevelOpen] = useState(false);
|
||||
const [parentOpen, setParentOpen] = useState(false);
|
||||
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
||||
|
||||
// Apply defaults when dialog opens
|
||||
const appliedParentId = parentId || newGoalDefaults.parentId || "";
|
||||
|
||||
const { data: goals } = useQuery({
|
||||
queryKey: queryKeys.goals.list(selectedCompanyId!),
|
||||
queryFn: () => goalsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && newGoalOpen,
|
||||
});
|
||||
|
||||
const createGoal = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
goalsApi.create(selectedCompanyId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.goals.list(selectedCompanyId!) });
|
||||
reset();
|
||||
closeNewGoal();
|
||||
},
|
||||
});
|
||||
|
||||
const uploadDescriptionImage = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
if (!selectedCompanyId) throw new Error("No company selected");
|
||||
return assetsApi.uploadImage(selectedCompanyId, file, "goals/drafts");
|
||||
},
|
||||
});
|
||||
|
||||
function reset() {
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setStatus("planned");
|
||||
setLevel("task");
|
||||
setParentId("");
|
||||
setExpanded(false);
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!selectedCompanyId || !title.trim()) return;
|
||||
createGoal.mutate({
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
status,
|
||||
level,
|
||||
...(appliedParentId ? { parentId: appliedParentId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
const currentParent = (goals ?? []).find((g) => g.id === appliedParentId);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={newGoalOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
reset();
|
||||
closeNewGoal();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={cn("p-0 gap-0", expanded ? "sm:max-w-2xl" : "sm:max-w-lg")}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{selectedCompany && (
|
||||
<span className="bg-muted px-1.5 py-0.5 rounded text-xs font-medium">
|
||||
{selectedCompany.name.slice(0, 3).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground/60">›</span>
|
||||
<span>{newGoalDefaults.parentId ? "New sub-goal" : "New goal"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => { reset(); closeNewGoal(); }}
|
||||
>
|
||||
<span className="text-lg leading-none">×</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="px-4 pt-3">
|
||||
<input
|
||||
className="w-full text-base font-medium bg-transparent outline-none placeholder:text-muted-foreground/50"
|
||||
placeholder="Goal title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Tab" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
descriptionEditorRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="px-4 pb-2">
|
||||
<MarkdownEditor
|
||||
ref={descriptionEditorRef}
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder="Add description..."
|
||||
bordered={false}
|
||||
contentClassName={cn("text-sm text-muted-foreground", expanded ? "min-h-[220px]" : "min-h-[120px]")}
|
||||
imageUploadHandler={async (file) => {
|
||||
const asset = await uploadDescriptionImage.mutateAsync(file);
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Property chips */}
|
||||
<div className="flex items-center gap-1.5 px-4 py-2 border-t border-border flex-wrap">
|
||||
{/* Status */}
|
||||
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
|
||||
<StatusBadge status={status} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-40 p-1" align="start">
|
||||
{GOAL_STATUSES.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 capitalize",
|
||||
s === status && "bg-accent"
|
||||
)}
|
||||
onClick={() => { setStatus(s); setStatusOpen(false); }}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Level */}
|
||||
<Popover open={levelOpen} onOpenChange={setLevelOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
|
||||
<Layers className="h-3 w-3 text-muted-foreground" />
|
||||
{levelLabels[level] ?? level}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-40 p-1" align="start">
|
||||
{GOAL_LEVELS.map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
l === level && "bg-accent"
|
||||
)}
|
||||
onClick={() => { setLevel(l); setLevelOpen(false); }}
|
||||
>
|
||||
{levelLabels[l] ?? l}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Parent goal */}
|
||||
<Popover open={parentOpen} onOpenChange={setParentOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
|
||||
<Target className="h-3 w-3 text-muted-foreground" />
|
||||
{currentParent ? currentParent.title : "Parent goal"}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48 p-1" align="start">
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
!appliedParentId && "bg-accent"
|
||||
)}
|
||||
onClick={() => { setParentId(""); setParentOpen(false); }}
|
||||
>
|
||||
No parent
|
||||
</button>
|
||||
{(goals ?? []).map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 truncate",
|
||||
g.id === appliedParentId && "bg-accent"
|
||||
)}
|
||||
onClick={() => { setParentId(g.id); setParentOpen(false); }}
|
||||
>
|
||||
{g.title}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end px-4 py-2.5 border-t border-border">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!title.trim() || createGoal.isPending}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{createGoal.isPending ? "Creating..." : newGoalDefaults.parentId ? "Create sub-goal" : "Create goal"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,10 @@ interface NewIssueDefaults {
|
||||
assigneeAgentId?: string;
|
||||
}
|
||||
|
||||
interface NewGoalDefaults {
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
interface DialogContextValue {
|
||||
newIssueOpen: boolean;
|
||||
newIssueDefaults: NewIssueDefaults;
|
||||
@@ -15,6 +19,10 @@ interface DialogContextValue {
|
||||
newProjectOpen: boolean;
|
||||
openNewProject: () => void;
|
||||
closeNewProject: () => void;
|
||||
newGoalOpen: boolean;
|
||||
newGoalDefaults: NewGoalDefaults;
|
||||
openNewGoal: (defaults?: NewGoalDefaults) => void;
|
||||
closeNewGoal: () => void;
|
||||
newAgentOpen: boolean;
|
||||
openNewAgent: () => void;
|
||||
closeNewAgent: () => void;
|
||||
@@ -29,6 +37,8 @@ export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
const [newIssueOpen, setNewIssueOpen] = useState(false);
|
||||
const [newIssueDefaults, setNewIssueDefaults] = useState<NewIssueDefaults>({});
|
||||
const [newProjectOpen, setNewProjectOpen] = useState(false);
|
||||
const [newGoalOpen, setNewGoalOpen] = useState(false);
|
||||
const [newGoalDefaults, setNewGoalDefaults] = useState<NewGoalDefaults>({});
|
||||
const [newAgentOpen, setNewAgentOpen] = useState(false);
|
||||
const [onboardingOpen, setOnboardingOpen] = useState(false);
|
||||
|
||||
@@ -50,6 +60,16 @@ export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
setNewProjectOpen(false);
|
||||
}, []);
|
||||
|
||||
const openNewGoal = useCallback((defaults: NewGoalDefaults = {}) => {
|
||||
setNewGoalDefaults(defaults);
|
||||
setNewGoalOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeNewGoal = useCallback(() => {
|
||||
setNewGoalOpen(false);
|
||||
setNewGoalDefaults({});
|
||||
}, []);
|
||||
|
||||
const openNewAgent = useCallback(() => {
|
||||
setNewAgentOpen(true);
|
||||
}, []);
|
||||
@@ -76,6 +96,10 @@ export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
newProjectOpen,
|
||||
openNewProject,
|
||||
closeNewProject,
|
||||
newGoalOpen,
|
||||
newGoalDefaults,
|
||||
openNewGoal,
|
||||
closeNewGoal,
|
||||
newAgentOpen,
|
||||
openNewAgent,
|
||||
closeNewAgent,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { projectsApi } from "../api/projects";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { GoalProperties } from "../components/GoalProperties";
|
||||
@@ -13,12 +14,15 @@ import { GoalTree } from "../components/GoalTree";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { EntityRow } from "../components/EntityRow";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { Goal, Project } from "@paperclip/shared";
|
||||
|
||||
export function GoalDetail() {
|
||||
const { goalId } = useParams<{ goalId: string }>();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewGoal } = useDialog();
|
||||
const { openPanel, closePanel } = usePanel();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const navigate = useNavigate();
|
||||
@@ -117,7 +121,13 @@ export function GoalDetail() {
|
||||
<TabsTrigger value="projects">Projects ({linkedProjects.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="children" className="mt-4">
|
||||
<TabsContent value="children" className="mt-4 space-y-3">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button size="sm" variant="outline" onClick={() => openNewGoal({ parentId: goalId })}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Sub Goal
|
||||
</Button>
|
||||
</div>
|
||||
{childGoals.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No sub-goals.</p>
|
||||
) : (
|
||||
|
||||
@@ -3,14 +3,17 @@ import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { goalsApi } from "../api/goals";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { GoalTree } from "../components/GoalTree";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { Target } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Target, Plus } from "lucide-react";
|
||||
|
||||
export function Goals() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewGoal } = useDialog();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -38,12 +41,20 @@ export function Goals() {
|
||||
icon={Target}
|
||||
message="No goals yet."
|
||||
action="Add Goal"
|
||||
onAction={() => {/* TODO: goal creation */}}
|
||||
onAction={() => openNewGoal()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{goals && goals.length > 0 && (
|
||||
<GoalTree goals={goals} onSelect={(goal) => navigate(`/goals/${goal.id}`)} />
|
||||
<>
|
||||
<div className="flex items-center justify-end">
|
||||
<Button size="sm" variant="outline" onClick={() => openNewGoal()}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
New Goal
|
||||
</Button>
|
||||
</div>
|
||||
<GoalTree goals={goals} onSelect={(goal) => navigate(`/goals/${goal.id}`)} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user