Merge pull request #293 from multica-ai/feature/issues-scope-tabs
feat(web): add scope tabs to Issues and My Issues pages
This commit is contained in:
commit
ae1c05af60
8 changed files with 347 additions and 199 deletions
|
|
@ -339,23 +339,26 @@ describe("IssuesPage", () => {
|
||||||
expect(screen.getByText("Issues")).toBeInTheDocument();
|
expect(screen.getByText("Issues")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows 'New Issue' button", () => {
|
it("shows scope buttons", () => {
|
||||||
mockStoreState.loading = false;
|
mockStoreState.loading = false;
|
||||||
mockStoreState.issues = [];
|
mockStoreState.issues = [];
|
||||||
|
|
||||||
render(<IssuesPage />);
|
render(<IssuesPage />);
|
||||||
|
|
||||||
expect(screen.getByText("New Issue")).toBeInTheDocument();
|
expect(screen.getByText("All")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Members")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Agents")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows filter buttons", () => {
|
it("shows filter and display icon buttons", () => {
|
||||||
mockStoreState.loading = false;
|
mockStoreState.loading = false;
|
||||||
mockStoreState.issues = mockIssues;
|
mockStoreState.issues = mockIssues;
|
||||||
|
|
||||||
render(<IssuesPage />);
|
render(<IssuesPage />);
|
||||||
|
|
||||||
expect(screen.getByText("Filter")).toBeInTheDocument();
|
// Filter and Display are now icon-only buttons, verify they render as buttons
|
||||||
expect(screen.getByText("Display")).toBeInTheDocument();
|
const buttons = screen.getAllByRole("button");
|
||||||
|
expect(buttons.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows empty board view when no issues exist", () => {
|
it("shows empty board view when no issues exist", () => {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import {
|
||||||
Columns3,
|
Columns3,
|
||||||
Filter,
|
Filter,
|
||||||
List,
|
List,
|
||||||
Plus,
|
|
||||||
SignalHigh,
|
SignalHigh,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
User,
|
User,
|
||||||
|
|
@ -18,7 +17,6 @@ import {
|
||||||
UserPen,
|
UserPen,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useIssueStore } from "@/features/issues/store";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
|
|
@ -38,7 +36,6 @@ import {
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { useModalStore } from "@/features/modals";
|
|
||||||
import {
|
import {
|
||||||
ALL_STATUSES,
|
ALL_STATUSES,
|
||||||
STATUS_CONFIG,
|
STATUS_CONFIG,
|
||||||
|
|
@ -54,13 +51,16 @@ import {
|
||||||
CARD_PROPERTY_OPTIONS,
|
CARD_PROPERTY_OPTIONS,
|
||||||
type ActorFilterValue,
|
type ActorFilterValue,
|
||||||
} from "@/features/issues/stores/view-store";
|
} from "@/features/issues/stores/view-store";
|
||||||
|
import {
|
||||||
|
useIssuesScopeStore,
|
||||||
|
type IssuesScope,
|
||||||
|
} from "@/features/issues/stores/issues-scope-store";
|
||||||
import { filterIssues } from "@/features/issues/utils/filter";
|
import { filterIssues } from "@/features/issues/utils/filter";
|
||||||
|
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||||
import type { Issue } from "@/shared/types";
|
import type { Issue } from "@/shared/types";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// HoverCheck — shadcn official pattern (PR #6862)
|
// HoverCheck — shadcn official pattern (PR #6862)
|
||||||
// Uses data-selected attr instead of Checkbox component to avoid
|
|
||||||
// DropdownMenuCheckboxItem's focus:**:text-accent-foreground cascade.
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const FILTER_ITEM_CLASS =
|
const FILTER_ITEM_CLASS =
|
||||||
|
|
@ -123,6 +123,16 @@ function useIssueCounts(allIssues: Issue[]) {
|
||||||
}, [allIssues]);
|
}, [allIssues]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scope config
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SCOPES: { value: IssuesScope; label: string; description: string }[] = [
|
||||||
|
{ value: "all", label: "All", description: "All issues in this workspace" },
|
||||||
|
{ value: "members", label: "Members", description: "Issues assigned to team members" },
|
||||||
|
{ value: "agents", label: "Agents", description: "Issues assigned to AI agents" },
|
||||||
|
];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Actor sub-menu content (shared between Assignee and Creator)
|
// Actor sub-menu content (shared between Assignee and Creator)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -262,7 +272,10 @@ function ActorSubContent({
|
||||||
// IssuesHeader
|
// IssuesHeader
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function IssuesHeader() {
|
export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
|
||||||
|
const scope = useIssuesScopeStore((s) => s.scope);
|
||||||
|
const setScope = useIssuesScopeStore((s) => s.setScope);
|
||||||
|
|
||||||
const viewMode = useIssueViewStore((s) => s.viewMode);
|
const viewMode = useIssueViewStore((s) => s.viewMode);
|
||||||
const statusFilters = useIssueViewStore((s) => s.statusFilters);
|
const statusFilters = useIssueViewStore((s) => s.statusFilters);
|
||||||
const priorityFilters = useIssueViewStore((s) => s.priorityFilters);
|
const priorityFilters = useIssueViewStore((s) => s.priorityFilters);
|
||||||
|
|
@ -274,74 +287,69 @@ export function IssuesHeader() {
|
||||||
const cardProperties = useIssueViewStore((s) => s.cardProperties);
|
const cardProperties = useIssueViewStore((s) => s.cardProperties);
|
||||||
const act = useIssueViewStore.getState();
|
const act = useIssueViewStore.getState();
|
||||||
|
|
||||||
const allIssues = useIssueStore((s) => s.issues);
|
const counts = useIssueCounts(scopedIssues);
|
||||||
const counts = useIssueCounts(allIssues);
|
|
||||||
|
|
||||||
const filteredCount = useMemo(
|
const hasActiveFilters =
|
||||||
() => filterIssues(allIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }).length,
|
getActiveFilterCount({
|
||||||
[allIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters],
|
statusFilters,
|
||||||
);
|
priorityFilters,
|
||||||
|
assigneeFilters,
|
||||||
const filterCount = getActiveFilterCount({
|
includeNoAssignee,
|
||||||
statusFilters,
|
creatorFilters,
|
||||||
priorityFilters,
|
}) > 0;
|
||||||
assigneeFilters,
|
|
||||||
includeNoAssignee,
|
|
||||||
creatorFilters,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortLabel =
|
const sortLabel =
|
||||||
SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? "Manual";
|
SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? "Manual";
|
||||||
const hasActiveFilters = filterCount > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-12 shrink-0 items-center justify-between px-4">
|
<div className="flex h-12 shrink-0 items-center justify-between px-4">
|
||||||
<div className="flex items-center gap-2">
|
{/* Left: scope buttons */}
|
||||||
{/* View toggle */}
|
<div className="flex items-center gap-1">
|
||||||
<DropdownMenu>
|
{SCOPES.map((s) => (
|
||||||
<DropdownMenuTrigger
|
<Tooltip key={s.value}>
|
||||||
render={
|
<TooltipTrigger
|
||||||
<Button variant="outline" size="sm">
|
render={
|
||||||
{viewMode === "board" ? <Columns3 className="size-3.5" /> : <List className="size-3.5" />}
|
<Button
|
||||||
{viewMode === "board" ? "Board" : "List"}
|
variant="outline"
|
||||||
</Button>
|
size="sm"
|
||||||
}
|
className={
|
||||||
/>
|
scope === s.value
|
||||||
<DropdownMenuContent align="start" className="w-auto">
|
? "bg-accent text-accent-foreground hover:bg-accent/80"
|
||||||
<DropdownMenuGroup>
|
: "text-muted-foreground"
|
||||||
<DropdownMenuLabel>View</DropdownMenuLabel>
|
}
|
||||||
<DropdownMenuItem onClick={() => act.setViewMode("board")}>
|
onClick={() => setScope(s.value)}
|
||||||
<Columns3 />
|
>
|
||||||
Board
|
{s.label}
|
||||||
</DropdownMenuItem>
|
</Button>
|
||||||
<DropdownMenuItem onClick={() => act.setViewMode("list")}>
|
}
|
||||||
<List />
|
/>
|
||||||
List
|
<TooltipContent side="bottom">{s.description}</TooltipContent>
|
||||||
</DropdownMenuItem>
|
</Tooltip>
|
||||||
</DropdownMenuGroup>
|
))}
|
||||||
</DropdownMenuContent>
|
</div>
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
{/* Filter — DropdownMenu with sub-menus */}
|
{/* Right: filter + display + view toggle */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{/* Filter */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<Tooltip>
|
||||||
render={
|
<DropdownMenuTrigger
|
||||||
<Button
|
render={
|
||||||
variant="outline"
|
<TooltipTrigger
|
||||||
size="sm"
|
render={
|
||||||
className={hasActiveFilters ? "border-primary/50 text-primary" : ""}
|
<Button variant="outline" size="icon-sm" className="relative">
|
||||||
>
|
<Filter className="size-4" />
|
||||||
<Filter className="size-3.5" />
|
{hasActiveFilters && (
|
||||||
Filter
|
<span className="absolute top-0 right-0 size-1.5 rounded-full bg-brand" />
|
||||||
{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">
|
</Button>
|
||||||
{filterCount}
|
}
|
||||||
</span>
|
/>
|
||||||
)}
|
}
|
||||||
</Button>
|
/>
|
||||||
}
|
<TooltipContent side="bottom">Filter</TooltipContent>
|
||||||
/>
|
</Tooltip>
|
||||||
<DropdownMenuContent align="start" className="w-auto">
|
<DropdownMenuContent align="end" className="w-auto">
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger>
|
<DropdownMenuSubTrigger>
|
||||||
|
|
@ -472,15 +480,21 @@ export function IssuesHeader() {
|
||||||
|
|
||||||
{/* Display settings */}
|
{/* Display settings */}
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger
|
<Tooltip>
|
||||||
render={
|
<PopoverTrigger
|
||||||
<Button variant="outline" size="sm">
|
render={
|
||||||
<SlidersHorizontal className="size-3.5" />
|
<TooltipTrigger
|
||||||
Display
|
render={
|
||||||
</Button>
|
<Button variant="outline" size="icon-sm">
|
||||||
}
|
<SlidersHorizontal className="size-4" />
|
||||||
/>
|
</Button>
|
||||||
<PopoverContent align="start" className="w-64 p-0">
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent side="bottom">Display settings</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<PopoverContent align="end" className="w-64 p-0">
|
||||||
<div className="border-b px-3 py-2.5">
|
<div className="border-b px-3 py-2.5">
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
Ordering
|
Ordering
|
||||||
|
|
@ -549,19 +563,43 @@ export function IssuesHeader() {
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
{/* View toggle */}
|
||||||
<span className="text-xs text-muted-foreground">
|
<DropdownMenu>
|
||||||
{filteredCount} {filteredCount === 1 ? "Issue" : "Issues"}
|
<Tooltip>
|
||||||
</span>
|
<DropdownMenuTrigger
|
||||||
<Button
|
render={
|
||||||
size="sm"
|
<TooltipTrigger
|
||||||
onClick={() => useModalStore.getState().open("create-issue")}
|
render={
|
||||||
>
|
<Button variant="outline" size="icon-sm">
|
||||||
<Plus />
|
{viewMode === "board" ? (
|
||||||
New Issue
|
<Columns3 className="size-4" />
|
||||||
</Button>
|
) : (
|
||||||
|
<List className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
{viewMode === "board" ? "Board view" : "List view"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<DropdownMenuContent align="end" 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type { IssueStatus } from "@/shared/types";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useIssueStore } from "@/features/issues/store";
|
import { useIssueStore } from "@/features/issues/store";
|
||||||
import { useIssueViewStore, initFilterWorkspaceSync } from "@/features/issues/stores/view-store";
|
import { useIssueViewStore, initFilterWorkspaceSync } from "@/features/issues/stores/view-store";
|
||||||
|
import { useIssuesScopeStore } from "@/features/issues/stores/issues-scope-store";
|
||||||
import { ViewStoreProvider } from "@/features/issues/stores/view-store-context";
|
import { ViewStoreProvider } from "@/features/issues/stores/view-store-context";
|
||||||
import { filterIssues } from "@/features/issues/utils/filter";
|
import { filterIssues } from "@/features/issues/utils/filter";
|
||||||
import { BOARD_STATUSES } from "@/features/issues/config";
|
import { BOARD_STATUSES } from "@/features/issues/config";
|
||||||
|
|
@ -23,6 +24,7 @@ export function IssuesPage() {
|
||||||
const allIssues = useIssueStore((s) => s.issues);
|
const allIssues = useIssueStore((s) => s.issues);
|
||||||
const loading = useIssueStore((s) => s.loading);
|
const loading = useIssueStore((s) => s.loading);
|
||||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||||
|
const scope = useIssuesScopeStore((s) => s.scope);
|
||||||
const viewMode = useIssueViewStore((s) => s.viewMode);
|
const viewMode = useIssueViewStore((s) => s.viewMode);
|
||||||
const statusFilters = useIssueViewStore((s) => s.statusFilters);
|
const statusFilters = useIssueViewStore((s) => s.statusFilters);
|
||||||
const priorityFilters = useIssueViewStore((s) => s.priorityFilters);
|
const priorityFilters = useIssueViewStore((s) => s.priorityFilters);
|
||||||
|
|
@ -36,11 +38,20 @@ export function IssuesPage() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
useIssueSelectionStore.getState().clear();
|
useIssueSelectionStore.getState().clear();
|
||||||
}, [viewMode]);
|
}, [viewMode, scope]);
|
||||||
|
|
||||||
|
// Scope pre-filter: narrow by assignee type
|
||||||
|
const scopedIssues = useMemo(() => {
|
||||||
|
if (scope === "members")
|
||||||
|
return allIssues.filter((i) => i.assignee_type === "member");
|
||||||
|
if (scope === "agents")
|
||||||
|
return allIssues.filter((i) => i.assignee_type === "agent");
|
||||||
|
return allIssues;
|
||||||
|
}, [allIssues, scope]);
|
||||||
|
|
||||||
const issues = useMemo(
|
const issues = useMemo(
|
||||||
() => filterIssues(allIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }),
|
() => filterIssues(scopedIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }),
|
||||||
[allIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters],
|
[scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters],
|
||||||
);
|
);
|
||||||
|
|
||||||
const visibleStatuses = useMemo(() => {
|
const visibleStatuses = useMemo(() => {
|
||||||
|
|
@ -115,8 +126,8 @@ export function IssuesPage() {
|
||||||
<span className="text-sm font-medium">Issues</span>
|
<span className="text-sm font-medium">Issues</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Header 2: View toggle + filters */}
|
{/* Header 2: Scope tabs + filters */}
|
||||||
<IssuesHeader />
|
<IssuesHeader scopedIssues={scopedIssues} />
|
||||||
|
|
||||||
{/* Content: scrollable */}
|
{/* Content: scrollable */}
|
||||||
<ViewStoreProvider store={useIssueViewStore}>
|
<ViewStoreProvider store={useIssueViewStore}>
|
||||||
|
|
@ -124,7 +135,7 @@ export function IssuesPage() {
|
||||||
{viewMode === "board" ? (
|
{viewMode === "board" ? (
|
||||||
<BoardView
|
<BoardView
|
||||||
issues={issues}
|
issues={issues}
|
||||||
allIssues={allIssues}
|
allIssues={scopedIssues}
|
||||||
visibleStatuses={visibleStatuses}
|
visibleStatuses={visibleStatuses}
|
||||||
hiddenStatuses={hiddenStatuses}
|
hiddenStatuses={hiddenStatuses}
|
||||||
onMoveIssue={handleMoveIssue}
|
onMoveIssue={handleMoveIssue}
|
||||||
|
|
|
||||||
21
apps/web/features/issues/stores/issues-scope-store.ts
Normal file
21
apps/web/features/issues/stores/issues-scope-store.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
export type IssuesScope = "all" | "members" | "agents";
|
||||||
|
|
||||||
|
interface IssuesScopeState {
|
||||||
|
scope: IssuesScope;
|
||||||
|
setScope: (scope: IssuesScope) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useIssuesScopeStore = create<IssuesScopeState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
scope: "all",
|
||||||
|
setScope: (scope) => set({ scope }),
|
||||||
|
}),
|
||||||
|
{ name: "multica_issues_scope" },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
@ -63,7 +63,7 @@ export interface IssueViewState {
|
||||||
toggleListCollapsed: (status: IssueStatus) => void;
|
toggleListCollapsed: (status: IssueStatus) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): IssueViewState => ({
|
export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): IssueViewState => ({
|
||||||
viewMode: "board",
|
viewMode: "board",
|
||||||
statusFilters: [],
|
statusFilters: [],
|
||||||
priorityFilters: [],
|
priorityFilters: [],
|
||||||
|
|
@ -162,7 +162,7 @@ const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): IssueViewSta
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
const viewStorePersistOptions = (name: string) => ({
|
export const viewStorePersistOptions = (name: string) => ({
|
||||||
name,
|
name,
|
||||||
partialize: (state: IssueViewState) => ({
|
partialize: (state: IssueViewState) => ({
|
||||||
viewMode: state.viewMode,
|
viewMode: state.viewMode,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import {
|
||||||
Columns3,
|
Columns3,
|
||||||
Filter,
|
Filter,
|
||||||
List,
|
List,
|
||||||
Plus,
|
|
||||||
SignalHigh,
|
SignalHigh,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
@ -35,7 +34,6 @@ import {
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { useModalStore } from "@/features/modals";
|
|
||||||
import {
|
import {
|
||||||
ALL_STATUSES,
|
ALL_STATUSES,
|
||||||
STATUS_CONFIG,
|
STATUS_CONFIG,
|
||||||
|
|
@ -48,11 +46,12 @@ import {
|
||||||
CARD_PROPERTY_OPTIONS,
|
CARD_PROPERTY_OPTIONS,
|
||||||
} from "@/features/issues/stores/view-store";
|
} from "@/features/issues/stores/view-store";
|
||||||
import { filterIssues } from "@/features/issues/utils/filter";
|
import { filterIssues } from "@/features/issues/utils/filter";
|
||||||
|
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||||
import type { Issue } from "@/shared/types";
|
import type { Issue } from "@/shared/types";
|
||||||
import { myIssuesViewStore } from "../stores/my-issues-view-store";
|
import { myIssuesViewStore, type MyIssuesScope } from "../stores/my-issues-view-store";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// HoverCheck — shadcn official pattern (PR #6862)
|
// HoverCheck
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const FILTER_ITEM_CLASS =
|
const FILTER_ITEM_CLASS =
|
||||||
|
|
@ -97,6 +96,16 @@ function useIssueCounts(allIssues: Issue[]) {
|
||||||
}, [allIssues]);
|
}, [allIssues]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scope config
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SCOPES: { value: MyIssuesScope; label: string; description: string }[] = [
|
||||||
|
{ value: "assigned", label: "Assigned", description: "Issues assigned to me" },
|
||||||
|
{ value: "created", label: "Created", description: "Issues I created" },
|
||||||
|
{ value: "agents", label: "My Agents", description: "Issues assigned to my agents" },
|
||||||
|
];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// MyIssuesHeader
|
// MyIssuesHeader
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -108,82 +117,66 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
|
||||||
const sortBy = useStore(myIssuesViewStore, (s) => s.sortBy);
|
const sortBy = useStore(myIssuesViewStore, (s) => s.sortBy);
|
||||||
const sortDirection = useStore(myIssuesViewStore, (s) => s.sortDirection);
|
const sortDirection = useStore(myIssuesViewStore, (s) => s.sortDirection);
|
||||||
const cardProperties = useStore(myIssuesViewStore, (s) => s.cardProperties);
|
const cardProperties = useStore(myIssuesViewStore, (s) => s.cardProperties);
|
||||||
|
const scope = useStore(myIssuesViewStore, (s) => s.scope);
|
||||||
const act = myIssuesViewStore.getState();
|
const act = myIssuesViewStore.getState();
|
||||||
|
|
||||||
const counts = useIssueCounts(allIssues);
|
const counts = useIssueCounts(allIssues);
|
||||||
|
|
||||||
const filteredCount = useMemo(
|
const hasActiveFilters =
|
||||||
() =>
|
getActiveFilterCount({ statusFilters, priorityFilters }) > 0;
|
||||||
filterIssues(allIssues, {
|
|
||||||
statusFilters,
|
|
||||||
priorityFilters,
|
|
||||||
assigneeFilters: [],
|
|
||||||
includeNoAssignee: false,
|
|
||||||
creatorFilters: [],
|
|
||||||
}).length,
|
|
||||||
[allIssues, statusFilters, priorityFilters],
|
|
||||||
);
|
|
||||||
|
|
||||||
const filterCount = getActiveFilterCount({ statusFilters, priorityFilters });
|
|
||||||
|
|
||||||
const sortLabel =
|
const sortLabel =
|
||||||
SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? "Manual";
|
SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? "Manual";
|
||||||
const hasActiveFilters = filterCount > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-12 shrink-0 items-center justify-between px-4">
|
<div className="flex h-12 shrink-0 items-center justify-between px-4">
|
||||||
<div className="flex items-center gap-2">
|
{/* Left: scope buttons */}
|
||||||
{/* View toggle */}
|
<div className="flex items-center gap-1">
|
||||||
<DropdownMenu>
|
{SCOPES.map((s) => (
|
||||||
<DropdownMenuTrigger
|
<Tooltip key={s.value}>
|
||||||
render={
|
<TooltipTrigger
|
||||||
<Button variant="outline" size="sm">
|
render={
|
||||||
{viewMode === "board" ? (
|
<Button
|
||||||
<Columns3 className="size-3.5" />
|
variant="outline"
|
||||||
) : (
|
size="sm"
|
||||||
<List className="size-3.5" />
|
className={
|
||||||
)}
|
scope === s.value
|
||||||
{viewMode === "board" ? "Board" : "List"}
|
? "bg-accent text-accent-foreground hover:bg-accent/80"
|
||||||
</Button>
|
: "text-muted-foreground"
|
||||||
}
|
}
|
||||||
/>
|
onClick={() => act.setScope(s.value)}
|
||||||
<DropdownMenuContent align="start" className="w-auto">
|
>
|
||||||
<DropdownMenuGroup>
|
{s.label}
|
||||||
<DropdownMenuLabel>View</DropdownMenuLabel>
|
</Button>
|
||||||
<DropdownMenuItem onClick={() => act.setViewMode("board")}>
|
}
|
||||||
<Columns3 />
|
/>
|
||||||
Board
|
<TooltipContent side="bottom">{s.description}</TooltipContent>
|
||||||
</DropdownMenuItem>
|
</Tooltip>
|
||||||
<DropdownMenuItem onClick={() => act.setViewMode("list")}>
|
))}
|
||||||
<List />
|
</div>
|
||||||
List
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
{/* Filter — DropdownMenu with sub-menus */}
|
{/* Right: filter + display + view toggle */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{/* Filter */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<Tooltip>
|
||||||
render={
|
<DropdownMenuTrigger
|
||||||
<Button
|
render={
|
||||||
variant="outline"
|
<TooltipTrigger
|
||||||
size="sm"
|
render={
|
||||||
className={
|
<Button variant="outline" size="icon-sm" className="relative">
|
||||||
hasActiveFilters ? "border-primary/50 text-primary" : ""
|
<Filter className="size-4" />
|
||||||
}
|
{hasActiveFilters && (
|
||||||
>
|
<span className="absolute top-0 right-0 size-1.5 rounded-full bg-brand" />
|
||||||
<Filter className="size-3.5" />
|
)}
|
||||||
Filter
|
</Button>
|
||||||
{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>
|
/>
|
||||||
)}
|
<TooltipContent side="bottom">Filter</TooltipContent>
|
||||||
</Button>
|
</Tooltip>
|
||||||
}
|
<DropdownMenuContent align="end" className="w-auto">
|
||||||
/>
|
|
||||||
<DropdownMenuContent align="start" className="w-auto">
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger>
|
<DropdownMenuSubTrigger>
|
||||||
|
|
@ -270,15 +263,21 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
|
||||||
|
|
||||||
{/* Display settings */}
|
{/* Display settings */}
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger
|
<Tooltip>
|
||||||
render={
|
<PopoverTrigger
|
||||||
<Button variant="outline" size="sm">
|
render={
|
||||||
<SlidersHorizontal className="size-3.5" />
|
<TooltipTrigger
|
||||||
Display
|
render={
|
||||||
</Button>
|
<Button variant="outline" size="icon-sm">
|
||||||
}
|
<SlidersHorizontal className="size-4" />
|
||||||
/>
|
</Button>
|
||||||
<PopoverContent align="start" className="w-64 p-0">
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent side="bottom">Display settings</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<PopoverContent align="end" className="w-64 p-0">
|
||||||
<div className="border-b px-3 py-2.5">
|
<div className="border-b px-3 py-2.5">
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
Ordering
|
Ordering
|
||||||
|
|
@ -349,19 +348,43 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
{/* View toggle */}
|
||||||
<span className="text-xs text-muted-foreground">
|
<DropdownMenu>
|
||||||
{filteredCount} {filteredCount === 1 ? "Issue" : "Issues"}
|
<Tooltip>
|
||||||
</span>
|
<DropdownMenuTrigger
|
||||||
<Button
|
render={
|
||||||
size="sm"
|
<TooltipTrigger
|
||||||
onClick={() => useModalStore.getState().open("create-issue")}
|
render={
|
||||||
>
|
<Button variant="outline" size="icon-sm">
|
||||||
<Plus />
|
{viewMode === "board" ? (
|
||||||
New Issue
|
<Columns3 className="size-4" />
|
||||||
</Button>
|
) : (
|
||||||
|
<List className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
{viewMode === "board" ? "Board view" : "List view"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<DropdownMenuContent align="end" 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ export function MyIssuesPage() {
|
||||||
const viewMode = useStore(myIssuesViewStore, (s) => s.viewMode);
|
const viewMode = useStore(myIssuesViewStore, (s) => s.viewMode);
|
||||||
const statusFilters = useStore(myIssuesViewStore, (s) => s.statusFilters);
|
const statusFilters = useStore(myIssuesViewStore, (s) => s.statusFilters);
|
||||||
const priorityFilters = useStore(myIssuesViewStore, (s) => s.priorityFilters);
|
const priorityFilters = useStore(myIssuesViewStore, (s) => s.priorityFilters);
|
||||||
|
const scope = useStore(myIssuesViewStore, (s) => s.scope);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerViewStoreForWorkspaceSync(myIssuesViewStore);
|
registerViewStoreForWorkspaceSync(myIssuesViewStore);
|
||||||
|
|
@ -38,7 +39,7 @@ export function MyIssuesPage() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
useIssueSelectionStore.getState().clear();
|
useIssueSelectionStore.getState().clear();
|
||||||
}, [viewMode]);
|
}, [viewMode, scope]);
|
||||||
|
|
||||||
const myAgentIds = useMemo(() => {
|
const myAgentIds = useMemo(() => {
|
||||||
if (!user) return new Set<string>();
|
if (!user) return new Set<string>();
|
||||||
|
|
@ -47,19 +48,40 @@ export function MyIssuesPage() {
|
||||||
);
|
);
|
||||||
}, [agents, user]);
|
}, [agents, user]);
|
||||||
|
|
||||||
// Pre-filter: union of (assigned to me + my agents + created by me)
|
// Per-scope issue lists
|
||||||
const myIssues = useMemo(() => {
|
const assignedToMe = useMemo(() => {
|
||||||
|
if (!user) return [];
|
||||||
|
return allIssues.filter(
|
||||||
|
(i) => i.assignee_type === "member" && i.assignee_id === user.id,
|
||||||
|
);
|
||||||
|
}, [allIssues, user]);
|
||||||
|
|
||||||
|
const myAgentIssues = useMemo(() => {
|
||||||
if (!user) return [];
|
if (!user) return [];
|
||||||
return allIssues.filter(
|
return allIssues.filter(
|
||||||
(i) =>
|
(i) =>
|
||||||
(i.assignee_type === "member" && i.assignee_id === user.id) ||
|
i.assignee_type === "agent" &&
|
||||||
(i.assignee_type === "agent" &&
|
i.assignee_id &&
|
||||||
i.assignee_id &&
|
myAgentIds.has(i.assignee_id),
|
||||||
myAgentIds.has(i.assignee_id)) ||
|
|
||||||
(i.creator_type === "member" && i.creator_id === user.id),
|
|
||||||
);
|
);
|
||||||
}, [allIssues, user, myAgentIds]);
|
}, [allIssues, user, myAgentIds]);
|
||||||
|
|
||||||
|
const createdByMe = useMemo(() => {
|
||||||
|
if (!user) return [];
|
||||||
|
return allIssues.filter(
|
||||||
|
(i) => i.creator_type === "member" && i.creator_id === user.id,
|
||||||
|
);
|
||||||
|
}, [allIssues, user]);
|
||||||
|
|
||||||
|
const myIssues = useMemo(() => {
|
||||||
|
switch (scope) {
|
||||||
|
case "assigned": return assignedToMe;
|
||||||
|
case "agents": return myAgentIssues;
|
||||||
|
case "created": return createdByMe;
|
||||||
|
default: return assignedToMe;
|
||||||
|
}
|
||||||
|
}, [scope, assignedToMe, myAgentIssues, createdByMe]);
|
||||||
|
|
||||||
// Apply status/priority filters from view store
|
// Apply status/priority filters from view store
|
||||||
const issues = useMemo(
|
const issues = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -144,7 +166,7 @@ export function MyIssuesPage() {
|
||||||
<span className="text-sm font-medium">My Issues</span>
|
<span className="text-sm font-medium">My Issues</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Header 2: View toggle + filters */}
|
{/* Header: scope tabs (left) + controls (right) */}
|
||||||
<MyIssuesHeader allIssues={myIssues} />
|
<MyIssuesHeader allIssues={myIssues} />
|
||||||
|
|
||||||
{/* Content: scrollable */}
|
{/* Content: scrollable */}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,35 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createIssueViewStore } from "@/features/issues/stores/view-store";
|
import { createStore, type StoreApi } from "zustand/vanilla";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
import {
|
||||||
|
type IssueViewState,
|
||||||
|
viewStoreSlice,
|
||||||
|
viewStorePersistOptions,
|
||||||
|
} from "@/features/issues/stores/view-store";
|
||||||
|
|
||||||
export const myIssuesViewStore = createIssueViewStore("multica_my_issues_view");
|
export type MyIssuesScope = "assigned" | "created" | "agents";
|
||||||
|
|
||||||
|
export interface MyIssuesViewState extends IssueViewState {
|
||||||
|
scope: MyIssuesScope;
|
||||||
|
setScope: (scope: MyIssuesScope) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePersist = viewStorePersistOptions("multica_my_issues_view");
|
||||||
|
|
||||||
|
export const myIssuesViewStore: StoreApi<MyIssuesViewState> = createStore<MyIssuesViewState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
...viewStoreSlice(set as unknown as StoreApi<IssueViewState>["setState"]),
|
||||||
|
scope: "assigned" as MyIssuesScope,
|
||||||
|
setScope: (scope: MyIssuesScope) => set({ scope }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: basePersist.name,
|
||||||
|
partialize: (state: MyIssuesViewState) => ({
|
||||||
|
...basePersist.partialize(state),
|
||||||
|
scope: state.scope,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue