From 8929a2dd3b15e88cf08f07159ae628c7a3aee17d Mon Sep 17 00:00:00 2001 From: Abdou TOP Date: Sun, 19 Jul 2026 11:13:24 +0000 Subject: [PATCH 1/2] Implement task management API and integrate into project page --- api/routes.ts | 140 ++++++++++++++++++++++++++++++++++++++ api/schema.ts | 29 ++++++++ web/lib/shared.tsx | 9 ++- web/pages/ProjectPage.tsx | 15 +++- 4 files changed, 191 insertions(+), 2 deletions(-) diff --git a/api/routes.ts b/api/routes.ts index 5262035..8c10dfd 100644 --- a/api/routes.ts +++ b/api/routes.ts @@ -8,6 +8,9 @@ import { DeploymentDef, DeploymentsCollection, ProjectsCollection, + type Task, + TaskCountersCollection, + TasksCollection, TeamDef, TeamDetailDef, User, @@ -143,6 +146,36 @@ const userInTeam = async (teamId: string, userId?: string) => { return !!matches } +const withProjectAccess = async ( + ctx: RequestContext & { session: User }, + slug: string, +) => { + const project = ProjectsCollection.get(slug) + if (!project) { + throw new respond.NotFoundError({ message: 'Project not found' }) + } + if (!project.isPublic && !ctx.session.isAdmin) { + if (!(await userInTeam(project.teamId, ctx.session.id))) { + throw new respond.ForbiddenError({ + message: 'Access to project tasks denied', + }) + } + } + return project +} + +// Allocates the next incremental task number for a project. The counter Map +// mutation happens synchronously (before the first await) so concurrent +// calls cannot observe a stale value. +const nextTaskNumber = (projectSlug: string) => { + const counter = TaskCountersCollection.get(projectSlug) + const lastNumber = (counter?.lastNumber ?? 0) + 1 + const save = counter + ? TaskCountersCollection.update(projectSlug, { lastNumber }) + : TaskCountersCollection.insert({ projectSlug, lastNumber }) + return save.then(() => lastNumber) +} + const withDeploymentTableAccess = async ( ctx: RequestContext & { session: User }, deployment: string, @@ -192,6 +225,21 @@ const projectOutput = OBJ({ updatedAt: optional(NUM('The last update date of the project')), }) +const taskOutput = OBJ({ + id: STR('The unique identifier for the task'), + projectSlug: STR('The slug of the project this task belongs to'), + number: NUM('The incremental number of the task within its project'), + title: STR('The title of the task'), + status: LIST(['todo', 'in_progress', 'done'], 'The status of the task'), + priority: LIST(['low', 'medium', 'high'], 'The priority of the task'), + description: optional(STR('Markdown description of the task')), + authorId: STR('The ID of the user who created the task'), + assigneeIds: ARR(STR('The ID of an assigned user'), 'Assigned users'), + position: NUM('Ordering position of the task within its status column'), + createdAt: optional(NUM('The creation date of the task')), + updatedAt: optional(NUM('The last update date of the task')), +}) + const apiDocOutputDef = ARR( OBJ({ method: LIST(['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], 'HTTP method'), @@ -855,6 +903,98 @@ const defs = { output: apiDocOutputDef, description: 'Get API documentation from the deployment', }), + 'GET/api/project/tasks': route({ + authorize: withUserSession, + fn: async (ctx, { project }) => { + await withProjectAccess(ctx, project) + return TasksCollection.filter((t) => t.projectSlug === project) + .sort((a, b) => a.position - b.position) + }, + input: OBJ({ project: STR('The slug of the project') }), + output: ARR(taskOutput, 'List of tasks for the project'), + description: 'Get all tasks for a project', + }), + 'POST/api/task': route({ + authorize: withUserSession, + fn: async (ctx, input) => { + await withProjectAccess(ctx, input.projectSlug) + const number = await nextTaskNumber(input.projectSlug) + const task = await TasksCollection.insert({ + id: `${input.projectSlug}-${number}`, + projectSlug: input.projectSlug, + number, + title: input.title, + status: 'todo', + priority: input.priority || 'medium', + description: input.description, + authorId: ctx.session.id, + assigneeIds: input.assigneeIds || [], + position: Date.now(), + }) + log.info('task-created', { id: task.id, projectSlug: input.projectSlug }) + return task + }, + input: OBJ({ + projectSlug: STR('The slug of the project'), + title: STR('The title of the task'), + description: optional(STR('Markdown description of the task')), + priority: optional( + LIST(['low', 'medium', 'high'], 'Priority of the task'), + ), + assigneeIds: optional( + ARR(STR('The ID of an assigned user'), 'Assigned users'), + ), + }, 'Create a new task'), + output: taskOutput, + description: 'Create a new task in a project', + }), + 'PUT/api/task': route({ + authorize: withUserSession, + fn: async (ctx, { id, ...changes }) => { + const task = TasksCollection.get(id) + if (!task) throw new respond.NotFoundError({ message: 'Task not found' }) + await withProjectAccess(ctx, task.projectSlug) + const patch = Object.fromEntries( + Object.entries(changes).filter(([, v]) => v != null), + ) as Partial> + const updated = await TasksCollection.update(id, patch) + log.info('task-updated', { id }) + return updated + }, + input: OBJ({ + id: STR('The ID of the task'), + title: optional(STR('The title of the task')), + status: optional( + LIST(['todo', 'in_progress', 'done'], 'Status of the task'), + ), + priority: optional( + LIST(['low', 'medium', 'high'], 'Priority of the task'), + ), + description: optional(STR('Markdown description of the task')), + assigneeIds: optional( + ARR(STR('The ID of an assigned user'), 'Assigned users'), + ), + position: optional( + NUM('Ordering position of the task within its status column'), + ), + }), + output: taskOutput, + description: 'Update a task', + }), + 'DELETE/api/task': route({ + authorize: withUserSession, + fn: async (ctx, { id }) => { + const task = TasksCollection.get(id) + if (!task) throw new respond.NotFoundError({ message: 'Task not found' }) + await withProjectAccess(ctx, task.projectSlug) + await TasksCollection.delete(id) + log.info('task-deleted', { id }) + return true + }, + input: OBJ({ id: STR('The ID of the task') }), + output: BOOL('Indicates if the task was deleted'), + description: 'Delete a task', + }), 'POST/api/deployment/fix-query': route({ authorize: withUserSession, fn: async (ctx, { id, deployment, metric, forceRefresh }) => { diff --git a/api/schema.ts b/api/schema.ts index 4ccd606..c264264 100644 --- a/api/schema.ts +++ b/api/schema.ts @@ -109,6 +109,35 @@ export const DatabaseSchemasCollection = await createCollection< 'deploymentUrl' >({ name: 'db_schemas', primaryKey: 'deploymentUrl' }) +export const TaskDef = OBJ({ + id: STR('Composite task ID, e.g. my-project-42'), + projectSlug: STR('Slug of the project this task belongs to'), + number: NUM('Incremental number of the task within its project'), + title: STR('Title of the task'), + status: LIST(['todo', 'in_progress', 'done'], 'Status of the task'), + priority: LIST(['low', 'medium', 'high'], 'Priority of the task'), + description: optional(STR('Markdown description of the task')), + authorId: STR('ID of the user who created the task'), + assigneeIds: ARR(STR('ID of an assigned user'), 'Assigned users'), + position: NUM('Ordering position of the task within its status column'), +}, 'A task belonging to a project') +export type Task = Asserted + +export const TaskCounterDef = OBJ({ + projectSlug: STR('Slug of the project'), + lastNumber: NUM('Last task number issued for this project'), +}, 'Per-project incremental task number counter') +export type TaskCounter = Asserted + +export const TasksCollection = await createCollection( + { name: 'tasks', primaryKey: 'id' }, +) + +export const TaskCountersCollection = await createCollection< + TaskCounter, + 'projectSlug' +>({ name: 'task_counters', primaryKey: 'projectSlug' }) + export const AIAnalysisCacheDef = OBJ({ cacheKey: STR('SHA-1 hash of deployment + query + explain + status'), analysis: STR('HTML-rendered AI analysis (high-quality, from forceRefresh)'), diff --git a/web/lib/shared.tsx b/web/lib/shared.tsx index 5841296..16a1321 100644 --- a/web/lib/shared.tsx +++ b/web/lib/shared.tsx @@ -2,6 +2,7 @@ import { Book, HardDrive, ListTodo } from 'lucide-preact' import { api } from './api.ts' import { DeploymentPage } from '../pages/DeploymentPage.tsx' import { ApiDocPage } from '../pages/project/ApiDocPage.tsx' +import { TasksPage } from '../pages/project/TaskPage.tsx' import { SidebarItem } from '../components/SideBar.tsx' import { url } from '@01edu/signal-router' import { Signal } from '@preact/signals' @@ -26,7 +27,7 @@ export const sidebarItems: Record = { label: 'API Docs', component: ApiDocPage, }, - 'tasks': { icon: ListTodo, label: 'Tasks', component: DeploymentPage }, + 'tasks': { icon: ListTodo, label: 'Tasks', component: TasksPage }, } as const // API signal for deployment queries @@ -38,6 +39,12 @@ export const deployments = api['GET/api/project/deployments'].signal() // API signal for current project export const project = api['GET/api/project'].signal() +// API signal for the project's tasks +export const tasks = api['GET/api/project/tasks'].signal() + +// API signal for the project's team (used to resolve assignee names) +export const projectTeam = api['GET/api/team'].signal() + export const runQuery = (query?: string) => { if (querier.pending) return const { dep, tab } = url.params diff --git a/web/pages/ProjectPage.tsx b/web/pages/ProjectPage.tsx index 9b8d88d..cd338be 100644 --- a/web/pages/ProjectPage.tsx +++ b/web/pages/ProjectPage.tsx @@ -2,7 +2,13 @@ import { effect } from '@preact/signals' import { navigate, url } from '@01edu/signal-router' import { user } from '../lib/session.ts' import { SettingsPage } from './SettingsPage.tsx' -import { deployments, project, sidebarItems } from '../lib/shared.tsx' +import { + deployments, + project, + projectTeam, + sidebarItems, + tasks, +} from '../lib/shared.tsx' import { Sidebar } from '../components/SideBar.tsx' effect(() => { @@ -11,6 +17,13 @@ effect(() => { if (!slug) return project.fetch({ slug }) deployments.fetch({ project: slug }) + tasks.fetch({ project: slug }) +}) + +effect(() => { + const teamId = project.data?.teamId + if (!teamId) return + projectTeam.fetch({ id: teamId }) }) export function ProjectPage() { From 1c944a7271490f254220a23fd5ab3184913712e5 Mon Sep 17 00:00:00 2001 From: Abdou TOP Date: Mon, 20 Jul 2026 09:23:59 +0000 Subject: [PATCH 2/2] Add Toast component for user notifications and integrate into task management pages --- web/components/Toast.tsx | 23 + web/pages/DeploymentPage.tsx | 22 +- web/pages/ProjectsPage.tsx | 25 +- web/pages/project/TaskPage.tsx | 755 ++++++++++++++++++++++++++++++++- 4 files changed, 766 insertions(+), 59 deletions(-) create mode 100644 web/components/Toast.tsx diff --git a/web/components/Toast.tsx b/web/components/Toast.tsx new file mode 100644 index 0000000..d70c651 --- /dev/null +++ b/web/components/Toast.tsx @@ -0,0 +1,23 @@ +import { Signal } from '@preact/signals' +import { AlertTriangle, CheckCircle2 } from 'lucide-preact' + +const toastSignal = new Signal< + { message: string; type: 'info' | 'error' } | null +>(null) + +export function toast(message: string, type: 'info' | 'error' = 'info') { + toastSignal.value = { message, type } + setTimeout(() => (toastSignal.value = null), 3000) +} + +export const Toast = () => { + if (!toastSignal.value) return null + return ( +
+ {toastSignal.value.type === 'error' + ? + : } + {toastSignal.value.message} +
+ ) +} diff --git a/web/pages/DeploymentPage.tsx b/web/pages/DeploymentPage.tsx index fc7cd61..966088f 100644 --- a/web/pages/DeploymentPage.tsx +++ b/web/pages/DeploymentPage.tsx @@ -44,6 +44,7 @@ import { api, type ApiOutput } from '../lib/api.ts' import { highlightSQL } from '../lib/highlight-sql.ts' import { QueryHistory } from '../components/QueryHistory.tsx' import { DeploymentHeader } from '../components/DeploymentHeader.tsx' +import { Toast, toast } from '../components/Toast.tsx' import type { ComponentChildren } from 'preact' import { querier, queriesHistory, runQuery } from '../lib/shared.tsx' import { apiDocs, TypeDefinition } from './project/ApiDocPage.tsx' @@ -59,27 +60,6 @@ export const logDetailsData = api['POST/api/deployment/logs'].signal() export const metricsData = api['GET/api/deployment/metrics-sql'].signal() const routerMetricsData = api['GET/api/deployment/metrics-router'].signal() -const toastSignal = new Signal< - { message: string; type: 'info' | 'error' } | null ->(null) - -function toast(message: string, type: 'info' | 'error' = 'info') { - toastSignal.value = { message, type } - setTimeout(() => (toastSignal.value = null), 3000) -} - -const Toast = () => { - if (!toastSignal.value) return null - return ( -
- {toastSignal.value.type === 'error' && ( - - )} - {toastSignal.value.message} -
- ) -} - type ColumnDef = NonNullable< ApiOutput['GET/api/deployment/schema']['tables'] >[number]['columns'][number] diff --git a/web/pages/ProjectsPage.tsx b/web/pages/ProjectsPage.tsx index 12312b0..c620565 100644 --- a/web/pages/ProjectsPage.tsx +++ b/web/pages/ProjectsPage.tsx @@ -1,7 +1,6 @@ -import { effect, signal, useSignal } from '@preact/signals' +import { effect, useSignal } from '@preact/signals' import { A, navigate } from '@01edu/signal-router' import { - AlertTriangle, ArrowRight, Calendar, Folder, @@ -13,6 +12,7 @@ import { Users, } from 'lucide-preact' import { Dialog, DialogModal } from '../components/Dialog.tsx' +import { Toast, toast } from '../components/Toast.tsx' import { url } from '@01edu/signal-router' import { JSX } from 'preact' import { user } from '../lib/session.ts' @@ -27,10 +27,6 @@ teams.fetch() const projects = api['GET/api/projects'].signal() projects.fetch() -const toastSignal = signal<{ message: string; type: 'info' | 'error' } | null>( - null, -) - const slugify = (str: string) => str.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^a-z0-9\-]/g, '') @@ -71,11 +67,6 @@ async function saveProject( } } -function toast(message: string, type: 'info' | 'error' = 'info') { - toastSignal.value = { message, type } - setTimeout(() => (toastSignal.value = null), 3000) -} - async function deleteProject(slug: string) { try { await api['DELETE/api/project'].fetch({ slug: slug }) @@ -162,18 +153,6 @@ const ProjectCard = ( ) } -const Toast = () => { - if (!toastSignal.value) return null - return ( -
- {toastSignal.value.type === 'error' && ( - - )} - {toastSignal.value.message} -
- ) -} - const TeamMembersRow = ({ user }: { user: Team['members'][number] }) => ( diff --git a/web/pages/project/TaskPage.tsx b/web/pages/project/TaskPage.tsx index 3017305..f46eddc 100644 --- a/web/pages/project/TaskPage.tsx +++ b/web/pages/project/TaskPage.tsx @@ -1,17 +1,742 @@ -import { Project } from '../../../api/schema.ts' -import { PageContent, PageHeader } from '../../components/Layout.tsx' - -export const TasksPage = ({ project }: { project: Project }) => { - return ( - <> - -

- Project: {project.name} -

-
- -

This is the project page for {project.name}.

-
- +import { effect } from '@preact/signals' +import { A, navigate, url } from '@01edu/signal-router' +import { + ArrowDown, + ArrowUp, + CheckCircle2, + Plus, + Search, + Trash2, +} from 'lucide-preact' +import type { ComponentChildren } from 'preact' +import { api, ApiOutput } from '../../lib/api.ts' +import { project, projectTeam, tasks } from '../../lib/shared.tsx' +import { Dialog, DialogModal } from '../../components/Dialog.tsx' +import { DeploymentHeader } from '../../components/DeploymentHeader.tsx' +import { Toast, toast } from '../../components/Toast.tsx' +import { parseSort } from '../../components/Filtre.tsx' + +type Task = ApiOutput['GET/api/project/tasks'][number] +type Status = Task['status'] +type Priority = Task['priority'] + +const statuses: { + key: Status + label: string + dot: string + lozenge: string +}[] = [ + { + key: 'todo', + label: 'To do', + dot: 'bg-base-content/30', + lozenge: 'bg-base-200 text-base-content/70', + }, + { + key: 'in_progress', + label: 'In progress', + dot: 'bg-warning', + lozenge: 'bg-warning/15 text-warning', + }, + { + key: 'done', + label: 'Done', + dot: 'bg-success', + lozenge: 'bg-success/15 text-success', + }, +] + +const statusOrder: Record = { + todo: 0, + in_progress: 1, + done: 2, +} + +const priorityOrder: Record = { low: 0, medium: 1, high: 2 } + +const priorityConfig: Record< + Priority, + { label: string; short: string; badge: string } +> = { + low: { label: 'Low priority', short: 'Low', badge: 'bg-info/10 text-info' }, + medium: { + label: 'Medium priority', + short: 'Medium', + badge: 'bg-warning/10 text-warning', + }, + high: { + label: 'High priority', + short: 'High', + badge: 'bg-error/10 text-error', + }, +} + +const initials = (name: string) => + name.split(/\s+/).filter(Boolean).slice(0, 2).map((n) => n[0]).join('') + .toUpperCase() + +const teamMember = (id: string) => + projectTeam.data?.members.find((m) => m.id === id) + +const refreshTasks = () => { + const slug = project.data?.slug + if (slug) tasks.fetch({ project: slug }) +} + +const patchTask = (id: string, changes: { + title?: string + status?: Status + priority?: Priority + description?: string + assigneeIds?: string[] + position?: number +}) => + api['PUT/api/task'].fetch({ + id, + title: undefined, + status: undefined, + priority: undefined, + description: undefined, + assigneeIds: undefined, + position: undefined, + ...changes, + }) + +const tasksInStatus = (status: Status, excludeId?: string) => + (tasks.data || []) + .filter((t) => t.status === status && t.id !== excludeId) + .sort((a, b) => a.position - b.position) + +const endPosition = (status: Status, excludeId?: string) => { + const group = tasksInStatus(status, excludeId) + return group.length ? group[group.length - 1].position + 1000 : Date.now() +} + +async function saveTask( + input: { + id?: string + title: string + description?: string + priority: Priority + assigneeIds: string[] + status: Status + previousStatus?: Status + }, +) { + const slug = project.data?.slug + if (!slug) return + try { + if (input.id) { + const statusChanged = input.previousStatus !== undefined && + input.previousStatus !== input.status + await patchTask(input.id, { + title: input.title, + description: input.description, + priority: input.priority, + assigneeIds: input.assigneeIds, + status: input.status, + position: statusChanged + ? endPosition(input.status, input.id) + : undefined, + }) + } else { + const created = await api['POST/api/task'].fetch({ + projectSlug: slug, + title: input.title, + description: input.description, + priority: input.priority, + assigneeIds: input.assigneeIds, + }) + if (input.status !== created.status) { + await patchTask(created.id, { + status: input.status, + position: endPosition(input.status, created.id), + }) + } + } + refreshTasks() + navigate({ + params: { dialog: null, taskId: null, status: null }, + replace: true, + }) + } catch (err) { + toast(err instanceof Error ? err.message : String(err), 'error') + } +} + +async function deleteTasks(ids: string[]) { + try { + await Promise.all(ids.map((id) => api['DELETE/api/task'].fetch({ id }))) + const remaining = selectedIds().filter((id) => !ids.includes(id)) + refreshTasks() + navigate({ + params: { + dialog: null, + taskId: null, + confirm: null, + sel: remaining.join(',') || null, + }, + replace: true, + }) + toast(ids.length > 1 ? `${ids.length} tasks deleted.` : 'Task deleted.') + } catch (err) { + navigate({ params: { confirm: null }, replace: true }) + toast(err instanceof Error ? err.message : String(err), 'error') + } +} + +async function changeStatus(task: Task, status: Status) { + if (status === task.status) return + try { + await patchTask(task.id, { status, position: endPosition(status, task.id) }) + refreshTasks() + } catch (err) { + toast(err instanceof Error ? err.message : String(err), 'error') + } +} + +const selectedIds = () => (url.params.sel || '').split(',').filter(Boolean) + +const setSelection = (ids: string[]) => + navigate({ params: { sel: ids.join(',') || null }, replace: true }) + +type SortKey = 'title' | 'priority' | 'status' | 'number' + +const comparators: Record number> = { + title: (a, b) => a.title.localeCompare(b.title), + priority: (a, b) => priorityOrder[a.priority] - priorityOrder[b.priority], + status: (a, b) => statusOrder[a.status] - statusOrder[b.status], + number: (a, b) => a.number - b.number, +} + +const taskSort = () => parseSort('tk')[0] + +const sortTasks = (list: Task[]) => { + const { key, dir } = taskSort() + if (!(key in comparators)) { + return [...list].sort((a, b) => + statusOrder[a.status] - statusOrder[b.status] || a.position - b.position + ) + } + const mul = dir === 'desc' ? -1 : 1 + return [...list].sort((a, b) => comparators[key as SortKey](a, b) * mul) +} + +let deleting = false +effect(() => { + const { dialog, taskId, confirm } = url.params + if (dialog !== 'delete-task' || !taskId || confirm !== '1' || deleting) return + deleting = true + deleteTasks(taskId.split(',')).finally(() => (deleting = false)) +}) + +const Avatar = ({ name, class: cls }: { name: string; class: string }) => ( + + {initials(name)} + +) + +const avatarStyle = 'h-6 w-6 bg-primary/15 text-[10px] font-medium text-primary' + +const AssigneeAvatar = ({ id }: { id: string }) => { + const name = teamMember(id)?.name || id + return ( +
+ +
+ ) +} + +const SelectCheckbox = ( + { label, checked, onChange }: { + label: string + checked: boolean + onChange: () => void + }, +) => ( + +) + +const NewTaskLink = ( + { class: cls, children }: { class: string; children: ComponentChildren }, +) => ( + + + {children} + +) + +const StatusOptions = () => + statuses.map((s) => ) + +const PriorityBadge = ({ priority }: { priority: Priority }) => { + const p = priorityConfig[priority] + return ( + + + {p.short} + + ) +} + +const StatusLozenge = ({ task }: { task: Task }) => { + const status = statuses.find((s) => s.key === task.status) ?? statuses[0] + return ( + + ) +} + +const thBase = + 'py-2.5 pr-2 text-left text-xs font-medium text-base-content/50 bg-base-100' + +const SortableTh = ( + { label, k, class: cls = '' }: { + label: string + k: SortKey + class?: string + }, +) => { + const sort = taskSort() + const active = sort.key === k + const asc = active && sort.dir === 'asc' + return ( + + + {label} + {active && + (asc ? : )} + + + ) +} + +const TaskRow = ({ task }: { task: Task }) => { + const selected = selectedIds() + const isSelected = selected.includes(task.id) + const toggle = () => + setSelection( + isSelected + ? selected.filter((id) => id !== task.id) + : [...selected, task.id], + ) + + return ( + + + + + + + + {task.title} + + + + + + {task.description || '—'} + + + + {task.assigneeIds.length > 0 + ? ( +
+ {task.assigneeIds.slice(0, 3).map((id) => ( + + ))} +
+ ) + : } + + + + + + + + + #{task.number} + + + + + + + + ) +} + +const TaskTable = ({ rows }: { rows: Task[] }) => { + const selected = selectedIds() + const allSelected = rows.length > 0 && + rows.every((t) => selected.includes(t.id)) + const toggleAll = () => setSelection(allSelected ? [] : rows.map((t) => t.id)) + + return ( +
+ + + + + + + + + + + + + {rows.length === 0 + ? ( + + + + ) + : rows.map((task) => )} + + + + +
+ + People +
+ No matching tasks +
+ + New task + +
+
+ ) +} + +const SelectionBar = () => { + const selected = selectedIds() + if (selected.length === 0) return null + return ( +
+ + {selected.length} selected + + + + Clear + + + + Delete + +
+ ) +} + +const AssigneeChip = ({ id, checked }: { id: string; checked: boolean }) => { + const name = teamMember(id)?.name || id + return ( + + ) +} + +const TaskForm = ({ task }: { task?: Task }) => { + const members = projectTeam.data?.members || [] + + const handleSubmit = (e: Event) => { + e.preventDefault() + const data = new FormData(e.target as HTMLFormElement) + const title = (data.get('title') as string || '').trim() + if (!title) return + saveTask({ + id: task?.id, + title, + description: (data.get('description') as string) || undefined, + priority: (data.get('priority') as Priority) || 'medium', + assigneeIds: data.getAll('assignees').map(String), + status: (data.get('status') as Status) || 'todo', + previousStatus: task?.status, + }) + } + + return ( +
+

+ {task ? `Edit task #${task.number}` : 'New task'} +

+ +