diff --git a/apps/desktop/plans/20260320-desktop-task-create-tiptap-plan.md b/apps/desktop/plans/20260320-desktop-task-create-tiptap-plan.md new file mode 100644 index 00000000000..a9114b2bf7a --- /dev/null +++ b/apps/desktop/plans/20260320-desktop-task-create-tiptap-plan.md @@ -0,0 +1,61 @@ +# Desktop Task Create: Linear-Style TipTap Plan + +## Goal + +Add a desktop-only create-task flow in the Tasks view that feels like Linear and uses the same editor surface as task editing. + +## Scope + +- Desktop only +- No web work +- No package extraction unless it becomes necessary later + +## Key Decision + +Create and edit should share one desktop task composer. Do not build a separate create modal with a second editor implementation. + +## Existing Anchors + +- Current task editor: `apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/TaskMarkdownRenderer.tsx` +- Bubble toolbar: `apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/components/BubbleMenuToolbar/BubbleMenuToolbar.tsx` +- Tasks top bar entry point: `apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/TasksTopBar.tsx` +- Existing metadata patterns: + - status: `apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/StatusProperty/StatusProperty.tsx` + - priority: `apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/shared/PriorityMenuItems.tsx` + - assignee: `apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/AssigneeFilter/AssigneeFilter.tsx` + +## Plan + +1. Extract a shared desktop `TaskComposer` under the desktop tasks feature. + Start from the current `TaskMarkdownRenderer`, bubble menu, and existing metadata controls. + +2. Migrate the existing task detail editor to `TaskComposer` in `edit` mode. + This keeps behavior aligned before adding create. + +3. Add a `CreateTaskDialog` launched from `TasksTopBar`. + The dialog should be compact and Linear-style: title first, metadata row, TipTap description, submit via `Cmd/Ctrl+Enter`. + +4. Add a desktop-facing create path that owns slug/default status resolution. + Do not change the existing low-level `task.create` contract in place if desktop/mobile sync depends on it. + +5. On successful create, close the dialog, refresh task data, and navigate to the new task. + +## Non-Goals for V1 + +- Web create flow +- Slash commands +- Image handling +- Full editor/package sharing outside the desktop task feature + +## Validation + +```bash +bun run typecheck +bun run lint +``` + +Manual checks: + +1. Create a task from the tasks top bar. +2. Edit an existing task with the refactored composer. +3. Confirm create and edit feel like the same surface. diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/TaskMarkdownRenderer.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/TaskMarkdownRenderer.tsx index 0130a0a5a1e..235ee01ddb0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/TaskMarkdownRenderer.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/TaskMarkdownRenderer.tsx @@ -1,6 +1,7 @@ import "highlight.js/styles/github-dark.css"; import "./task-markdown.css"; +import { cn } from "@superset/ui/utils"; import { Extension } from "@tiptap/core"; import { Blockquote } from "@tiptap/extension-blockquote"; import { Bold } from "@tiptap/extension-bold"; @@ -24,9 +25,15 @@ import TaskItem from "@tiptap/extension-task-item"; import TaskList from "@tiptap/extension-task-list"; import { Text } from "@tiptap/extension-text"; import { Underline } from "@tiptap/extension-underline"; -import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react"; +import { + type Editor, + EditorContent, + ReactNodeViewRenderer, + useEditor, +} from "@tiptap/react"; import { BubbleMenu } from "@tiptap/react/menus"; import { common, createLowlight } from "lowlight"; +import { useEffect } from "react"; import { BubbleMenuToolbar } from "renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/components/BubbleMenuToolbar"; import { env } from "renderer/env.renderer"; import { Markdown } from "tiptap-markdown"; @@ -117,14 +124,34 @@ const KeyboardHandler = Extension.create({ interface TaskMarkdownRendererProps { content: string; - onSave: (markdown: string) => void; + onSave?: (markdown: string) => void; + onChange?: (markdown: string) => void; + placeholder?: string; + autoFocus?: boolean; + className?: string; + editorClassName?: string; + onModEnter?: () => void; +} + +function getMarkdown(editor: Editor | null): string { + const storage = editor?.storage as + | Record string }> + | undefined; + return storage?.markdown?.getMarkdown?.() ?? ""; } export function TaskMarkdownRenderer({ content, onSave, + onChange, + placeholder = "Add description...", + autoFocus = false, + className, + editorClassName, + onModEnter, }: TaskMarkdownRendererProps) { const editor = useEditor({ + autofocus: autoFocus ? "end" : false, extensions: [ Document, Text, @@ -198,7 +225,7 @@ export function TaskMarkdownRenderer({ Placeholder.configure({ placeholder: ({ node }) => { if (node.type.name === "paragraph") { - return "Add description..."; + return placeholder; } return ""; }, @@ -217,21 +244,35 @@ export function TaskMarkdownRenderer({ content, editorProps: { attributes: { - class: "focus:outline-none min-h-[100px]", + class: cn("focus:outline-none min-h-[100px]", editorClassName), }, + handleKeyDown: (_, event) => { + if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { + onModEnter?.(); + return true; + } + return false; + }, + }, + onUpdate: ({ editor }) => { + onChange?.(getMarkdown(editor)); }, onBlur: ({ editor }) => { - const storage = editor.storage as unknown as Record< - string, - { getMarkdown?: () => string } - >; - const markdown = storage.markdown?.getMarkdown?.() ?? ""; - onSave(markdown); + onSave?.(getMarkdown(editor)); }, }); + useEffect(() => { + if (!editor || editor.isFocused) return; + + const currentMarkdown = getMarkdown(editor); + if (currentMarkdown === content) return; + + editor.commands.setContent(content, { emitUpdate: false }); + }, [content, editor]); + return ( -
+
{editor && ( { const s: Record = {}; @@ -62,6 +68,17 @@ function TaskDetailPage() { if (!taskData || taskData.length === 0) return null; return taskData[0]; }, [taskData]); + const taskFallbackQuery = useQuery({ + queryKey: ["task-detail-fallback", taskId, isUuidTaskId ? "id" : "slug"], + queryFn: () => + isUuidTaskId + ? apiTrpcClient.task.byId.query(taskId) + : apiTrpcClient.task.bySlug.query(taskId), + enabled: !task, + retry: false, + }); + const isTaskSyncing = !task && !!taskFallbackQuery.data; + const isTaskLoading = !task && taskFallbackQuery.isPending; const handleBack = () => { navigate({ to: "/tasks", search: backSearch }); @@ -82,6 +99,16 @@ function TaskDetailPage() { }; if (!task) { + if (isTaskLoading || isTaskSyncing) { + return ( +
+ + {isTaskSyncing ? "Syncing task..." : "Loading task..."} + +
+ ); + } + return (
Task not found diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/TasksTopBar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/TasksTopBar.tsx index c22f0ed6ee6..4b6585151c3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/TasksTopBar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/TasksTopBar.tsx @@ -2,9 +2,10 @@ import { Button } from "@superset/ui/button"; import { Input } from "@superset/ui/input"; import { Tabs, TabsList, TabsTrigger } from "@superset/ui/tabs"; import { cn } from "@superset/ui/utils"; -import { useRef } from "react"; +import { useRef, useState } from "react"; import { HiOutlineMagnifyingGlass, + HiOutlinePencilSquare, HiOutlineQueueList, HiOutlineViewColumns, HiXMark, @@ -16,6 +17,7 @@ import { ActiveIcon } from "../shared/icons/ActiveIcon"; import { AllIssuesIcon } from "../shared/icons/AllIssuesIcon"; import { BacklogIcon } from "../shared/icons/BacklogIcon"; import { AssigneeFilter } from "./components/AssigneeFilter"; +import { CreateTaskDialog } from "./components/CreateTaskDialog"; import { RunInWorkspacePopover } from "./components/RunInWorkspacePopover"; export type TabValue = "all" | "active" | "backlog"; @@ -65,6 +67,7 @@ export function TasksTopBar({ }: TasksTopBarProps) { const selectedCount = selectedTasks.length; const searchInputRef = useRef(null); + const [isCreateTaskOpen, setIsCreateTaskOpen] = useState(false); useAppHotkey( "FOCUS_TASK_SEARCH", @@ -78,110 +81,130 @@ export function TasksTopBar({ const hasSelection = selectedCount > 0; return ( -
- {/* Left side: tabs/filters or selection actions */} -
- {hasSelection ? ( - <> - - - {selectedCount} selected - -
- {})} - /> - - ) : ( - <> - onTabChange(value as TabValue)} - > - - {TABS.map((tab) => { - const Icon = tab.Icon; - return ( - - - {tab.label} - - ); - })} - - + <> +
+ {/* Left side: tabs/filters or selection actions */} +
+ {hasSelection ? ( + <> + + + {selectedCount} selected + +
+ {})} + /> + + ) : ( + <> + onTabChange(value as TabValue)} + > + + {TABS.map((tab) => { + const Icon = tab.Icon; + return ( + + + {tab.label} + + ); + })} + + -
+
- - - )} -
+ + + )} +
- {/* Right side: view toggle + search */} -
-
- - -
+ + New task + + +
+ + +
-
- - onSearchChange(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Escape") { - onSearchChange(""); - searchInputRef.current?.blur(); - } - }} - className="h-8 pl-9 pr-3 text-sm bg-muted/50 border-0 focus-visible:ring-1" - /> +
+ + onSearchChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") { + onSearchChange(""); + searchInputRef.current?.blur(); + } + }} + className="h-8 pl-9 pr-3 text-sm bg-muted/50 border-0 focus-visible:ring-1" + /> +
-
+ + + ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx new file mode 100644 index 00000000000..e7b38af2f36 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx @@ -0,0 +1,278 @@ +import { authClient } from "@superset/auth/client"; +import type { TaskPriority } from "@superset/db/enums"; +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { Kbd, KbdGroup } from "@superset/ui/kbd"; +import { toast } from "@superset/ui/sonner"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { HiChevronRight, HiOutlinePaperClip, HiXMark } from "react-icons/hi2"; +import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import { TaskMarkdownRenderer } from "renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useHotkeysStore } from "renderer/stores/hotkeys/store"; +import { compareStatusesForDropdown } from "../../../../utils/sorting"; +import type { TabValue } from "../../TasksTopBar"; +import { CreateTaskAssigneePicker } from "./components/CreateTaskAssigneePicker"; +import { CreateTaskPriorityPicker } from "./components/CreateTaskPriorityPicker"; +import { CreateTaskStatusPicker } from "./components/CreateTaskStatusPicker"; + +interface CreateTaskDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + currentTab: TabValue; + searchQuery: string; + assigneeFilter: string | null; +} + +export function CreateTaskDialog({ + open, + onOpenChange, + currentTab, + searchQuery, + assigneeFilter, +}: CreateTaskDialogProps) { + const collections = useCollections(); + const { data: session } = authClient.useSession(); + const navigate = useNavigate(); + const platform = useHotkeysStore((state) => state.platform); + const modKey = platform === "darwin" ? "⌘" : "Ctrl"; + const titleInputRef = useRef(null); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [statusId, setStatusId] = useState(null); + const [priority, setPriority] = useState("none"); + const [assigneeId, setAssigneeId] = useState(null); + const [isCreating, setIsCreating] = useState(false); + + const { data: statusData } = useLiveQuery( + (q) => + q + .from({ taskStatuses: collections.taskStatuses }) + .select(({ taskStatuses }) => ({ ...taskStatuses })), + [collections], + ); + + const { data: userData } = useLiveQuery( + (q) => + q + .from({ users: collections.users }) + .select(({ users }) => ({ ...users })), + [collections], + ); + const { data: organizationData } = useLiveQuery( + (q) => + q + .from({ organizations: collections.organizations }) + .select(({ organizations }) => ({ ...organizations })), + [collections], + ); + + const statuses = useMemo(() => statusData ?? [], [statusData]); + const users = useMemo(() => userData ?? [], [userData]); + const activeOrganizationId = session?.session?.activeOrganizationId ?? null; + const organizationLabel = useMemo(() => { + const organization = organizationData?.find( + (org) => org.id === activeOrganizationId, + ); + return organization?.name ?? "Task"; + }, [activeOrganizationId, organizationData]); + + const defaultStatusId = useMemo(() => { + const sortedStatuses = [...statuses].sort(compareStatusesForDropdown); + return ( + sortedStatuses.find((status) => status.type === "backlog")?.id ?? + sortedStatuses[0]?.id ?? + null + ); + }, [statuses]); + + useEffect(() => { + if (open && statusId === null && defaultStatusId) { + setStatusId(defaultStatusId); + } + }, [defaultStatusId, open, statusId]); + + useEffect(() => { + if (open) return; + + setTitle(""); + setDescription(""); + setStatusId(defaultStatusId); + setPriority("none"); + setAssigneeId(null); + setIsCreating(false); + }, [defaultStatusId, open]); + + const currentStatusType = useMemo( + () => statuses.find((status) => status.id === statusId)?.type, + [statusId, statuses], + ); + const handleAttachmentClick = () => { + toast.info("Attachments are not wired yet"); + }; + const handleCreate = async () => { + if (!title.trim() || isCreating) return; + + setIsCreating(true); + + try { + const result = await apiTrpcClient.task.createFromUi.mutate({ + title: title.trim(), + description: description.trim() || null, + statusId, + priority, + assigneeId, + }); + + if (!result.task) { + throw new Error("Task creation returned no task"); + } + + const nextSearch: Record = {}; + if (currentTab !== "all") nextSearch.tab = currentTab; + if (assigneeFilter) nextSearch.assignee = assigneeFilter; + if (searchQuery) nextSearch.search = searchQuery; + + onOpenChange(false); + toast.success(`Created ${result.task.slug}`); + navigate({ + to: "/tasks/$taskId", + params: { taskId: result.task.id }, + search: nextSearch, + }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to create task", + ); + setIsCreating(false); + } + }; + + return ( + + { + event.preventDefault(); + titleInputRef.current?.focus(); + }} + > + + Create Task + + Create a new task from the desktop tasks view. + + + +
+
+
+ {organizationLabel} +
+ + New issue +
+ + + + +
+ +
+ setTitle(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + void handleCreate(); + } + }} + placeholder="Task title" + className="w-full bg-transparent text-3xl font-semibold tracking-tight outline-none placeholder:text-muted-foreground/60" + /> + +
+ +
+ +
+ + + +
+
+ + + + +
+ +
+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskAssigneePicker/CreateTaskAssigneePicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskAssigneePicker/CreateTaskAssigneePicker.tsx new file mode 100644 index 00000000000..1855d6b06fa --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskAssigneePicker/CreateTaskAssigneePicker.tsx @@ -0,0 +1,126 @@ +import type { SelectUser } from "@superset/db/schema"; +import { Avatar } from "@superset/ui/atoms/Avatar"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { useMemo, useState } from "react"; +import { HiCheck, HiChevronDown, HiOutlineUserCircle } from "react-icons/hi2"; + +interface CreateTaskAssigneePickerProps { + users: SelectUser[]; + value: string | null; + onChange: (value: string | null) => void; +} + +export function CreateTaskAssigneePicker({ + users, + value, + onChange, +}: CreateTaskAssigneePickerProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + + const selectedUser = useMemo( + () => users.find((user) => user.id === value) ?? null, + [users, value], + ); + + const filteredUsers = useMemo(() => { + const query = search.trim().toLowerCase(); + if (!query) return users; + + return users.filter((user) => { + return ( + user.name?.toLowerCase().includes(query) || + user.email?.toLowerCase().includes(query) + ); + }); + }, [search, users]); + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + setSearch(""); + } + }; + + const handleSelect = (nextValue: string | null) => { + onChange(nextValue); + setOpen(false); + setSearch(""); + }; + + return ( + + + + + + + + + + handleSelect(null)}> + + No assignee + {value === null && } + + + + {filteredUsers.length === 0 ? ( + No people found. + ) : ( + + {filteredUsers.map((user) => ( + handleSelect(user.id)} + > + +
+ {user.name} + + {user.email} + +
+ {user.id === value && } +
+ ))} +
+ )} +
+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskAssigneePicker/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskAssigneePicker/index.ts new file mode 100644 index 00000000000..8a4778e73d3 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskAssigneePicker/index.ts @@ -0,0 +1 @@ +export { CreateTaskAssigneePicker } from "./CreateTaskAssigneePicker"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskPriorityPicker/CreateTaskPriorityPicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskPriorityPicker/CreateTaskPriorityPicker.tsx new file mode 100644 index 00000000000..f2268f5960c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskPriorityPicker/CreateTaskPriorityPicker.tsx @@ -0,0 +1,59 @@ +import type { TaskPriority } from "@superset/db/enums"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { useState } from "react"; +import { HiChevronDown } from "react-icons/hi2"; +import { PriorityIcon } from "renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/shared/PriorityIcon"; +import { PriorityMenuItems } from "renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/shared/PriorityMenuItems"; + +const PRIORITY_LABELS: Record = { + none: "No priority", + urgent: "Urgent", + high: "High", + medium: "Medium", + low: "Low", +}; + +interface CreateTaskPriorityPickerProps { + value: TaskPriority; + statusType?: string; + onChange: (value: TaskPriority) => void; +} + +export function CreateTaskPriorityPicker({ + value, + statusType, + onChange, +}: CreateTaskPriorityPickerProps) { + const [open, setOpen] = useState(false); + + return ( + + + + + + { + onChange(priority); + setOpen(false); + }} + MenuItem={DropdownMenuItem} + /> + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskPriorityPicker/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskPriorityPicker/index.ts new file mode 100644 index 00000000000..f0a09791d0b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskPriorityPicker/index.ts @@ -0,0 +1 @@ +export { CreateTaskPriorityPicker } from "./CreateTaskPriorityPicker"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskStatusPicker/CreateTaskStatusPicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskStatusPicker/CreateTaskStatusPicker.tsx new file mode 100644 index 00000000000..bb92a66696a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskStatusPicker/CreateTaskStatusPicker.tsx @@ -0,0 +1,76 @@ +import type { SelectTaskStatus } from "@superset/db/schema"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { useMemo, useState } from "react"; +import { HiChevronDown } from "react-icons/hi2"; +import { + StatusIcon, + type StatusType, +} from "renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/shared/StatusIcon"; +import { StatusMenuItems } from "renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/shared/StatusMenuItems"; +import { compareStatusesForDropdown } from "renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/utils/sorting"; + +interface CreateTaskStatusPickerProps { + statuses: SelectTaskStatus[]; + value: string | null; + onChange: (value: string) => void; +} + +export function CreateTaskStatusPicker({ + statuses, + value, + onChange, +}: CreateTaskStatusPickerProps) { + const [open, setOpen] = useState(false); + + const currentStatus = useMemo( + () => statuses.find((status) => status.id === value) ?? null, + [statuses, value], + ); + + const sortedStatuses = useMemo( + () => [...statuses].sort(compareStatusesForDropdown), + [statuses], + ); + + return ( + + + + + + { + onChange(status.id); + setOpen(false); + }} + MenuItem={DropdownMenuItem} + /> + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskStatusPicker/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskStatusPicker/index.ts new file mode 100644 index 00000000000..2d8a6360d6d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskStatusPicker/index.ts @@ -0,0 +1 @@ +export { CreateTaskStatusPicker } from "./CreateTaskStatusPicker"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/index.ts new file mode 100644 index 00000000000..cf44a97b1f9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/index.ts @@ -0,0 +1 @@ +export { CreateTaskDialog } from "./CreateTaskDialog"; diff --git a/bun.lock b/bun.lock index 90ebfe60372..d9e62010fce 100644 --- a/bun.lock +++ b/bun.lock @@ -110,7 +110,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "1.2.2", + "version": "1.2.3", "dependencies": { "@ai-sdk/anthropic": "^3.0.43", "@ai-sdk/openai": "3.0.36", diff --git a/packages/mcp/src/tools/tasks/create-task/create-task.ts b/packages/mcp/src/tools/tasks/create-task/create-task.ts index 0db73cefcff..c3d9a73b3e6 100644 --- a/packages/mcp/src/tools/tasks/create-task/create-task.ts +++ b/packages/mcp/src/tools/tasks/create-task/create-task.ts @@ -2,6 +2,10 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { db, dbWs } from "@superset/db/client"; import { tasks } from "@superset/db/schema"; import { seedDefaultStatuses } from "@superset/db/seed-default-statuses"; +import { + generateBaseTaskSlug, + generateUniqueTaskSlug, +} from "@superset/shared/task-slug"; import { and, eq, ilike, or } from "drizzle-orm"; import { z } from "zod"; import { getMcpContext } from "../../utils"; @@ -38,28 +42,6 @@ const taskInputSchema = z.object({ type TaskInput = z.infer; -function generateBaseSlug(title: string): string { - return title - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, "") - .slice(0, 50); -} - -function generateUniqueSlug( - baseSlug: string, - existingSlugs: Set, -): string { - let slug = baseSlug; - if (existingSlugs.has(slug)) { - let counter = 1; - while (existingSlugs.has(slug)) { - slug = `${baseSlug}-${counter++}`; - } - } - return slug; -} - export function register(server: McpServer) { server.registerTool( "create_task", @@ -93,7 +75,7 @@ export function register(server: McpServer) { defaultStatusId = await seedDefaultStatuses(ctx.organizationId); } - const baseSlugs = taskInputs.map((t) => generateBaseSlug(t.title)); + const baseSlugs = taskInputs.map((t) => generateBaseTaskSlug(t.title)); const uniqueBaseSlugs = [...new Set(baseSlugs)]; const slugConditions = uniqueBaseSlugs.map((baseSlug) => @@ -131,7 +113,7 @@ export function register(server: McpServer) { for (const [i, input] of taskInputs.entries()) { const baseSlug = baseSlugs[i] ?? ""; - const slug = generateUniqueSlug(baseSlug, usedSlugs); + const slug = generateUniqueTaskSlug(baseSlug, usedSlugs); usedSlugs.add(slug); const priority: TaskPriority = isPriority(input.priority) diff --git a/packages/shared/package.json b/packages/shared/package.json index 44c2369171a..19d5bc1d35b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -36,6 +36,10 @@ "types": "./src/agent-prompt-template.ts", "default": "./src/agent-prompt-template.ts" }, + "./task-slug": { + "types": "./src/task-slug.ts", + "default": "./src/task-slug.ts" + }, "./claude-command": { "types": "./src/claude-command.ts", "default": "./src/claude-command.ts" diff --git a/packages/shared/src/task-slug.test.ts b/packages/shared/src/task-slug.test.ts new file mode 100644 index 00000000000..ba17133babc --- /dev/null +++ b/packages/shared/src/task-slug.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "bun:test"; +import { generateBaseTaskSlug, generateUniqueTaskSlug } from "./task-slug"; + +describe("generateBaseTaskSlug", () => { + it("normalizes titles into lowercase kebab-case slugs", () => { + expect(generateBaseTaskSlug("Fix Linear Sync!")).toBe("fix-linear-sync"); + }); + + it("falls back to task when the title has no slug characters", () => { + expect(generateBaseTaskSlug("!!!")).toBe("task"); + }); +}); + +describe("generateUniqueTaskSlug", () => { + it("returns the base slug when unused", () => { + expect(generateUniqueTaskSlug("fix-linear-sync", [])).toBe( + "fix-linear-sync", + ); + }); + + it("increments numeric suffixes until it finds a free slug", () => { + expect( + generateUniqueTaskSlug("fix-linear-sync", [ + "fix-linear-sync", + "fix-linear-sync-1", + "fix-linear-sync-2", + ]), + ).toBe("fix-linear-sync-3"); + }); +}); diff --git a/packages/shared/src/task-slug.ts b/packages/shared/src/task-slug.ts new file mode 100644 index 00000000000..ae72ffd8846 --- /dev/null +++ b/packages/shared/src/task-slug.ts @@ -0,0 +1,30 @@ +export function generateBaseTaskSlug(title: string): string { + const slug = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 50); + + return slug || "task"; +} + +export function generateUniqueTaskSlug( + baseSlug: string, + existingSlugs: Iterable, +): string { + const usedSlugs = new Set(existingSlugs); + + if (!usedSlugs.has(baseSlug)) { + return baseSlug; + } + + let counter = 1; + let slug = `${baseSlug}-${counter}`; + + while (usedSlugs.has(slug)) { + counter += 1; + slug = `${baseSlug}-${counter}`; + } + + return slug; +} diff --git a/packages/trpc/src/router/task/schema.ts b/packages/trpc/src/router/task/schema.ts index ee550732b4c..09f0050b14d 100644 --- a/packages/trpc/src/router/task/schema.ts +++ b/packages/trpc/src/router/task/schema.ts @@ -16,6 +16,17 @@ export const createTaskSchema = z.object({ labels: z.array(z.string()).nullish(), }); +export const createTaskFromUiSchema = z.object({ + title: z.string().min(1), + description: z.string().nullish(), + statusId: z.string().uuid().nullish(), + priority: z.enum(taskPriorityValues).default("none"), + assigneeId: z.string().uuid().nullish(), + estimate: z.number().int().positive().nullish(), + dueDate: z.coerce.date().nullish(), + labels: z.array(z.string()).nullish(), +}); + export const updateTaskSchema = z.object({ id: z.string().uuid(), title: z.string().min(1).optional(), diff --git a/packages/trpc/src/router/task/task.ts b/packages/trpc/src/router/task/task.ts index f7e1db36bb2..f15101fe89e 100644 --- a/packages/trpc/src/router/task/task.ts +++ b/packages/trpc/src/router/task/task.ts @@ -1,13 +1,34 @@ import { db, dbWs } from "@superset/db/client"; -import { tasks, users } from "@superset/db/schema"; +import { members, taskStatuses, tasks, users } from "@superset/db/schema"; +import { seedDefaultStatuses } from "@superset/db/seed-default-statuses"; import { getCurrentTxid } from "@superset/db/utils"; -import type { TRPCRouterRecord } from "@trpc/server"; -import { and, desc, eq, isNull } from "drizzle-orm"; +import { + generateBaseTaskSlug, + generateUniqueTaskSlug, +} from "@superset/shared/task-slug"; +import { TRPCError, type TRPCRouterRecord } from "@trpc/server"; +import { and, desc, eq, ilike, isNull } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import { z } from "zod"; import { syncTask } from "../../lib/integrations/sync"; import { protectedProcedure, publicProcedure } from "../../trpc"; -import { createTaskSchema, updateTaskSchema } from "./schema"; +import { + createTaskFromUiSchema, + createTaskSchema, + updateTaskSchema, +} from "./schema"; + +const TASK_SLUG_CONSTRAINT = "tasks_org_slug_unique"; +const TASK_SLUG_RETRY_LIMIT = 5; + +function isConstraintError(error: unknown, constraint: string): boolean { + if (!error || typeof error !== "object") { + return false; + } + + const maybeError = error as { code?: string; constraint?: string }; + return maybeError.code === "23505" && maybeError.constraint === constraint; +} export const taskRouter = { all: publicProcedure.query(() => { @@ -88,6 +109,125 @@ export const taskRouter = { return result; }), + createFromUi: protectedProcedure + .input(createTaskFromUiSchema) + .mutation(async ({ ctx, input }) => { + const organizationId = ctx.session.session.activeOrganizationId; + + if (!organizationId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "No active organization selected", + }); + } + + for (let attempt = 0; attempt < TASK_SLUG_RETRY_LIMIT; attempt += 1) { + try { + const result = await dbWs.transaction(async (tx) => { + const statusId = input.statusId + ? ( + await tx + .select({ id: taskStatuses.id }) + .from(taskStatuses) + .where( + and( + eq(taskStatuses.id, input.statusId), + eq(taskStatuses.organizationId, organizationId), + ), + ) + .limit(1) + )[0]?.id + : await seedDefaultStatuses(organizationId, tx); + + if (!statusId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Status must belong to the active organization", + }); + } + + const assigneeId = input.assigneeId + ? (( + await tx + .select({ userId: members.userId }) + .from(members) + .where( + and( + eq(members.organizationId, organizationId), + eq(members.userId, input.assigneeId), + ), + ) + .limit(1) + )[0]?.userId ?? null) + : null; + + if (input.assigneeId && !assigneeId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Assignee must belong to the active organization", + }); + } + + const baseSlug = generateBaseTaskSlug(input.title); + const existingSlugs = await tx + .select({ slug: tasks.slug }) + .from(tasks) + .where( + and( + eq(tasks.organizationId, organizationId), + ilike(tasks.slug, `${baseSlug}%`), + ), + ); + const slug = generateUniqueTaskSlug( + baseSlug, + existingSlugs.map((task) => task.slug), + ); + + const [task] = await tx + .insert(tasks) + .values({ + slug, + title: input.title, + description: input.description ?? null, + statusId, + priority: input.priority ?? "none", + organizationId, + creatorId: ctx.session.user.id, + assigneeId, + estimate: input.estimate ?? null, + dueDate: input.dueDate ?? null, + labels: input.labels ?? [], + }) + .returning(); + + const txid = await getCurrentTxid(tx); + + return { task, txid }; + }); + + if (result.task) { + syncTask(result.task.id); + } + + return result; + } catch (error) { + if ( + isConstraintError(error, TASK_SLUG_CONSTRAINT) && + attempt < TASK_SLUG_RETRY_LIMIT - 1 + ) { + continue; + } + + throw error; + } + } + + throw new TRPCError({ + code: "CONFLICT", + message: "Failed to generate a unique task slug", + }); + }), + update: protectedProcedure .input(updateTaskSchema) .mutation(async ({ input }) => {