feat(tracker-web): SegmentedControl view toggle + board Tooltips (UX-12.1)
Replace the bespoke roadmap board/list toggle buttons (hardcoded blue-600) with the shared SegmentedControl, and wrap truncated board-card titles in Tooltip. Update the e2e toggle selector from button to radio for SegmentedControl. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
328e307212
commit
ddf25cf501
@ -215,8 +215,11 @@ pnpm build # final gate
|
||||
|
||||
## UX-12 — Detail & board richness (Tabs · Tooltip · Drawer · Timeline · rich-text)
|
||||
|
||||
- [ ] **12.1** `/dashboard` board↔list: use `SegmentedControl` (or `Tabs`) for the view toggle
|
||||
- [x] **12.1** `/dashboard` board↔list: use `SegmentedControl` (or `Tabs`) for the view toggle
|
||||
instead of bespoke buttons; `Tooltip` on truncated titles / status dots.
|
||||
(SegmentedControl now drives the roadmap board/list toggle — the app's only board↔list
|
||||
toggle — replacing the bespoke `blue-600` buttons; `Tooltip` wraps the truncated board
|
||||
card titles. e2e toggle selector updated button→radio. UX-12.1 verified: tc/lint/test 162 ✓/build/e2e 18 ✓)
|
||||
- [ ] **12.2** Item detail: move row/item actions into an `ActionMenu`, and render the item's
|
||||
activity/comment history with `Timeline`.
|
||||
- [ ] **12.3** _(stretch — needs HTML-capable description/comment storage)_ Swap the plain
|
||||
|
||||
@ -309,7 +309,8 @@ test.describe('Tracker — Public Roadmap', () => {
|
||||
test('can toggle between board and list view', async ({ page }) => {
|
||||
await page.goto('/roadmap');
|
||||
await expect(page.getByRole('heading', { name: 'Dark mode toggle' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'List', exact: true }).click();
|
||||
// The view toggle is a shared SegmentedControl (role=radio), UX-12.1.
|
||||
await page.getByRole('radio', { name: 'List', exact: true }).click();
|
||||
// List view still shows the items (rendered as rows, not a <table>)
|
||||
await expect(page.getByRole('heading', { name: 'Dark mode toggle' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Export to CSV' })).toBeVisible();
|
||||
|
||||
@ -3,6 +3,12 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { PageHeader } from '@bytelyst/dashboard-components';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/Primitives';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import { listItems, updateItemStatus, type TrackerItem } from '@/lib/tracker-client';
|
||||
|
||||
@ -56,88 +62,95 @@ export default function BoardPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Board"
|
||||
breadcrumbs={[{ label: 'Dashboard', href: '/dashboard' }, { label: 'Board' }]}
|
||||
/>
|
||||
<p className="-mt-4 text-sm text-muted-foreground">Kanban view of all items</p>
|
||||
<TooltipProvider>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Board"
|
||||
breadcrumbs={[{ label: 'Dashboard', href: '/dashboard' }, { label: 'Board' }]}
|
||||
/>
|
||||
<p className="-mt-4 text-sm text-muted-foreground">Kanban view of all items</p>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
{COLUMNS.map(col => {
|
||||
const colItems = items.filter(i => i.status === col.key);
|
||||
return (
|
||||
<div
|
||||
key={col.key}
|
||||
className={`rounded-xl border border-border border-t-4 ${col.color} bg-card p-3`}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">{col.label}</h3>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{colItems.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
{COLUMNS.map(col => {
|
||||
const colItems = items.filter(i => i.status === col.key);
|
||||
return (
|
||||
<div
|
||||
key={col.key}
|
||||
className={`rounded-xl border border-border border-t-4 ${col.color} bg-card p-3`}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">{col.label}</h3>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{colItems.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{colItems.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-lg border border-border bg-background p-3 shadow-sm transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${TYPE_DOT[item.type] || 'bg-gray-400'}`}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{item.type}</span>
|
||||
<span
|
||||
className={`ml-auto text-xs font-medium ${PRIORITY_LABEL[item.priority] || ''}`}
|
||||
>
|
||||
{item.priority}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/dashboard/items/${item.id}`}
|
||||
className="text-sm font-medium leading-tight hover:text-primary hover:underline"
|
||||
<div className="space-y-2">
|
||||
{colItems.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-lg border border-border bg-background p-3 shadow-sm transition-shadow hover:shadow-md"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{item.voteCount > 0 && <span>{item.voteCount} votes</span>}
|
||||
{item.commentCount > 0 && <span>{item.commentCount} comments</span>}
|
||||
</div>
|
||||
|
||||
{/* Quick status move */}
|
||||
<div className="mt-2 flex gap-1">
|
||||
{COLUMNS.filter(c => c.key !== item.status).map(c => (
|
||||
<button
|
||||
key={c.key}
|
||||
onClick={() => handleStatusChange(item.id, c.key)}
|
||||
className="rounded px-1.5 py-0.5 text-[10px] text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
title={`Move to ${c.label}`}
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${TYPE_DOT[item.type] || 'bg-gray-400'}`}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{item.type}</span>
|
||||
<span
|
||||
className={`ml-auto text-xs font-medium ${PRIORITY_LABEL[item.priority] || ''}`}
|
||||
>
|
||||
→ {c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{item.priority}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{colItems.length === 0 && (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">No items</div>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={`/dashboard/items/${item.id}`}
|
||||
className="block truncate text-sm font-medium leading-tight hover:text-primary hover:underline"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{item.title}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{item.voteCount > 0 && <span>{item.voteCount} votes</span>}
|
||||
{item.commentCount > 0 && <span>{item.commentCount} comments</span>}
|
||||
</div>
|
||||
|
||||
{/* Quick status move */}
|
||||
<div className="mt-2 flex gap-1">
|
||||
{COLUMNS.filter(c => c.key !== item.status).map(c => (
|
||||
<button
|
||||
key={c.key}
|
||||
onClick={() => handleStatusChange(item.id, c.key)}
|
||||
className="rounded px-1.5 py-0.5 text-[10px] text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
title={`Move to ${c.label}`}
|
||||
>
|
||||
→ {c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{colItems.length === 0 && (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">No items</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { SegmentedControl } from '@/components/ui/Primitives';
|
||||
import {
|
||||
getRoadmapItems,
|
||||
getRoadmapStats,
|
||||
@ -264,20 +265,15 @@ export default function RoadmapPage() {
|
||||
<option value="bug">Bugs</option>
|
||||
<option value="task">Tasks</option>
|
||||
</select>
|
||||
<div className="flex border border-slate-300 dark:border-slate-600 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setView('board')}
|
||||
className={`px-3 py-2 text-sm ${view === 'board' ? 'bg-blue-600 text-white' : 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300'}`}
|
||||
>
|
||||
Board
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('list')}
|
||||
className={`px-3 py-2 text-sm ${view === 'list' ? 'bg-blue-600 text-white' : 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300'}`}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
aria-label="Roadmap view"
|
||||
value={view}
|
||||
onValueChange={v => setView(v as 'board' | 'list')}
|
||||
options={[
|
||||
{ value: 'board', label: 'Board' },
|
||||
{ value: 'list', label: 'List' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user