Merge pull request #269 from multica-ai/NevilleQingNY/my-issues-kanban
feat(web): My Issues kanban board + list view + filtering
This commit is contained in:
commit
09376dc879
15 changed files with 737 additions and 305 deletions
|
|
@ -150,9 +150,17 @@ vi.mock("@/features/issues/stores/view-store", () => ({
|
|||
],
|
||||
}));
|
||||
|
||||
// Mock view store context (shared components read from context)
|
||||
vi.mock("@/features/issues/stores/view-store-context", () => ({
|
||||
ViewStoreProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
useViewStore: (selector?: any) => (selector ? selector(mockViewState) : mockViewState),
|
||||
useViewStoreApi: () => ({ getState: () => mockViewState, setState: vi.fn(), subscribe: vi.fn() }),
|
||||
}));
|
||||
|
||||
// Mock issue config
|
||||
vi.mock("@/features/issues/config", () => ({
|
||||
ALL_STATUSES: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"],
|
||||
BOARD_STATUSES: ["backlog", "todo", "in_progress", "in_review", "done", "blocked"],
|
||||
STATUS_ORDER: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"],
|
||||
STATUS_CONFIG: {
|
||||
backlog: { label: "Backlog", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ import { useIssueStore } from "@/features/issues/store";
|
|||
import { PriorityIcon } from "./priority-icon";
|
||||
import { PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers";
|
||||
import { PRIORITY_CONFIG } from "@/features/issues/config";
|
||||
import { useIssueViewStore, type CardProperties } from "@/features/issues/stores/view-store";
|
||||
import type { CardProperties } from "@/features/issues/stores/view-store";
|
||||
import { useViewStore } from "@/features/issues/stores/view-store-context";
|
||||
|
||||
function formatDate(date: string): string {
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
|
|
@ -42,7 +43,7 @@ export const BoardCardContent = memo(function BoardCardContent({
|
|||
issue: Issue;
|
||||
editable?: boolean;
|
||||
}) {
|
||||
const storeProperties = useIssueViewStore((s) => s.cardProperties);
|
||||
const storeProperties = useViewStore((s) => s.cardProperties);
|
||||
const priorityCfg = PRIORITY_CONFIG[issue.priority];
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
} from "@/components/ui/dropdown-menu";
|
||||
import { STATUS_CONFIG } from "@/features/issues/config";
|
||||
import { useModalStore } from "@/features/modals";
|
||||
import { useIssueViewStore } from "@/features/issues/stores/view-store";
|
||||
import { useViewStore, useViewStoreApi } from "@/features/issues/stores/view-store-context";
|
||||
import { sortIssues } from "@/features/issues/utils/sort";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
import { DraggableBoardCard } from "./board-card";
|
||||
|
|
@ -29,8 +29,9 @@ export function BoardColumn({
|
|||
}) {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const { setNodeRef, isOver } = useDroppable({ id: status });
|
||||
const sortBy = useIssueViewStore((s) => s.sortBy);
|
||||
const sortDirection = useIssueViewStore((s) => s.sortDirection);
|
||||
const viewStoreApi = useViewStoreApi();
|
||||
const sortBy = useViewStore((s) => s.sortBy);
|
||||
const sortDirection = useViewStore((s) => s.sortDirection);
|
||||
|
||||
const sortedIssues = useMemo(
|
||||
() => sortIssues(issues, sortBy, sortDirection),
|
||||
|
|
@ -67,7 +68,7 @@ export function BoardColumn({
|
|||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => useIssueViewStore.getState().hideStatus(status)}>
|
||||
<DropdownMenuItem onClick={() => viewStoreApi.getState().hideStatus(status)}>
|
||||
<EyeOff className="size-3.5" />
|
||||
Hide column
|
||||
</DropdownMenuItem>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ALL_STATUSES, STATUS_CONFIG } from "@/features/issues/config";
|
||||
import { useIssueViewStore } from "@/features/issues/stores/view-store";
|
||||
import { useViewStoreApi } from "@/features/issues/stores/view-store-context";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
import { BoardColumn } from "./board-column";
|
||||
import { BoardCardContent } from "./board-card";
|
||||
|
|
@ -205,6 +205,7 @@ function HiddenColumnsPanel({
|
|||
hiddenStatuses: IssueStatus[];
|
||||
issues: Issue[];
|
||||
}) {
|
||||
const viewStoreApi = useViewStoreApi();
|
||||
return (
|
||||
<div className="flex w-[240px] shrink-0 flex-col">
|
||||
<div className="mb-2 flex items-center gap-2 px-1">
|
||||
|
|
@ -242,7 +243,7 @@ function HiddenColumnsPanel({
|
|||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
useIssueViewStore.getState().showStatus(status)
|
||||
viewStoreApi.getState().showStatus(status)
|
||||
}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
|
|
|
|||
|
|
@ -272,16 +272,7 @@ export function IssuesHeader() {
|
|||
const sortBy = useIssueViewStore((s) => s.sortBy);
|
||||
const sortDirection = useIssueViewStore((s) => s.sortDirection);
|
||||
const cardProperties = useIssueViewStore((s) => s.cardProperties);
|
||||
const setViewMode = useIssueViewStore((s) => s.setViewMode);
|
||||
const toggleStatusFilter = useIssueViewStore((s) => s.toggleStatusFilter);
|
||||
const togglePriorityFilter = useIssueViewStore((s) => s.togglePriorityFilter);
|
||||
const toggleAssigneeFilter = useIssueViewStore((s) => s.toggleAssigneeFilter);
|
||||
const toggleNoAssignee = useIssueViewStore((s) => s.toggleNoAssignee);
|
||||
const toggleCreatorFilter = useIssueViewStore((s) => s.toggleCreatorFilter);
|
||||
const clearFilters = useIssueViewStore((s) => s.clearFilters);
|
||||
const setSortBy = useIssueViewStore((s) => s.setSortBy);
|
||||
const setSortDirection = useIssueViewStore((s) => s.setSortDirection);
|
||||
const toggleCardProperty = useIssueViewStore((s) => s.toggleCardProperty);
|
||||
const act = useIssueViewStore.getState();
|
||||
|
||||
const allIssues = useIssueStore((s) => s.issues);
|
||||
const counts = useIssueCounts(allIssues);
|
||||
|
|
@ -319,11 +310,11 @@ export function IssuesHeader() {
|
|||
<DropdownMenuContent align="start" className="w-auto">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>View</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => setViewMode("board")}>
|
||||
<DropdownMenuItem onClick={() => act.setViewMode("board")}>
|
||||
<Columns3 />
|
||||
Board
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setViewMode("list")}>
|
||||
<DropdownMenuItem onClick={() => act.setViewMode("list")}>
|
||||
<List />
|
||||
List
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -343,14 +334,14 @@ export function IssuesHeader() {
|
|||
<Filter className="size-3.5" />
|
||||
Filter
|
||||
{hasActiveFilters && (
|
||||
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
|
||||
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-xs font-medium text-primary-foreground">
|
||||
{filterCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-44">
|
||||
<DropdownMenuContent align="start" className="w-auto">
|
||||
{/* Status */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
|
|
@ -370,7 +361,7 @@ export function IssuesHeader() {
|
|||
<DropdownMenuCheckboxItem
|
||||
key={s}
|
||||
checked={checked}
|
||||
onCheckedChange={() => toggleStatusFilter(s)}
|
||||
onCheckedChange={() => act.toggleStatusFilter(s)}
|
||||
className={FILTER_ITEM_CLASS}
|
||||
>
|
||||
<HoverCheck checked={checked} />
|
||||
|
|
@ -406,7 +397,7 @@ export function IssuesHeader() {
|
|||
<DropdownMenuCheckboxItem
|
||||
key={p}
|
||||
checked={checked}
|
||||
onCheckedChange={() => togglePriorityFilter(p)}
|
||||
onCheckedChange={() => act.togglePriorityFilter(p)}
|
||||
className={FILTER_ITEM_CLASS}
|
||||
>
|
||||
<HoverCheck checked={checked} />
|
||||
|
|
@ -438,10 +429,10 @@ export function IssuesHeader() {
|
|||
<ActorSubContent
|
||||
counts={counts.assignee}
|
||||
selected={assigneeFilters}
|
||||
onToggle={toggleAssigneeFilter}
|
||||
onToggle={act.toggleAssigneeFilter}
|
||||
showNoAssignee
|
||||
includeNoAssignee={includeNoAssignee}
|
||||
onToggleNoAssignee={toggleNoAssignee}
|
||||
onToggleNoAssignee={act.toggleNoAssignee}
|
||||
noAssigneeCount={counts.noAssignee}
|
||||
/>
|
||||
</DropdownMenuSubContent>
|
||||
|
|
@ -462,7 +453,7 @@ export function IssuesHeader() {
|
|||
<ActorSubContent
|
||||
counts={counts.creator}
|
||||
selected={creatorFilters}
|
||||
onToggle={toggleCreatorFilter}
|
||||
onToggle={act.toggleCreatorFilter}
|
||||
/>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
|
@ -471,7 +462,7 @@ export function IssuesHeader() {
|
|||
{hasActiveFilters && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={clearFilters}>
|
||||
<DropdownMenuItem onClick={act.clearFilters}>
|
||||
Reset all filters
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
|
|
@ -512,7 +503,7 @@ export function IssuesHeader() {
|
|||
{SORT_OPTIONS.map((opt) => (
|
||||
<DropdownMenuItem
|
||||
key={opt.value}
|
||||
onClick={() => setSortBy(opt.value)}
|
||||
onClick={() => act.setSortBy(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -523,7 +514,7 @@ export function IssuesHeader() {
|
|||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() =>
|
||||
setSortDirection(sortDirection === "asc" ? "desc" : "asc")
|
||||
act.setSortDirection(sortDirection === "asc" ? "desc" : "asc")
|
||||
}
|
||||
title={sortDirection === "asc" ? "Ascending" : "Descending"}
|
||||
>
|
||||
|
|
@ -550,7 +541,7 @@ export function IssuesHeader() {
|
|||
<Switch
|
||||
size="sm"
|
||||
checked={cardProperties[opt.key]}
|
||||
onCheckedChange={() => toggleCardProperty(opt.key)}
|
||||
onCheckedChange={() => act.toggleCardProperty(opt.key)}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import type { IssueStatus } from "@/shared/types";
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { useIssueViewStore, initFilterWorkspaceSync } from "@/features/issues/stores/view-store";
|
||||
import { ViewStoreProvider } from "@/features/issues/stores/view-store-context";
|
||||
import { filterIssues } from "@/features/issues/utils/filter";
|
||||
import { BOARD_STATUSES } from "@/features/issues/config";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { WorkspaceAvatar } from "@/features/workspace";
|
||||
import { api } from "@/shared/api";
|
||||
|
|
@ -17,15 +19,6 @@ import { BoardView } from "./board-view";
|
|||
import { ListView } from "./list-view";
|
||||
import { BatchActionToolbar } from "./batch-action-toolbar";
|
||||
|
||||
const BOARD_STATUSES: IssueStatus[] = [
|
||||
"backlog",
|
||||
"todo",
|
||||
"in_progress",
|
||||
"in_review",
|
||||
"done",
|
||||
"blocked",
|
||||
];
|
||||
|
||||
export function IssuesPage() {
|
||||
const allIssues = useIssueStore((s) => s.issues);
|
||||
const loading = useIssueStore((s) => s.loading);
|
||||
|
|
@ -63,9 +56,10 @@ export function IssuesPage() {
|
|||
const handleMoveIssue = useCallback(
|
||||
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
|
||||
// Auto-switch to manual sort so drag ordering is preserved
|
||||
if (useIssueViewStore.getState().sortBy !== "position") {
|
||||
useIssueViewStore.getState().setSortBy("position");
|
||||
useIssueViewStore.getState().setSortDirection("asc");
|
||||
const viewState = useIssueViewStore.getState();
|
||||
if (viewState.sortBy !== "position") {
|
||||
viewState.setSortBy("position");
|
||||
viewState.setSortDirection("asc");
|
||||
}
|
||||
|
||||
const updates: Partial<{ status: IssueStatus; position: number }> = {
|
||||
|
|
@ -125,20 +119,22 @@ export function IssuesPage() {
|
|||
<IssuesHeader />
|
||||
|
||||
{/* Content: scrollable */}
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{viewMode === "board" ? (
|
||||
<BoardView
|
||||
issues={issues}
|
||||
allIssues={allIssues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
/>
|
||||
) : (
|
||||
<ListView issues={issues} visibleStatuses={visibleStatuses} />
|
||||
)}
|
||||
</div>
|
||||
{viewMode === "list" && <BatchActionToolbar />}
|
||||
<ViewStoreProvider store={useIssueViewStore}>
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{viewMode === "board" ? (
|
||||
<BoardView
|
||||
issues={issues}
|
||||
allIssues={allIssues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
/>
|
||||
) : (
|
||||
<ListView issues={issues} visibleStatuses={visibleStatuses} />
|
||||
)}
|
||||
</div>
|
||||
{viewMode === "list" && <BatchActionToolbar />}
|
||||
</ViewStoreProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button";
|
|||
import type { Issue, IssueStatus } from "@/shared/types";
|
||||
import { STATUS_CONFIG } from "@/features/issues/config";
|
||||
import { useModalStore } from "@/features/modals";
|
||||
import { useIssueViewStore } from "@/features/issues/stores/view-store";
|
||||
import { useViewStore } from "@/features/issues/stores/view-store-context";
|
||||
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
|
||||
import { sortIssues } from "@/features/issues/utils/sort";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
|
|
@ -21,12 +21,12 @@ export function ListView({
|
|||
issues: Issue[];
|
||||
visibleStatuses: IssueStatus[];
|
||||
}) {
|
||||
const sortBy = useIssueViewStore((s) => s.sortBy);
|
||||
const sortDirection = useIssueViewStore((s) => s.sortDirection);
|
||||
const listCollapsedStatuses = useIssueViewStore(
|
||||
const sortBy = useViewStore((s) => s.sortBy);
|
||||
const sortDirection = useViewStore((s) => s.sortDirection);
|
||||
const listCollapsedStatuses = useViewStore(
|
||||
(s) => s.listCollapsedStatuses
|
||||
);
|
||||
const toggleListCollapsed = useIssueViewStore(
|
||||
const toggleListCollapsed = useViewStore(
|
||||
(s) => s.toggleListCollapsed
|
||||
);
|
||||
const selectedIds = useIssueSelectionStore((s) => s.selectedIds);
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
export { STATUS_ORDER, ALL_STATUSES, STATUS_CONFIG } from "./status";
|
||||
export { STATUS_ORDER, ALL_STATUSES, BOARD_STATUSES, STATUS_CONFIG } from "./status";
|
||||
export { PRIORITY_ORDER, PRIORITY_CONFIG } from "./priority";
|
||||
|
|
|
|||
|
|
@ -20,6 +20,16 @@ export const ALL_STATUSES: IssueStatus[] = [
|
|||
"cancelled",
|
||||
];
|
||||
|
||||
/** Statuses shown as board columns (excludes cancelled). */
|
||||
export const BOARD_STATUSES: IssueStatus[] = [
|
||||
"backlog",
|
||||
"todo",
|
||||
"in_progress",
|
||||
"in_review",
|
||||
"done",
|
||||
"blocked",
|
||||
];
|
||||
|
||||
export const STATUS_CONFIG: Record<
|
||||
IssueStatus,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export { useIssueStore } from "./store";
|
||||
export { useIssueViewStore } from "./stores/view-store";
|
||||
export { useIssueViewStore, createIssueViewStore } from "./stores/view-store";
|
||||
export { ViewStoreProvider, useViewStore } from "./stores/view-store-context";
|
||||
export { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, AssigneePicker } from "./components";
|
||||
export * from "./config";
|
||||
|
|
|
|||
35
apps/web/features/issues/stores/view-store-context.tsx
Normal file
35
apps/web/features/issues/stores/view-store-context.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import { useStore, type StoreApi } from "zustand";
|
||||
import type { IssueViewState } from "./view-store";
|
||||
|
||||
const ViewStoreContext = createContext<StoreApi<IssueViewState> | null>(null);
|
||||
|
||||
export function ViewStoreProvider({
|
||||
store,
|
||||
children,
|
||||
}: {
|
||||
store: StoreApi<IssueViewState>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ViewStoreContext.Provider value={store}>
|
||||
{children}
|
||||
</ViewStoreContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useViewStore<T>(selector: (state: IssueViewState) => T): T {
|
||||
const store = useContext(ViewStoreContext);
|
||||
if (!store)
|
||||
throw new Error("useViewStore must be used within ViewStoreProvider");
|
||||
return useStore(store, selector);
|
||||
}
|
||||
|
||||
export function useViewStoreApi(): StoreApi<IssueViewState> {
|
||||
const store = useContext(ViewStoreContext);
|
||||
if (!store)
|
||||
throw new Error("useViewStoreApi must be used within ViewStoreProvider");
|
||||
return store;
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createStore, type StoreApi } from "zustand/vanilla";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { IssueStatus, IssuePriority } from "@/shared/types";
|
||||
import { ALL_STATUSES } from "@/features/issues/config";
|
||||
|
|
@ -36,7 +37,7 @@ export const CARD_PROPERTY_OPTIONS: { key: keyof CardProperties; label: string }
|
|||
{ key: "dueDate", label: "Due date" },
|
||||
];
|
||||
|
||||
interface IssueViewState {
|
||||
export interface IssueViewState {
|
||||
viewMode: ViewMode;
|
||||
statusFilters: IssueStatus[];
|
||||
priorityFilters: IssuePriority[];
|
||||
|
|
@ -62,130 +63,142 @@ interface IssueViewState {
|
|||
toggleListCollapsed: (status: IssueStatus) => void;
|
||||
}
|
||||
|
||||
export const useIssueViewStore = create<IssueViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
viewMode: "board",
|
||||
const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): IssueViewState => ({
|
||||
viewMode: "board",
|
||||
statusFilters: [],
|
||||
priorityFilters: [],
|
||||
assigneeFilters: [],
|
||||
includeNoAssignee: false,
|
||||
creatorFilters: [],
|
||||
sortBy: "position",
|
||||
sortDirection: "asc",
|
||||
cardProperties: {
|
||||
priority: true,
|
||||
description: true,
|
||||
assignee: true,
|
||||
dueDate: true,
|
||||
},
|
||||
listCollapsedStatuses: [],
|
||||
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
toggleStatusFilter: (status) =>
|
||||
set((state) => ({
|
||||
statusFilters: state.statusFilters.includes(status)
|
||||
? state.statusFilters.filter((s) => s !== status)
|
||||
: [...state.statusFilters, status],
|
||||
})),
|
||||
togglePriorityFilter: (priority) =>
|
||||
set((state) => ({
|
||||
priorityFilters: state.priorityFilters.includes(priority)
|
||||
? state.priorityFilters.filter((p) => p !== priority)
|
||||
: [...state.priorityFilters, priority],
|
||||
})),
|
||||
toggleAssigneeFilter: (value) =>
|
||||
set((state) => {
|
||||
const exists = state.assigneeFilters.some(
|
||||
(f) => f.type === value.type && f.id === value.id,
|
||||
);
|
||||
return {
|
||||
assigneeFilters: exists
|
||||
? state.assigneeFilters.filter(
|
||||
(f) => !(f.type === value.type && f.id === value.id),
|
||||
)
|
||||
: [...state.assigneeFilters, value],
|
||||
};
|
||||
}),
|
||||
toggleNoAssignee: () =>
|
||||
set((state) => ({ includeNoAssignee: !state.includeNoAssignee })),
|
||||
toggleCreatorFilter: (value) =>
|
||||
set((state) => {
|
||||
const exists = state.creatorFilters.some(
|
||||
(f) => f.type === value.type && f.id === value.id,
|
||||
);
|
||||
return {
|
||||
creatorFilters: exists
|
||||
? state.creatorFilters.filter(
|
||||
(f) => !(f.type === value.type && f.id === value.id),
|
||||
)
|
||||
: [...state.creatorFilters, value],
|
||||
};
|
||||
}),
|
||||
hideStatus: (status) =>
|
||||
set((state) => {
|
||||
// If no filter active, activate filter with all EXCEPT this one
|
||||
if (state.statusFilters.length === 0) {
|
||||
return { statusFilters: ALL_STATUSES.filter((s) => s !== status) };
|
||||
}
|
||||
return {
|
||||
statusFilters: state.statusFilters.filter((s) => s !== status),
|
||||
};
|
||||
}),
|
||||
showStatus: (status) =>
|
||||
set((state) => {
|
||||
if (state.statusFilters.length === 0) return state;
|
||||
if (state.statusFilters.includes(status)) return state;
|
||||
return { statusFilters: [...state.statusFilters, status] };
|
||||
}),
|
||||
clearFilters: () =>
|
||||
set({
|
||||
statusFilters: [],
|
||||
priorityFilters: [],
|
||||
assigneeFilters: [],
|
||||
includeNoAssignee: false,
|
||||
creatorFilters: [],
|
||||
sortBy: "position",
|
||||
sortDirection: "asc",
|
||||
cardProperties: {
|
||||
priority: true,
|
||||
description: true,
|
||||
assignee: true,
|
||||
dueDate: true,
|
||||
},
|
||||
listCollapsedStatuses: [],
|
||||
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
toggleStatusFilter: (status) =>
|
||||
set((state) => ({
|
||||
statusFilters: state.statusFilters.includes(status)
|
||||
? state.statusFilters.filter((s) => s !== status)
|
||||
: [...state.statusFilters, status],
|
||||
})),
|
||||
togglePriorityFilter: (priority) =>
|
||||
set((state) => ({
|
||||
priorityFilters: state.priorityFilters.includes(priority)
|
||||
? state.priorityFilters.filter((p) => p !== priority)
|
||||
: [...state.priorityFilters, priority],
|
||||
})),
|
||||
toggleAssigneeFilter: (value) =>
|
||||
set((state) => {
|
||||
const exists = state.assigneeFilters.some(
|
||||
(f) => f.type === value.type && f.id === value.id,
|
||||
);
|
||||
return {
|
||||
assigneeFilters: exists
|
||||
? state.assigneeFilters.filter(
|
||||
(f) => !(f.type === value.type && f.id === value.id),
|
||||
)
|
||||
: [...state.assigneeFilters, value],
|
||||
};
|
||||
}),
|
||||
toggleNoAssignee: () =>
|
||||
set((state) => ({ includeNoAssignee: !state.includeNoAssignee })),
|
||||
toggleCreatorFilter: (value) =>
|
||||
set((state) => {
|
||||
const exists = state.creatorFilters.some(
|
||||
(f) => f.type === value.type && f.id === value.id,
|
||||
);
|
||||
return {
|
||||
creatorFilters: exists
|
||||
? state.creatorFilters.filter(
|
||||
(f) => !(f.type === value.type && f.id === value.id),
|
||||
)
|
||||
: [...state.creatorFilters, value],
|
||||
};
|
||||
}),
|
||||
hideStatus: (status) =>
|
||||
set((state) => {
|
||||
// If no filter active, activate filter with all EXCEPT this one
|
||||
if (state.statusFilters.length === 0) {
|
||||
return { statusFilters: ALL_STATUSES.filter((s) => s !== status) };
|
||||
}
|
||||
return {
|
||||
statusFilters: state.statusFilters.filter((s) => s !== status),
|
||||
};
|
||||
}),
|
||||
showStatus: (status) =>
|
||||
set((state) => {
|
||||
if (state.statusFilters.length === 0) return state;
|
||||
if (state.statusFilters.includes(status)) return state;
|
||||
return { statusFilters: [...state.statusFilters, status] };
|
||||
}),
|
||||
clearFilters: () =>
|
||||
set({
|
||||
statusFilters: [],
|
||||
priorityFilters: [],
|
||||
assigneeFilters: [],
|
||||
includeNoAssignee: false,
|
||||
creatorFilters: [],
|
||||
}),
|
||||
setSortBy: (field) => set({ sortBy: field }),
|
||||
setSortDirection: (dir) => set({ sortDirection: dir }),
|
||||
toggleCardProperty: (key) =>
|
||||
set((state) => ({
|
||||
cardProperties: {
|
||||
...state.cardProperties,
|
||||
[key]: !state.cardProperties[key],
|
||||
},
|
||||
})),
|
||||
toggleListCollapsed: (status) =>
|
||||
set((state) => ({
|
||||
listCollapsedStatuses: state.listCollapsedStatuses.includes(status)
|
||||
? state.listCollapsedStatuses.filter((s) => s !== status)
|
||||
: [...state.listCollapsedStatuses, status],
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: "multica_issues_view",
|
||||
partialize: (state) => ({
|
||||
viewMode: state.viewMode,
|
||||
statusFilters: state.statusFilters,
|
||||
priorityFilters: state.priorityFilters,
|
||||
assigneeFilters: state.assigneeFilters,
|
||||
includeNoAssignee: state.includeNoAssignee,
|
||||
creatorFilters: state.creatorFilters,
|
||||
sortBy: state.sortBy,
|
||||
sortDirection: state.sortDirection,
|
||||
cardProperties: state.cardProperties,
|
||||
listCollapsedStatuses: state.listCollapsedStatuses,
|
||||
}),
|
||||
}
|
||||
)
|
||||
setSortBy: (field) => set({ sortBy: field }),
|
||||
setSortDirection: (dir) => set({ sortDirection: dir }),
|
||||
toggleCardProperty: (key) =>
|
||||
set((state) => ({
|
||||
cardProperties: {
|
||||
...state.cardProperties,
|
||||
[key]: !state.cardProperties[key],
|
||||
},
|
||||
})),
|
||||
toggleListCollapsed: (status) =>
|
||||
set((state) => ({
|
||||
listCollapsedStatuses: state.listCollapsedStatuses.includes(status)
|
||||
? state.listCollapsedStatuses.filter((s) => s !== status)
|
||||
: [...state.listCollapsedStatuses, status],
|
||||
})),
|
||||
});
|
||||
|
||||
const viewStorePersistOptions = (name: string) => ({
|
||||
name,
|
||||
partialize: (state: IssueViewState) => ({
|
||||
viewMode: state.viewMode,
|
||||
statusFilters: state.statusFilters,
|
||||
priorityFilters: state.priorityFilters,
|
||||
assigneeFilters: state.assigneeFilters,
|
||||
includeNoAssignee: state.includeNoAssignee,
|
||||
creatorFilters: state.creatorFilters,
|
||||
sortBy: state.sortBy,
|
||||
sortDirection: state.sortDirection,
|
||||
cardProperties: state.cardProperties,
|
||||
listCollapsedStatuses: state.listCollapsedStatuses,
|
||||
}),
|
||||
});
|
||||
|
||||
/** Factory: creates a vanilla StoreApi for use with React Context. */
|
||||
export function createIssueViewStore(persistKey: string): StoreApi<IssueViewState> {
|
||||
return createStore<IssueViewState>()(
|
||||
persist(viewStoreSlice, viewStorePersistOptions(persistKey))
|
||||
);
|
||||
}
|
||||
|
||||
/** Global singleton for the /issues page. */
|
||||
export const useIssueViewStore = create<IssueViewState>()(
|
||||
persist(viewStoreSlice, viewStorePersistOptions("multica_issues_view"))
|
||||
);
|
||||
|
||||
// Clear actor-based filters when workspace switches (IDs are workspace-scoped).
|
||||
// Clear filters on all registered view stores when workspace switches.
|
||||
// Deferred to avoid circular dependency: view-store → workspace → issues → view-store.
|
||||
let _filterSubInitialized = false;
|
||||
export function initFilterWorkspaceSync() {
|
||||
if (_filterSubInitialized) return;
|
||||
_filterSubInitialized = true;
|
||||
const _syncedStores = new Set<StoreApi<IssueViewState>>();
|
||||
let _workspaceSyncInitialized = false;
|
||||
|
||||
export function registerViewStoreForWorkspaceSync(store: StoreApi<IssueViewState>) {
|
||||
_syncedStores.add(store);
|
||||
if (_workspaceSyncInitialized) return;
|
||||
_workspaceSyncInitialized = true;
|
||||
|
||||
// Dynamic import breaks the circular module evaluation chain.
|
||||
import("@/features/workspace").then(({ useWorkspaceStore }) => {
|
||||
|
|
@ -193,9 +206,13 @@ export function initFilterWorkspaceSync() {
|
|||
useWorkspaceStore.subscribe((state) => {
|
||||
const id = state.workspace?.id;
|
||||
if (prevId && id !== prevId) {
|
||||
useIssueViewStore.getState().clearFilters();
|
||||
for (const s of _syncedStores) s.getState().clearFilters();
|
||||
}
|
||||
prevId = id;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Backward-compatible alias — registers the global singleton for workspace sync. */
|
||||
export const initFilterWorkspaceSync = () =>
|
||||
registerViewStoreForWorkspaceSync(useIssueViewStore);
|
||||
|
|
|
|||
368
apps/web/features/my-issues/components/my-issues-header.tsx
Normal file
368
apps/web/features/my-issues/components/my-issues-header.tsx
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useStore } from "zustand";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Check,
|
||||
ChevronDown,
|
||||
CircleDot,
|
||||
Columns3,
|
||||
Filter,
|
||||
List,
|
||||
Plus,
|
||||
SignalHigh,
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useModalStore } from "@/features/modals";
|
||||
import {
|
||||
ALL_STATUSES,
|
||||
STATUS_CONFIG,
|
||||
PRIORITY_ORDER,
|
||||
PRIORITY_CONFIG,
|
||||
} from "@/features/issues/config";
|
||||
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
CARD_PROPERTY_OPTIONS,
|
||||
} from "@/features/issues/stores/view-store";
|
||||
import { filterIssues } from "@/features/issues/utils/filter";
|
||||
import type { Issue } from "@/shared/types";
|
||||
import { myIssuesViewStore } from "../stores/my-issues-view-store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HoverCheck — shadcn official pattern (PR #6862)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MyIssuesHeader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
|
||||
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 cardProperties = useStore(myIssuesViewStore, (s) => s.cardProperties);
|
||||
const act = myIssuesViewStore.getState();
|
||||
|
||||
const counts = useIssueCounts(allIssues);
|
||||
|
||||
const filteredCount = useMemo(
|
||||
() =>
|
||||
filterIssues(allIssues, {
|
||||
statusFilters,
|
||||
priorityFilters,
|
||||
assigneeFilters: [],
|
||||
includeNoAssignee: false,
|
||||
creatorFilters: [],
|
||||
}).length,
|
||||
[allIssues, statusFilters, priorityFilters],
|
||||
);
|
||||
|
||||
const filterCount = getActiveFilterCount({ statusFilters, priorityFilters });
|
||||
|
||||
const sortLabel =
|
||||
SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? "Manual";
|
||||
const hasActiveFilters = filterCount > 0;
|
||||
|
||||
return (
|
||||
<div className="flex h-12 shrink-0 items-center justify-between px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View toggle */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm">
|
||||
{viewMode === "board" ? (
|
||||
<Columns3 className="size-3.5" />
|
||||
) : (
|
||||
<List className="size-3.5" />
|
||||
)}
|
||||
{viewMode === "board" ? "Board" : "List"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-auto">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>View</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => act.setViewMode("board")}>
|
||||
<Columns3 />
|
||||
Board
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => act.setViewMode("list")}>
|
||||
<List />
|
||||
List
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Filter — DropdownMenu with sub-menus */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={
|
||||
hasActiveFilters ? "border-primary/50 text-primary" : ""
|
||||
}
|
||||
>
|
||||
<Filter className="size-3.5" />
|
||||
Filter
|
||||
{hasActiveFilters && (
|
||||
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-xs font-medium text-primary-foreground">
|
||||
{filterCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-auto">
|
||||
{/* Status */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<CircleDot className="size-3.5" />
|
||||
<span className="flex-1">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">
|
||||
{count} {count === 1 ? "issue" : "issues"}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{/* Priority */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<SignalHigh className="size-3.5" />
|
||||
<span className="flex-1">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">
|
||||
{count} {count === 1 ? "issue" : "issues"}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{/* Reset */}
|
||||
{hasActiveFilters && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={act.clearFilters}>
|
||||
Reset all filters
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Display settings */}
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm">
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
Display
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-64 p-0">
|
||||
<div className="border-b px-3 py-2.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
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" ? "Ascending" : "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">
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{filteredCount} {filteredCount === 1 ? "Issue" : "Issues"}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => useModalStore.getState().open("create-issue")}
|
||||
>
|
||||
<Plus />
|
||||
New Issue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,65 +1,25 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, User, Bot, SquarePen, ListTodo } from "lucide-react";
|
||||
import { Accordion } from "@base-ui/react/accordion";
|
||||
import type { Issue } from "@/shared/types";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { WorkspaceAvatar } from "@/features/workspace";
|
||||
import { StatusIcon } from "@/features/issues/components/status-icon";
|
||||
import { PriorityIcon } from "@/features/issues/components/priority-icon";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useStore } from "zustand";
|
||||
import { toast } from "sonner";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { IssueStatus } from "@/shared/types";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface GroupConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const GROUPS: GroupConfig[] = [
|
||||
{ key: "assigned_to_me", label: "Assigned to me", icon: User },
|
||||
{ key: "assigned_to_my_agents", label: "Assigned to my agents", icon: Bot },
|
||||
{ key: "created_by_me", label: "Created by me", icon: SquarePen },
|
||||
];
|
||||
|
||||
function formatDate(date: string): string {
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function IssueRow({ issue }: { issue: Issue }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/issues/${issue.id}`}
|
||||
className="flex h-9 items-center gap-2 px-4 text-sm transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<PriorityIcon priority={issue.priority} className="shrink-0" />
|
||||
<StatusIcon status={issue.status} className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="w-16 shrink-0 text-xs text-muted-foreground">
|
||||
{issue.identifier}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{issue.title}</span>
|
||||
{issue.due_date && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatDate(issue.due_date)}
|
||||
</span>
|
||||
)}
|
||||
{issue.assignee_type && issue.assignee_id && (
|
||||
<ActorAvatar
|
||||
actorType={issue.assignee_type}
|
||||
actorId={issue.assignee_id}
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore, WorkspaceAvatar } from "@/features/workspace";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { filterIssues } from "@/features/issues/utils/filter";
|
||||
import { BOARD_STATUSES } from "@/features/issues/config";
|
||||
import { ViewStoreProvider } from "@/features/issues/stores/view-store-context";
|
||||
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
|
||||
import { BoardView } from "@/features/issues/components/board-view";
|
||||
import { ListView } from "@/features/issues/components/list-view";
|
||||
import { BatchActionToolbar } from "@/features/issues/components/batch-action-toolbar";
|
||||
import { registerViewStoreForWorkspaceSync } from "@/features/issues/stores/view-store";
|
||||
import { api } from "@/shared/api";
|
||||
import { myIssuesViewStore } from "../stores/my-issues-view-store";
|
||||
import { MyIssuesHeader } from "./my-issues-header";
|
||||
|
||||
export function MyIssuesPage() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
|
@ -68,31 +28,86 @@ export function MyIssuesPage() {
|
|||
const allIssues = useIssueStore((s) => s.issues);
|
||||
const loading = useIssueStore((s) => s.loading);
|
||||
|
||||
const viewMode = useStore(myIssuesViewStore, (s) => s.viewMode);
|
||||
const statusFilters = useStore(myIssuesViewStore, (s) => s.statusFilters);
|
||||
const priorityFilters = useStore(myIssuesViewStore, (s) => s.priorityFilters);
|
||||
|
||||
useEffect(() => {
|
||||
registerViewStoreForWorkspaceSync(myIssuesViewStore);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
useIssueSelectionStore.getState().clear();
|
||||
}, [viewMode]);
|
||||
|
||||
const myAgentIds = useMemo(() => {
|
||||
if (!user) return new Set<string>();
|
||||
return new Set(agents.filter((a) => a.owner_id === user.id).map((a) => a.id));
|
||||
return new Set(
|
||||
agents.filter((a) => a.owner_id === user.id).map((a) => a.id),
|
||||
);
|
||||
}, [agents, user]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
if (!user) return new Map<string, Issue[]>();
|
||||
|
||||
const assignedToMe = allIssues.filter(
|
||||
(i) => i.assignee_type === "member" && i.assignee_id === user.id,
|
||||
// Pre-filter: union of (assigned to me + my agents + created by me)
|
||||
const myIssues = useMemo(() => {
|
||||
if (!user) return [];
|
||||
return allIssues.filter(
|
||||
(i) =>
|
||||
(i.assignee_type === "member" && i.assignee_id === user.id) ||
|
||||
(i.assignee_type === "agent" &&
|
||||
i.assignee_id &&
|
||||
myAgentIds.has(i.assignee_id)) ||
|
||||
(i.creator_type === "member" && i.creator_id === user.id),
|
||||
);
|
||||
const assignedToMyAgents = allIssues.filter(
|
||||
(i) => i.assignee_type === "agent" && i.assignee_id && myAgentIds.has(i.assignee_id),
|
||||
);
|
||||
const createdByMe = allIssues.filter(
|
||||
(i) => i.creator_type === "member" && i.creator_id === user.id,
|
||||
);
|
||||
|
||||
const map = new Map<string, Issue[]>();
|
||||
map.set("assigned_to_me", assignedToMe);
|
||||
map.set("assigned_to_my_agents", assignedToMyAgents);
|
||||
map.set("created_by_me", createdByMe);
|
||||
return map;
|
||||
}, [allIssues, user, myAgentIds]);
|
||||
|
||||
// Apply status/priority filters from view store
|
||||
const issues = useMemo(
|
||||
() =>
|
||||
filterIssues(myIssues, {
|
||||
statusFilters,
|
||||
priorityFilters,
|
||||
assigneeFilters: [],
|
||||
includeNoAssignee: false,
|
||||
creatorFilters: [],
|
||||
}),
|
||||
[myIssues, statusFilters, priorityFilters],
|
||||
);
|
||||
|
||||
const visibleStatuses = useMemo(() => {
|
||||
if (statusFilters.length > 0)
|
||||
return BOARD_STATUSES.filter((s) => statusFilters.includes(s));
|
||||
return BOARD_STATUSES;
|
||||
}, [statusFilters]);
|
||||
|
||||
const hiddenStatuses = useMemo(() => {
|
||||
return BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s));
|
||||
}, [visibleStatuses]);
|
||||
|
||||
const handleMoveIssue = useCallback(
|
||||
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
|
||||
const viewState = myIssuesViewStore.getState();
|
||||
if (viewState.sortBy !== "position") {
|
||||
viewState.setSortBy("position");
|
||||
viewState.setSortDirection("asc");
|
||||
}
|
||||
|
||||
const updates: Partial<{ status: IssueStatus; position: number }> = {
|
||||
status: newStatus,
|
||||
};
|
||||
if (newPosition !== undefined) updates.position = newPosition;
|
||||
|
||||
useIssueStore.getState().updateIssue(issueId, updates);
|
||||
|
||||
api.updateIssue(issueId, updates).catch(() => {
|
||||
toast.error("Failed to move issue");
|
||||
api.listIssues({ limit: 200 }).then((res) => {
|
||||
useIssueStore.getState().setIssues(res.issues);
|
||||
});
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
|
|
@ -100,12 +115,16 @@ export function MyIssuesPage() {
|
|||
<Skeleton className="h-5 w-5 rounded" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<div className="flex-1 p-4 space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex min-w-52 flex-1 flex-col gap-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-24 w-full rounded-lg" />
|
||||
<Skeleton className="h-24 w-full rounded-lg" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -115,7 +134,7 @@ export function MyIssuesPage() {
|
|||
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
{/* Header: Workspace breadcrumb */}
|
||||
{/* Header 1: Workspace breadcrumb */}
|
||||
<div className="flex h-12 shrink-0 items-center gap-1.5 border-b px-4">
|
||||
<WorkspaceAvatar name={workspace?.name ?? "W"} size="sm" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
|
|
@ -125,47 +144,26 @@ export function MyIssuesPage() {
|
|||
<span className="text-sm font-medium">My Issues</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2">
|
||||
<Accordion.Root
|
||||
multiple
|
||||
className="space-y-1"
|
||||
defaultValue={GROUPS.map((g) => g.key)}
|
||||
>
|
||||
{GROUPS.map((group) => {
|
||||
const issues = grouped.get(group.key) ?? [];
|
||||
const Icon = group.icon;
|
||||
{/* Header 2: View toggle + filters */}
|
||||
<MyIssuesHeader allIssues={myIssues} />
|
||||
|
||||
return (
|
||||
<Accordion.Item key={group.key} value={group.key}>
|
||||
<Accordion.Header className="flex h-10 items-center rounded-lg bg-muted/40 transition-colors hover:bg-accent/30">
|
||||
<Accordion.Trigger className="group/trigger flex flex-1 items-center gap-2 px-3 h-full text-left outline-none">
|
||||
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground transition-transform group-aria-expanded/trigger:rotate-90" />
|
||||
<Icon className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{group.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{issues.length}
|
||||
</span>
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Panel className="pt-1">
|
||||
{issues.length > 0 ? (
|
||||
issues.map((issue) => (
|
||||
<IssueRow key={issue.id} issue={issue} />
|
||||
))
|
||||
) : (
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">
|
||||
No issues
|
||||
</p>
|
||||
)}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
})}
|
||||
</Accordion.Root>
|
||||
</div>
|
||||
{/* Content: scrollable */}
|
||||
<ViewStoreProvider store={myIssuesViewStore}>
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{viewMode === "board" ? (
|
||||
<BoardView
|
||||
issues={issues}
|
||||
allIssues={myIssues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
/>
|
||||
) : (
|
||||
<ListView issues={issues} visibleStatuses={visibleStatuses} />
|
||||
)}
|
||||
</div>
|
||||
{viewMode === "list" && <BatchActionToolbar />}
|
||||
</ViewStoreProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { createIssueViewStore } from "@/features/issues/stores/view-store";
|
||||
|
||||
export const myIssuesViewStore = createIssueViewStore("multica_my_issues_view");
|
||||
Loading…
Add table
Add a link
Reference in a new issue