Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions packages/web/src/components/InteractiveGraphVisualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,17 @@ interface InteractiveGraphVisualizationProps {
/** Notifies the host (Workspace) which node is selected, so a docked
* inspector can show its contents/diagram. Fires null on deselect. */
onNodeSelected?: (node: WorkItem | null) => void;
/** When true (phone "locked" mode), node + edge-label dragging is disabled so
* a touch gesture pans the canvas instead of accidentally moving things. */
interactionLocked?: boolean;
}

export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: InteractiveGraphVisualizationProps = {}) {
export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, interactionLocked }: InteractiveGraphVisualizationProps = {}) {
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Live-readable lock flag for d3 drag filters (avoids re-binding handlers on toggle).
const interactionLockedRef = useRef<boolean>(!!interactionLocked);
useEffect(() => { interactionLockedRef.current = !!interactionLocked; }, [interactionLocked]);
const { currentGraph, availableGraphs, descendInto } = useGraph();
// descendInto from context isn't memoized; hold the latest in a ref so the
// D3-bound node click handler can call it without re-binding every render.
Expand Down Expand Up @@ -2282,8 +2288,10 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }:
.attr('class', (d: WorkItem) => `node node-type-${d.type.toLowerCase()}`)
.style('cursor', 'pointer')
.call(d3.drag<any, any>()
// Locked (phone) → reject the drag so the gesture pans the canvas instead.
.filter((event: any) => !interactionLockedRef.current && !event.ctrlKey && !event.button)
.on('start', (event, d: any) => {

// Check if this is an edge creation attempt (Alt/Option key held)
if (event.sourceEvent.altKey) {
mousedownNodeRef.current = d;
Expand Down Expand Up @@ -3686,6 +3694,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }:
edgeLabelGroups
.style('cursor', 'grab')
.call(d3.drag<any, any>()
// Locked (phone) → reject so the gesture pans instead of sliding the label.
.filter((event: any) => !interactionLockedRef.current && !event.ctrlKey && !event.button)
.on('start', (event) => {
event.sourceEvent?.stopPropagation();
})
Expand Down
5 changes: 3 additions & 2 deletions packages/web/src/components/SafeGraphVisualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,17 @@ import type { WorkItem } from '../types/graph';
*/
interface SafeGraphVisualizationProps {
onNodeSelected?: (node: WorkItem | null) => void;
interactionLocked?: boolean;
}

export function SafeGraphVisualization({ onNodeSelected }: SafeGraphVisualizationProps = {}) {
export function SafeGraphVisualization({ onNodeSelected, interactionLocked }: SafeGraphVisualizationProps = {}) {
return (
<GraphErrorBoundary
onError={() => {
// Error logged by boundary for debugging
}}
>
<InteractiveGraphVisualization onNodeSelected={onNodeSelected} />
<InteractiveGraphVisualization onNodeSelected={onNodeSelected} interactionLocked={interactionLocked} />
</GraphErrorBoundary>
);
}
118 changes: 113 additions & 5 deletions packages/web/src/pages/Workspace.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Plus, Share2, Users, Table, Activity, Network, CreditCard, Columns, CalendarDays, GanttChartSquare, LayoutDashboard, Database, AlertTriangle, Map, X, Minimize2, Edit3, Trash2, FolderPlus, ChevronLeft, ChevronRight } from 'lucide-react';
import { Plus, Share2, Users, Table, Activity, Network, CreditCard, Columns, CalendarDays, GanttChartSquare, LayoutDashboard, Database, AlertTriangle, Map, X, Minimize2, Edit3, Trash2, FolderPlus, ChevronLeft, ChevronRight, MoreHorizontal, Lock, Unlock } from 'lucide-react';
import { createPortal } from 'react-dom';
import { useQuery } from '@apollo/client';
import { SafeGraphVisualization } from '../components/SafeGraphVisualization';
Expand Down Expand Up @@ -35,6 +35,12 @@ export function Workspace() {
const [isMobile, setIsMobile] = useState(() =>
typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches
);
const [showMoreSheet, setShowMoreSheet] = useState(false);
// Graph touch-interaction lock — on a phone, default to locked so panning the
// canvas never accidentally drags a node or an edge label; unlock to edit.
const [graphLocked, setGraphLocked] = useState(() =>
typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches
);
const [showMiniMap, setShowMiniMap] = useState(true);
const { currentGraph, availableGraphs, getBreadcrumb, ascendTo } = useGraph();
const breadcrumb = getBreadcrumb();
Expand Down Expand Up @@ -124,9 +130,9 @@ export function Workspace() {
</div>
</div>

{/* Center Section: View Mode Buttons — horizontally scrollable on mobile so
all eight stay reachable without clipping on a phone-width screen. */}
<div className="flex justify-start sm:justify-center lg:order-2 overflow-x-auto no-scrollbar -mx-3 px-3 sm:mx-0 sm:px-0">
{/* Center Section: View Mode Buttons — desktop/tablet only. Phones use the
bottom tab bar (List / Graph / More) instead of this strip. */}
<div className="hidden md:flex justify-start sm:justify-center lg:order-2 overflow-x-auto no-scrollbar -mx-3 px-3 sm:mx-0 sm:px-0">
<div className="flex flex-nowrap bg-gray-700/50 backdrop-blur-sm rounded-lg p-1.5 sm:p-2 gap-1 border border-gray-600/50">
<button
onClick={() => setViewMode('graph')}
Expand Down Expand Up @@ -425,8 +431,23 @@ export function Workspace() {
</div>
</div>
)}
<SafeGraphVisualization onNodeSelected={setInspectorNode} />
<SafeGraphVisualization onNodeSelected={setInspectorNode} interactionLocked={isMobile && graphLocked} />
</div>
{/* Lock toggle (phones): when locked, touch pans the canvas and never
drags a node or edge label; unlock to rearrange/edit. */}
<button
data-testid="graph-lock-toggle"
onClick={() => setGraphLocked((v) => !v)}
className={`md:hidden absolute top-3 right-3 z-40 flex items-center gap-1.5 px-3 py-2 rounded-full shadow-lg backdrop-blur-sm border text-xs font-medium transition-colors ${
graphLocked
? 'bg-gray-900/90 border-gray-600 text-gray-200'
: 'bg-green-600/90 border-green-400 text-white'
}`}
title={graphLocked ? 'Locked — tap to edit' : 'Editing — tap to lock'}
>
{graphLocked ? <Lock className="h-4 w-4" /> : <Unlock className="h-4 w-4" />}
{graphLocked ? 'Locked' : 'Editing'}
</button>
{inspectorNode && (
<div className="absolute z-40 inset-x-3 bottom-3 md:inset-x-auto md:bottom-auto md:top-3 md:right-3">
<NodeInspector node={inspectorNode} onClose={() => setInspectorNode(null)} />
Expand All @@ -438,6 +459,93 @@ export function Workspace() {
)}
</div>

{/* Mobile bottom navigation — the primary way to move between views on a phone.
List first, then Graph; the rest live in the More sheet. */}
{currentGraph && (
<nav data-testid="mobile-bottom-nav" className="md:hidden flex-shrink-0 flex items-stretch border-t border-gray-700/60 bg-gray-900/95 backdrop-blur-md pb-safe">
{([
{ mode: 'cards', label: 'List', Icon: CreditCard },
{ mode: 'graph', label: 'Graph', Icon: Network },
] as const).map(({ mode, label, Icon }) => (
<button
key={mode}
onClick={() => setViewMode(mode)}
className={`flex-1 flex flex-col items-center justify-center gap-0.5 py-2 min-h-[3.25rem] transition-colors ${
viewMode === mode ? 'text-green-400' : 'text-gray-400 hover:text-gray-200'
}`}
>
<Icon className="h-5 w-5" strokeWidth={1.75} />
<span className="text-[11px] font-medium">{label}</span>
</button>
))}
<button
onClick={() => setShowMoreSheet(true)}
className={`flex-1 flex flex-col items-center justify-center gap-0.5 py-2 min-h-[3.25rem] transition-colors ${
!['cards', 'graph'].includes(viewMode) ? 'text-green-400' : 'text-gray-400 hover:text-gray-200'
}`}
>
<MoreHorizontal className="h-5 w-5" strokeWidth={1.75} />
<span className="text-[11px] font-medium">More</span>
</button>
</nav>
)}

{/* Create FAB (phones) — primary action; hidden for read-only guests and in
graph view (where the on-canvas "+" grow flow creates nodes). */}
{currentGraph && currentUser?.role !== 'GUEST' && viewMode !== 'graph' && (
<button
onClick={() => setShowCreateModal(true)}
className="md:hidden fixed right-4 bottom-[4.75rem] z-40 w-14 h-14 rounded-full bg-gradient-to-br from-green-600 to-blue-600 text-white shadow-xl flex items-center justify-center active:scale-95 transition-transform"
style={{ marginBottom: 'env(safe-area-inset-bottom)' }}
aria-label="New work item"
title="New work item"
>
<Plus className="h-7 w-7" />
</button>
)}

{/* "More" views sheet (phones) */}
{showMoreSheet && createPortal(
<div
className="md:hidden fixed inset-0 z-[100] flex flex-col justify-end"
onClick={() => setShowMoreSheet(false)}
>
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
<div
data-testid="mobile-more-sheet"
className="relative bg-gray-900 border-t border-gray-700 rounded-t-2xl p-4 pb-[calc(1rem+env(safe-area-inset-bottom))] shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="mx-auto mb-3 h-1 w-10 rounded-full bg-gray-600" />
<h3 className="text-sm font-semibold text-gray-200 mb-3 px-1">More views</h3>
<div className="grid grid-cols-3 gap-2">
{([
{ mode: 'dashboard', label: 'Dashboard', Icon: LayoutDashboard },
{ mode: 'table', label: 'Table', Icon: Table },
{ mode: 'kanban', label: 'Board', Icon: Columns },
{ mode: 'gantt', label: 'Gantt', Icon: GanttChartSquare },
{ mode: 'calendar', label: 'Calendar', Icon: CalendarDays },
{ mode: 'activity', label: 'Activity', Icon: Activity },
] as const).map(({ mode, label, Icon }) => (
<button
key={mode}
onClick={() => { setViewMode(mode); setShowMoreSheet(false); }}
className={`flex flex-col items-center justify-center gap-1.5 py-4 rounded-xl border transition-colors ${
viewMode === mode
? 'bg-green-600/20 border-green-500/40 text-green-300'
: 'bg-gray-800/60 border-gray-700/60 text-gray-300 active:bg-gray-700'
}`}
>
<Icon className="h-6 w-6" strokeWidth={1.5} />
<span className="text-xs font-medium">{label}</span>
</button>
))}
</div>
</div>
</div>,
document.body
)}

{/* Mini-Map Navigation - Bottom Right Corner (hidden on mobile: it covers too
much of a phone screen, and panning the graph by touch is the primary nav there) */}
{viewMode === 'graph' && currentGraph && showMiniMap && !isMobile && createPortal(
Expand Down
38 changes: 23 additions & 15 deletions tests/e2e/mobile-experience.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,29 +37,37 @@ test.describe('mobile experience @mobile', () => {
expect(overflow, 'no horizontal page overflow on a phone').toBeLessThanOrEqual(1);
});

test('all eight view tabs stay reachable and the graph view is mobile-clean', async ({ page }) => {
test('bottom nav (List/Graph/More) drives the views and the graph is mobile-clean', async ({ page }) => {
await login(page, TEST_USERS.ADMIN);
await page.waitForTimeout(1500);

// Every view tab must exist and be clickable (the strip scrolls, never clips).
const tabTitles = [
'Graph View', 'Dashboard View', 'Table View', 'Card View',
'Kanban View', 'Gantt Chart', 'Calendar View', 'Activity Feed',
];
for (const title of tabTitles) {
const tab = page.locator(`button[title="${title}"]`);
await expect(tab, `${title} tab present`).toHaveCount(1);
// The phone uses a bottom tab bar, not the desktop tab strip.
const nav = page.getByTestId('mobile-bottom-nav');
await expect(nav, 'mobile bottom nav present').toBeVisible();
await expect(nav.getByText('List', { exact: true })).toBeVisible();
await expect(nav.getByText('Graph', { exact: true })).toBeVisible();
await expect(nav.getByText('More', { exact: true })).toBeVisible();

// More opens a sheet with the secondary views (kanban shows as "Board").
await nav.getByText('More', { exact: true }).click();
const sheet = page.getByTestId('mobile-more-sheet');
for (const label of ['Dashboard', 'Table', 'Board', 'Gantt', 'Calendar', 'Activity']) {
await expect(sheet.getByText(label, { exact: true }), `${label} in More sheet`).toBeVisible();
}
await page.keyboard.press('Escape').catch(() => {});
await page.mouse.click(10, 200); // dismiss the sheet

// Switch to the graph view and verify the mobile-clean treatment.
const graphTab = page.locator('button[title="Graph View"]');
await graphTab.scrollIntoViewIfNeeded();
await graphTab.click();
// Switch to the graph via the bottom nav and verify the mobile-clean treatment.
await nav.getByText('Graph', { exact: true }).click();
await page.waitForTimeout(3000);

// Minimap is hidden on mobile (it covered ~18% of the screen).
// The lock toggle defaults to "Locked" so panning never drags a node/edge.
const lock = page.getByTestId('graph-lock-toggle');
await expect(lock, 'graph lock toggle present on mobile').toBeVisible();
await expect(lock).toContainText('Locked');

// Minimap is hidden on mobile; no connection banner while GraphQL is healthy.
await expect(page.getByText('Mini-Map', { exact: true })).toHaveCount(0);
// No connection banner while the GraphQL data layer is healthy.
await expect(page.getByText('Connection Lost', { exact: true })).toHaveCount(0);
});
});
40 changes: 21 additions & 19 deletions tests/e2e/mobile-views.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,31 +10,33 @@ import { login, TEST_USERS } from '../helpers/auth';
*/
test.use({ viewport: { width: 390, height: 844 } });

const VIEWS = [
{ name: 'Dashboard', tab: 'Dashboard View' },
{ name: 'Table', tab: 'Table View' },
{ name: 'Card', tab: 'Card View' },
{ name: 'Kanban', tab: 'Kanban View' },
{ name: 'Gantt', tab: 'Gantt Chart' },
{ name: 'Calendar', tab: 'Calendar View' },
{ name: 'Activity', tab: 'Activity Feed' },
];
const VIEWS = ['Dashboard', 'Table', 'Card', 'Kanban', 'Gantt', 'Calendar', 'Activity'];

async function openView(page: Page, tab: string) {
const t = page.locator(`button[title="${tab}"]`);
await t.scrollIntoViewIfNeeded();
await t.click();
// Navigate via the mobile bottom nav (List/Graph) + More sheet — the top tab
// strip is desktop-only now.
async function openView(page: Page, name: string) {
const nav = page.getByTestId('mobile-bottom-nav');
if (name === 'Card') {
await nav.getByText('List', { exact: true }).click();
} else if (name === 'Graph') {
await nav.getByText('Graph', { exact: true }).click();
} else {
await nav.getByText('More', { exact: true }).click();
const sheet = page.getByTestId('mobile-more-sheet');
const label = name === 'Kanban' ? 'Board' : name;
await sheet.getByText(label, { exact: true }).click();
}
await page.waitForTimeout(2000);
}

test.describe('mobile views are explorable by scrolling down, not sideways @mobile', () => {
for (const v of VIEWS) {
test(`${v.name} view needs no horizontal scrolling on a phone`, async ({ page }) => {
for (const name of VIEWS) {
test(`${name} view needs no horizontal scrolling on a phone`, async ({ page }) => {
const pageErrors: string[] = [];
page.on('pageerror', (e) => pageErrors.push(e.message));

await login(page, TEST_USERS.ADMIN);
await openView(page, v.tab);
await openView(page, name);

const probe = await page.evaluate(() => {
const el = document.querySelector('[data-testid="view-content"]');
Expand Down Expand Up @@ -69,15 +71,15 @@ test.describe('mobile views are explorable by scrolling down, not sideways @mobi
expect(probe.docOverflow, 'page itself must not overflow sideways').toBeLessThanOrEqual(1);
expect(
probe.offenders,
`${v.name}: these elements force horizontal scrolling on a phone`
`${name}: these elements force horizontal scrolling on a phone`
).toEqual([]);
expect(pageErrors, `${v.name}: no uncaught JS errors`).toEqual([]);
expect(pageErrors, `${name}: no uncaught JS errors`).toEqual([]);
});
}

test('Calendar defaults to the agenda list on a phone (not the cramped month grid)', async ({ page }) => {
await login(page, TEST_USERS.ADMIN);
await openView(page, 'Calendar View');
await openView(page, 'Calendar');
const agendaActive = await page.evaluate(() => {
const btn = [...document.querySelectorAll('button')].find(
(b) => (b.textContent || '').trim() === 'Agenda'
Expand Down
Loading