From 399f3081c60bf048d903447e0d01a539f56c6461 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Mon, 10 Aug 2026 11:06:42 -0400 Subject: [PATCH 01/12] feat(browse): add Properties/Cart drawer mode to useLayoutPrefs --- .../useLayoutPrefsDrawerMode.test.tsx | 49 +++++++++++++++++++ frontend/src/hooks/useLayoutPrefs.ts | 17 ++++++- 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 frontend/src/__tests__/componentTests/useLayoutPrefsDrawerMode.test.tsx diff --git a/frontend/src/__tests__/componentTests/useLayoutPrefsDrawerMode.test.tsx b/frontend/src/__tests__/componentTests/useLayoutPrefsDrawerMode.test.tsx new file mode 100644 index 000000000..2ae7e7ace --- /dev/null +++ b/frontend/src/__tests__/componentTests/useLayoutPrefsDrawerMode.test.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +vi.mock('@/contexts/PreferencesContext', () => ({ + usePreferencesContext: () => ({ + layout: '', + handleUpdateLayout: vi.fn().mockResolvedValue(undefined), + preferenceQuery: { isPending: false } + }) +})); +vi.mock('@/contexts/ServerHealthContext', () => ({ + useServerHealthContext: () => ({ status: 'up' }) +})); + +import useLayoutPrefs from '@/hooks/useLayoutPrefs'; + +describe('useLayoutPrefs drawer mode', () => { + beforeEach(() => { + // layout==='' on a wide screen opens the drawer in the init effect. + window.innerWidth = 1200; + }); + + it('defaults to properties mode', () => { + const { result } = renderHook(() => useLayoutPrefs()); + expect(result.current.propertiesDrawerMode).toBe('properties'); + }); + + it('selectDrawerMode opens the drawer and sets the mode', () => { + const { result } = renderHook(() => useLayoutPrefs()); + act(() => result.current.selectDrawerMode('cart')); + expect(result.current.showPropertiesDrawer).toBe(true); + expect(result.current.propertiesDrawerMode).toBe('cart'); + }); + + it('selecting the already-open mode closes the drawer', () => { + const { result } = renderHook(() => useLayoutPrefs()); + act(() => result.current.selectDrawerMode('cart')); // open in cart + act(() => result.current.selectDrawerMode('cart')); // toggle closed + expect(result.current.showPropertiesDrawer).toBe(false); + }); + + it('switching mode while open keeps it open', () => { + const { result } = renderHook(() => useLayoutPrefs()); + act(() => result.current.selectDrawerMode('cart')); + act(() => result.current.selectDrawerMode('properties')); + expect(result.current.showPropertiesDrawer).toBe(true); + expect(result.current.propertiesDrawerMode).toBe('properties'); + }); +}); diff --git a/frontend/src/hooks/useLayoutPrefs.ts b/frontend/src/hooks/useLayoutPrefs.ts index 7ade99609..ff303a809 100644 --- a/frontend/src/hooks/useLayoutPrefs.ts +++ b/frontend/src/hooks/useLayoutPrefs.ts @@ -23,6 +23,9 @@ const DEBOUNCE_MS = 500; export default function useLayoutPrefs() { const [showPropertiesDrawer, setShowPropertiesDrawer] = useState(false); + const [propertiesDrawerMode, setPropertiesDrawerMode] = useState< + 'properties' | 'cart' + >('properties'); const [showSidebar, setShowSidebar] = useState(true); const { layout, handleUpdateLayout, preferenceQuery } = usePreferencesContext(); @@ -62,6 +65,16 @@ export default function useLayoutPrefs() { setShowSidebar(prev => !prev); }; + const selectDrawerMode = (mode: 'properties' | 'cart') => { + if (showPropertiesDrawer && propertiesDrawerMode === mode) { + setShowPropertiesDrawer(false); + } else { + setPropertiesDrawerMode(mode); + setShowPropertiesDrawer(true); + } + }; + // ponytail: mode is ephemeral (resets to 'properties' on reload). Persisting it would touch the layout-preference schema for a cosmetic default — skip until asked. + // Initialize layouts from saved preferences (only once on mount) useEffect(() => { if (preferenceQuery.isPending || hasInitializedRef.current) { @@ -249,6 +262,8 @@ export default function useLayoutPrefs() { showPropertiesDrawer, togglePropertiesDrawer, showSidebar, - toggleSidebar + toggleSidebar, + propertiesDrawerMode, + selectDrawerMode }; } From 476f9020c7feafbf3a6efbd0020b2bd0a60c28f5 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Mon, 10 Aug 2026 11:11:49 -0400 Subject: [PATCH 02/12] refactor(views): extract CartList and add ?tab=cart deep-link --- .../componentTests/CartList.test.tsx | 49 ++++++ .../__tests__/componentTests/CartTab.test.tsx | 5 +- .../__tests__/componentTests/NGViews.test.tsx | 21 +-- frontend/src/components/NGViews.tsx | 151 ++---------------- frontend/src/components/ui/Views/CartList.tsx | 101 ++++++++++++ 5 files changed, 164 insertions(+), 163 deletions(-) create mode 100644 frontend/src/__tests__/componentTests/CartList.test.tsx create mode 100644 frontend/src/components/ui/Views/CartList.tsx diff --git a/frontend/src/__tests__/componentTests/CartList.test.tsx b/frontend/src/__tests__/componentTests/CartList.test.tsx new file mode 100644 index 000000000..e0dbe1478 --- /dev/null +++ b/frontend/src/__tests__/componentTests/CartList.test.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import type { CartItem } from '@/contexts/CartContext'; + +const cartA: CartItem = { fsp_name: 'f', path: '/a', label: 'Dataset A' }; +const cartB: CartItem = { fsp_name: 'f', path: '/b', label: 'Dataset B' }; + +let cart: CartItem[] = []; +vi.mock('@/contexts/CartContext', () => ({ + useCartContext: () => ({ + cart, + clearCart: vi.fn().mockResolvedValue(undefined) + }) +})); +vi.mock('@/queries/proxiedPathQueries', () => ({ + useAllProxiedPathsQuery: () => ({ data: [] }) +})); +vi.mock('@/components/ui/Views/CartDatasetRow', () => ({ + default: ({ label }: { label: string }) => ( +
{label}
+ ) +})); +vi.mock('@/components/ui/Views/CreateViewButton', () => ({ + default: ({ label }: { label?: string }) => ( + + ) +})); + +import CartList from '@/components/ui/Views/CartList'; + +describe('CartList', () => { + it('shows the empty state when the cart is empty', () => { + cart = []; + render(); + expect(screen.getByText(/your layer cart is empty/i)).toBeInTheDocument(); + }); + + it('renders one row per dataset plus the footer actions', () => { + cart = [cartA, cartB]; + render(); + expect(screen.getAllByTestId('row')).toHaveLength(2); + expect( + screen.getByRole('button', { name: /create view/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /clear cart/i }) + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/componentTests/CartTab.test.tsx b/frontend/src/__tests__/componentTests/CartTab.test.tsx index 99ca29017..408f0fa11 100644 --- a/frontend/src/__tests__/componentTests/CartTab.test.tsx +++ b/frontend/src/__tests__/componentTests/CartTab.test.tsx @@ -112,7 +112,7 @@ vi.mock('@/components/ui/Views/CreateViewButton', () => ({ ) })); -import NGViews from '@/components/NGViews'; +import CartList from '@/components/ui/Views/CartList'; beforeEach(() => { addToCart.mockClear(); @@ -127,10 +127,9 @@ async function renderCartTab() { const user = userEvent.setup(); render( - + ); - await user.click(screen.getByRole('button', { name: /layer cart/i })); return user; } diff --git a/frontend/src/__tests__/componentTests/NGViews.test.tsx b/frontend/src/__tests__/componentTests/NGViews.test.tsx index 1d6dd42f8..dfa2ccc04 100644 --- a/frontend/src/__tests__/componentTests/NGViews.test.tsx +++ b/frontend/src/__tests__/componentTests/NGViews.test.tsx @@ -1,6 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router'; import type { View } from '@/queries/viewQueries'; @@ -48,29 +47,13 @@ vi.mock('@/components/ui/Views/CreateViewButton', () => ({ import NGViews from '@/components/NGViews'; describe('NGViews page', () => { - it('shows Saved Views and Layer Cart tabs, with the seeded view listed', () => { + it('lists the saved views', () => { render( ); - expect( - screen.getByRole('button', { name: /saved views/i }) - ).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: /layer cart/i }) - ).toBeInTheDocument(); + expect(screen.getByText('Neuroglancer Views')).toBeInTheDocument(); expect(screen.getByText('Seeded View')).toBeInTheDocument(); }); - - it('switches to the Layer Cart tab and shows the cart item', async () => { - const user = userEvent.setup(); - render( - - - - ); - await user.click(screen.getByRole('button', { name: /layer cart/i })); - expect(screen.getByText('a')).toBeInTheDocument(); // cart item label - }); }); diff --git a/frontend/src/components/NGViews.tsx b/frontend/src/components/NGViews.tsx index e66f39660..76704dd7b 100644 --- a/frontend/src/components/NGViews.tsx +++ b/frontend/src/components/NGViews.tsx @@ -1,83 +1,21 @@ -import { useMemo, useState } from 'react'; +import { useState } from 'react'; import { Typography } from '@material-tailwind/react'; import toast from 'react-hot-toast'; import { TableCard } from '@/components/ui/Table/TableCard'; import { useNGViewsColumns } from '@/components/ui/Table/ngViewsColumns'; import FgDialog from '@/components/ui/Dialogs/FgDialog'; -import CartDatasetRow from '@/components/ui/Views/CartDatasetRow'; -import CreateViewButton from '@/components/ui/Views/CreateViewButton'; import FgButton from '@/components/designSystem/atoms/FgButton'; -import FgBadge from '@/components/designSystem/atoms/FgBadge'; import FgInput from '@/components/designSystem/atoms/formElements/FgInput'; import { useViewsContext } from '@/contexts/ViewsContext'; -import { useCartContext } from '@/contexts/CartContext'; import { useDefaultNeuroglancerBaseUrl } from '@/hooks/useDefaultNeuroglancerBaseUrl'; -import { useAllProxiedPathsQuery } from '@/queries/proxiedPathQueries'; -import { datasetKey } from '@/utils/pathHandling'; import type { View } from '@/queries/viewQueries'; -import type { CartItem } from '@/contexts/CartContext'; - -type ViewsTab = 'views' | 'cart'; - -type CartGroup = { - fsp_name: string; - path: string; - label: string; - items: CartItem[]; -}; - -function groupCartByDataset(cart: CartItem[]): CartGroup[] { - const groups = new Map(); - for (const item of cart) { - const key = datasetKey(item.fsp_name, item.path); - const existing = groups.get(key); - if (existing) { - existing.items.push(item); - // Prefer the base (no-channel) entry's label for the dataset row. - if (!item.channel) { - existing.label = item.label; - } - } else { - // A channel-only entry's label is the channel name (e.g. "DAPI"), not - // the dataset name - fall back to the path until/unless a base entry - // shows up, rather than letting a channel string become the header. - groups.set(key, { - fsp_name: item.fsp_name, - path: item.path, - label: item.channel ? item.path : item.label, - items: [item] - }); - } - } - return Array.from(groups.values()); -} export default function NGViews() { const { allViewsQuery, updateViewMutation, deleteViewMutation } = useViewsContext(); - const { cart, cartCount, clearCart } = useCartContext(); - const allProxiedPathsQuery = useAllProxiedPathsQuery(); const baseUrl = useDefaultNeuroglancerBaseUrl(); - const cartGroups = useMemo(() => groupCartByDataset(cart), [cart]); - const dataLinkUrlByDataset = useMemo(() => { - const map = new Map(); - for (const p of allProxiedPathsQuery.data ?? []) { - map.set(datasetKey(p.fsp_name, p.path), p.url); - } - return map; - }, [allProxiedPathsQuery.data]); - - const handleClearCart = async () => { - try { - await clearCart(); - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Clear failed'); - } - }; - - const [tab, setTab] = useState('views'); const [renameItem, setRenameItem] = useState(undefined); const [renameValue, setRenameValue] = useState(''); const [deleteItem, setDeleteItem] = useState(undefined); @@ -119,13 +57,6 @@ export default function NGViews() { const columns = useNGViewsColumns(handleOpenRename, setDeleteItem, baseUrl); - const tabClass = (active: boolean) => - `flex items-center gap-2 px-4 py-2 border-b-2 ${ - active - ? 'border-primary text-foreground font-semibold' - : 'border-transparent text-foreground/70' - }`; - return ( <>
@@ -133,79 +64,17 @@ export default function NGViews() { Neuroglancer Views - Saved Neuroglancer Views and your working Layer Cart. + Your saved Neuroglancer Views. - {/* ponytail: local-state tab bar, not the AppsLayout resizable rail — - two tabs don't need panels. */} -
- - -
- - {tab === 'views' ? ( - - ) : ( -
- {cart.length === 0 ? ( - - Your Layer Cart is empty. Add datasets from the file browser. - - ) : ( - <> - {cartGroups.map(group => ( - - ))} -
- - void handleClearCart()} - variant="ghost" - > - Clear cart - -
- - )} -
- )} +
{renameItem ? ( diff --git a/frontend/src/components/ui/Views/CartList.tsx b/frontend/src/components/ui/Views/CartList.tsx new file mode 100644 index 000000000..76d76e96a --- /dev/null +++ b/frontend/src/components/ui/Views/CartList.tsx @@ -0,0 +1,101 @@ +import { useMemo } from 'react'; +import { Typography } from '@material-tailwind/react'; +import toast from 'react-hot-toast'; + +import CartDatasetRow from '@/components/ui/Views/CartDatasetRow'; +import CreateViewButton from '@/components/ui/Views/CreateViewButton'; +import FgButton from '@/components/designSystem/atoms/FgButton'; +import { useCartContext } from '@/contexts/CartContext'; +import { useAllProxiedPathsQuery } from '@/queries/proxiedPathQueries'; +import { datasetKey } from '@/utils/pathHandling'; +import type { CartItem } from '@/contexts/CartContext'; + +type CartGroup = { + fsp_name: string; + path: string; + label: string; + items: CartItem[]; +}; + +function groupCartByDataset(cart: CartItem[]): CartGroup[] { + const groups = new Map(); + for (const item of cart) { + const key = datasetKey(item.fsp_name, item.path); + const existing = groups.get(key); + if (existing) { + existing.items.push(item); + // Prefer the base (no-channel) entry's label for the dataset row. + if (!item.channel) { + existing.label = item.label; + } + } else { + // A channel-only entry's label is the channel name (e.g. "DAPI"), not + // the dataset name - fall back to the path until/unless a base entry + // shows up, rather than letting a channel string become the header. + groups.set(key, { + fsp_name: item.fsp_name, + path: item.path, + label: item.channel ? item.path : item.label, + items: [item] + }); + } + } + return Array.from(groups.values()); +} + +export default function CartList() { + const { cart, clearCart } = useCartContext(); + const allProxiedPathsQuery = useAllProxiedPathsQuery(); + + const cartGroups = useMemo(() => groupCartByDataset(cart), [cart]); + const dataLinkUrlByDataset = useMemo(() => { + const map = new Map(); + for (const p of allProxiedPathsQuery.data ?? []) { + map.set(datasetKey(p.fsp_name, p.path), p.url); + } + return map; + }, [allProxiedPathsQuery.data]); + + const handleClearCart = async () => { + try { + await clearCart(); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Clear failed'); + } + }; + + if (cart.length === 0) { + return ( + + Your Layer Cart is empty. Add datasets from the file browser. + + ); + } + + return ( +
+ {cartGroups.map(group => ( + + ))} +
+ + void handleClearCart()} variant="ghost"> + Clear cart + +
+
+ ); +} From 2173ce95764db8ef7250ffd3f6aec0ee5ae3bd7b Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Mon, 10 Aug 2026 11:19:48 -0400 Subject: [PATCH 03/12] feat(browse): right-edge Properties/Cart rail with cart drawer mode --- .../componentTests/BrowseRightRail.test.tsx | 28 + .../ui/BrowsePage/BrowseRightRail.tsx | 60 ++ .../ui/PropertiesDrawer/PropertiesDrawer.tsx | 536 ++++++++++-------- frontend/src/layouts/BrowseLayout.tsx | 133 +++-- 4 files changed, 444 insertions(+), 313 deletions(-) create mode 100644 frontend/src/__tests__/componentTests/BrowseRightRail.test.tsx create mode 100644 frontend/src/components/ui/BrowsePage/BrowseRightRail.tsx diff --git a/frontend/src/__tests__/componentTests/BrowseRightRail.test.tsx b/frontend/src/__tests__/componentTests/BrowseRightRail.test.tsx new file mode 100644 index 000000000..db2538613 --- /dev/null +++ b/frontend/src/__tests__/componentTests/BrowseRightRail.test.tsx @@ -0,0 +1,28 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +vi.mock('@/hooks/useCartCount', () => ({ useCartCount: () => 3 })); + +import BrowseRightRail from '@/components/ui/BrowsePage/BrowseRightRail'; + +describe('BrowseRightRail', () => { + it('shows the cart count badge', () => { + render( + + ); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('calls onSelect with the clicked mode', async () => { + const onSelect = vi.fn(); + const user = userEvent.setup(); + render( + + ); + await user.click(screen.getByRole('button', { name: /layer cart/i })); + expect(onSelect).toHaveBeenCalledWith('cart'); + await user.click(screen.getByRole('button', { name: /properties/i })); + expect(onSelect).toHaveBeenCalledWith('properties'); + }); +}); diff --git a/frontend/src/components/ui/BrowsePage/BrowseRightRail.tsx b/frontend/src/components/ui/BrowsePage/BrowseRightRail.tsx new file mode 100644 index 000000000..8f709d7c5 --- /dev/null +++ b/frontend/src/components/ui/BrowsePage/BrowseRightRail.tsx @@ -0,0 +1,60 @@ +import { IconButton } from '@material-tailwind/react'; +import { + HiOutlineInformationCircle, + HiOutlineShoppingCart +} from 'react-icons/hi'; + +import FgIcon from '@/components/designSystem/atoms/FgIcon'; +import FgBadge from '@/components/designSystem/atoms/FgBadge'; +import { useCartCount } from '@/hooks/useCartCount'; + +interface BrowseRightRailProps { + readonly mode: 'properties' | 'cart'; + readonly isOpen: boolean; + readonly onSelect: (mode: 'properties' | 'cart') => void; +} + +export default function BrowseRightRail({ + mode, + isOpen, + onSelect +}: BrowseRightRailProps) { + const cartCount = useCartCount(); + const activeClass = (target: 'properties' | 'cart') => + isOpen && mode === target + ? 'text-primary bg-secondary-light/20' + : 'text-foreground'; + + return ( +
+ onSelect('properties')} + variant="ghost" + > + + +
+ onSelect('cart')} + variant="ghost" + > + + + {cartCount > 0 ? ( + + {cartCount > 9 ? '9+' : cartCount} + + ) : null} +
+
+ ); +} diff --git a/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx b/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx index 3125a026e..cb3bafe0d 100644 --- a/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx +++ b/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx @@ -3,9 +3,10 @@ import { Card, IconButton, Typography, Tabs } from '@material-tailwind/react'; import toast from 'react-hot-toast'; import { HiOutlineDocument, HiOutlineDuplicate, HiX } from 'react-icons/hi'; import { HiFolder } from 'react-icons/hi2'; -import { useLocation } from 'react-router'; +import { useLocation, useNavigate } from 'react-router'; import FgIcon from '@/components/designSystem/atoms/FgIcon'; +import CartList from '@/components/ui/Views/CartList'; import PermissionsTable from '@/components/ui/PropertiesDrawer/PermissionsTable'; import OverviewTable from '@/components/ui/PropertiesDrawer/OverviewTable'; @@ -35,6 +36,7 @@ type PropertiesDrawerProps = { readonly setShowConvertFileDialog: React.Dispatch< React.SetStateAction >; + readonly mode?: 'properties' | 'cart'; }; function CopyPathButton({ @@ -86,9 +88,11 @@ function CopyPathButton({ export default function PropertiesDrawer({ togglePropertiesDrawer, setShowPermissionsDialog, - setShowConvertFileDialog + setShowConvertFileDialog, + mode = 'properties' }: PropertiesDrawerProps) { const location = useLocation(); + const navigate = useNavigate(); const [showDataLinkDialog, setShowDataLinkDialog] = useState(false); const [activeTab, setActiveTab] = useState('overview'); @@ -131,7 +135,9 @@ export default function PropertiesDrawer({
- Properties + + {mode === 'cart' ? 'Layer Cart' : 'Properties'} +
- {fileQuery.data?.currentFileOrFolder && - fileBrowserState.propertiesTarget ? ( -
- {fileBrowserState.propertiesTarget.is_symlink ? ( - <> - {fileBrowserState.propertiesTarget.symlink_target_fsp ? ( - + {mode === 'cart' ? ( + // ponytail: cart body reuses ; the "dot on the ⓘ icon + // when a file is selected behind the open cart" (design §7) is + // deferred - it needs cross-panel selection wiring for no + // functional gain. +
+ + navigate('/ngviews?tab=cart')} + variant="outline" + > + Open full Layer Cart + +
+ ) : ( + <> + {fileQuery.data?.currentFileOrFolder && + fileBrowserState.propertiesTarget ? ( +
+ {fileBrowserState.propertiesTarget.is_symlink ? ( + <> + {fileBrowserState.propertiesTarget.symlink_target_fsp ? ( + + ) : ( + + )} +
+ + + {fileBrowserState.propertiesTarget.name} + + +
+ ) : ( - + <> + {fileBrowserState.propertiesTarget.is_dir ? ( + + ) : ( + + )} + + + {fileBrowserState.propertiesTarget?.name} + + + )} -
- - - {fileBrowserState.propertiesTarget.name} - - -
- +
) : ( - <> - {fileBrowserState.propertiesTarget.is_dir ? ( - - ) : ( - - )} - - - {fileBrowserState.propertiesTarget?.name} - - - + + Click on a file or folder to view its properties + )} -
- ) : ( - - Click on a file or folder to view its properties - - )} - {fileBrowserState.propertiesTarget ? ( - - - - Overview - - - - Permissions - + + + Overview + - {tasksEnabled && !fileBrowserState.propertiesTarget.is_symlink ? ( - - Convert - - ) : null} - - + + Permissions + - {/*Overview panel*/} - - - - {/* Show data link controls for any path (directories, files, and symlinks) */} - {proxiedPathByFspAndPathQuery.isPending || - externalDataUrlQuery.isPending ? ( - - Loading data link information... - - ) : proxiedPathByFspAndPathQuery.isError ? ( - <> - - Error loading data link information - - - {proxiedPathByFspAndPathQuery.error.message || - 'An unknown error occurred'} - - - ) : externalDataUrlQuery.isError ? ( - <> - - Error loading external data link information - - - {externalDataUrlQuery.error.message || - 'An unknown error occurred'} - - - ) : ( - <> -
- { - if ( - areDataLinksAutomatic && - dataLinkSubpathMode !== 'custom' && - !proxiedPathByFspAndPathQuery.data - ) { - await handleCreateDataLink(); - } else { - setShowDataLinkDialog(true); - } - }} - /> - - {externalDataUrlQuery.data - ? 'Public data link already exists since this data is on s3.janelia.org.' - : proxiedPathByFspAndPathQuery.data - ? 'Deleting the data link will remove data access for collaborators with the link.' - : 'Creating a data link allows you to share the data at this path with internal collaborators or use tools to view the data.'} + Convert + + ) : null} + + + + {/*Overview panel*/} + + + + {/* Show data link controls for any path (directories, files, and symlinks) */} + {proxiedPathByFspAndPathQuery.isPending || + externalDataUrlQuery.isPending ? ( + + Loading data link information... - {!externalDataUrlQuery.data && - !proxiedPathByFspAndPathQuery.data ? ( - - Learn more about data links - - ) : null} -
- {(externalDataUrlQuery.data ?? - proxiedPathByFspAndPathQuery.data?.url) ? ( + ) : proxiedPathByFspAndPathQuery.isError ? ( <> - - - {closeDialog => ( - + Error loading data link information + + + {proxiedPathByFspAndPathQuery.error.message || + 'An unknown error occurred'} + + + ) : externalDataUrlQuery.isError ? ( + <> + + Error loading external data link information + + + {externalDataUrlQuery.error.message || + 'An unknown error occurred'} + + + ) : ( + <> +
+ { + if ( + areDataLinksAutomatic && + dataLinkSubpathMode !== 'custom' && + !proxiedPathByFspAndPathQuery.data + ) { + await handleCreateDataLink(); + } else { + setShowDataLinkDialog(true); } - fspName={ - fileQuery.data?.currentFileSharePath?.name ?? '' + }} + /> + + {externalDataUrlQuery.data + ? 'Public data link already exists since this data is on s3.janelia.org.' + : proxiedPathByFspAndPathQuery.data + ? 'Deleting the data link will remove data access for collaborators with the link.' + : 'Creating a data link allows you to share the data at this path with internal collaborators or use tools to view the data.'} + + {!externalDataUrlQuery.data && + !proxiedPathByFspAndPathQuery.data ? ( + + Learn more about data links + + ) : null} +
+ {(externalDataUrlQuery.data ?? + proxiedPathByFspAndPathQuery.data?.url) ? ( + <> + - )} -
+ + {closeDialog => ( + + )} + + + ) : null} - ) : null} - - )} -
+ )} + - {/*Permissions panel*/} - - - { - setShowPermissionsDialog(true); - }} - variant="outline" - > - Change Permissions - - + {/*Permissions panel*/} + + + { + setShowPermissionsDialog(true); + }} + variant="outline" + > + Change Permissions + + - {/*Task panel*/} - {tasksEnabled && !fileBrowserState.propertiesTarget.is_symlink ? ( - - {ticketByPathQuery.isPending ? ( - - Loading ticket information... - - ) : ticketByPathQuery.isError ? ( - <> - - Error loading ticket information - - - {ticketByPathQuery.error.message || - 'An unknown error occurred'} - - - ) : ticketByPathQuery.data ? ( - - ) : ( - <> - - Scientific Computing can help you convert images to - OME-Zarr format, suitable for viewing in external viewers - like Neuroglancer. - - { - setShowConvertFileDialog(true); - }} - variant="outline" - > - Open conversion request - - - )} - + {/*Task panel*/} + {tasksEnabled && + !fileBrowserState.propertiesTarget.is_symlink ? ( + + {ticketByPathQuery.isPending ? ( + + Loading ticket information... + + ) : ticketByPathQuery.isError ? ( + <> + + Error loading ticket information + + + {ticketByPathQuery.error.message || + 'An unknown error occurred'} + + + ) : ticketByPathQuery.data ? ( + + ) : ( + <> + + Scientific Computing can help you convert images to + OME-Zarr format, suitable for viewing in external + viewers like Neuroglancer. + + { + setShowConvertFileDialog(true); + }} + variant="outline" + > + Open conversion request + + + )} + + ) : null} +
) : null} - - ) : null} + + )}
{showDataLinkDialog && !proxiedPathByFspAndPathQuery.data && diff --git a/frontend/src/layouts/BrowseLayout.tsx b/frontend/src/layouts/BrowseLayout.tsx index ecf0399c1..c68c466c4 100644 --- a/frontend/src/layouts/BrowseLayout.tsx +++ b/frontend/src/layouts/BrowseLayout.tsx @@ -9,6 +9,7 @@ import { usePreferencesContext } from '@/contexts/PreferencesContext'; import useLayoutPrefs from '@/hooks/useLayoutPrefs'; import Sidebar from '@/components/ui/Sidebar/Sidebar'; import PropertiesDrawer from '@/components/ui/PropertiesDrawer/PropertiesDrawer'; +import BrowseRightRail from '@/components/ui/BrowsePage/BrowseRightRail'; export type OutletContextType = { setShowPermissionsDialog: Dispatch>; @@ -31,7 +32,9 @@ export const BrowsePageLayout = () => { togglePropertiesDrawer, showPropertiesDrawer, showSidebar, - toggleSidebar + toggleSidebar, + propertiesDrawerMode, + selectDrawerMode } = useLayoutPrefs(); const outletContextValue: OutletContextType = { @@ -46,66 +49,74 @@ export const BrowsePageLayout = () => { }; return ( -
- {preferenceQuery.isPending ? ( - <> -
-
-
- - ) : ( - - {showSidebar ? ( - <> - - - - - - - - ) : null} - - - - {showPropertiesDrawer ? ( - <> - {/* Need a little extra width on this handle to make up for the apparent extra width added by the sidebar grey inner border on the other handle */} - - - - - - - - ) : null} - - )} +
+
+ {preferenceQuery.isPending ? ( + <> +
+
+
+ + ) : ( + + {showSidebar ? ( + <> + + + + + + + + ) : null} + + + + {showPropertiesDrawer ? ( + <> + {/* Need a little extra width on this handle to make up for the apparent extra width added by the sidebar grey inner border on the other handle */} + + + + + + + + ) : null} + + )} +
+
); }; From a073aa2c9bf477408ad7b2f37ccba37bc2d9a470 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Mon, 10 Aug 2026 11:26:47 -0400 Subject: [PATCH 04/12] feat(views): preserve 409 dependent-Views body on data-link delete --- .../deleteProxiedPath409.test.tsx | 76 +++++++++++++++++++ frontend/src/hooks/useDataToolLinks.ts | 22 +++++- frontend/src/queries/proxiedPathQueries.ts | 41 +++++++++- 3 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 frontend/src/__tests__/componentTests/deleteProxiedPath409.test.tsx diff --git a/frontend/src/__tests__/componentTests/deleteProxiedPath409.test.tsx b/frontend/src/__tests__/componentTests/deleteProxiedPath409.test.tsx new file mode 100644 index 000000000..530ba5eeb --- /dev/null +++ b/frontend/src/__tests__/componentTests/deleteProxiedPath409.test.tsx @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; + +const sendFetchRequest = vi.fn(); +vi.mock('@/utils', () => ({ + sendFetchRequest: (...args: unknown[]) => sendFetchRequest(...args), + buildUrl: ( + base: string, + seg: string | null, + q?: Record | null + ) => `${base}${seg ?? ''}${q ? '?' + new URLSearchParams(q).toString() : ''}` +})); + +import { + useDeleteProxiedPathMutation, + DependentViewsError +} from '@/queries/proxiedPathQueries'; + +const fakeResponse = (status: number, body: unknown) => + ({ + ok: status >= 200 && status < 300, + status, + statusText: String(status), + json: async () => body + }) as unknown as Response; + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } } + }); + return {children}; +} + +beforeEach(() => sendFetchRequest.mockReset()); + +describe('useDeleteProxiedPathMutation 409 handling', () => { + it('throws DependentViewsError carrying the dependent views on 409', async () => { + sendFetchRequest.mockResolvedValue( + fakeResponse(409, { + detail: { + message: + 'This data link backs Views you own; they will be marked broken.', + dependent_views: [{ short_key: 'v1', name: 'My View' }] + } + }) + ); + const { result } = renderHook(() => useDeleteProxiedPathMutation(), { + wrapper + }); + await expect( + result.current.mutateAsync({ sharing_key: 'k1' }) + ).rejects.toBeInstanceOf(DependentViewsError); + const err = (await result.current + .mutateAsync({ sharing_key: 'k1' }) + .catch(e => e as DependentViewsError)) as DependentViewsError; + expect(err.views).toEqual([{ short_key: 'v1', name: 'My View' }]); + }); + + it('adds ?confirm=true when confirm is set and resolves on success', async () => { + sendFetchRequest.mockResolvedValue( + fakeResponse(200, { message: 'deleted' }) + ); + const { result } = renderHook(() => useDeleteProxiedPathMutation(), { + wrapper + }); + await result.current.mutateAsync({ sharing_key: 'k1', confirm: true }); + await waitFor(() => + expect(sendFetchRequest).toHaveBeenCalledWith( + expect.stringContaining('confirm=true'), + 'DELETE' + ) + ); + }); +}); diff --git a/frontend/src/hooks/useDataToolLinks.ts b/frontend/src/hooks/useDataToolLinks.ts index c06611795..9b766568b 100644 --- a/frontend/src/hooks/useDataToolLinks.ts +++ b/frontend/src/hooks/useDataToolLinks.ts @@ -7,6 +7,7 @@ import { type ProxiedPath } from '@/contexts/ProxiedPathContext'; import { usePreferencesContext } from '@/contexts/PreferencesContext'; +import { DependentViewsError } from '@/queries/proxiedPathQueries'; import { useExternalBucketContext } from '@/contexts/ExternalBucketContext'; import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; import { @@ -52,7 +53,10 @@ export default function useDataToolLinks( setPendingToolKey: Dispatch> ): { handleCreateDataLink: (pathOverride?: string) => Promise; - handleDeleteDataLink: (proxiedPath: ProxiedPath) => Promise; + handleDeleteDataLink: ( + proxiedPath: ProxiedPath, + confirm?: boolean + ) => Promise; handleToolClick: (toolKey: PendingToolKey) => Promise; handleDialogConfirm: (urlPrefixOverride?: string) => Promise; handleDialogCancel: () => void; @@ -64,7 +68,10 @@ export default function useDataToolLinks( setShowDataLinkDialog: Dispatch> ): { handleCreateDataLink: (pathOverride?: string) => Promise; - handleDeleteDataLink: (proxiedPath: ProxiedPath) => Promise; + handleDeleteDataLink: ( + proxiedPath: ProxiedPath, + confirm?: boolean + ) => Promise; handleToolClick: (toolKey: PendingToolKey) => Promise; handleDialogConfirm: (urlPrefixOverride?: string) => Promise; handleDialogCancel: () => void; @@ -294,7 +301,10 @@ export default function useDataToolLinks( setShowDataLinkDialog(false); }; - const handleDeleteDataLink = async (proxiedPath: ProxiedPath) => { + const handleDeleteDataLink = async ( + proxiedPath: ProxiedPath, + confirm = false + ) => { if (!proxiedPath) { toast.error('Proxied path not found'); return; @@ -302,11 +312,15 @@ export default function useDataToolLinks( try { await deleteProxiedPathMutation.mutateAsync({ - sharing_key: proxiedPath.sharing_key + sharing_key: proxiedPath.sharing_key, + confirm }); await allProxiedPathsQuery.refetch(); toast.success('Successfully deleted data link'); } catch (error) { + if (error instanceof DependentViewsError) { + throw error; // the dialog catches this to show the confirm step + } const errorMessage = error instanceof Error ? error.message : 'Unknown error'; toast.error(`Error deleting data link: ${errorMessage}`); diff --git a/frontend/src/queries/proxiedPathQueries.ts b/frontend/src/queries/proxiedPathQueries.ts index 604ed1e50..1dfa1c2b2 100644 --- a/frontend/src/queries/proxiedPathQueries.ts +++ b/frontend/src/queries/proxiedPathQueries.ts @@ -35,8 +35,25 @@ type CreateProxiedPathPayload = { */ type DeleteProxiedPathPayload = { sharing_key: string; + confirm?: boolean; }; +/** + * Thrown when a data link backs Views the caller owns and `confirm` was not + * set. Carries the dependent Views so the UI can list them and re-issue the + * delete with confirm=true. The backend returns 409 with this structured body + * (see server.py delete_proxied_path); the app's usual {error} envelope would + * lose the list, so we branch on status manually here. + */ +export class DependentViewsError extends Error { + views: { short_key: string; name: string }[]; + constructor(message: string, views: { short_key: string; name: string }[]) { + super(message); + this.name = 'DependentViewsError'; + this.views = views; + } +} + // Query key factory for proxied paths export const proxiedPathQueryKeys = { all: ['proxiedPaths'] as const, @@ -247,8 +264,28 @@ export function useDeleteProxiedPathMutation(): UseMutationResult< return useMutation({ mutationFn: async (payload: DeleteProxiedPathPayload) => { - const url = buildUrl('/api/proxied-path/', payload.sharing_key, null); - await sendRequestAndThrowForNotOk(url, 'DELETE'); + const url = buildUrl( + '/api/proxied-path/', + payload.sharing_key, + payload.confirm ? { confirm: 'true' } : null + ); + const response = await sendFetchRequest(url, 'DELETE'); + if (response.status === 409) { + const body = (await getResponseJsonOrError(response)) as { + detail?: { + message?: string; + dependent_views?: { short_key: string; name: string }[]; + }; + }; + throw new DependentViewsError( + body?.detail?.message ?? 'This data link is used by Views you own.', + body?.detail?.dependent_views ?? [] + ); + } + if (!response.ok) { + const body = await getResponseJsonOrError(response); + throwResponseNotOkError(response, body); + } }, // Optimistic update onMutate: async (deletedPath: DeleteProxiedPathPayload) => { From a004d7b91944b568ba48dddf4583e1580d1bae9c Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Mon, 10 Aug 2026 11:33:06 -0400 Subject: [PATCH 05/12] feat(views): confirm dialog listing dependent Views on data-link delete --- .../DataLinkDeleteDependentViews.test.tsx | 70 +++++++++++++++++++ .../src/components/ui/Dialogs/DataLink.tsx | 45 ++++++++++-- 2 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 frontend/src/__tests__/componentTests/DataLinkDeleteDependentViews.test.tsx diff --git a/frontend/src/__tests__/componentTests/DataLinkDeleteDependentViews.test.tsx b/frontend/src/__tests__/componentTests/DataLinkDeleteDependentViews.test.tsx new file mode 100644 index 000000000..915dbb398 --- /dev/null +++ b/frontend/src/__tests__/componentTests/DataLinkDeleteDependentViews.test.tsx @@ -0,0 +1,70 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +// DataLinkDialog reads several contexts for the create branch; stub them so the +// delete branch renders standalone. +vi.mock('@/contexts/FileBrowserContext', () => ({ + useFileBrowserContext: () => ({ fspName: 'f', filePath: '/a' }) +})); +vi.mock('@/contexts/PreferencesContext', () => ({ + usePreferencesContext: () => ({ + pathPreference: ['linux_path'], + areDataLinksAutomatic: false, + dataLinkSubpathMode: 'name' + }) +})); +vi.mock('@/contexts/ZonesAndFspMapContext', () => ({ + useZoneAndFspMapContext: () => ({ + zonesAndFspQuery: { isSuccess: false, data: {} } + }) +})); + +import DataLinkDialog from '@/components/ui/Dialogs/DataLink'; +import { DependentViewsError } from '@/queries/proxiedPathQueries'; +import type { ProxiedPath } from '@/contexts/ProxiedPathContext'; + +const proxiedPath = { + username: 'me', + sharing_key: 'k1', + sharing_name: 'n', + path: '/a', + fsp_name: 'f', + created_at: '', + updated_at: '', + url: 'http://x', + url_prefix: '' +} as ProxiedPath; + +describe('DataLinkDialog delete → dependent Views', () => { + it('lists dependent Views on 409 and confirms with confirm=true', async () => { + const user = userEvent.setup(); + const handleDeleteDataLink = vi + .fn() + .mockRejectedValueOnce( + new DependentViewsError('backs your Views', [ + { short_key: 'v1', name: 'My View' } + ]) + ) + .mockResolvedValueOnce(undefined); + + render( + + ); + + await user.click(screen.getByRole('button', { name: /^delete$/i })); + expect(handleDeleteDataLink).toHaveBeenNthCalledWith(1, proxiedPath, false); + // confirm sub-view now lists the View + expect(await screen.findByText('My View')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /delete anyway/i })); + expect(handleDeleteDataLink).toHaveBeenNthCalledWith(2, proxiedPath, true); + }); +}); diff --git a/frontend/src/components/ui/Dialogs/DataLink.tsx b/frontend/src/components/ui/Dialogs/DataLink.tsx index 624364122..b1c6401ff 100644 --- a/frontend/src/components/ui/Dialogs/DataLink.tsx +++ b/frontend/src/components/ui/Dialogs/DataLink.tsx @@ -21,6 +21,7 @@ import { } from '@/utils/pathHandling'; import type { FileSharePath } from '@/shared.types'; import type { PendingToolKey } from '@/hooks/useZarrMetadata'; +import { DependentViewsError } from '@/queries/proxiedPathQueries'; import FgDialog from './FgDialog'; import TextWithFilePath from './TextWithFilePath'; import DataLinkOptions, { @@ -54,7 +55,10 @@ interface DeleteLinkDialogProps extends CommonDataLinkDialogProps { action: 'delete'; pending: boolean; proxiedPath: ProxiedPath; - handleDeleteDataLink: (proxiedPath: ProxiedPath) => Promise; + handleDeleteDataLink: ( + proxiedPath: ProxiedPath, + confirm?: boolean + ) => Promise; } type DataLinkDialogProps = @@ -190,6 +194,9 @@ export default function DataLinkDialog(props: DataLinkDialogProps) { const [openAdvancedSections, setOpenAdvancedSections] = useState( [] ); + const [dependentViews, setDependentViews] = useState< + { short_key: string; name: string }[] | null + >(null); const customSubpathError = useMemo( () => @@ -356,18 +363,46 @@ export default function DataLinkDialog(props: DataLinkDialogProps) { be able to use it to view these data. You can create a new data link at any time. + {dependentViews && dependentViews.length > 0 ? ( +
+ + These Neuroglancer Views you own use this data link and will + be marked broken: + +
    + {dependentViews.map(v => ( +
  • + {v.name || v.short_key} +
  • + ))} +
+
+ ) : null} { - await props.handleDeleteDataLink(props.proxiedPath); - props.setShowDataLinkDialog(false); + if (dependentViews) { + // Second click: user confirmed despite dependent Views. + await props.handleDeleteDataLink(props.proxiedPath, true); + props.setShowDataLinkDialog(false); + return; + } + try { + await props.handleDeleteDataLink(props.proxiedPath, false); + props.setShowDataLinkDialog(false); + } catch (error) { + if (error instanceof DependentViewsError) { + setDependentViews(error.views); + } + // other errors are already toasted in handleDeleteDataLink + } }} > - Delete + {dependentViews ? 'Delete anyway' : 'Delete'} From 62cae8b9acda71b98894a9d9a68b6515cce16691 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Mon, 10 Aug 2026 11:37:28 -0400 Subject: [PATCH 06/12] feat(views): add useViewsForDataLinkQuery for dependent-Views lookups --- .../unitTests/viewsForDataLink.test.tsx | 51 +++++++++++++++++++ frontend/src/queries/viewQueries.ts | 40 ++++++++++++++- 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 frontend/src/__tests__/unitTests/viewsForDataLink.test.tsx diff --git a/frontend/src/__tests__/unitTests/viewsForDataLink.test.tsx b/frontend/src/__tests__/unitTests/viewsForDataLink.test.tsx new file mode 100644 index 000000000..b52a3a3a2 --- /dev/null +++ b/frontend/src/__tests__/unitTests/viewsForDataLink.test.tsx @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; + +const sendFetchRequest = vi.fn(); +vi.mock('@/utils', () => ({ + sendFetchRequest: (...args: unknown[]) => sendFetchRequest(...args), + buildUrl: (base: string, seg: string) => `${base}/${seg}` +})); + +import { useViewsForDataLinkQuery } from '@/queries/viewQueries'; + +const fakeResponse = (status: number, body: unknown) => + ({ + ok: status < 300, + status, + statusText: String(status), + json: async () => body + }) as unknown as Response; + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +} + +beforeEach(() => sendFetchRequest.mockReset()); + +describe('useViewsForDataLinkQuery', () => { + it('returns the views array on success', async () => { + sendFetchRequest.mockResolvedValue( + fakeResponse(200, { views: [{ short_key: 'v1', name: 'A' }] }) + ); + const { result } = renderHook(() => useViewsForDataLinkQuery('k1'), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveLength(1); + }); + + it('treats 404 as an empty list', async () => { + sendFetchRequest.mockResolvedValue(fakeResponse(404, {})); + const { result } = renderHook(() => useViewsForDataLinkQuery('k1'), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([]); + }); + + it('is disabled without a sharing key', () => { + const { result } = renderHook(() => useViewsForDataLinkQuery(undefined), { wrapper }); + expect(result.current.fetchStatus).toBe('idle'); + expect(sendFetchRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/queries/viewQueries.ts b/frontend/src/queries/viewQueries.ts index 0f59a8865..72ca593f3 100644 --- a/frontend/src/queries/viewQueries.ts +++ b/frontend/src/queries/viewQueries.ts @@ -62,7 +62,9 @@ type ViewsResponse = { // Query key factory for Views export const viewQueryKeys = { all: ['views'] as const, - list: () => ['views', 'list'] as const + list: () => ['views', 'list'] as const, + forDataLink: (sharingKey: string) => + ['views', 'forDataLink', sharingKey] as const }; /** @@ -105,6 +107,28 @@ const fetchViews = async (signal?: AbortSignal): Promise => { } }; +/** + * Fetches the Views (owned by the current user) that depend on a Data Link. + * Returns [] on 404. Mirrors fetchViews: manual status branch so a 404 is not + * an error and the {error} envelope is not required. + */ +const fetchViewsForDataLink = async ( + sharingKey: string, + signal?: AbortSignal +): Promise => { + const url = buildUrl('/api/proxied-path', `${sharingKey}/views`); + const response = await sendFetchRequest(url, 'GET', undefined, { signal }); + const data = (await getResponseJsonOrError(response)) as ViewsResponse; + + if (response.ok) { + return data?.views ?? []; + } + if (response.status === 404) { + return []; + } + throwResponseNotOkError(response, data); +}; + /** * Query hook for fetching all Views belonging to the current user * @@ -117,6 +141,20 @@ export function useViewsQuery(): UseQueryResult { }); } +/** + * Query hook for the Views that depend on a given Data Link (by sharing key). + * Disabled until a sharing key is provided. + */ +export function useViewsForDataLinkQuery( + sharingKey?: string +): UseQueryResult { + return useQuery({ + queryKey: viewQueryKeys.forDataLink(sharingKey ?? ''), + queryFn: ({ signal }) => fetchViewsForDataLink(sharingKey!, signal), + enabled: !!sharingKey + }); +} + /** * Mutation hook for creating a View * From c6649b354d05da074f215b99ae871d2e338cdfe0 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Mon, 10 Aug 2026 11:43:53 -0400 Subject: [PATCH 07/12] feat(views): show 'Appears in N Views' in Properties overview --- .../componentTests/AppearsInViews.test.tsx | 36 +++++++++++++++++ .../ui/PropertiesDrawer/AppearsInViews.tsx | 39 +++++++++++++++++++ .../ui/PropertiesDrawer/PropertiesDrawer.tsx | 6 +++ 3 files changed, 81 insertions(+) create mode 100644 frontend/src/__tests__/componentTests/AppearsInViews.test.tsx create mode 100644 frontend/src/components/ui/PropertiesDrawer/AppearsInViews.tsx diff --git a/frontend/src/__tests__/componentTests/AppearsInViews.test.tsx b/frontend/src/__tests__/componentTests/AppearsInViews.test.tsx new file mode 100644 index 000000000..ecc4ab8b6 --- /dev/null +++ b/frontend/src/__tests__/componentTests/AppearsInViews.test.tsx @@ -0,0 +1,36 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +const { useViewsForDataLinkQuery } = vi.hoisted(() => ({ + useViewsForDataLinkQuery: vi.fn() +})); +vi.mock('@/queries/viewQueries', () => ({ useViewsForDataLinkQuery })); + +import AppearsInViews from '@/components/ui/PropertiesDrawer/AppearsInViews'; + +describe('AppearsInViews', () => { + it('lists the dependent Views with a count', () => { + useViewsForDataLinkQuery.mockReturnValue({ + data: [ + { short_key: 'v1', name: 'Alpha' }, + { short_key: 'v2', name: 'Beta' } + ], + isPending: false, + isError: false + }); + render(); + expect(screen.getByText(/appears in 2 views/i)).toBeInTheDocument(); + expect(screen.getByText('Alpha')).toBeInTheDocument(); + expect(screen.getByText('Beta')).toBeInTheDocument(); + }); + + it('renders nothing when there are no dependent Views', () => { + useViewsForDataLinkQuery.mockReturnValue({ + data: [], + isPending: false, + isError: false + }); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/frontend/src/components/ui/PropertiesDrawer/AppearsInViews.tsx b/frontend/src/components/ui/PropertiesDrawer/AppearsInViews.tsx new file mode 100644 index 000000000..24e0039b0 --- /dev/null +++ b/frontend/src/components/ui/PropertiesDrawer/AppearsInViews.tsx @@ -0,0 +1,39 @@ +import { Typography } from '@material-tailwind/react'; + +import { useViewsForDataLinkQuery } from '@/queries/viewQueries'; + +interface AppearsInViewsProps { + readonly sharingKey: string; +} + +export default function AppearsInViews({ sharingKey }: AppearsInViewsProps) { + const viewsQuery = useViewsForDataLinkQuery(sharingKey); + + // Stay quiet while loading / on error / when unused — this is a + // supplementary read-only hint, not a primary control. + if (viewsQuery.isPending || viewsQuery.isError) { + return null; + } + const views = viewsQuery.data ?? []; + if (views.length === 0) { + return null; + } + + return ( +
+ + Appears in {views.length} View{views.length === 1 ? '' : 's'} + + {/* ponytail: names only, not links — PR 6 makes View names + navigable to the embedded viewer; a link to nowhere now is worse + than plain text. */} +
    + {views.map(v => ( +
  • + {v.name || v.short_key} +
  • + ))} +
+
+ ); +} diff --git a/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx b/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx index cb3bafe0d..78e505181 100644 --- a/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx +++ b/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx @@ -8,6 +8,7 @@ import { useLocation, useNavigate } from 'react-router'; import FgIcon from '@/components/designSystem/atoms/FgIcon'; import CartList from '@/components/ui/Views/CartList'; +import AppearsInViews from '@/components/ui/PropertiesDrawer/AppearsInViews'; import PermissionsTable from '@/components/ui/PropertiesDrawer/PermissionsTable'; import OverviewTable from '@/components/ui/PropertiesDrawer/OverviewTable'; import TicketDetails from '@/components/ui/PropertiesDrawer/TicketDetails'; @@ -371,6 +372,11 @@ export default function PropertiesDrawer({ ) : null} )} + {proxiedPathByFspAndPathQuery.data?.sharing_key ? ( + + ) : null} {/*Permissions panel*/} From 823c347fe26ed7eab94d17727713d4072fa6829a Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Mon, 10 Aug 2026 11:53:53 -0400 Subject: [PATCH 08/12] fix(views): guard confirm-delete branch and add aria-pressed to cart rail --- .../__tests__/componentTests/Browse.test.tsx | 4 +- .../componentTests/BrowseRightRail.test.tsx | 28 -------- .../FileTableSelectColumn.test.tsx | 4 +- frontend/src/components/Browse.tsx | 7 +- .../ui/BrowsePage/BrowseRightRail.tsx | 60 ---------------- .../src/components/ui/BrowsePage/Toolbar.tsx | 68 +++++++++++++------ .../src/components/ui/Dialogs/DataLink.tsx | 11 ++- .../ui/PropertiesDrawer/PropertiesDrawer.tsx | 10 +-- frontend/src/layouts/BrowseLayout.tsx | 12 ++-- 9 files changed, 73 insertions(+), 131 deletions(-) delete mode 100644 frontend/src/__tests__/componentTests/BrowseRightRail.test.tsx delete mode 100644 frontend/src/components/ui/BrowsePage/BrowseRightRail.tsx diff --git a/frontend/src/__tests__/componentTests/Browse.test.tsx b/frontend/src/__tests__/componentTests/Browse.test.tsx index 6a463f293..5047a95b5 100644 --- a/frontend/src/__tests__/componentTests/Browse.test.tsx +++ b/frontend/src/__tests__/componentTests/Browse.test.tsx @@ -24,7 +24,9 @@ vi.mock('react-router', async () => { showPermissionsDialog: false, showPropertiesDrawer: false, showSidebar: false, - showConvertFileDialog: false + showConvertFileDialog: false, + propertiesDrawerMode: 'properties', + selectDrawerMode: vi.fn() }) }; }); diff --git a/frontend/src/__tests__/componentTests/BrowseRightRail.test.tsx b/frontend/src/__tests__/componentTests/BrowseRightRail.test.tsx deleted file mode 100644 index db2538613..000000000 --- a/frontend/src/__tests__/componentTests/BrowseRightRail.test.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -vi.mock('@/hooks/useCartCount', () => ({ useCartCount: () => 3 })); - -import BrowseRightRail from '@/components/ui/BrowsePage/BrowseRightRail'; - -describe('BrowseRightRail', () => { - it('shows the cart count badge', () => { - render( - - ); - expect(screen.getByText('3')).toBeInTheDocument(); - }); - - it('calls onSelect with the clicked mode', async () => { - const onSelect = vi.fn(); - const user = userEvent.setup(); - render( - - ); - await user.click(screen.getByRole('button', { name: /layer cart/i })); - expect(onSelect).toHaveBeenCalledWith('cart'); - await user.click(screen.getByRole('button', { name: /properties/i })); - expect(onSelect).toHaveBeenCalledWith('properties'); - }); -}); diff --git a/frontend/src/__tests__/componentTests/FileTableSelectColumn.test.tsx b/frontend/src/__tests__/componentTests/FileTableSelectColumn.test.tsx index 27f5607ba..66bc87f87 100644 --- a/frontend/src/__tests__/componentTests/FileTableSelectColumn.test.tsx +++ b/frontend/src/__tests__/componentTests/FileTableSelectColumn.test.tsx @@ -27,7 +27,9 @@ vi.mock('react-router', async () => { showPermissionsDialog: false, showPropertiesDrawer: false, showSidebar: false, - showConvertFileDialog: false + showConvertFileDialog: false, + propertiesDrawerMode: 'properties', + selectDrawerMode: vi.fn() }) }; }); diff --git a/frontend/src/components/Browse.tsx b/frontend/src/components/Browse.tsx index cc532eff5..4f89124df 100644 --- a/frontend/src/components/Browse.tsx +++ b/frontend/src/components/Browse.tsx @@ -25,7 +25,9 @@ export default function Browse() { showPermissionsDialog, showPropertiesDrawer, showSidebar, - showConvertFileDialog + showConvertFileDialog, + propertiesDrawerMode, + selectDrawerMode } = useOutletContext(); const { fspName } = useFileBrowserContext(); @@ -106,9 +108,10 @@ export default function Browse() { tabIndex={0} >
void; -} - -export default function BrowseRightRail({ - mode, - isOpen, - onSelect -}: BrowseRightRailProps) { - const cartCount = useCartCount(); - const activeClass = (target: 'properties' | 'cart') => - isOpen && mode === target - ? 'text-primary bg-secondary-light/20' - : 'text-foreground'; - - return ( -
- onSelect('properties')} - variant="ghost" - > - - -
- onSelect('cart')} - variant="ghost" - > - - - {cartCount > 0 ? ( - - {cartCount > 9 ? '9+' : cartCount} - - ) : null} -
-
- ); -} diff --git a/frontend/src/components/ui/BrowsePage/Toolbar.tsx b/frontend/src/components/ui/BrowsePage/Toolbar.tsx index 17788780a..197555ae2 100644 --- a/frontend/src/components/ui/BrowsePage/Toolbar.tsx +++ b/frontend/src/components/ui/BrowsePage/Toolbar.tsx @@ -9,11 +9,15 @@ import { HiOutlineClipboardCopy, HiHome, HiOutlineStar, - HiStar + HiStar, + HiOutlineInformationCircle, + HiOutlineShoppingCart } from 'react-icons/hi'; import { GoSidebarCollapse, GoSidebarExpand } from 'react-icons/go'; import FgTooltip from '@/components/ui/widgets/FgTooltip'; +import FgBadge from '@/components/designSystem/atoms/FgBadge'; +import { useCartCount } from '@/hooks/useCartCount'; import NavigationButton from './NavigationButton'; import NewFolderButton from './NewFolderButton'; import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; @@ -31,19 +35,22 @@ import { useRefreshFileBrowser } from '@/hooks/useRefreshFileBrowser'; type ToolbarProps = { readonly showPropertiesDrawer: boolean; - readonly togglePropertiesDrawer: () => void; + readonly propertiesDrawerMode: 'properties' | 'cart'; + readonly selectDrawerMode: (mode: 'properties' | 'cart') => void; readonly showSidebar: boolean; readonly toggleSidebar: () => void; }; export default function Toolbar({ showPropertiesDrawer, - togglePropertiesDrawer, + propertiesDrawerMode, + selectDrawerMode, showSidebar, toggleSidebar }: ToolbarProps) { const { fileQuery } = useFileBrowserContext(); const { refreshFileBrowser } = useRefreshFileBrowser(); + const cartCount = useCartCount(); const { currentFileSharePath, currentFileOrFolder } = fileQuery.data || {}; const { profile } = useProfileContext(); @@ -130,12 +137,6 @@ export default function Toolbar({ } }; - const handleTogglePropertiesDrawer = ( - e: React.MouseEvent - ) => { - togglePropertiesDrawer(); - }; - return (
@@ -222,18 +223,43 @@ export default function Toolbar({ ) : null} - {/* Show/hide properties drawer */} - + {/* Right drawer: file properties (info) or Layer Cart */} +
+ selectDrawerMode('properties')} + triggerClasses={`${triggerClasses} ${ + showPropertiesDrawer && propertiesDrawerMode === 'properties' + ? '!bg-primary !text-primary-foreground' + : '' + }`} + /> +
+ selectDrawerMode('cart')} + triggerClasses={`${triggerClasses} ${ + showPropertiesDrawer && propertiesDrawerMode === 'cart' + ? '!bg-primary !text-primary-foreground' + : '' + }`} + /> + {cartCount > 0 ? ( + + {cartCount > 9 ? '9+' : cartCount} + + ) : null} +
+
); diff --git a/frontend/src/components/ui/Dialogs/DataLink.tsx b/frontend/src/components/ui/Dialogs/DataLink.tsx index b1c6401ff..8a042b7cb 100644 --- a/frontend/src/components/ui/Dialogs/DataLink.tsx +++ b/frontend/src/components/ui/Dialogs/DataLink.tsx @@ -387,8 +387,15 @@ export default function DataLinkDialog(props: DataLinkDialogProps) { onClick={async () => { if (dependentViews) { // Second click: user confirmed despite dependent Views. - await props.handleDeleteDataLink(props.proxiedPath, true); - props.setShowDataLinkDialog(false); + try { + await props.handleDeleteDataLink(props.proxiedPath, true); + props.setShowDataLinkDialog(false); + } catch (error) { + if (error instanceof DependentViewsError) { + setDependentViews(error.views); + } + // other errors are already toasted in handleDeleteDataLink + } return; } try { diff --git a/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx b/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx index 78e505181..6ba6832a7 100644 --- a/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx +++ b/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx @@ -3,7 +3,7 @@ import { Card, IconButton, Typography, Tabs } from '@material-tailwind/react'; import toast from 'react-hot-toast'; import { HiOutlineDocument, HiOutlineDuplicate, HiX } from 'react-icons/hi'; import { HiFolder } from 'react-icons/hi2'; -import { useLocation, useNavigate } from 'react-router'; +import { useLocation } from 'react-router'; import FgIcon from '@/components/designSystem/atoms/FgIcon'; import CartList from '@/components/ui/Views/CartList'; @@ -93,7 +93,6 @@ export default function PropertiesDrawer({ mode = 'properties' }: PropertiesDrawerProps) { const location = useLocation(); - const navigate = useNavigate(); const [showDataLinkDialog, setShowDataLinkDialog] = useState(false); const [activeTab, setActiveTab] = useState('overview'); @@ -159,13 +158,6 @@ export default function PropertiesDrawer({ // functional gain.
- navigate('/ngviews?tab=cart')} - variant="outline" - > - Open full Layer Cart -
) : ( <> diff --git a/frontend/src/layouts/BrowseLayout.tsx b/frontend/src/layouts/BrowseLayout.tsx index c68c466c4..6a345e674 100644 --- a/frontend/src/layouts/BrowseLayout.tsx +++ b/frontend/src/layouts/BrowseLayout.tsx @@ -9,7 +9,6 @@ import { usePreferencesContext } from '@/contexts/PreferencesContext'; import useLayoutPrefs from '@/hooks/useLayoutPrefs'; import Sidebar from '@/components/ui/Sidebar/Sidebar'; import PropertiesDrawer from '@/components/ui/PropertiesDrawer/PropertiesDrawer'; -import BrowseRightRail from '@/components/ui/BrowsePage/BrowseRightRail'; export type OutletContextType = { setShowPermissionsDialog: Dispatch>; @@ -20,6 +19,8 @@ export type OutletContextType = { showPropertiesDrawer: boolean; showSidebar: boolean; showConvertFileDialog: boolean; + propertiesDrawerMode: 'properties' | 'cart'; + selectDrawerMode: (mode: 'properties' | 'cart') => void; }; export const BrowsePageLayout = () => { @@ -45,7 +46,9 @@ export const BrowsePageLayout = () => { showPermissionsDialog: showPermissionsDialog, showPropertiesDrawer: showPropertiesDrawer, showSidebar: showSidebar, - showConvertFileDialog: showConvertFileDialog + showConvertFileDialog: showConvertFileDialog, + propertiesDrawerMode: propertiesDrawerMode, + selectDrawerMode: selectDrawerMode }; return ( @@ -112,11 +115,6 @@ export const BrowsePageLayout = () => { )}
-
); }; From a04dec761789d4e0174105429e22a359a1fc8b64 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Mon, 10 Aug 2026 11:56:52 -0400 Subject: [PATCH 09/12] style: prettier-format viewsForDataLink test --- .../unitTests/viewsForDataLink.test.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/frontend/src/__tests__/unitTests/viewsForDataLink.test.tsx b/frontend/src/__tests__/unitTests/viewsForDataLink.test.tsx index b52a3a3a2..0f8fd38c4 100644 --- a/frontend/src/__tests__/unitTests/viewsForDataLink.test.tsx +++ b/frontend/src/__tests__/unitTests/viewsForDataLink.test.tsx @@ -20,7 +20,9 @@ const fakeResponse = (status: number, body: unknown) => }) as unknown as Response; function wrapper({ children }: { children: ReactNode }) { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } } + }); return {children}; } @@ -31,20 +33,26 @@ describe('useViewsForDataLinkQuery', () => { sendFetchRequest.mockResolvedValue( fakeResponse(200, { views: [{ short_key: 'v1', name: 'A' }] }) ); - const { result } = renderHook(() => useViewsForDataLinkQuery('k1'), { wrapper }); + const { result } = renderHook(() => useViewsForDataLinkQuery('k1'), { + wrapper + }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data).toHaveLength(1); }); it('treats 404 as an empty list', async () => { sendFetchRequest.mockResolvedValue(fakeResponse(404, {})); - const { result } = renderHook(() => useViewsForDataLinkQuery('k1'), { wrapper }); + const { result } = renderHook(() => useViewsForDataLinkQuery('k1'), { + wrapper + }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data).toEqual([]); }); it('is disabled without a sharing key', () => { - const { result } = renderHook(() => useViewsForDataLinkQuery(undefined), { wrapper }); + const { result } = renderHook(() => useViewsForDataLinkQuery(undefined), { + wrapper + }); expect(result.current.fetchStatus).toBe('idle'); expect(sendFetchRequest).not.toHaveBeenCalled(); }); From 5ffa321db8895a0fc4894aa45f1d6ade217242a0 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 18 Aug 2026 12:08:38 -0400 Subject: [PATCH 10/12] fix(nav): use a question-mark icon for the Help link --- frontend/src/components/ui/Navbar/Navbar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ui/Navbar/Navbar.tsx b/frontend/src/components/ui/Navbar/Navbar.tsx index bb9a19276..d242e0162 100644 --- a/frontend/src/components/ui/Navbar/Navbar.tsx +++ b/frontend/src/components/ui/Navbar/Navbar.tsx @@ -10,7 +10,7 @@ import { import { Link } from 'react-router'; import type { IconType } from 'react-icons'; import { - HiOutlineInformationCircle, + HiOutlineQuestionMarkCircle, HiOutlineMoon, HiOutlineMenu, HiOutlineX, @@ -91,7 +91,7 @@ function NavList() { badge: activeJobCount }, { icon: HiOutlineBriefcase, title: 'Tasks', href: '/jobs' }, - { icon: HiOutlineInformationCircle, title: 'Help', href: '/help' } + { icon: HiOutlineQuestionMarkCircle, title: 'Help', href: '/help' } ]; const filteredLinks = links.filter(link => { From 8a2d948fa3c30247274df3497c02d34ea9485577 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 18 Aug 2026 17:02:07 -0400 Subject: [PATCH 11/12] fix(views): match cart badge to navbar style; Clear cart uses warning color The toolbar cart count now uses the same MT Badge (solid secondary) as the navbar, and 'Clear cart' is styled with the error color. --- .../src/components/ui/BrowsePage/Toolbar.tsx | 41 +++++++++---------- frontend/src/components/ui/Views/CartList.tsx | 6 ++- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/ui/BrowsePage/Toolbar.tsx b/frontend/src/components/ui/BrowsePage/Toolbar.tsx index 197555ae2..ee67407b7 100644 --- a/frontend/src/components/ui/BrowsePage/Toolbar.tsx +++ b/frontend/src/components/ui/BrowsePage/Toolbar.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import toast from 'react-hot-toast'; import { Link } from 'react-router'; -import { ButtonGroup, IconButton } from '@material-tailwind/react'; +import { Badge, ButtonGroup, IconButton } from '@material-tailwind/react'; import { HiRefresh, HiEye, @@ -16,7 +16,6 @@ import { import { GoSidebarCollapse, GoSidebarExpand } from 'react-icons/go'; import FgTooltip from '@/components/ui/widgets/FgTooltip'; -import FgBadge from '@/components/designSystem/atoms/FgBadge'; import { useCartCount } from '@/hooks/useCartCount'; import NavigationButton from './NavigationButton'; import NewFolderButton from './NewFolderButton'; @@ -236,29 +235,27 @@ export default function Toolbar({ : '' }`} /> -
- selectDrawerMode('cart')} - triggerClasses={`${triggerClasses} ${ - showPropertiesDrawer && propertiesDrawerMode === 'cart' - ? '!bg-primary !text-primary-foreground' - : '' - }`} - /> + {/* Cart badge mirrors the count badge style used in the navbar. */} + + + selectDrawerMode('cart')} + triggerClasses={`${triggerClasses} ${ + showPropertiesDrawer && propertiesDrawerMode === 'cart' + ? '!bg-primary !text-primary-foreground' + : '' + }`} + /> + {cartCount > 0 ? ( - + {cartCount > 9 ? '9+' : cartCount} - + ) : null} -
+
diff --git a/frontend/src/components/ui/Views/CartList.tsx b/frontend/src/components/ui/Views/CartList.tsx index 76d76e96a..966744465 100644 --- a/frontend/src/components/ui/Views/CartList.tsx +++ b/frontend/src/components/ui/Views/CartList.tsx @@ -92,7 +92,11 @@ export default function CartList() { defaultName="New View" label="Create View" /> - void handleClearCart()} variant="ghost"> + void handleClearCart()} + variant="ghost" + > Clear cart
From f3bf1c69c667779be352347891e360c565e90fa4 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 18 Aug 2026 17:02:07 -0400 Subject: [PATCH 12/12] feat(views): add 'Add current dataset' button to the Layer Cart panel Adds the currently-browsed directory to the cart without navigating up to select it. Enabled only for OME-Zarr / N5 datasets. --- .../ui/PropertiesDrawer/PropertiesDrawer.tsx | 56 ++++++++++++++++++- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx b/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx index 6ba6832a7..b846c5716 100644 --- a/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx +++ b/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx @@ -22,6 +22,9 @@ import FgSwitch from '@/components/ui/widgets/FgSwitch'; import { getPreferredPathForDisplay } from '@/utils'; import { copyToClipboard } from '@/utils/copyText'; import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; +import { useCartContext } from '@/contexts/CartContext'; +import { areZarrMetadataFilesPresent } from '@/queries/zarrQueries'; +import { detectN5 } from '@/queries/n5Queries'; import { usePreferencesContext } from '@/contexts/PreferencesContext'; import { useTicketContext } from '@/contexts/TicketsContext'; import { useProxiedPathContext } from '@/contexts/ProxiedPathContext'; @@ -106,6 +109,40 @@ export default function PropertiesDrawer({ deleteProxiedPathMutation } = useProxiedPathContext(); const { externalDataUrlQuery } = useExternalBucketContext(); + const { addToCart } = useCartContext(); + + // "Add current dataset" adds the directory being browsed to the Layer Cart + // (the row-menu action only covers subdirectories). Enabled only for + // OME-Zarr / N5 datasets, the same paths Neuroglancer can open. + const currentFsp = fileQuery.data?.currentFileSharePath; + const currentItem = fileQuery.data?.currentFileOrFolder; + const currentDirName = currentItem?.name ?? ''; + const currentDirIsDataset = + Boolean(currentFsp && currentItem?.is_dir) && + (areZarrMetadataFilesPresent(fileQuery.data?.files ?? []) || + detectN5(fileQuery.data?.files ?? []) || + currentDirName.endsWith('.zarr') || + currentDirName.endsWith('.n5')); + + const handleAddCurrentDirToCart = async () => { + if (!currentFsp || !currentItem) { + return; + } + try { + await addToCart([ + { + fsp_name: currentFsp.name, + path: currentItem.path, + label: currentItem.name + } + ]); + toast.success(`Added "${currentItem.name}" to the Layer Cart`); + } catch (error) { + toast.error( + error instanceof Error ? error.message : 'Failed to add to cart' + ); + } + }; const { handleDialogConfirm, @@ -135,9 +172,22 @@ export default function PropertiesDrawer({
- - {mode === 'cart' ? 'Layer Cart' : 'Properties'} - +
+ + {mode === 'cart' ? 'Layer Cart' : 'Properties'} + + {mode === 'cart' ? ( + void handleAddCurrentDirToCart()} + variant="solid" + > + Add current dataset + + ) : null} +