mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-29 06:28:23 +02:00
* feat(properties): add custom icons Co-authored-by: multica-agent <github@multica.ai> * fix(migrations): use unique property icon prefix Co-authored-by: multica-agent <github@multica.ai> * fix(properties): replace emoji icons with Lucide picker Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
251 lines
8.7 KiB
TypeScript
251 lines
8.7 KiB
TypeScript
"use client";
|
|
|
|
import { memo, type Ref } from "react";
|
|
import { useSortable, defaultAnimateLayoutChanges } from "@dnd-kit/sortable";
|
|
import type { AnimateLayoutChanges } from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
import { AppLink } from "../../navigation";
|
|
import type { Issue, Project,
|
|
IssueProperty,
|
|
} from "@multica/core/types";
|
|
import { formatDateOnly } from "@multica/core/issues/date";
|
|
import { ActorAvatar } from "../../common/actor-avatar";
|
|
import { PropertyIcon } from "../../common/property-icon";
|
|
import { useWorkspacePaths } from "@multica/core/paths";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useViewStore } from "@multica/core/issues/stores/view-store-context";
|
|
import { useWorkspaceId } from "@multica/core/hooks";
|
|
import { propertyListOptions } from "@multica/core/properties";
|
|
import { CustomPropertyValueDisplay } from "./pickers/custom-property-picker";
|
|
import { ProjectIcon } from "../../projects/components/project-icon";
|
|
import { PriorityIcon } from "./priority-icon";
|
|
import { ProgressRing } from "./progress-ring";
|
|
import { IssueActionsContextMenu } from "../actions";
|
|
import { LabelChip } from "../../labels/label-chip";
|
|
import { IssueAgentActivityIndicator } from "./issue-agent-activity-indicator";
|
|
import { useIssueSurfaceSelection } from "../surface/selection-context";
|
|
|
|
export interface ChildProgress {
|
|
done: number;
|
|
total: number;
|
|
}
|
|
|
|
function formatDate(date: string): string {
|
|
return formatDateOnly(date, { month: "short", day: "numeric" }, "en-US");
|
|
}
|
|
|
|
function ListRowContent({
|
|
issue,
|
|
childProgress,
|
|
project,
|
|
isDragging,
|
|
containerRef,
|
|
containerStyle,
|
|
containerProps,
|
|
checkboxProps,
|
|
}: {
|
|
issue: Issue;
|
|
childProgress?: ChildProgress;
|
|
project?: Project;
|
|
isDragging?: boolean;
|
|
containerRef?: Ref<HTMLDivElement>;
|
|
containerStyle?: React.CSSProperties;
|
|
containerProps?: Record<string, unknown>;
|
|
checkboxProps?: Pick<React.HTMLAttributes<HTMLDivElement>, "onClick" | "onMouseDown" | "onPointerDown">;
|
|
}) {
|
|
const selection = useIssueSurfaceSelection();
|
|
const selected = selection.selectedIds.has(issue.id);
|
|
const toggle = selection.toggle;
|
|
const p = useWorkspacePaths();
|
|
const storeProperties = useViewStore((s) => s.cardProperties);
|
|
const cardPropertyIds = useViewStore((s) => s.cardPropertyIds);
|
|
const rowWsId = useWorkspaceId();
|
|
const { data: workspaceProperties = [] } = useQuery(propertyListOptions(rowWsId));
|
|
const cardCustomProperties = cardPropertyIds
|
|
.map((id) => workspaceProperties.find((p) => p.id === id))
|
|
.filter((p): p is IssueProperty => !!p && issue.properties?.[p.id] !== undefined);
|
|
const labels = issue.labels ?? [];
|
|
|
|
const showProject = storeProperties.project && project;
|
|
const showChildProgress = storeProperties.childProgress && childProgress;
|
|
const showAssignee = storeProperties.assignee && issue.assignee_type && issue.assignee_id;
|
|
const showStartDate = storeProperties.startDate && issue.start_date;
|
|
const showDueDate = storeProperties.dueDate && issue.due_date;
|
|
const showLabels = storeProperties.labels && labels.length > 0;
|
|
|
|
return (
|
|
<IssueActionsContextMenu issue={issue}>
|
|
<div
|
|
ref={containerRef}
|
|
style={containerStyle}
|
|
{...containerProps}
|
|
className={`group/row flex h-9 items-center gap-2 px-4 text-sm transition-colors ${
|
|
selected
|
|
? "bg-surface-selected hover:not-data-[popup-open]:bg-surface-selected data-[popup-open]:bg-surface-selected"
|
|
: "hover:not-data-[popup-open]:bg-surface-hover data-[popup-open]:bg-surface-hover"
|
|
} ${isDragging ? "opacity-30" : ""}`}
|
|
>
|
|
<div
|
|
className="relative flex shrink-0 items-center justify-center w-4 h-4"
|
|
{...checkboxProps}
|
|
>
|
|
<PriorityIcon
|
|
priority={issue.priority}
|
|
className={selected ? "hidden" : "group-hover/row:hidden"}
|
|
/>
|
|
<input
|
|
type="checkbox"
|
|
checked={selected}
|
|
onChange={() => toggle(issue.id)}
|
|
className={`absolute inset-0 cursor-pointer accent-primary ${
|
|
selected ? "" : "hidden group-hover/row:block"
|
|
}`}
|
|
/>
|
|
</div>
|
|
<AppLink
|
|
href={p.issueDetail(issue.id)}
|
|
className={`flex flex-1 items-center gap-2 min-w-0 ${isDragging ? "pointer-events-none" : ""}`}
|
|
>
|
|
<span className="w-16 shrink-0 text-xs text-muted-foreground">
|
|
{issue.identifier}
|
|
</span>
|
|
<IssueAgentActivityIndicator issueId={issue.id} />
|
|
|
|
<span className="flex min-w-0 flex-1 items-center gap-1.5">
|
|
<span className="truncate">{issue.title}</span>
|
|
{showChildProgress && (
|
|
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-muted/60 px-1.5 py-0.5">
|
|
<ProgressRing done={childProgress!.done} total={childProgress!.total} size={14} />
|
|
<span className="text-[11px] text-muted-foreground tabular-nums font-medium">
|
|
{childProgress!.done}/{childProgress!.total}
|
|
</span>
|
|
</span>
|
|
)}
|
|
{showLabels && (
|
|
<span className="ml-1.5 hidden md:inline-flex shrink-0 items-center gap-1 max-w-[260px] overflow-hidden">
|
|
{labels.slice(0, 3).map((label) => (
|
|
<LabelChip key={label.id} label={label} />
|
|
))}
|
|
{labels.length > 3 && (
|
|
<span className="text-[11px] text-muted-foreground">
|
|
+{labels.length - 3}
|
|
</span>
|
|
)}
|
|
</span>
|
|
)}
|
|
{cardCustomProperties.length > 0 && (
|
|
<span className="ml-1.5 hidden md:inline-flex shrink-0 items-center gap-1 max-w-[260px] overflow-hidden">
|
|
{cardCustomProperties.slice(0, 3).map((property) => (
|
|
<span
|
|
key={property.id}
|
|
className="inline-flex max-w-[120px] items-center gap-1 rounded-full bg-muted/60 px-1.5 py-0.5 text-[11px] text-muted-foreground"
|
|
>
|
|
<PropertyIcon property={property} className="size-3 text-[11px]" />
|
|
<CustomPropertyValueDisplay property={property} value={issue.properties?.[property.id]} />
|
|
</span>
|
|
))}
|
|
</span>
|
|
)}
|
|
</span>
|
|
{showProject && (
|
|
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground max-w-[140px]">
|
|
<ProjectIcon project={project} size="sm" />
|
|
<span className="truncate">{project!.title}</span>
|
|
</span>
|
|
)}
|
|
{showStartDate && (
|
|
<span className="shrink-0 text-xs text-muted-foreground">
|
|
{formatDate(issue.start_date!)}
|
|
</span>
|
|
)}
|
|
{showDueDate && (
|
|
<span className="shrink-0 text-xs text-muted-foreground">
|
|
{formatDate(issue.due_date!)}
|
|
</span>
|
|
)}
|
|
{showAssignee && (
|
|
<ActorAvatar
|
|
actorType={issue.assignee_type!}
|
|
actorId={issue.assignee_id!}
|
|
size="sm"
|
|
enableHoverCard
|
|
/>
|
|
)}
|
|
</AppLink>
|
|
</div>
|
|
</IssueActionsContextMenu>
|
|
);
|
|
}
|
|
|
|
export const ListRow = memo(function ListRow({
|
|
issue,
|
|
childProgress,
|
|
project,
|
|
}: {
|
|
issue: Issue;
|
|
childProgress?: ChildProgress;
|
|
project?: Project;
|
|
}) {
|
|
return (
|
|
<ListRowContent
|
|
issue={issue}
|
|
childProgress={childProgress}
|
|
project={project}
|
|
/>
|
|
);
|
|
});
|
|
|
|
const animateLayoutChanges: AnimateLayoutChanges = (args) => {
|
|
const { isSorting, wasDragging } = args;
|
|
if (isSorting || wasDragging) return false;
|
|
return defaultAnimateLayoutChanges(args);
|
|
};
|
|
|
|
const stopDrag = (e: React.SyntheticEvent) => {
|
|
e.stopPropagation();
|
|
};
|
|
|
|
export const DraggableListRow = memo(function DraggableListRow({
|
|
issue,
|
|
childProgress,
|
|
project,
|
|
disableSorting,
|
|
}: {
|
|
issue: Issue;
|
|
childProgress?: ChildProgress;
|
|
project?: Project;
|
|
disableSorting?: boolean;
|
|
}) {
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({
|
|
id: issue.id,
|
|
data: { status: issue.status },
|
|
animateLayoutChanges,
|
|
disabled: disableSorting ? { droppable: true } : undefined,
|
|
});
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
};
|
|
|
|
return (
|
|
<ListRowContent
|
|
issue={issue}
|
|
childProgress={childProgress}
|
|
project={project}
|
|
isDragging={isDragging}
|
|
containerRef={setNodeRef}
|
|
containerStyle={style}
|
|
containerProps={{ ...attributes, ...listeners }}
|
|
checkboxProps={{ onClick: stopDrag, onMouseDown: stopDrag, onPointerDown: stopDrag }}
|
|
/>
|
|
);
|
|
});
|