feat(web): add kanban board + list view + filtering to My Issues page

Upgrade /my-issues from a simple accordion to a full-featured view
matching /issues — kanban board, list view, status/priority filtering,
sorting, and display settings, scoped to the user's own issues.

Key changes:
- Extract view store factory (createIssueViewStore) using zustand v5
  vanilla createStore + React Context for shared component reuse
- Create ViewStoreProvider + useViewStore/useViewStoreApi hooks
- Decouple BoardView, BoardColumn, BoardCard, ListView from global
  useIssueViewStore — they now read from context
- New independent persisted store for /my-issues (multica_my_issues_view)
- Simplified MyIssuesHeader (no assignee/creator filters)
- Pre-filter logic: assigned to me ∪ my agents ∪ created by me
- Generalize workspace sync to clear filters on all registered stores
- Fix existing debt: text-[10px] → text-xs, w-44 → w-auto, reduce
  unnecessary selector subscriptions in both headers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-04-01 16:56:22 +08:00
parent fe963d672d
commit 3c5a3b5e6a
15 changed files with 737 additions and 305 deletions

View file

@ -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(

View file

@ -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>

View file

@ -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" />

View file

@ -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() {
<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>
@ -349,14 +340,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>
@ -376,7 +367,7 @@ export function IssuesHeader() {
<DropdownMenuCheckboxItem
key={s}
checked={checked}
onCheckedChange={() => toggleStatusFilter(s)}
onCheckedChange={() => act.toggleStatusFilter(s)}
className={FILTER_ITEM_CLASS}
>
<HoverCheck checked={checked} />
@ -412,7 +403,7 @@ export function IssuesHeader() {
<DropdownMenuCheckboxItem
key={p}
checked={checked}
onCheckedChange={() => togglePriorityFilter(p)}
onCheckedChange={() => act.togglePriorityFilter(p)}
className={FILTER_ITEM_CLASS}
>
<HoverCheck checked={checked} />
@ -444,10 +435,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>
@ -468,7 +459,7 @@ export function IssuesHeader() {
<ActorSubContent
counts={counts.creator}
selected={creatorFilters}
onToggle={toggleCreatorFilter}
onToggle={act.toggleCreatorFilter}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>
@ -477,7 +468,7 @@ export function IssuesHeader() {
{hasActiveFilters && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={clearFilters}>
<DropdownMenuItem onClick={act.clearFilters}>
Reset all filters
</DropdownMenuItem>
</>
@ -518,7 +509,7 @@ export function IssuesHeader() {
{SORT_OPTIONS.map((opt) => (
<DropdownMenuItem
key={opt.value}
onClick={() => setSortBy(opt.value)}
onClick={() => act.setSortBy(opt.value)}
>
{opt.label}
</DropdownMenuItem>
@ -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() {
<Switch
size="sm"
checked={cardProperties[opt.key]}
onCheckedChange={() => toggleCardProperty(opt.key)}
onCheckedChange={() => act.toggleCardProperty(opt.key)}
/>
</label>
))}

View file

@ -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>
);
}

View file

@ -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);