dashboards: drive list selection from a ?selected= url param, not a global
the /dashboards list row was a <div onClick> that set SELECTED_DASHBOARD_KEY (a global) with no url change on-page, so selection wasn't deep-linkable and the row wasn't a real link. selection now lives in the url as ?selected=<id>, read by the list, the screen (mobile panel), and the preview. rows are real <Link>s (edit/delete kept as sibling buttons, not nested in the anchor); the preview + its "open" link are unchanged. edit-save and delete update/clear the param. NOT runtime-tested yet (server not restarted) — verify the /dashboards preview, create/edit/delete, and mobile panel flows on next restart. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,16 @@
|
|||||||
import type { LayoutNode } from 'officerdev';
|
import type { LayoutNode } from 'officerdev';
|
||||||
import { WorkspaceView, SELECTED_DASHBOARD_KEY } from 'officerdev';
|
import { useSearchParams } from 'react-router';
|
||||||
|
import { WorkspaceView } from 'officerdev';
|
||||||
import { useIsMobile } from 'hooks/useIsMobile';
|
import { useIsMobile } from 'hooks/useIsMobile';
|
||||||
import { useGlobal } from 'hooks/useGlobal';
|
|
||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
import { defaultLayout } from './defaultLayout';
|
import { defaultLayout } from './defaultLayout';
|
||||||
|
|
||||||
export const DashboardsScreen = () => {
|
export const DashboardsScreen = () => {
|
||||||
const workspace = useDashboardState<LayoutNode>('screens/dashboards', defaultLayout);
|
const workspace = useDashboardState<LayoutNode>('screens/dashboards', defaultLayout);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [selected, setSelected] = useGlobal<string | null>(SELECTED_DASHBOARD_KEY, null);
|
// Selection lives in the URL (?selected=<id>); on mobile it drives which panel is shown.
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const selected = searchParams.get('selected');
|
||||||
const mobilePanelId = isMobile && selected ? 'ws-home-right' : undefined;
|
const mobilePanelId = isMobile && selected ? 'ws-home-right' : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -18,7 +20,14 @@ export const DashboardsScreen = () => {
|
|||||||
locked
|
locked
|
||||||
mobilePanelId={mobilePanelId}
|
mobilePanelId={mobilePanelId}
|
||||||
onMobilePanelChange={(id) => {
|
onMobilePanelChange={(id) => {
|
||||||
if (!id) setSelected(null);
|
if (!id)
|
||||||
|
setSearchParams(
|
||||||
|
(p) => {
|
||||||
|
p.delete('selected');
|
||||||
|
return p;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, useLocation, useNavigate } from 'react-router';
|
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||||
import { LayoutGrid, Plus, Pencil, Trash2, Search } from 'lucide-react';
|
import { LayoutGrid, Plus, Pencil, Trash2, Search } from 'lucide-react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useGlobal } from 'hooks/useGlobal';
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import {
|
import {
|
||||||
SELECTED_DASHBOARD_KEY,
|
|
||||||
CREATING_DASHBOARD_KEY,
|
CREATING_DASHBOARD_KEY,
|
||||||
EDITING_DASHBOARD_KEY,
|
EDITING_DASHBOARD_KEY,
|
||||||
NEW_DASH_NAME_KEY,
|
NEW_DASH_NAME_KEY,
|
||||||
@@ -33,7 +32,17 @@ export const DashboardListApp = () => {
|
|||||||
const client = useClient();
|
const client = useClient();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { value: dashboards, setValue: setDashboards } = useDashboardState<DashboardDefinition[]>('workspaces', []);
|
const { value: dashboards, setValue: setDashboards } = useDashboardState<DashboardDefinition[]>('workspaces', []);
|
||||||
const [selected, setSelected] = useGlobal<string | null>(SELECTED_DASHBOARD_KEY, null);
|
// Selection is URL state now (?selected=<id>), not a global — so it's deep-linkable and the rows are real links.
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const selected = searchParams.get('selected');
|
||||||
|
const clearSelected = () =>
|
||||||
|
setSearchParams(
|
||||||
|
(p) => {
|
||||||
|
p.delete('selected');
|
||||||
|
return p;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
const [, setCreating] = useGlobal<boolean>(CREATING_DASHBOARD_KEY, false);
|
const [, setCreating] = useGlobal<boolean>(CREATING_DASHBOARD_KEY, false);
|
||||||
const [, setEditing] = useGlobal<string | null>(EDITING_DASHBOARD_KEY, null);
|
const [, setEditing] = useGlobal<string | null>(EDITING_DASHBOARD_KEY, null);
|
||||||
const [, setName] = useGlobal<string>(NEW_DASH_NAME_KEY, '');
|
const [, setName] = useGlobal<string>(NEW_DASH_NAME_KEY, '');
|
||||||
@@ -53,7 +62,7 @@ export const DashboardListApp = () => {
|
|||||||
|
|
||||||
const handleEdit = (ev: React.MouseEvent, ws: DashboardDefinition) => {
|
const handleEdit = (ev: React.MouseEvent, ws: DashboardDefinition) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
setSelected(null);
|
clearSelected();
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
setEditing(ws.id);
|
setEditing(ws.id);
|
||||||
setName(ws.name);
|
setName(ws.name);
|
||||||
@@ -71,7 +80,7 @@ export const DashboardListApp = () => {
|
|||||||
const confirmDelete = () => {
|
const confirmDelete = () => {
|
||||||
if (!deleting) return;
|
if (!deleting) return;
|
||||||
setDashboards((prev) => prev.filter((w) => w.id !== deleting.id));
|
setDashboards((prev) => prev.filter((w) => w.id !== deleting.id));
|
||||||
if (selected === deleting.id) setSelected(null);
|
if (selected === deleting.id) clearSelected();
|
||||||
|
|
||||||
const layoutKey = `ws-layout-${deleting.id}`;
|
const layoutKey = `ws-layout-${deleting.id}`;
|
||||||
const currentState = queryClient.getQueryData<Record<string, unknown>>(['DASHBOARD_STATE']) ?? {};
|
const currentState = queryClient.getQueryData<Record<string, unknown>>(['DASHBOARD_STATE']) ?? {};
|
||||||
@@ -81,16 +90,6 @@ export const DashboardListApp = () => {
|
|||||||
setDeleting(null);
|
setDeleting(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = (ws: DashboardDefinition) => {
|
|
||||||
if (isDashboardsPage) {
|
|
||||||
setSelected(ws.id);
|
|
||||||
setCreating(false);
|
|
||||||
setEditing(null);
|
|
||||||
} else {
|
|
||||||
navigate(`/dashboards/${ws.id}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-y-auto">
|
<div className="flex flex-col h-full overflow-y-auto">
|
||||||
<div className="p-3 pb-0 flex flex-col gap-2">
|
<div className="p-3 pb-0 flex flex-col gap-2">
|
||||||
@@ -104,7 +103,7 @@ export const DashboardListApp = () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelected(null);
|
clearSelected();
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
setName(generateSlug());
|
setName(generateSlug());
|
||||||
setDescription('');
|
setDescription('');
|
||||||
@@ -133,17 +132,21 @@ export const DashboardListApp = () => {
|
|||||||
{filtered.map((ws) => (
|
{filtered.map((ws) => (
|
||||||
<div
|
<div
|
||||||
key={ws.id}
|
key={ws.id}
|
||||||
className={`flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium cursor-pointer group ${
|
className={`flex items-center rounded-lg text-sm font-medium group ${
|
||||||
isDashboardsPage && selected === ws.id
|
isDashboardsPage && selected === ws.id
|
||||||
? 'bg-duck-teal/10 text-duck-teal'
|
? 'bg-duck-teal/10 text-duck-teal'
|
||||||
: 'text-duck-dark/80 dark:text-white/80 hover:bg-duck-dark/5'
|
: 'text-duck-dark/80 dark:text-white/80 hover:bg-duck-dark/5'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleClick(ws)}
|
|
||||||
>
|
>
|
||||||
<LayoutGrid className="h-4 w-4 shrink-0" />
|
<Link
|
||||||
<span className="flex-1 text-left truncate">{ws.name}</span>
|
to={isDashboardsPage ? `/dashboards?selected=${ws.id}` : `/dashboards/${ws.id}`}
|
||||||
|
className="flex flex-1 items-center gap-2.5 py-2 px-3 min-w-0 cursor-pointer"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="flex-1 text-left truncate">{ws.name}</span>
|
||||||
|
</Link>
|
||||||
{isDashboardsPage && (
|
{isDashboardsPage && (
|
||||||
<>
|
<div className="flex shrink-0 items-center pr-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(ev) => handleEdit(ev, ws)}
|
onClick={(ev) => handleEdit(ev, ws)}
|
||||||
@@ -158,18 +161,21 @@ export const DashboardListApp = () => {
|
|||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<p className="text-xs text-gray-500 px-3 py-4 text-center">
|
<p className="text-xs text-gray-500 px-3 py-4 text-center">{search ? 'No matches' : 'No dashboards yet'}</p>
|
||||||
{search ? 'No matches' : 'No dashboards yet'}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AlertDialog open={deleting !== null} onOpenChange={(open) => { if (!open) setDeleting(null); }}>
|
<AlertDialog
|
||||||
|
open={deleting !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setDeleting(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete dashboard</AlertDialogTitle>
|
<AlertDialogTitle>Delete dashboard</AlertDialogTitle>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router';
|
import { Link, useNavigate, useSearchParams } from 'react-router';
|
||||||
import { FolderOpen, LayoutGrid, Plus, ArrowRight, Type, Rocket, FileText, Layout } from 'lucide-react';
|
import { FolderOpen, LayoutGrid, Plus, ArrowRight, Type, Rocket, FileText, Layout } from 'lucide-react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useGlobal } from 'hooks/useGlobal';
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
@@ -13,7 +13,6 @@ import { generateSlug, slugify } from 'helpers/slug';
|
|||||||
import { FileBrowserApp } from '../FileBrowser';
|
import { FileBrowserApp } from '../FileBrowser';
|
||||||
import { useAppRegistry } from '../../AppRegistry';
|
import { useAppRegistry } from '../../AppRegistry';
|
||||||
import {
|
import {
|
||||||
SELECTED_DASHBOARD_KEY,
|
|
||||||
CREATING_DASHBOARD_KEY,
|
CREATING_DASHBOARD_KEY,
|
||||||
EDITING_DASHBOARD_KEY,
|
EDITING_DASHBOARD_KEY,
|
||||||
NEW_DASH_NAME_KEY,
|
NEW_DASH_NAME_KEY,
|
||||||
@@ -221,7 +220,10 @@ const tplPanelLayout: LayoutNode = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const tplRegistry = Object.fromEntries(
|
const tplRegistry = Object.fromEntries(
|
||||||
templates.map((tpl, i) => [`tpl-${i}`, { name: tpl.name, icon: Layout, component: () => <TemplateCell index={i} /> }]),
|
templates.map((tpl, i) => [
|
||||||
|
`tpl-${i}`,
|
||||||
|
{ name: tpl.name, icon: Layout, component: () => <TemplateCell index={i} /> },
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const TemplatePanel = () => (
|
const TemplatePanel = () => (
|
||||||
@@ -284,7 +286,7 @@ const CreatePanel = () => {
|
|||||||
const [templateIdx, setTemplateIdx] = useGlobal<number>(NEW_DASH_TEMPLATE_KEY, 0);
|
const [templateIdx, setTemplateIdx] = useGlobal<number>(NEW_DASH_TEMPLATE_KEY, 0);
|
||||||
const [editingId, setEditingId] = useGlobal<string | null>(EDITING_DASHBOARD_KEY, null);
|
const [editingId, setEditingId] = useGlobal<string | null>(EDITING_DASHBOARD_KEY, null);
|
||||||
const [, setCreating] = useGlobal<boolean>(CREATING_DASHBOARD_KEY, false);
|
const [, setCreating] = useGlobal<boolean>(CREATING_DASHBOARD_KEY, false);
|
||||||
const [, setSelected] = useGlobal<string | null>(SELECTED_DASHBOARD_KEY, null);
|
const [, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const isEditing = !!editingId;
|
const isEditing = !!editingId;
|
||||||
|
|
||||||
@@ -302,9 +304,7 @@ const CreatePanel = () => {
|
|||||||
|
|
||||||
setDashboards((prev) =>
|
setDashboards((prev) =>
|
||||||
prev.map((ws) =>
|
prev.map((ws) =>
|
||||||
ws.id === editingId
|
ws.id === editingId ? { ...ws, id: newId, name: trimmed, description: desc || undefined, templateIdx } : ws,
|
||||||
? { ...ws, id: newId, name: trimmed, description: desc || undefined, templateIdx }
|
|
||||||
: ws,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -327,7 +327,13 @@ const CreatePanel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setSelected(newId);
|
setSearchParams(
|
||||||
|
(p) => {
|
||||||
|
p.set('selected', newId);
|
||||||
|
return p;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const existingIds = new Set(dashboards.map((w) => w.id));
|
const existingIds = new Set(dashboards.map((w) => w.id));
|
||||||
let id = slugify(trimmed) || generateSlug();
|
let id = slugify(trimmed) || generateSlug();
|
||||||
@@ -373,11 +379,7 @@ const CreatePanel = () => {
|
|||||||
{isEditing ? 'Update Dashboard' : 'Create Dashboard'}
|
{isEditing ? 'Update Dashboard' : 'Create Dashboard'}
|
||||||
</Button>
|
</Button>
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<Button
|
<Button onClick={() => navigate(`/dashboards/${editingId}`)} variant="outline" className="cursor-pointer">
|
||||||
onClick={() => navigate(`/dashboards/${editingId}`)}
|
|
||||||
variant="outline"
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
<ArrowRight className="h-4 w-4 mr-1" />
|
<ArrowRight className="h-4 w-4 mr-1" />
|
||||||
Open Dashboard
|
Open Dashboard
|
||||||
</Button>
|
</Button>
|
||||||
@@ -487,7 +489,8 @@ const DashboardPreviewInner = ({ dashboard }: { dashboard: DashboardDefinition }
|
|||||||
// --- Main Export ---
|
// --- Main Export ---
|
||||||
|
|
||||||
export const DashboardPreview = () => {
|
export const DashboardPreview = () => {
|
||||||
const [selectedId] = useGlobal<string | null>(SELECTED_DASHBOARD_KEY, null);
|
const [searchParams] = useSearchParams();
|
||||||
|
const selectedId = searchParams.get('selected');
|
||||||
const { value: dashboards } = useDashboardState<DashboardDefinition[]>('workspaces', []);
|
const { value: dashboards } = useDashboardState<DashboardDefinition[]>('workspaces', []);
|
||||||
const dashboard = selectedId ? dashboards.find((ws) => ws.id === selectedId) : null;
|
const dashboard = selectedId ? dashboards.find((ws) => ws.id === selectedId) : null;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user