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:
parent
fe963d672d
commit
3c5a3b5e6a
15 changed files with 737 additions and 305 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue