chore: update generated files to orval v8.3.0 and adjust related interfaces
This commit is contained in:
@@ -11,6 +11,7 @@ interface TaskCardProps {
|
||||
assignee?: string;
|
||||
due?: string;
|
||||
approvalsPendingCount?: number;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
isBlocked?: boolean;
|
||||
blockedByCount?: number;
|
||||
onClick?: () => void;
|
||||
@@ -27,6 +28,7 @@ export function TaskCard({
|
||||
assignee,
|
||||
due,
|
||||
approvalsPendingCount = 0,
|
||||
tags = [],
|
||||
isBlocked = false,
|
||||
blockedByCount = 0,
|
||||
onClick,
|
||||
@@ -61,6 +63,7 @@ export function TaskCard({
|
||||
};
|
||||
|
||||
const priorityLabel = priority ? priority.toUpperCase() : "MEDIUM";
|
||||
const visibleTags = tags.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -115,6 +118,27 @@ export function TaskCard({
|
||||
Waiting for lead review
|
||||
</div>
|
||||
) : null}
|
||||
{visibleTags.length ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{visibleTags.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-semibold text-slate-700"
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{ backgroundColor: `#${tag.color}` }}
|
||||
/>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
{tags.length > visibleTags.length ? (
|
||||
<span className="text-[10px] font-semibold text-slate-500">
|
||||
+{tags.length - visibleTags.length}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 flex-col items-end gap-2">
|
||||
<span
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Building2,
|
||||
LayoutGrid,
|
||||
Network,
|
||||
Tags,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/auth/clerk";
|
||||
@@ -110,6 +111,18 @@ export function DashboardSidebar() {
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
Boards
|
||||
</Link>
|
||||
<Link
|
||||
href="/tags"
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
|
||||
pathname.startsWith("/tags")
|
||||
? "bg-blue-100 text-blue-800 font-medium"
|
||||
: "hover:bg-slate-100",
|
||||
)}
|
||||
>
|
||||
<Tags className="h-4 w-4" />
|
||||
Tags
|
||||
</Link>
|
||||
<Link
|
||||
href="/organization"
|
||||
className={cn(
|
||||
|
||||
@@ -25,6 +25,7 @@ type Task = {
|
||||
assigned_agent_id?: string | null;
|
||||
assignee?: string | null;
|
||||
approvals_pending_count?: number;
|
||||
tags?: Array<{ id: string; name: string; slug: string; color: string }>;
|
||||
depends_on_task_ids?: string[];
|
||||
blocked_by_task_ids?: string[];
|
||||
is_blocked?: boolean;
|
||||
@@ -453,6 +454,7 @@ export const TaskBoard = memo(function TaskBoard({
|
||||
assignee={task.assignee ?? undefined}
|
||||
due={formatDueDate(task.due_at)}
|
||||
approvalsPendingCount={task.approvals_pending_count}
|
||||
tags={task.tags}
|
||||
isBlocked={task.is_blocked}
|
||||
blockedByCount={task.blocked_by_task_ids?.length ?? 0}
|
||||
onClick={() => onTaskSelect?.(task)}
|
||||
|
||||
214
frontend/src/components/tags/TaskTagForm.tsx
Normal file
214
frontend/src/components/tags/TaskTagForm.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { ApiError } from "@/api/mutator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
export type TaskTagFormValues = {
|
||||
name: string;
|
||||
slug: string;
|
||||
color: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type TaskTagFormProps = {
|
||||
initialValues?: TaskTagFormValues;
|
||||
onSubmit: (values: {
|
||||
name: string;
|
||||
slug: string;
|
||||
color: string;
|
||||
description: string | null;
|
||||
}) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
submitLabel: string;
|
||||
submittingLabel: string;
|
||||
isSubmitting: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_VALUES: TaskTagFormValues = {
|
||||
name: "",
|
||||
slug: "",
|
||||
color: "9e9e9e",
|
||||
description: "",
|
||||
};
|
||||
|
||||
const normalizeColorInput = (value: string) => {
|
||||
const cleaned = value.trim().replace(/^#/, "").toLowerCase();
|
||||
return /^[0-9a-f]{6}$/.test(cleaned) ? cleaned : "9e9e9e";
|
||||
};
|
||||
|
||||
const slugify = (value: string) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
const extractErrorMessage = (error: unknown, fallback: string) => {
|
||||
if (error instanceof ApiError) return error.message || fallback;
|
||||
if (error instanceof Error) return error.message || fallback;
|
||||
return fallback;
|
||||
};
|
||||
|
||||
export function TaskTagForm({
|
||||
initialValues,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitLabel,
|
||||
submittingLabel,
|
||||
isSubmitting,
|
||||
}: TaskTagFormProps) {
|
||||
const resolvedInitial = initialValues ?? DEFAULT_VALUES;
|
||||
const [name, setName] = useState(() => resolvedInitial.name);
|
||||
const [slug, setSlug] = useState(() => resolvedInitial.slug);
|
||||
const [color, setColor] = useState(() =>
|
||||
normalizeColorInput(resolvedInitial.color),
|
||||
);
|
||||
const [description, setDescription] = useState(
|
||||
() => resolvedInitial.description,
|
||||
);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const previewColor = useMemo(() => normalizeColorInput(color), [color]);
|
||||
const suggestedSlug = useMemo(() => slugify(name.trim()), [name]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const normalizedName = name.trim();
|
||||
if (!normalizedName) {
|
||||
setErrorMessage("Tag name is required.");
|
||||
return;
|
||||
}
|
||||
const normalizedSlug = slugify(slug.trim() || normalizedName);
|
||||
if (!normalizedSlug) {
|
||||
setErrorMessage("Tag slug is required.");
|
||||
return;
|
||||
}
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
await onSubmit({
|
||||
name: normalizedName,
|
||||
slug: normalizedSlug,
|
||||
color: normalizeColorInput(color),
|
||||
description: description.trim() || null,
|
||||
});
|
||||
} catch (error) {
|
||||
setErrorMessage(extractErrorMessage(error, "Unable to save tag."));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50/40 p-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="e.g. Backend"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Slug
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSlug(suggestedSlug)}
|
||||
className="text-xs font-medium text-slate-500 underline underline-offset-2 transition hover:text-slate-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!suggestedSlug || isSubmitting}
|
||||
>
|
||||
Use from name
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
value={slug}
|
||||
onChange={(event) => setSlug(event.target.value)}
|
||||
placeholder="backend"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
Leave slug blank to auto-generate from the tag name.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-[1fr_auto]">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Color
|
||||
</label>
|
||||
<div className="flex items-center rounded-lg border border-slate-200 bg-white px-3">
|
||||
<span className="text-sm font-medium text-slate-400">#</span>
|
||||
<Input
|
||||
value={color}
|
||||
onChange={(event) => setColor(event.target.value)}
|
||||
placeholder="9e9e9e"
|
||||
disabled={isSubmitting}
|
||||
className="border-0 px-2 shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Preview
|
||||
</label>
|
||||
<div className="inline-flex h-[42px] items-center gap-2 rounded-lg border border-slate-200 bg-white px-3">
|
||||
<span
|
||||
className="h-4 w-4 rounded border border-slate-300"
|
||||
style={{ backgroundColor: `#${previewColor}` }}
|
||||
/>
|
||||
<span className="text-xs font-semibold text-slate-700">
|
||||
#{previewColor.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
placeholder="Optional description"
|
||||
className="min-h-[110px]"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? submittingLabel : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
181
frontend/src/components/tags/TaskTagsTable.tsx
Normal file
181
frontend/src/components/tags/TaskTagsTable.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
type ColumnDef,
|
||||
type OnChangeFn,
|
||||
type SortingState,
|
||||
type Updater,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { type TaskTagRead } from "@/api/generated/model";
|
||||
import {
|
||||
DataTable,
|
||||
type DataTableEmptyState,
|
||||
} from "@/components/tables/DataTable";
|
||||
import { dateCell } from "@/components/tables/cell-formatters";
|
||||
|
||||
type TaskTagsTableProps = {
|
||||
tags: TaskTagRead[];
|
||||
isLoading?: boolean;
|
||||
sorting?: SortingState;
|
||||
onSortingChange?: OnChangeFn<SortingState>;
|
||||
stickyHeader?: boolean;
|
||||
onEdit?: (tag: TaskTagRead) => void;
|
||||
onDelete?: (tag: TaskTagRead) => void;
|
||||
emptyState?: Omit<DataTableEmptyState, "icon"> & {
|
||||
icon?: DataTableEmptyState["icon"];
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_EMPTY_ICON = (
|
||||
<svg
|
||||
className="h-16 w-16 text-slate-300"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M4 6h16" />
|
||||
<path d="M4 12h16" />
|
||||
<path d="M4 18h10" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const normalizeColor = (value?: string | null) => {
|
||||
const cleaned = (value ?? "").trim().replace(/^#/, "").toLowerCase();
|
||||
if (!/^[0-9a-f]{6}$/.test(cleaned)) return "9e9e9e";
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
export function TaskTagsTable({
|
||||
tags,
|
||||
isLoading = false,
|
||||
sorting,
|
||||
onSortingChange,
|
||||
stickyHeader = false,
|
||||
onEdit,
|
||||
onDelete,
|
||||
emptyState,
|
||||
}: TaskTagsTableProps) {
|
||||
const [internalSorting, setInternalSorting] = useState<SortingState>([
|
||||
{ id: "name", desc: false },
|
||||
]);
|
||||
const resolvedSorting = sorting ?? internalSorting;
|
||||
const handleSortingChange: OnChangeFn<SortingState> =
|
||||
onSortingChange ??
|
||||
((updater: Updater<SortingState>) => {
|
||||
setInternalSorting(updater);
|
||||
});
|
||||
|
||||
const columns = useMemo<ColumnDef<TaskTagRead>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Tag",
|
||||
cell: ({ row }) => {
|
||||
const color = normalizeColor(row.original.color);
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-slate-800">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: `#${color}` }}
|
||||
/>
|
||||
{row.original.name}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
{row.original.slug}
|
||||
{row.original.description
|
||||
? ` · ${row.original.description}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "color",
|
||||
header: "Color",
|
||||
cell: ({ row }) => {
|
||||
const color = normalizeColor(row.original.color);
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2 text-xs text-slate-700">
|
||||
<span
|
||||
className="h-4 w-4 rounded border border-slate-300"
|
||||
style={{ backgroundColor: `#${color}` }}
|
||||
/>
|
||||
#{color.toUpperCase()}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "task_count",
|
||||
header: "Tasks",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
{row.original.task_count ?? 0}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: "Updated",
|
||||
cell: ({ row }) => dateCell(row.original.updated_at),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const table = useReactTable({
|
||||
data: tags,
|
||||
columns,
|
||||
state: {
|
||||
sorting: resolvedSorting,
|
||||
},
|
||||
onSortingChange: handleSortingChange,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
table={table}
|
||||
isLoading={isLoading}
|
||||
stickyHeader={stickyHeader}
|
||||
rowClassName="transition hover:bg-slate-50"
|
||||
cellClassName="px-6 py-4 align-top"
|
||||
rowActions={
|
||||
onEdit || onDelete
|
||||
? {
|
||||
actions: [
|
||||
...(onEdit
|
||||
? [{ key: "edit", label: "Edit", onClick: onEdit }]
|
||||
: []),
|
||||
...(onDelete
|
||||
? [{ key: "delete", label: "Delete", onClick: onDelete }]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
emptyState={
|
||||
emptyState
|
||||
? {
|
||||
icon: emptyState.icon ?? DEFAULT_EMPTY_ICON,
|
||||
title: emptyState.title,
|
||||
description: emptyState.description,
|
||||
actionHref: emptyState.actionHref,
|
||||
actionLabel: emptyState.actionLabel,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user