diff --git a/apps/web/app/(dashboard)/issues/page.test.tsx b/apps/web/app/(dashboard)/issues/page.test.tsx index 5e9d662e..8b2a143e 100644 --- a/apps/web/app/(dashboard)/issues/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/page.test.tsx @@ -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" }, diff --git a/apps/web/features/issues/components/board-card.tsx b/apps/web/features/issues/components/board-card.tsx index 9ef4f473..25f4610b 100644 --- a/apps/web/features/issues/components/board-card.tsx +++ b/apps/web/features/issues/components/board-card.tsx @@ -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( diff --git a/apps/web/features/issues/components/board-column.tsx b/apps/web/features/issues/components/board-column.tsx index bdb89929..66d2808a 100644 --- a/apps/web/features/issues/components/board-column.tsx +++ b/apps/web/features/issues/components/board-column.tsx @@ -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({ } /> - useIssueViewStore.getState().hideStatus(status)}> + viewStoreApi.getState().hideStatus(status)}> Hide column diff --git a/apps/web/features/issues/components/board-view.tsx b/apps/web/features/issues/components/board-view.tsx index 38a1d2fd..a8038ed6 100644 --- a/apps/web/features/issues/components/board-view.tsx +++ b/apps/web/features/issues/components/board-view.tsx @@ -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 (
@@ -242,7 +243,7 @@ function HiddenColumnsPanel({ - useIssueViewStore.getState().showStatus(status) + viewStoreApi.getState().showStatus(status) } > diff --git a/apps/web/features/issues/components/issues-header.tsx b/apps/web/features/issues/components/issues-header.tsx index 33b6680d..73f394b1 100644 --- a/apps/web/features/issues/components/issues-header.tsx +++ b/apps/web/features/issues/components/issues-header.tsx @@ -278,16 +278,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); @@ -325,11 +316,11 @@ export function IssuesHeader() { View - setViewMode("board")}> + act.setViewMode("board")}> Board - setViewMode("list")}> + act.setViewMode("list")}> List @@ -349,14 +340,14 @@ export function IssuesHeader() { Filter {hasActiveFilters && ( - + {filterCount} )} } /> - + {/* Status */} @@ -376,7 +367,7 @@ export function IssuesHeader() { toggleStatusFilter(s)} + onCheckedChange={() => act.toggleStatusFilter(s)} className={FILTER_ITEM_CLASS} > @@ -412,7 +403,7 @@ export function IssuesHeader() { togglePriorityFilter(p)} + onCheckedChange={() => act.togglePriorityFilter(p)} className={FILTER_ITEM_CLASS} > @@ -444,10 +435,10 @@ export function IssuesHeader() { @@ -468,7 +459,7 @@ export function IssuesHeader() { @@ -477,7 +468,7 @@ export function IssuesHeader() { {hasActiveFilters && ( <> - + Reset all filters @@ -518,7 +509,7 @@ export function IssuesHeader() { {SORT_OPTIONS.map((opt) => ( setSortBy(opt.value)} + onClick={() => act.setSortBy(opt.value)} > {opt.label} @@ -529,7 +520,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"} > @@ -556,7 +547,7 @@ export function IssuesHeader() { toggleCardProperty(opt.key)} + onCheckedChange={() => act.toggleCardProperty(opt.key)} /> ))} diff --git a/apps/web/features/issues/components/issues-page.tsx b/apps/web/features/issues/components/issues-page.tsx index 8f0a754e..f09b4e7f 100644 --- a/apps/web/features/issues/components/issues-page.tsx +++ b/apps/web/features/issues/components/issues-page.tsx @@ -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() { {/* Content: scrollable */} -
- {viewMode === "board" ? ( - - ) : ( - - )} -
- {viewMode === "list" && } + +
+ {viewMode === "board" ? ( + + ) : ( + + )} +
+ {viewMode === "list" && } +
); } diff --git a/apps/web/features/issues/components/list-view.tsx b/apps/web/features/issues/components/list-view.tsx index 6e3f50b8..bc0ef436 100644 --- a/apps/web/features/issues/components/list-view.tsx +++ b/apps/web/features/issues/components/list-view.tsx @@ -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); diff --git a/apps/web/features/issues/config/index.ts b/apps/web/features/issues/config/index.ts index 60d97c53..8a58b480 100644 --- a/apps/web/features/issues/config/index.ts +++ b/apps/web/features/issues/config/index.ts @@ -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"; diff --git a/apps/web/features/issues/config/status.ts b/apps/web/features/issues/config/status.ts index 88ca389a..dc8b7e82 100644 --- a/apps/web/features/issues/config/status.ts +++ b/apps/web/features/issues/config/status.ts @@ -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, { diff --git a/apps/web/features/issues/index.ts b/apps/web/features/issues/index.ts index 6de13853..8a274f1d 100644 --- a/apps/web/features/issues/index.ts +++ b/apps/web/features/issues/index.ts @@ -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"; diff --git a/apps/web/features/issues/stores/view-store-context.tsx b/apps/web/features/issues/stores/view-store-context.tsx new file mode 100644 index 00000000..2a619f96 --- /dev/null +++ b/apps/web/features/issues/stores/view-store-context.tsx @@ -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 | null>(null); + +export function ViewStoreProvider({ + store, + children, +}: { + store: StoreApi; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useViewStore(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 { + const store = useContext(ViewStoreContext); + if (!store) + throw new Error("useViewStoreApi must be used within ViewStoreProvider"); + return store; +} diff --git a/apps/web/features/issues/stores/view-store.ts b/apps/web/features/issues/stores/view-store.ts index 3a68842f..01dae9f1 100644 --- a/apps/web/features/issues/stores/view-store.ts +++ b/apps/web/features/issues/stores/view-store.ts @@ -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()( - persist( - (set) => ({ - viewMode: "board", +const viewStoreSlice = (set: StoreApi["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 { + return createStore()( + persist(viewStoreSlice, viewStorePersistOptions(persistKey)) + ); +} + +/** Global singleton for the /issues page. */ +export const useIssueViewStore = create()( + 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>(); +let _workspaceSyncInitialized = false; + +export function registerViewStoreForWorkspaceSync(store: StoreApi) { + _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); diff --git a/apps/web/features/my-issues/components/my-issues-header.tsx b/apps/web/features/my-issues/components/my-issues-header.tsx new file mode 100644 index 00000000..eb934987 --- /dev/null +++ b/apps/web/features/my-issues/components/my-issues-header.tsx @@ -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 ( +
+ +
+ ); +} + +// --------------------------------------------------------------------------- +// 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(); + const priority = new Map(); + + 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 ( +
+
+ {/* View toggle */} + + + {viewMode === "board" ? ( + + ) : ( + + )} + {viewMode === "board" ? "Board" : "List"} + + } + /> + + + View + act.setViewMode("board")}> + + Board + + act.setViewMode("list")}> + + List + + + + + + {/* Filter — DropdownMenu with sub-menus */} + + + + Filter + {hasActiveFilters && ( + + {filterCount} + + )} + + } + /> + + {/* Status */} + + + + Status + {statusFilters.length > 0 && ( + + {statusFilters.length} + + )} + + + {ALL_STATUSES.map((s) => { + const checked = statusFilters.includes(s); + const count = counts.status.get(s) ?? 0; + return ( + act.toggleStatusFilter(s)} + className={FILTER_ITEM_CLASS} + > + + + {STATUS_CONFIG[s].label} + {count > 0 && ( + + {count} {count === 1 ? "issue" : "issues"} + + )} + + ); + })} + + + + {/* Priority */} + + + + Priority + {priorityFilters.length > 0 && ( + + {priorityFilters.length} + + )} + + + {PRIORITY_ORDER.map((p) => { + const checked = priorityFilters.includes(p); + const count = counts.priority.get(p) ?? 0; + return ( + act.togglePriorityFilter(p)} + className={FILTER_ITEM_CLASS} + > + + + {PRIORITY_CONFIG[p].label} + {count > 0 && ( + + {count} {count === 1 ? "issue" : "issues"} + + )} + + ); + })} + + + + {/* Reset */} + {hasActiveFilters && ( + <> + + + Reset all filters + + + )} + + + + {/* Display settings */} + + + + Display + + } + /> + +
+ + Ordering + +
+ + + {sortLabel} + + + } + /> + + {SORT_OPTIONS.map((opt) => ( + act.setSortBy(opt.value)} + > + {opt.label} + + ))} + + + +
+
+ +
+ + Card properties + +
+ {CARD_PROPERTY_OPTIONS.map((opt) => ( + + ))} +
+
+
+
+
+ +
+ + {filteredCount} {filteredCount === 1 ? "Issue" : "Issues"} + + +
+
+ ); +} diff --git a/apps/web/features/my-issues/components/my-issues-page.tsx b/apps/web/features/my-issues/components/my-issues-page.tsx index 9a8c98a3..757c4b49 100644 --- a/apps/web/features/my-issues/components/my-issues-page.tsx +++ b/apps/web/features/my-issues/components/my-issues-page.tsx @@ -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 ( - - - - - {issue.identifier} - - {issue.title} - {issue.due_date && ( - - {formatDate(issue.due_date)} - - )} - {issue.assignee_type && issue.assignee_id && ( - - )} - - ); -} +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(); - 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(); - - 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(); - 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 (
@@ -100,12 +115,16 @@ export function MyIssuesPage() {
-
- {Array.from({ length: 3 }).map((_, i) => ( -
- - - +
+ + +
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + +
))}
@@ -115,7 +134,7 @@ export function MyIssuesPage() { return (
- {/* Header: Workspace breadcrumb */} + {/* Header 1: Workspace breadcrumb */}
@@ -125,47 +144,26 @@ export function MyIssuesPage() { My Issues
- {/* Content */} -
- g.key)} - > - {GROUPS.map((group) => { - const issues = grouped.get(group.key) ?? []; - const Icon = group.icon; + {/* Header 2: View toggle + filters */} + - return ( - - - - - - - {group.label} - - - {issues.length} - - - - - {issues.length > 0 ? ( - issues.map((issue) => ( - - )) - ) : ( -

- No issues -

- )} -
-
- ); - })} -
-
+ {/* Content: scrollable */} + +
+ {viewMode === "board" ? ( + + ) : ( + + )} +
+ {viewMode === "list" && } +
); } diff --git a/apps/web/features/my-issues/stores/my-issues-view-store.ts b/apps/web/features/my-issues/stores/my-issues-view-store.ts new file mode 100644 index 00000000..3f59c776 --- /dev/null +++ b/apps/web/features/my-issues/stores/my-issues-view-store.ts @@ -0,0 +1,5 @@ +"use client"; + +import { createIssueViewStore } from "@/features/issues/stores/view-store"; + +export const myIssuesViewStore = createIssueViewStore("multica_my_issues_view");