Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion packages/core/issues/stores/view-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { ALL_STATUSES } from "../config";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";

export type ViewMode = "board" | "list" | "gantt";
export type ViewMode = "board" | "list" | "gantt" | "swimlane";
export type GanttZoom = "day" | "week" | "month";
export type IssueGrouping = "status" | "assignee";
export type SortField = "position" | "priority" | "start_date" | "due_date" | "created_at" | "title";
Expand Down Expand Up @@ -79,6 +79,8 @@ export interface IssueViewState {
listCollapsedStatuses: IssueStatus[];
ganttZoom: GanttZoom;
ganttShowCompleted: boolean;
swimlaneOrder: string[];
collapsedSwimlanes: string[];
setViewMode: (mode: ViewMode) => void;
setGanttZoom: (zoom: GanttZoom) => void;
toggleGanttShowCompleted: () => void;
Expand All @@ -99,6 +101,8 @@ export interface IssueViewState {
setSortDirection: (dir: SortDirection) => void;
toggleCardProperty: (key: keyof CardProperties) => void;
toggleListCollapsed: (status: IssueStatus) => void;
setSwimlaneOrder: (order: string[]) => void;
toggleSwimlaneCollapsed: (key: string) => void;
}

export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): IssueViewState => ({
Expand Down Expand Up @@ -128,6 +132,8 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
listCollapsedStatuses: [],
ganttZoom: "week",
ganttShowCompleted: false,
swimlaneOrder: [],
collapsedSwimlanes: [],

setViewMode: (mode) => set({ viewMode: mode }),
setGanttZoom: (zoom) => set({ ganttZoom: zoom }),
Expand Down Expand Up @@ -233,6 +239,13 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
? state.listCollapsedStatuses.filter((s) => s !== status)
: [...state.listCollapsedStatuses, status],
})),
setSwimlaneOrder: (order) => set({ swimlaneOrder: order }),
toggleSwimlaneCollapsed: (key) =>
set((state) => ({
collapsedSwimlanes: state.collapsedSwimlanes.includes(key)
? state.collapsedSwimlanes.filter((k) => k !== key)
: [...state.collapsedSwimlanes, key],
})),
});

export const viewStorePersistOptions = (name: string) => ({
Expand All @@ -259,6 +272,8 @@ export const viewStorePersistOptions = (name: string) => ({
listCollapsedStatuses: state.listCollapsedStatuses,
ganttZoom: state.ganttZoom,
ganttShowCompleted: state.ganttShowCompleted,
swimlaneOrder: state.swimlaneOrder,
collapsedSwimlanes: state.collapsedSwimlanes,
}),
// Default Zustand merge is shallow, so a persisted `cardProperties` snapshot
// saved before a new toggle was introduced wins entirely and the new key is
Expand Down
97 changes: 28 additions & 69 deletions packages/views/issues/components/board-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,16 @@ import {
} from "@dnd-kit/core";
import type { QueryKey } from "@tanstack/react-query";
import { arrayMove } from "@dnd-kit/sortable";
import { Eye, MoreHorizontal } from "lucide-react";
import type { Issue, IssueAssigneeGroup, IssueAssigneeType, IssueStatus, UpdateIssueRequest } from "@multica/core/types";
import { Button } from "@multica/ui/components/ui/button";
import { useLoadMoreByAssigneeGroup, useLoadMoreByStatus } from "@multica/core/issues/mutations";
import type { AssigneeGroupedIssuesFilter, MyIssuesFilter } from "@multica/core/issues/queries";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@multica/ui/components/ui/dropdown-menu";
import { useViewStoreApi, useViewStore } from "@multica/core/issues/stores/view-store-context";
import { useViewStore } from "@multica/core/issues/stores/view-store-context";
import type { IssueGrouping, SortField, SortDirection } from "@multica/core/issues/stores/view-store";
import { useActorName } from "@multica/core/workspace/hooks";
import { sortIssues } from "../utils/sort";
import { StatusIcon } from "./status-icon";
import { BoardColumn, type BoardColumnGroup } from "./board-column";
import { BoardCardContent } from "./board-card";
import { HiddenColumnsPanel, HiddenColumnRow } from "./hidden-columns-panel";
import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel";
import type { ChildProgress } from "./list-row";
import { useT } from "../../i18n";
Expand Down Expand Up @@ -512,7 +504,7 @@ export function BoardView({
)}

{grouping === "status" && hiddenStatuses.length > 0 && (
<HiddenColumnsPanel
<BoardHiddenColumnsPanel
hiddenStatuses={hiddenStatuses}
myIssuesOpts={myIssuesOpts}
/>
Expand Down Expand Up @@ -609,74 +601,41 @@ function PaginatedBoardColumn({
);
}

function HiddenColumnsPanel({
hiddenStatuses,
/**
* Board-view-specific row that pulls the server-aggregated total from
* `useLoadMoreByStatus` and hands it to the shared {@link HiddenColumnRow}.
* Lives here (not in `hidden-columns-panel.tsx`) so the shared panel stays
* free of `useLoadMoreByStatus` / `myIssuesOpts` coupling — the swimlane
* uses an in-memory total instead.
*/
function BoardHiddenColumnRow({
status,
myIssuesOpts,
}: {
hiddenStatuses: IssueStatus[];
status: IssueStatus;
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
}) {
const { t } = useT("issues");
return (
<div className="flex w-[240px] shrink-0 flex-col">
<div className="mb-2 flex items-center gap-2 px-1">
<span className="text-sm font-medium text-muted-foreground">
{t(($) => $.board.hidden_columns_label)}
</span>
</div>
<div className="flex-1 space-y-0.5">
{hiddenStatuses.map((status) => (
<HiddenColumnRow
key={status}
status={status}
myIssuesOpts={myIssuesOpts}
/>
))}
</div>
</div>
);
const { total } = useLoadMoreByStatus(status, myIssuesOpts);
return <HiddenColumnRow status={status} total={total} />;
}

function HiddenColumnRow({
status,
function BoardHiddenColumnsPanel({
hiddenStatuses,
myIssuesOpts,
}: {
status: IssueStatus;
hiddenStatuses: IssueStatus[];
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
}) {
const { t } = useT("issues");
const viewStoreApi = useViewStoreApi();
const { total } = useLoadMoreByStatus(status, myIssuesOpts);
return (
<div className="flex items-center justify-between rounded-lg px-2.5 py-2 hover:bg-muted/50">
<div className="flex items-center gap-2">
<StatusIcon status={status} className="h-3.5 w-3.5" />
<span className="text-sm">{t(($) => $.status[status])}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">{total}</span>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
>
<MoreHorizontal className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => viewStoreApi.getState().showStatus(status)}
>
<Eye className="size-3.5" />
{t(($) => $.board.show_column)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<HiddenColumnsPanel
hiddenStatuses={hiddenStatuses}
renderRow={(status) => (
<BoardHiddenColumnRow
key={status}
status={status}
myIssuesOpts={myIssuesOpts}
/>
)}
/>
);
}
95 changes: 95 additions & 0 deletions packages/views/issues/components/hidden-columns-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"use client";

import { Eye, MoreHorizontal } from "lucide-react";
import type { IssueStatus } from "@multica/core/types";
import { Button } from "@multica/ui/components/ui/button";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@multica/ui/components/ui/dropdown-menu";
import { useViewStoreApi } from "@multica/core/issues/stores/view-store-context";
import { StatusIcon } from "./status-icon";
import { useT } from "../../i18n";

/**
* Single source of truth for the "Hidden columns" side panel rendered by
* the kanban-style views (board and swimlane).
*
* Each consumer renders its own per-row count via the {@link renderRow} slot —
* the board uses `useLoadMoreByStatus` to fetch the workspace-wide aggregate,
* while the swimlane uses an in-memory total derived from already-loaded
* issues. Centralising the chrome here keeps a future view (calendar /
* timeline / etc.) from forking yet another copy.
*/
export function HiddenColumnsPanel({
hiddenStatuses,
renderRow,
}: {
hiddenStatuses: IssueStatus[];
renderRow: (status: IssueStatus) => React.ReactNode;
}) {
const { t } = useT("issues");
return (
<div className="flex w-[240px] shrink-0 flex-col">
<div className="mb-2 flex items-center gap-2 px-1">
<span className="text-sm font-medium text-muted-foreground">
{t(($) => $.board.hidden_columns_label)}
</span>
</div>
<div className="flex-1 space-y-0.5">
{hiddenStatuses.map((status) => renderRow(status))}
</div>
</div>
);
}

/**
* One row inside {@link HiddenColumnsPanel}. Pure presentational — the caller
* computes `total` however it likes (cached query vs. in-memory aggregate).
*/
export function HiddenColumnRow({
status,
total,
}: {
status: IssueStatus;
total: number;
}) {
const { t } = useT("issues");
const viewStoreApi = useViewStoreApi();
return (
<div className="flex items-center justify-between rounded-lg px-2.5 py-2 hover:bg-muted/50">
<div className="flex items-center gap-2">
<StatusIcon status={status} className="h-3.5 w-3.5" />
<span className="text-sm">{t(($) => $.status[status])}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">{total}</span>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t(($) => $.board.show_column)}
className="rounded-full text-muted-foreground"
>
<MoreHorizontal className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => viewStoreApi.getState().showStatus(status)}
>
<Eye className="size-3.5" />
{t(($) => $.board.show_column)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
9 changes: 9 additions & 0 deletions packages/views/issues/components/issues-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
User,
UserMinus,
UserPen,
Waves,
} from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import {
Expand Down Expand Up @@ -969,6 +970,8 @@ export function IssueDisplayControls({
<Columns3 className="size-4" />
) : viewMode === "gantt" && allowGantt ? (
<ChartGantt className="size-4" />
) : viewMode === "swimlane" ? (
<Waves className="size-4" />
) : (
<List className="size-4" />
)}
Expand All @@ -982,6 +985,8 @@ export function IssueDisplayControls({
? t(($) => $.view.tooltip_board)
: viewMode === "gantt" && allowGantt
? t(($) => $.view.tooltip_gantt)
: viewMode === "swimlane"
? t(($) => $.view.tooltip_swimlane)
: t(($) => $.view.tooltip_list)}
</TooltipContent>
</Tooltip>
Expand All @@ -1002,6 +1007,10 @@ export function IssueDisplayControls({
{t(($) => $.view.gantt)}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => act.setViewMode("swimlane")}>
<Waves />
{t(($) => $.view.swimlane)}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
Expand Down
11 changes: 10 additions & 1 deletion packages/views/issues/components/issues-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { PageHeader } from "../../layout/page-header";
import { IssuesHeader } from "./issues-header";
import { BoardView } from "./board-view";
import { ListView } from "./list-view";
import { SwimLaneView } from "./swimlane-view";
import { BatchActionToolbar } from "./batch-action-toolbar";
import { useT } from "../../i18n";

Expand Down Expand Up @@ -135,7 +136,7 @@ export function IssuesPage() {

const updateIssueMutation = useUpdateIssue();
const handleMoveIssue = useCallback(
(issueId: string, updates: Pick<UpdateIssueRequest, "status" | "assignee_type" | "assignee_id" | "position">) => {
(issueId: string, updates: Pick<UpdateIssueRequest, "status" | "assignee_type" | "assignee_id" | "position" | "parent_issue_id">) => {
updateIssueMutation.mutate(
{ id: issueId, ...updates },
{
Expand Down Expand Up @@ -227,6 +228,14 @@ export function IssuesPage() {
onMoveIssue={handleMoveIssue}
childProgressMap={childProgressMap}
/>
) : viewMode === "swimlane" ? (
<SwimLaneView
issues={issues}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
childProgressMap={childProgressMap}
/>
) : (
<ListView issues={issues} visibleStatuses={visibleStatuses} childProgressMap={childProgressMap} />
)}
Expand Down
Loading
Loading