feat(web): add scope tabs to Issues and My Issues pages
Redesign both Issues and My Issues headers with Linear-style layout: - Left: scope pill buttons (All/Members/Agents for Issues; Assigned/Created/My Agents for My Issues) - Right: compact icon buttons for Filter, Display, and View toggle - Selected scope has accent background, all buttons use consistent outline variant - Filter active indicator uses brand-colored dot - Tooltips on all buttons with 500ms global delay - Remove New Issue button and issue count from headers - Scope selection persisted to localStorage Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
808a0e9c38
commit
8719e2cedd
8 changed files with 347 additions and 199 deletions
|
|
@ -11,7 +11,6 @@ import {
|
|||
Columns3,
|
||||
Filter,
|
||||
List,
|
||||
Plus,
|
||||
SignalHigh,
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
|
|
@ -35,7 +34,6 @@ import {
|
|||
PopoverContent,
|
||||
} from "@/components/ui/popover";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useModalStore } from "@/features/modals";
|
||||
import {
|
||||
ALL_STATUSES,
|
||||
STATUS_CONFIG,
|
||||
|
|
@ -48,11 +46,12 @@ import {
|
|||
CARD_PROPERTY_OPTIONS,
|
||||
} from "@/features/issues/stores/view-store";
|
||||
import { filterIssues } from "@/features/issues/utils/filter";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
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 =
|
||||
|
|
@ -97,6 +96,16 @@ function useIssueCounts(allIssues: Issue[]) {
|
|||
}, [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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -108,82 +117,66 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
|
|||
const sortBy = useStore(myIssuesViewStore, (s) => s.sortBy);
|
||||
const sortDirection = useStore(myIssuesViewStore, (s) => s.sortDirection);
|
||||
const cardProperties = useStore(myIssuesViewStore, (s) => s.cardProperties);
|
||||
const scope = useStore(myIssuesViewStore, (s) => s.scope);
|
||||
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 hasActiveFilters =
|
||||
getActiveFilterCount({ statusFilters, priorityFilters }) > 0;
|
||||
|
||||
const sortLabel =
|
||||
SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? "Manual";
|
||||
const hasActiveFilters = filterCount > 0;
|
||||
|
||||
return (
|
||||
<div className="flex h-12 shrink-0 items-center justify-between px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View toggle */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm">
|
||||
{viewMode === "board" ? (
|
||||
<Columns3 className="size-3.5" />
|
||||
) : (
|
||||
<List className="size-3.5" />
|
||||
)}
|
||||
{viewMode === "board" ? "Board" : "List"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-auto">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>View</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => act.setViewMode("board")}>
|
||||
<Columns3 />
|
||||
Board
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => act.setViewMode("list")}>
|
||||
<List />
|
||||
List
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{/* Left: scope buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
{SCOPES.map((s) => (
|
||||
<Tooltip key={s.value}>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={
|
||||
scope === s.value
|
||||
? "bg-accent text-accent-foreground hover:bg-accent/80"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
onClick={() => act.setScope(s.value)}
|
||||
>
|
||||
{s.label}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">{s.description}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filter — DropdownMenu with sub-menus */}
|
||||
{/* Right: filter + display + view toggle */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={
|
||||
hasActiveFilters ? "border-primary/50 text-primary" : ""
|
||||
}
|
||||
>
|
||||
<Filter className="size-3.5" />
|
||||
Filter
|
||||
{hasActiveFilters && (
|
||||
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-xs font-medium text-primary-foreground">
|
||||
{filterCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-auto">
|
||||
<Tooltip>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button variant="outline" size="icon-sm" className="relative">
|
||||
<Filter className="size-4" />
|
||||
{hasActiveFilters && (
|
||||
<span className="absolute top-0 right-0 size-1.5 rounded-full bg-brand" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">Filter</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="w-auto">
|
||||
{/* Status */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
|
|
@ -270,15 +263,21 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
|
|||
|
||||
{/* Display settings */}
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm">
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
Display
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-64 p-0">
|
||||
<Tooltip>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button variant="outline" size="icon-sm">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">Display settings</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent align="end" className="w-64 p-0">
|
||||
<div className="border-b px-3 py-2.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Ordering
|
||||
|
|
@ -349,19 +348,43 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
|
|||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{filteredCount} {filteredCount === 1 ? "Issue" : "Issues"}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => useModalStore.getState().open("create-issue")}
|
||||
>
|
||||
<Plus />
|
||||
New Issue
|
||||
</Button>
|
||||
{/* View toggle */}
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button variant="outline" size="icon-sm">
|
||||
{viewMode === "board" ? (
|
||||
<Columns3 className="size-4" />
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export function MyIssuesPage() {
|
|||
const viewMode = useStore(myIssuesViewStore, (s) => s.viewMode);
|
||||
const statusFilters = useStore(myIssuesViewStore, (s) => s.statusFilters);
|
||||
const priorityFilters = useStore(myIssuesViewStore, (s) => s.priorityFilters);
|
||||
const scope = useStore(myIssuesViewStore, (s) => s.scope);
|
||||
|
||||
useEffect(() => {
|
||||
registerViewStoreForWorkspaceSync(myIssuesViewStore);
|
||||
|
|
@ -38,7 +39,7 @@ export function MyIssuesPage() {
|
|||
|
||||
useEffect(() => {
|
||||
useIssueSelectionStore.getState().clear();
|
||||
}, [viewMode]);
|
||||
}, [viewMode, scope]);
|
||||
|
||||
const myAgentIds = useMemo(() => {
|
||||
if (!user) return new Set<string>();
|
||||
|
|
@ -47,19 +48,40 @@ export function MyIssuesPage() {
|
|||
);
|
||||
}, [agents, user]);
|
||||
|
||||
// Pre-filter: union of (assigned to me + my agents + created by me)
|
||||
const myIssues = useMemo(() => {
|
||||
// Per-scope issue lists
|
||||
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 [];
|
||||
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),
|
||||
i.assignee_type === "agent" &&
|
||||
i.assignee_id &&
|
||||
myAgentIds.has(i.assignee_id),
|
||||
);
|
||||
}, [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
|
||||
const issues = useMemo(
|
||||
() =>
|
||||
|
|
@ -144,7 +166,7 @@ export function MyIssuesPage() {
|
|||
<span className="text-sm font-medium">My Issues</span>
|
||||
</div>
|
||||
|
||||
{/* Header 2: View toggle + filters */}
|
||||
{/* Header: scope tabs (left) + controls (right) */}
|
||||
<MyIssuesHeader allIssues={myIssues} />
|
||||
|
||||
{/* Content: scrollable */}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue