mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-26 17:09:14 +02:00
Compare commits
4 Commits
agent/lamb
...
fix/list-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6be967e0ac | ||
|
|
f2ac9d7715 | ||
|
|
881f81fc42 | ||
|
|
a2dd8c2bd0 |
@@ -206,7 +206,7 @@ export function ActorIssuesPanel({
|
||||
<p className="text-sm">{t(($) => $.actor_issues.search_empty)}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
<div className="flex flex-1 min-h-0 flex-col p-1">
|
||||
<ListView
|
||||
issues={issues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
|
||||
@@ -7,16 +7,13 @@ import {
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
pointerWithin,
|
||||
closestCenter,
|
||||
type CollisionDetection,
|
||||
type DragStartEvent,
|
||||
type DragEndEvent,
|
||||
type DragOverEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
import type { Issue, IssueAssigneeGroup, IssueAssigneeType, IssueStatus, UpdateIssueRequest } from "@multica/core/types";
|
||||
import type { Issue, IssueAssigneeGroup, IssueStatus } from "@multica/core/types";
|
||||
import { useLoadMoreByAssigneeGroup, useLoadMoreByStatus } from "@multica/core/issues/mutations";
|
||||
import type { AssigneeGroupedIssuesFilter, IssueSortParam, MyIssuesFilter } from "@multica/core/issues/queries";
|
||||
import { useViewStore } from "@multica/core/issues/stores/view-store-context";
|
||||
@@ -28,41 +25,17 @@ import { HiddenColumnsPanel, HiddenColumnRow } from "./hidden-columns-panel";
|
||||
import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel";
|
||||
import type { ChildProgress } from "./list-row";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
type BoardMoveUpdates = Pick<
|
||||
UpdateIssueRequest,
|
||||
"status" | "assignee_type" | "assignee_id" | "position"
|
||||
>;
|
||||
|
||||
const UNASSIGNED_GROUP_ID = "assignee:unassigned";
|
||||
|
||||
function makeKanbanCollision(columnIds: Set<string>): CollisionDetection {
|
||||
return (args) => {
|
||||
const pointer = pointerWithin(args);
|
||||
if (pointer.length > 0) {
|
||||
const cards = pointer.filter((c) => !columnIds.has(c.id as string));
|
||||
if (cards.length > 0) return cards;
|
||||
return pointer;
|
||||
}
|
||||
return closestCenter(args);
|
||||
};
|
||||
}
|
||||
|
||||
function statusGroupId(status: IssueStatus): string {
|
||||
return `status:${status}`;
|
||||
}
|
||||
|
||||
function assigneeGroupId(
|
||||
type: IssueAssigneeType | null,
|
||||
id: string | null,
|
||||
): string {
|
||||
return type && id ? `assignee:${type}:${id}` : UNASSIGNED_GROUP_ID;
|
||||
}
|
||||
|
||||
function getIssueGroupId(issue: Issue, grouping: IssueGrouping): string {
|
||||
if (grouping === "status") return statusGroupId(issue.status);
|
||||
return assigneeGroupId(issue.assignee_type, issue.assignee_id);
|
||||
}
|
||||
import {
|
||||
type DragMoveUpdates,
|
||||
makeKanbanCollision,
|
||||
statusGroupId,
|
||||
assigneeGroupId,
|
||||
buildColumns,
|
||||
computePosition,
|
||||
findColumn,
|
||||
issueMatchesGroup,
|
||||
getMoveUpdates,
|
||||
} from "../utils/drag-utils";
|
||||
|
||||
function isStatusGroup(
|
||||
group: BoardColumnGroup,
|
||||
@@ -132,65 +105,6 @@ function buildGroups(
|
||||
});
|
||||
}
|
||||
|
||||
/** Build column ID arrays from TQ issue data (server-sorted). */
|
||||
function buildColumns(
|
||||
issues: Issue[],
|
||||
groups: BoardColumnGroup[],
|
||||
grouping: IssueGrouping,
|
||||
): Record<string, string[]> {
|
||||
const cols: Record<string, string[]> = {};
|
||||
for (const group of groups) cols[group.id] = [];
|
||||
for (const issue of issues) {
|
||||
const gid = getIssueGroupId(issue, grouping);
|
||||
if (cols[gid]) cols[gid].push(issue.id);
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
/** Compute a float position for `activeId` based on its neighbors in `ids`. */
|
||||
function computePosition(ids: string[], activeId: string, issueMap: Map<string, Issue>): number {
|
||||
const idx = ids.indexOf(activeId);
|
||||
if (idx === -1) return 0;
|
||||
const getPos = (id: string) => issueMap.get(id)?.position ?? 0;
|
||||
if (ids.length === 1) return issueMap.get(activeId)?.position ?? 0;
|
||||
if (idx === 0) return getPos(ids[1]!) - 1;
|
||||
if (idx === ids.length - 1) return getPos(ids[idx - 1]!) + 1;
|
||||
return (getPos(ids[idx - 1]!) + getPos(ids[idx + 1]!)) / 2;
|
||||
}
|
||||
|
||||
/** Find which column contains a given ID (issue or column droppable). */
|
||||
function findColumn(
|
||||
columns: Record<string, string[]>,
|
||||
id: string,
|
||||
columnIds: Set<string>,
|
||||
): string | null {
|
||||
if (columnIds.has(id)) return id;
|
||||
for (const [columnId, ids] of Object.entries(columns)) {
|
||||
if (ids.includes(id)) return columnId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function issueMatchesGroup(issue: Issue, group: BoardColumnGroup): boolean {
|
||||
if (group.status) return issue.status === group.status;
|
||||
return (
|
||||
(issue.assignee_type ?? null) === (group.assigneeType ?? null) &&
|
||||
(issue.assignee_id ?? null) === (group.assigneeId ?? null)
|
||||
);
|
||||
}
|
||||
|
||||
function getMoveUpdates(
|
||||
group: BoardColumnGroup,
|
||||
position: number,
|
||||
): BoardMoveUpdates {
|
||||
if (group.status) return { status: group.status, position };
|
||||
return {
|
||||
assignee_type: group.assigneeType ?? null,
|
||||
assignee_id: group.assigneeId ?? null,
|
||||
position,
|
||||
};
|
||||
}
|
||||
|
||||
const EMPTY_PROGRESS_MAP = new Map<string, ChildProgress>();
|
||||
const EMPTY_IDS: string[] = [];
|
||||
|
||||
@@ -214,7 +128,7 @@ export function BoardView({
|
||||
assigneeGroupFilter?: AssigneeGroupedIssuesFilter;
|
||||
visibleStatuses: IssueStatus[];
|
||||
hiddenStatuses: IssueStatus[];
|
||||
onMoveIssue: (issueId: string, updates: BoardMoveUpdates, onSettled?: () => void) => void;
|
||||
onMoveIssue: (issueId: string, updates: DragMoveUpdates, onSettled?: () => void) => void;
|
||||
childProgressMap?: Map<string, ChildProgress>;
|
||||
/** When set, per-status load-more targets the scoped cache instead of the workspace one. */
|
||||
myIssuesScope?: string;
|
||||
@@ -481,7 +395,7 @@ export function BoardView({
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
|
||||
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-2">
|
||||
{groups.length === 0 ? (
|
||||
<div className="flex min-w-full flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
{t(($) => $.board.empty_grouping)}
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
@@ -61,6 +63,7 @@ import {
|
||||
type ActorFilterValue,
|
||||
} from "@multica/core/issues/stores/view-store";
|
||||
import { useViewStore, useViewStoreApi } from "@multica/core/issues/stores/view-store-context";
|
||||
import type { SortField, IssueGrouping, ViewMode } from "@multica/core/issues/stores/view-store";
|
||||
import {
|
||||
useIssuesScopeStore,
|
||||
type IssuesScope,
|
||||
@@ -896,14 +899,13 @@ export function IssueDisplayControls({
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-auto">
|
||||
{GROUPING_OPTIONS.map((opt) => (
|
||||
<DropdownMenuItem
|
||||
key={opt.value}
|
||||
onClick={() => act.setGrouping(opt.value)}
|
||||
>
|
||||
{t(($) => $.display[GROUPING_LABEL_KEY[opt.value]])}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuRadioGroup value={grouping} onValueChange={(v) => act.setGrouping(v as IssueGrouping)}>
|
||||
{GROUPING_OPTIONS.map((opt) => (
|
||||
<DropdownMenuRadioItem key={opt.value} value={opt.value}>
|
||||
{t(($) => $.display[GROUPING_LABEL_KEY[opt.value]])}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -929,14 +931,13 @@ export function IssueDisplayControls({
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-auto">
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<DropdownMenuItem
|
||||
key={opt.value}
|
||||
onClick={() => act.setSortBy(opt.value)}
|
||||
>
|
||||
{t(($) => $.display[SORT_LABEL_KEY[opt.value]])}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuRadioGroup value={sortBy} onValueChange={(v) => act.setSortBy(v as SortField)}>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<DropdownMenuRadioItem key={opt.value} value={opt.value}>
|
||||
{t(($) => $.display[SORT_LABEL_KEY[opt.value]])}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{sortBy !== "position" && (
|
||||
@@ -994,19 +995,19 @@ export function IssueDisplayControls({
|
||||
<Button variant="outline" size="sm" className="gap-1 text-muted-foreground">
|
||||
{viewMode === "board" ? (
|
||||
<Columns3 className="size-3.5" />
|
||||
) : viewMode === "gantt" && allowGantt ? (
|
||||
<ChartGantt className="size-3.5" />
|
||||
) : viewMode === "swimlane" ? (
|
||||
<Waves className="size-3.5" />
|
||||
) : viewMode === "gantt" && allowGantt ? (
|
||||
<ChartGantt className="size-3.5" />
|
||||
) : (
|
||||
<List className="size-3.5" />
|
||||
)}
|
||||
{viewMode === "board"
|
||||
? t(($) => $.view.board)
|
||||
: viewMode === "gantt" && allowGantt
|
||||
? t(($) => $.view.gantt)
|
||||
: viewMode === "swimlane"
|
||||
? t(($) => $.view.swimlane)
|
||||
: viewMode === "gantt" && allowGantt
|
||||
? t(($) => $.view.gantt)
|
||||
: t(($) => $.view.list)}
|
||||
</Button>
|
||||
}
|
||||
@@ -1016,35 +1017,37 @@ export function IssueDisplayControls({
|
||||
<TooltipContent side="bottom">
|
||||
{viewMode === "board"
|
||||
? t(($) => $.view.tooltip_board)
|
||||
: viewMode === "gantt" && allowGantt
|
||||
? t(($) => $.view.tooltip_gantt)
|
||||
: viewMode === "swimlane"
|
||||
? t(($) => $.view.tooltip_swimlane)
|
||||
: viewMode === "gantt" && allowGantt
|
||||
? t(($) => $.view.tooltip_gantt)
|
||||
: t(($) => $.view.tooltip_list)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="w-auto">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>{t(($) => $.view.section)}</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => act.setViewMode("board")}>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuRadioGroup value={viewMode} onValueChange={(v) => act.setViewMode(v as ViewMode)}>
|
||||
<DropdownMenuRadioItem value="board">
|
||||
<Columns3 />
|
||||
{t(($) => $.view.board)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => act.setViewMode("list")}>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="list">
|
||||
<List />
|
||||
{t(($) => $.view.list)}
|
||||
</DropdownMenuItem>
|
||||
{allowGantt && (
|
||||
<DropdownMenuItem onClick={() => act.setViewMode("gantt")}>
|
||||
<ChartGantt />
|
||||
{t(($) => $.view.gantt)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => act.setViewMode("swimlane")}>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="swimlane">
|
||||
<Waves />
|
||||
{t(($) => $.view.swimlane)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuRadioItem>
|
||||
{allowGantt && (
|
||||
<DropdownMenuRadioItem value="gantt">
|
||||
<ChartGantt />
|
||||
{t(($) => $.view.gantt)}
|
||||
</DropdownMenuRadioItem>
|
||||
)}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
@@ -236,7 +236,7 @@ export function IssuesPage() {
|
||||
sort={sort}
|
||||
/>
|
||||
) : (
|
||||
<ListView issues={issues} visibleStatuses={visibleStatuses} childProgressMap={childProgressMap} sort={sort} />
|
||||
<ListView issues={issues} visibleStatuses={visibleStatuses} childProgressMap={childProgressMap} sort={sort} onMoveIssue={handleMoveIssue} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { memo, type Ref } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
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 } from "@multica/core/types";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
@@ -29,12 +32,22 @@ function formatDate(date: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
export const ListRow = memo(function ListRow({
|
||||
function ListRowContent({
|
||||
issue,
|
||||
childProgress,
|
||||
isDragging,
|
||||
containerRef,
|
||||
containerStyle,
|
||||
containerProps,
|
||||
checkboxProps,
|
||||
}: {
|
||||
issue: Issue;
|
||||
childProgress?: ChildProgress;
|
||||
isDragging?: boolean;
|
||||
containerRef?: Ref<HTMLDivElement>;
|
||||
containerStyle?: React.CSSProperties;
|
||||
containerProps?: Record<string, unknown>;
|
||||
checkboxProps?: Pick<React.HTMLAttributes<HTMLDivElement>, "onClick" | "onMouseDown" | "onPointerDown">;
|
||||
}) {
|
||||
const selected = useIssueSelectionStore((s) => s.selectedIds.has(issue.id));
|
||||
const toggle = useIssueSelectionStore((s) => s.toggle);
|
||||
@@ -58,11 +71,17 @@ export const ListRow = memo(function ListRow({
|
||||
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 hover:not-data-[popup-open]:bg-accent/60 data-[popup-open]:bg-accent ${
|
||||
selected ? "bg-accent/30" : ""
|
||||
}`}
|
||||
} ${isDragging ? "opacity-30" : ""}`}
|
||||
>
|
||||
<div className="relative flex shrink-0 items-center justify-center w-4 h-4">
|
||||
<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"}
|
||||
@@ -78,7 +97,7 @@ export const ListRow = memo(function ListRow({
|
||||
</div>
|
||||
<AppLink
|
||||
href={p.issueDetail(issue.id)}
|
||||
className="flex flex-1 items-center gap-2 min-w-0"
|
||||
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}
|
||||
@@ -136,4 +155,65 @@ export const ListRow = memo(function ListRow({
|
||||
</div>
|
||||
</IssueActionsContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export const ListRow = memo(function ListRow({
|
||||
issue,
|
||||
childProgress,
|
||||
}: {
|
||||
issue: Issue;
|
||||
childProgress?: ChildProgress;
|
||||
}) {
|
||||
return <ListRowContent issue={issue} childProgress={childProgress} />;
|
||||
});
|
||||
|
||||
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,
|
||||
disableSorting,
|
||||
}: {
|
||||
issue: Issue;
|
||||
childProgress?: ChildProgress;
|
||||
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}
|
||||
isDragging={isDragging}
|
||||
containerRef={setNodeRef}
|
||||
containerStyle={style}
|
||||
containerProps={{ ...attributes, ...listeners }}
|
||||
checkboxProps={{ onClick: stopDrag, onMouseDown: stopDrag, onPointerDown: stopDrag }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||
import { ChevronRight, Plus } from "lucide-react";
|
||||
import { Accordion } from "@base-ui/react/accordion";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragStartEvent,
|
||||
type DragEndEvent,
|
||||
type DragOverEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import type { Issue, IssueStatus } from "@multica/core/types";
|
||||
@@ -12,11 +24,32 @@ import { useModalStore } from "@multica/core/modals";
|
||||
import { useViewStore } from "@multica/core/issues/stores/view-store-context";
|
||||
import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store";
|
||||
import { StatusHeading } from "./status-heading";
|
||||
import { ListRow, type ChildProgress } from "./list-row";
|
||||
import { ListRow, DraggableListRow, type ChildProgress } from "./list-row";
|
||||
import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel";
|
||||
import { useT } from "../../i18n";
|
||||
import {
|
||||
type DragMoveUpdates,
|
||||
makeListCollision,
|
||||
statusGroupId,
|
||||
buildColumns,
|
||||
computePosition,
|
||||
findColumn,
|
||||
issueMatchesGroup,
|
||||
getMoveUpdates,
|
||||
} from "../utils/drag-utils";
|
||||
import type { BoardColumnGroup } from "./board-column";
|
||||
|
||||
const EMPTY_PROGRESS_MAP = new Map<string, ChildProgress>();
|
||||
const EMPTY_IDS: string[] = [];
|
||||
|
||||
function buildListGroups(visibleStatuses: IssueStatus[]): BoardColumnGroup[] {
|
||||
return visibleStatuses.map((status) => ({
|
||||
id: statusGroupId(status),
|
||||
title: status,
|
||||
status,
|
||||
createData: { status },
|
||||
}));
|
||||
}
|
||||
|
||||
export function ListView({
|
||||
issues,
|
||||
@@ -24,19 +57,18 @@ export function ListView({
|
||||
childProgressMap = EMPTY_PROGRESS_MAP,
|
||||
myIssuesScope,
|
||||
myIssuesFilter,
|
||||
sort,
|
||||
projectId,
|
||||
onMoveIssue,
|
||||
sort,
|
||||
}: {
|
||||
issues: Issue[];
|
||||
visibleStatuses: IssueStatus[];
|
||||
childProgressMap?: Map<string, ChildProgress>;
|
||||
/** When set, per-status load-more targets the scoped cache instead of the workspace one. */
|
||||
myIssuesScope?: string;
|
||||
myIssuesFilter?: MyIssuesFilter;
|
||||
/** Must match the sort the page queried with — embedded in the cache key. */
|
||||
sort?: IssueSortParam;
|
||||
/** When set, the per-section "+" pre-fills the project on the create form. */
|
||||
projectId?: string;
|
||||
onMoveIssue?: (issueId: string, updates: DragMoveUpdates, onSettled?: () => void) => void;
|
||||
sort?: IssueSortParam;
|
||||
}) {
|
||||
const listCollapsedStatuses = useViewStore(
|
||||
(s) => s.listCollapsedStatuses
|
||||
@@ -44,15 +76,13 @@ export function ListView({
|
||||
const toggleListCollapsed = useViewStore(
|
||||
(s) => s.toggleListCollapsed
|
||||
);
|
||||
const sortBy = useViewStore((s) => s.sortBy);
|
||||
const { t } = useT("issues");
|
||||
|
||||
const issuesByStatus = useMemo(() => {
|
||||
const map = new Map<IssueStatus, Issue[]>();
|
||||
for (const status of visibleStatuses) {
|
||||
const filtered = issues.filter((i) => i.status === status);
|
||||
map.set(status, filtered);
|
||||
}
|
||||
return map;
|
||||
}, [issues, visibleStatuses]);
|
||||
const sortFieldKey = sortBy === "created_at" ? "created" : sortBy;
|
||||
const sortLabel = sortBy !== "position"
|
||||
? t(($) => $.board.ordered_by, { field: t(($) => $.display[`sort_${sortFieldKey}` as keyof typeof $.display]) })
|
||||
: null;
|
||||
|
||||
const expandedStatuses = useMemo(
|
||||
() =>
|
||||
@@ -66,52 +96,282 @@ export function ListView({
|
||||
? { scope: myIssuesScope, filter: myIssuesFilter ?? {} }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2 pt-0">
|
||||
<Accordion.Root
|
||||
multiple
|
||||
className="space-y-1"
|
||||
value={expandedStatuses}
|
||||
onValueChange={(value: string[]) => {
|
||||
for (const status of visibleStatuses) {
|
||||
const wasExpanded = expandedStatuses.includes(status);
|
||||
const isExpanded = value.includes(status);
|
||||
if (wasExpanded !== isExpanded) {
|
||||
toggleListCollapsed(status as IssueStatus);
|
||||
}
|
||||
const dragEnabled = !!onMoveIssue;
|
||||
|
||||
const groups = useMemo(
|
||||
() => buildListGroups(visibleStatuses),
|
||||
[visibleStatuses],
|
||||
);
|
||||
const groupIds = useMemo(
|
||||
() => new Set(groups.map((g) => g.id)),
|
||||
[groups],
|
||||
);
|
||||
const groupMap = useMemo(
|
||||
() => new Map(groups.map((g) => [g.id, g])),
|
||||
[groups],
|
||||
);
|
||||
|
||||
// --- Drag state ---
|
||||
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
const isSettlingRef = useRef(false);
|
||||
const [settleVersion, setSettleVersion] = useState(0);
|
||||
|
||||
const [columns, setColumns] = useState<Record<string, string[]>>(() =>
|
||||
buildColumns(issues, groups, "status"),
|
||||
);
|
||||
const columnsRef = useRef(columns);
|
||||
columnsRef.current = columns;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraggingRef.current && !isSettlingRef.current) {
|
||||
setColumns(buildColumns(issues, groups, "status"));
|
||||
}
|
||||
}, [issues, groups, settleVersion]);
|
||||
|
||||
const recentlyMovedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => {
|
||||
recentlyMovedRef.current = false;
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [columns]);
|
||||
|
||||
const issueMap = useMemo(() => {
|
||||
const map = new Map<string, Issue>();
|
||||
for (const issue of issues) map.set(issue.id, issue);
|
||||
return map;
|
||||
}, [issues]);
|
||||
|
||||
const issueMapRef = useRef(issueMap);
|
||||
if (!isDraggingRef.current && !isSettlingRef.current) {
|
||||
issueMapRef.current = issueMap;
|
||||
}
|
||||
|
||||
const collisionDetection = useMemo(
|
||||
() => makeListCollision(groupIds),
|
||||
[groupIds],
|
||||
);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
isDraggingRef.current = true;
|
||||
const issue = issueMapRef.current.get(event.active.id as string) ?? null;
|
||||
setActiveIssue(issue);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(event: DragOverEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || recentlyMovedRef.current) return;
|
||||
|
||||
const activeId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
setColumns((prev) => {
|
||||
const activeCol = findColumn(prev, activeId, groupIds);
|
||||
const overCol = findColumn(prev, overId, groupIds);
|
||||
if (!activeCol || !overCol || activeCol === overCol) return prev;
|
||||
|
||||
if (sortBy !== "position") return prev;
|
||||
|
||||
recentlyMovedRef.current = true;
|
||||
const oldIds = prev[activeCol]!.filter((id) => id !== activeId);
|
||||
const newIds = [...prev[overCol]!];
|
||||
const overIndex = newIds.indexOf(overId);
|
||||
const insertIndex = overIndex >= 0 ? overIndex : newIds.length;
|
||||
newIds.splice(insertIndex, 0, activeId);
|
||||
return { ...prev, [activeCol]: oldIds, [overCol]: newIds };
|
||||
});
|
||||
},
|
||||
[groupIds, sortBy],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
isDraggingRef.current = false;
|
||||
setActiveIssue(null);
|
||||
|
||||
const resetColumns = () =>
|
||||
setColumns(buildColumns(issues, groups, "status"));
|
||||
|
||||
if (!over || !onMoveIssue) {
|
||||
resetColumns();
|
||||
return;
|
||||
}
|
||||
|
||||
const activeId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
const cols = columnsRef.current;
|
||||
const activeCol = findColumn(cols, activeId, groupIds);
|
||||
const overCol = findColumn(cols, overId, groupIds);
|
||||
if (!activeCol || !overCol) {
|
||||
resetColumns();
|
||||
return;
|
||||
}
|
||||
|
||||
let finalColumns = cols;
|
||||
if (activeCol === overCol && sortBy === "position") {
|
||||
const ids = cols[activeCol]!;
|
||||
const oldIndex = ids.indexOf(activeId);
|
||||
const newIndex = ids.indexOf(overId);
|
||||
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
||||
const reordered = arrayMove(ids, oldIndex, newIndex);
|
||||
finalColumns = { ...cols, [activeCol]: reordered };
|
||||
setColumns(finalColumns);
|
||||
}
|
||||
}
|
||||
|
||||
const finalCol = sortBy === "position"
|
||||
? findColumn(finalColumns, activeId, groupIds)
|
||||
: overCol;
|
||||
if (!finalCol) {
|
||||
resetColumns();
|
||||
return;
|
||||
}
|
||||
const finalGroup = groupMap.get(finalCol);
|
||||
if (!finalGroup) {
|
||||
resetColumns();
|
||||
return;
|
||||
}
|
||||
|
||||
const map = issueMapRef.current;
|
||||
|
||||
if (sortBy !== "position") {
|
||||
const currentIssue = map.get(activeId);
|
||||
if (!currentIssue || issueMatchesGroup(currentIssue, finalGroup)) {
|
||||
resetColumns();
|
||||
return;
|
||||
}
|
||||
isSettlingRef.current = true;
|
||||
onMoveIssue(activeId, getMoveUpdates(finalGroup, currentIssue.position), () => {
|
||||
isSettlingRef.current = false;
|
||||
setSettleVersion((v) => v + 1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const finalIds = finalColumns[finalCol]!;
|
||||
const newPosition = computePosition(finalIds, activeId, map);
|
||||
const currentIssue = map.get(activeId);
|
||||
|
||||
if (
|
||||
currentIssue &&
|
||||
issueMatchesGroup(currentIssue, finalGroup) &&
|
||||
currentIssue.position === newPosition
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSettlingRef.current = true;
|
||||
onMoveIssue(activeId, getMoveUpdates(finalGroup, newPosition), () => {
|
||||
isSettlingRef.current = false;
|
||||
});
|
||||
},
|
||||
[issues, groups, onMoveIssue, groupIds, groupMap, sortBy],
|
||||
);
|
||||
|
||||
const content = (
|
||||
<Accordion.Root
|
||||
multiple
|
||||
className="space-y-1"
|
||||
value={expandedStatuses}
|
||||
onValueChange={(value: string[]) => {
|
||||
if (isDraggingRef.current) return;
|
||||
for (const status of visibleStatuses) {
|
||||
const wasExpanded = expandedStatuses.includes(status);
|
||||
const isExpanded = value.includes(status);
|
||||
if (wasExpanded !== isExpanded) {
|
||||
toggleListCollapsed(status as IssueStatus);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{visibleStatuses.map((status) => (
|
||||
}
|
||||
}}
|
||||
>
|
||||
{visibleStatuses.map((status) => {
|
||||
const isExpanded = expandedStatuses.includes(status);
|
||||
return (
|
||||
<StatusAccordionItem
|
||||
key={status}
|
||||
status={status}
|
||||
issues={issuesByStatus.get(status) ?? []}
|
||||
issueIds={columns[statusGroupId(status)] ?? EMPTY_IDS}
|
||||
issueMap={issueMapRef.current}
|
||||
childProgressMap={childProgressMap}
|
||||
myIssuesOpts={myIssuesOpts}
|
||||
sort={sort}
|
||||
projectId={projectId}
|
||||
dragEnabled={dragEnabled}
|
||||
isExpanded={isExpanded}
|
||||
sortLabel={sortLabel}
|
||||
sort={sort}
|
||||
/>
|
||||
))}
|
||||
</Accordion.Root>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Accordion.Root>
|
||||
);
|
||||
|
||||
if (!dragEnabled) {
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2 pt-0">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetection}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2 pt-0">
|
||||
{content}
|
||||
</div>
|
||||
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeIssue ? (
|
||||
<div className="max-w-2xl rotate-1 cursor-grabbing opacity-90 shadow-lg shadow-black/10 rounded-md border border-border bg-card px-4 py-2">
|
||||
<span className="text-xs text-muted-foreground mr-2">{activeIssue.identifier}</span>
|
||||
<span className="text-sm">{activeIssue.title}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusAccordionItem({
|
||||
status,
|
||||
issues,
|
||||
issueIds,
|
||||
issueMap,
|
||||
childProgressMap,
|
||||
myIssuesOpts,
|
||||
sort,
|
||||
projectId,
|
||||
dragEnabled,
|
||||
isExpanded,
|
||||
sortLabel,
|
||||
sort,
|
||||
}: {
|
||||
status: IssueStatus;
|
||||
issues: Issue[];
|
||||
issueIds: string[];
|
||||
issueMap: Map<string, Issue>;
|
||||
childProgressMap: Map<string, ChildProgress>;
|
||||
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
|
||||
sort?: IssueSortParam;
|
||||
projectId?: string;
|
||||
dragEnabled: boolean;
|
||||
isExpanded: boolean;
|
||||
sortLabel: string | null;
|
||||
sort?: IssueSortParam;
|
||||
}) {
|
||||
const { t } = useT("issues");
|
||||
const selectedIds = useIssueSelectionStore((s) => s.selectedIds);
|
||||
@@ -123,14 +383,34 @@ function StatusAccordionItem({
|
||||
sort,
|
||||
);
|
||||
|
||||
const issueIds = issues.map((i) => i.id);
|
||||
const issues = useMemo(
|
||||
() => issueIds.flatMap((id) => {
|
||||
const issue = issueMap.get(id);
|
||||
return issue ? [issue] : [];
|
||||
}),
|
||||
[issueIds, issueMap],
|
||||
);
|
||||
|
||||
const selectedCount = issueIds.filter((id) => selectedIds.has(id)).length;
|
||||
const allSelected = issues.length > 0 && selectedCount === issues.length;
|
||||
const someSelected = selectedCount > 0;
|
||||
|
||||
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
|
||||
id: statusGroupId(status),
|
||||
disabled: !dragEnabled,
|
||||
});
|
||||
|
||||
const disableSorting = !!sortLabel;
|
||||
|
||||
return (
|
||||
<Accordion.Item value={status}>
|
||||
<Accordion.Header className="group/header sticky top-0 z-10 flex h-10 items-center rounded-lg bg-muted transition-colors hover:bg-accent">
|
||||
<Accordion.Item value={status} ref={dragEnabled ? setDroppableRef : undefined}>
|
||||
<Accordion.Header
|
||||
className={`group/header sticky top-0 z-10 flex h-10 items-center rounded-lg bg-muted transition-colors hover:bg-accent ${
|
||||
isOver && (!isExpanded || issues.length === 0 || disableSorting)
|
||||
? "ring-2 ring-brand/25 bg-accent/15"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="pl-3 flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -174,20 +454,40 @@ function StatusAccordionItem({
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Accordion.Header>
|
||||
<Accordion.Panel className="pt-1">
|
||||
<Accordion.Panel>
|
||||
{issues.length > 0 ? (
|
||||
<>
|
||||
{issues.map((issue) => (
|
||||
<ListRow key={issue.id} issue={issue} childProgress={childProgressMap.get(issue.id)} />
|
||||
))}
|
||||
{hasMore && (
|
||||
<InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} />
|
||||
)}
|
||||
</>
|
||||
dragEnabled ? (
|
||||
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
|
||||
{issues.map((issue) => (
|
||||
<DraggableListRow
|
||||
key={issue.id}
|
||||
issue={issue}
|
||||
childProgress={childProgressMap.get(issue.id)}
|
||||
disableSorting={disableSorting}
|
||||
/>
|
||||
))}
|
||||
{hasMore && (
|
||||
<InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} />
|
||||
)}
|
||||
</SortableContext>
|
||||
) : (
|
||||
<>
|
||||
{issues.map((issue) => (
|
||||
<ListRow key={issue.id} issue={issue} childProgress={childProgressMap.get(issue.id)} />
|
||||
))}
|
||||
{hasMore && (
|
||||
<InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">
|
||||
{t(($) => $.list.empty_status)}
|
||||
</p>
|
||||
<div className={`flex min-h-20 items-center justify-center rounded-lg transition-colors ${
|
||||
isOver ? "bg-accent/30 ring-2 ring-brand/25" : ""
|
||||
}`}>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(($) => $.list.empty_status)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
117
packages/views/issues/utils/drag-utils.ts
Normal file
117
packages/views/issues/utils/drag-utils.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
pointerWithin,
|
||||
closestCenter,
|
||||
type CollisionDetection,
|
||||
} from "@dnd-kit/core";
|
||||
import type { Issue, IssueAssigneeType, IssueStatus, UpdateIssueRequest } from "@multica/core/types";
|
||||
import type { IssueGrouping } from "@multica/core/issues/stores/view-store";
|
||||
import type { BoardColumnGroup } from "../components/board-column";
|
||||
|
||||
export type DragMoveUpdates = Pick<
|
||||
UpdateIssueRequest,
|
||||
"status" | "assignee_type" | "assignee_id" | "position"
|
||||
>;
|
||||
|
||||
const UNASSIGNED_GROUP_ID = "assignee:unassigned";
|
||||
|
||||
export function makeKanbanCollision(groupIds: Set<string>): CollisionDetection {
|
||||
return (args) => {
|
||||
const pointer = pointerWithin(args);
|
||||
if (pointer.length > 0) {
|
||||
const items = pointer.filter((c) => !groupIds.has(c.id as string));
|
||||
if (items.length > 0) return items;
|
||||
return pointer;
|
||||
}
|
||||
return closestCenter(args);
|
||||
};
|
||||
}
|
||||
|
||||
export function makeListCollision(groupIds: Set<string>): CollisionDetection {
|
||||
return (args) => {
|
||||
const pointer = pointerWithin(args);
|
||||
if (pointer.length > 0) {
|
||||
const items = pointer.filter((c) => !groupIds.has(c.id as string));
|
||||
if (items.length > 0) return items;
|
||||
return pointer;
|
||||
}
|
||||
const groupOnly = {
|
||||
...args,
|
||||
droppableContainers: args.droppableContainers.filter(
|
||||
(c) => groupIds.has(c.id as string),
|
||||
),
|
||||
};
|
||||
return closestCenter(groupOnly);
|
||||
};
|
||||
}
|
||||
|
||||
export function statusGroupId(status: IssueStatus): string {
|
||||
return `status:${status}`;
|
||||
}
|
||||
|
||||
export function assigneeGroupId(
|
||||
type: IssueAssigneeType | null,
|
||||
id: string | null,
|
||||
): string {
|
||||
return type && id ? `assignee:${type}:${id}` : UNASSIGNED_GROUP_ID;
|
||||
}
|
||||
|
||||
export function getIssueGroupId(issue: Issue, grouping: IssueGrouping): string {
|
||||
if (grouping === "status") return statusGroupId(issue.status);
|
||||
return assigneeGroupId(issue.assignee_type, issue.assignee_id);
|
||||
}
|
||||
|
||||
export function buildColumns(
|
||||
issues: Issue[],
|
||||
groups: BoardColumnGroup[],
|
||||
grouping: IssueGrouping,
|
||||
): Record<string, string[]> {
|
||||
const cols: Record<string, string[]> = {};
|
||||
for (const group of groups) cols[group.id] = [];
|
||||
for (const issue of issues) {
|
||||
const gid = getIssueGroupId(issue, grouping);
|
||||
if (cols[gid]) cols[gid].push(issue.id);
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
export function computePosition(ids: string[], activeId: string, issueMap: Map<string, Issue>): number {
|
||||
const idx = ids.indexOf(activeId);
|
||||
if (idx === -1) return 0;
|
||||
const getPos = (id: string) => issueMap.get(id)?.position ?? 0;
|
||||
if (ids.length === 1) return issueMap.get(activeId)?.position ?? 0;
|
||||
if (idx === 0) return getPos(ids[1]!) - 1;
|
||||
if (idx === ids.length - 1) return getPos(ids[idx - 1]!) + 1;
|
||||
return (getPos(ids[idx - 1]!) + getPos(ids[idx + 1]!)) / 2;
|
||||
}
|
||||
|
||||
export function findColumn(
|
||||
columns: Record<string, string[]>,
|
||||
id: string,
|
||||
columnIds: Set<string>,
|
||||
): string | null {
|
||||
if (columnIds.has(id)) return id;
|
||||
for (const [columnId, ids] of Object.entries(columns)) {
|
||||
if (ids.includes(id)) return columnId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function issueMatchesGroup(issue: Issue, group: BoardColumnGroup): boolean {
|
||||
if (group.status) return issue.status === group.status;
|
||||
return (
|
||||
(issue.assignee_type ?? null) === (group.assigneeType ?? null) &&
|
||||
(issue.assignee_id ?? null) === (group.assigneeId ?? null)
|
||||
);
|
||||
}
|
||||
|
||||
export function getMoveUpdates(
|
||||
group: BoardColumnGroup,
|
||||
position: number,
|
||||
): DragMoveUpdates {
|
||||
if (group.status) return { status: group.status, position };
|
||||
return {
|
||||
assignee_type: group.assigneeType ?? null,
|
||||
assignee_id: group.assigneeId ?? null,
|
||||
position,
|
||||
};
|
||||
}
|
||||
@@ -2,116 +2,16 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useStore } from "zustand";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Check,
|
||||
ChevronDown,
|
||||
CircleDot,
|
||||
Columns3,
|
||||
Filter,
|
||||
List,
|
||||
SignalHigh,
|
||||
SlidersHorizontal,
|
||||
Waves,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@multica/ui/components/ui/popover";
|
||||
import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import {
|
||||
ALL_STATUSES,
|
||||
STATUS_CONFIG,
|
||||
PRIORITY_ORDER,
|
||||
PRIORITY_CONFIG,
|
||||
} from "@multica/core/issues/config";
|
||||
import { StatusIcon, PriorityIcon } from "../../issues/components";
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
GROUPING_OPTIONS,
|
||||
CARD_PROPERTY_OPTIONS,
|
||||
} from "@multica/core/issues/stores/view-store";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import type { Issue } from "@multica/core/types";
|
||||
import { myIssuesViewStore, type MyIssuesScope } from "@multica/core/issues/stores/my-issues-view-store";
|
||||
import { useT } from "../../i18n";
|
||||
import { WorkspaceAgentWorkingChip } from "../../issues/components/workspace-agent-working-chip";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HoverCheck
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FILTER_ITEM_CLASS =
|
||||
"group/fitem pr-1.5! [&>[data-slot=dropdown-menu-checkbox-item-indicator]]:hidden";
|
||||
|
||||
function HoverCheck({ checked }: { checked: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className="border-input data-[selected=true]:border-primary data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground pointer-events-none size-4 shrink-0 rounded-[4px] border transition-all select-none *:[svg]:opacity-0 data-[selected=true]:*:[svg]:opacity-100 opacity-0 group-hover/fitem:opacity-100 group-focus/fitem:opacity-100 data-[selected=true]:opacity-100"
|
||||
data-selected={checked}
|
||||
>
|
||||
<Check className="size-3.5 text-current" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getActiveFilterCount(state: {
|
||||
statusFilters: string[];
|
||||
priorityFilters: string[];
|
||||
}) {
|
||||
let count = 0;
|
||||
if (state.statusFilters.length > 0) count++;
|
||||
if (state.priorityFilters.length > 0) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
function useIssueCounts(allIssues: Issue[]) {
|
||||
return useMemo(() => {
|
||||
const status = new Map<string, number>();
|
||||
const priority = new Map<string, number>();
|
||||
|
||||
for (const issue of allIssues) {
|
||||
status.set(issue.status, (status.get(issue.status) ?? 0) + 1);
|
||||
priority.set(issue.priority, (priority.get(issue.priority) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return { status, priority };
|
||||
}, [allIssues]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scope config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MyIssuesHeader
|
||||
// ---------------------------------------------------------------------------
|
||||
import { IssueDisplayControls } from "../../issues/components/issues-header";
|
||||
|
||||
export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
|
||||
const { t } = useT("my-issues");
|
||||
// Pulls the chip-wide "Viewing only working agents" label from the
|
||||
// shared issues namespace so the copy stays identical with the global
|
||||
// /issues page header — single source of truth for this filter cue.
|
||||
const { t: tIssues } = useT("issues");
|
||||
const SCOPES: { value: MyIssuesScope; label: string; description: string }[] = [
|
||||
{ value: "all", label: t(($) => $.header.scope.all_label), description: t(($) => $.header.scope.all_description) },
|
||||
@@ -119,41 +19,16 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
|
||||
{ value: "created", label: t(($) => $.header.scope.created_label), description: t(($) => $.header.scope.created_description) },
|
||||
{ value: "agents", label: t(($) => $.header.scope.agents_label), description: t(($) => $.header.scope.agents_description) },
|
||||
];
|
||||
const viewMode = useStore(myIssuesViewStore, (s) => s.viewMode);
|
||||
const statusFilters = useStore(myIssuesViewStore, (s) => s.statusFilters);
|
||||
const priorityFilters = useStore(myIssuesViewStore, (s) => s.priorityFilters);
|
||||
const sortBy = useStore(myIssuesViewStore, (s) => s.sortBy);
|
||||
const sortDirection = useStore(myIssuesViewStore, (s) => s.sortDirection);
|
||||
const grouping = useStore(myIssuesViewStore, (s) => s.grouping);
|
||||
const cardProperties = useStore(myIssuesViewStore, (s) => s.cardProperties);
|
||||
const scope = useStore(myIssuesViewStore, (s) => s.scope);
|
||||
const agentRunningFilter = useStore(myIssuesViewStore, (s) => s.agentRunningFilter);
|
||||
const act = myIssuesViewStore.getState();
|
||||
// Limit the chip to issues actually visible on the My Issues page —
|
||||
// without this scoping, the chip would report workspace-wide running
|
||||
// agents (e.g. 3) while the my-scope list only contains one of them,
|
||||
// and the post-toggle list count would never match the chip number.
|
||||
const scopedIssueIds = useMemo(
|
||||
() => new Set(allIssues.map((i) => i.id)),
|
||||
[allIssues],
|
||||
);
|
||||
|
||||
const counts = useIssueCounts(allIssues);
|
||||
|
||||
const hasActiveFilters =
|
||||
getActiveFilterCount({ statusFilters, priorityFilters }) > 0;
|
||||
|
||||
const sortLabel =
|
||||
SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? t(($) => $.header.sort_manual);
|
||||
const GROUPING_LABEL_KEY: Record<typeof GROUPING_OPTIONS[number]["value"], "group_status" | "group_assignee"> = {
|
||||
status: "group_status",
|
||||
assignee: "group_assignee",
|
||||
};
|
||||
const groupingLabel = t(($) => $.header[GROUPING_LABEL_KEY[grouping]]);
|
||||
|
||||
return (
|
||||
<div className="flex h-12 shrink-0 items-center justify-between px-4">
|
||||
{/* Left: scope buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
{SCOPES.map((s) => (
|
||||
<Tooltip key={s.value}>
|
||||
@@ -178,7 +53,6 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right: agent working chip + filter + display + view toggle */}
|
||||
<div className="flex items-center gap-1">
|
||||
{agentRunningFilter && (
|
||||
<span className="mr-1 text-xs text-muted-foreground">
|
||||
@@ -190,278 +64,7 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
|
||||
onToggle={act.toggleAgentRunningFilter}
|
||||
scopedIssueIds={scopedIssueIds}
|
||||
/>
|
||||
{/* Filter */}
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button variant="outline" size="icon-sm" className="relative text-muted-foreground">
|
||||
<Filter className="size-4" />
|
||||
{hasActiveFilters && (
|
||||
<span className="absolute top-0 right-0 size-1.5 rounded-full bg-brand" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">{t(($) => $.header.filter_button)}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="w-auto">
|
||||
{/* Status */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<CircleDot className="size-3.5" />
|
||||
<span className="flex-1">{t(($) => $.header.filter_status)}</span>
|
||||
{statusFilters.length > 0 && (
|
||||
<span className="text-xs text-primary font-medium">
|
||||
{statusFilters.length}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-auto min-w-48">
|
||||
{ALL_STATUSES.map((s) => {
|
||||
const checked = statusFilters.includes(s);
|
||||
const count = counts.status.get(s) ?? 0;
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={s}
|
||||
checked={checked}
|
||||
onCheckedChange={() => act.toggleStatusFilter(s)}
|
||||
className={FILTER_ITEM_CLASS}
|
||||
>
|
||||
<HoverCheck checked={checked} />
|
||||
<StatusIcon status={s} className="h-3.5 w-3.5" />
|
||||
{STATUS_CONFIG[s].label}
|
||||
{count > 0 && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{t(($) => $.header.issue_count, { count })}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{/* Priority */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<SignalHigh className="size-3.5" />
|
||||
<span className="flex-1">{t(($) => $.header.filter_priority)}</span>
|
||||
{priorityFilters.length > 0 && (
|
||||
<span className="text-xs text-primary font-medium">
|
||||
{priorityFilters.length}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-auto min-w-44">
|
||||
{PRIORITY_ORDER.map((p) => {
|
||||
const checked = priorityFilters.includes(p);
|
||||
const count = counts.priority.get(p) ?? 0;
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={p}
|
||||
checked={checked}
|
||||
onCheckedChange={() => act.togglePriorityFilter(p)}
|
||||
className={FILTER_ITEM_CLASS}
|
||||
>
|
||||
<HoverCheck checked={checked} />
|
||||
<PriorityIcon priority={p} />
|
||||
{PRIORITY_CONFIG[p].label}
|
||||
{count > 0 && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{t(($) => $.header.issue_count, { count })}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{/* Reset */}
|
||||
{hasActiveFilters && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={act.clearFilters}>
|
||||
{t(($) => $.header.reset_filters)}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Display settings */}
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button variant="outline" size="icon-sm" className="text-muted-foreground">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">{t(($) => $.header.display_settings)}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent align="end" className="w-64 p-0">
|
||||
{viewMode === "board" && (
|
||||
<div className="border-b px-3 py-2.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t(($) => $.header.grouping)}
|
||||
</span>
|
||||
<div className="mt-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-between text-xs"
|
||||
>
|
||||
{groupingLabel}
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-auto">
|
||||
{GROUPING_OPTIONS.map((opt) => (
|
||||
<DropdownMenuItem
|
||||
key={opt.value}
|
||||
onClick={() => act.setGrouping(opt.value)}
|
||||
>
|
||||
{t(($) => $.header[GROUPING_LABEL_KEY[opt.value]])}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-b px-3 py-2.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t(($) => $.header.ordering)}
|
||||
</span>
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 justify-between text-xs"
|
||||
>
|
||||
{sortLabel}
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-auto">
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<DropdownMenuItem
|
||||
key={opt.value}
|
||||
onClick={() => act.setSortBy(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() =>
|
||||
act.setSortDirection(
|
||||
sortDirection === "asc" ? "desc" : "asc",
|
||||
)
|
||||
}
|
||||
title={sortDirection === "asc" ? t(($) => $.header.ascending) : t(($) => $.header.descending)}
|
||||
>
|
||||
{sortDirection === "asc" ? (
|
||||
<ArrowUp className="size-3.5" />
|
||||
) : (
|
||||
<ArrowDown className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t(($) => $.header.card_properties)}
|
||||
</span>
|
||||
<div className="mt-2 space-y-2">
|
||||
{CARD_PROPERTY_OPTIONS.map((opt) => (
|
||||
<label
|
||||
key={opt.key}
|
||||
className="flex cursor-pointer items-center justify-between"
|
||||
>
|
||||
<span className="text-sm">{opt.label}</span>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={cardProperties[opt.key]}
|
||||
onCheckedChange={() => act.toggleCardProperty(opt.key)}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* View toggle */}
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button variant="outline" size="icon-sm" className="text-muted-foreground">
|
||||
{viewMode === "board" ? (
|
||||
<Columns3 className="size-4" />
|
||||
) : viewMode === "swimlane" ? (
|
||||
<Waves className="size-4" />
|
||||
) : (
|
||||
<List className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">
|
||||
{viewMode === "board"
|
||||
? t(($) => $.header.view_board)
|
||||
: viewMode === "swimlane"
|
||||
? t(($) => $.header.view_swimlane)
|
||||
: t(($) => $.header.view_list)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="w-auto">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>{t(($) => $.header.view_label)}</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => act.setViewMode("board")}>
|
||||
<Columns3 />
|
||||
{t(($) => $.header.view_board_short)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => act.setViewMode("list")}>
|
||||
<List />
|
||||
{t(($) => $.header.view_list_short)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => act.setViewMode("swimlane")}>
|
||||
<Waves />
|
||||
{t(($) => $.header.view_swimlane_short)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<IssueDisplayControls scopedIssues={allIssues} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -248,11 +248,9 @@ export function MyIssuesPage() {
|
||||
<span className="text-sm font-medium">{t(($) => $.page.breadcrumb)}</span>
|
||||
</PageHeader>
|
||||
|
||||
{/* Header: scope tabs (left) + controls (right) */}
|
||||
<MyIssuesHeader allIssues={myIssues} />
|
||||
|
||||
{/* Content: scrollable */}
|
||||
<ViewStoreProvider store={myIssuesViewStore}>
|
||||
{/* Header: scope tabs (left) + controls (right) */}
|
||||
<MyIssuesHeader allIssues={myIssues} />
|
||||
{myIssues.length === 0 ? (
|
||||
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<ListTodo className="h-10 w-10 text-muted-foreground/40" />
|
||||
@@ -295,6 +293,7 @@ export function MyIssuesPage() {
|
||||
myIssuesScope={scope}
|
||||
myIssuesFilter={filter}
|
||||
sort={sort}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -244,6 +244,7 @@ function ProjectIssuesContent({
|
||||
myIssuesFilter={filter}
|
||||
sort={sort}
|
||||
projectId={projectId}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
/>
|
||||
)}
|
||||
{viewMode === "gantt" && <GanttView issues={filteredGanttIssues} />}
|
||||
|
||||
Reference in New Issue
Block a user