diff --git a/frontend/src/__tests__/componentTests/AppearsInViews.test.tsx b/frontend/src/__tests__/componentTests/AppearsInViews.test.tsx new file mode 100644 index 00000000..ecc4ab8b --- /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/__tests__/componentTests/BrowseRightRail.test.tsx b/frontend/src/__tests__/componentTests/BrowseRightRail.test.tsx new file mode 100644 index 00000000..eb171026 --- /dev/null +++ b/frontend/src/__tests__/componentTests/BrowseRightRail.test.tsx @@ -0,0 +1,40 @@ +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'); + }); + + it('marks only the open mode as aria-pressed', () => { + render(); + expect(screen.getByRole('button', { name: /layer cart/i })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + expect(screen.getByRole('button', { name: /properties/i })).toHaveAttribute( + 'aria-pressed', + 'false' + ); + }); +}); diff --git a/frontend/src/__tests__/componentTests/CartList.test.tsx b/frontend/src/__tests__/componentTests/CartList.test.tsx new file mode 100644 index 00000000..e0dbe147 --- /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/DataLinkDeleteDependentViews.test.tsx b/frontend/src/__tests__/componentTests/DataLinkDeleteDependentViews.test.tsx new file mode 100644 index 00000000..915dbb39 --- /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/__tests__/componentTests/deleteProxiedPath409.test.tsx b/frontend/src/__tests__/componentTests/deleteProxiedPath409.test.tsx new file mode 100644 index 00000000..530ba5ee --- /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/__tests__/componentTests/useLayoutPrefsDrawerMode.test.tsx b/frontend/src/__tests__/componentTests/useLayoutPrefsDrawerMode.test.tsx new file mode 100644 index 00000000..2ae7e7ac --- /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/__tests__/unitTests/viewsForDataLink.test.tsx b/frontend/src/__tests__/unitTests/viewsForDataLink.test.tsx new file mode 100644 index 00000000..0f8fd38c --- /dev/null +++ b/frontend/src/__tests__/unitTests/viewsForDataLink.test.tsx @@ -0,0 +1,59 @@ +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/components/NGViews.tsx b/frontend/src/components/NGViews.tsx index e66f3966..49f8fbb6 100644 --- a/frontend/src/components/NGViews.tsx +++ b/frontend/src/components/NGViews.tsx @@ -1,83 +1,32 @@ -import { useMemo, useState } from 'react'; +import { useState } from 'react'; import { Typography } from '@material-tailwind/react'; import toast from 'react-hot-toast'; +import { useSearchParams } from 'react-router'; 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 CartList from '@/components/ui/Views/CartList'; 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 { cartCount } = useCartContext(); 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 [searchParams] = useSearchParams(); + const [tab, setTab] = useState( + searchParams.get('tab') === 'cart' ? 'cart' : 'views' + ); const [renameItem, setRenameItem] = useState(undefined); const [renameValue, setRenameValue] = useState(''); const [deleteItem, setDeleteItem] = useState(undefined); @@ -170,41 +119,7 @@ export default function NGViews() { loadingState={allViewsQuery.isPending} /> ) : ( -
- {cart.length === 0 ? ( - - Your Layer Cart is empty. Add datasets from the file browser. - - ) : ( - <> - {cartGroups.map(group => ( - - ))} -
- - void handleClearCart()} - variant="ghost" - > - Clear cart - -
- - )} -
+ )} diff --git a/frontend/src/components/ui/BrowsePage/BrowseRightRail.tsx b/frontend/src/components/ui/BrowsePage/BrowseRightRail.tsx new file mode 100644 index 00000000..af75e741 --- /dev/null +++ b/frontend/src/components/ui/BrowsePage/BrowseRightRail.tsx @@ -0,0 +1,62 @@ +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/Dialogs/DataLink.tsx b/frontend/src/components/ui/Dialogs/DataLink.tsx index 62436412..8a042b7c 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,53 @@ 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. + 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 { + 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'} diff --git a/frontend/src/components/ui/PropertiesDrawer/AppearsInViews.tsx b/frontend/src/components/ui/PropertiesDrawer/AppearsInViews.tsx new file mode 100644 index 00000000..24e0039b --- /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 3125a026..78e50518 100644 --- a/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx +++ b/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx @@ -3,10 +3,12 @@ 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 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'; @@ -35,6 +37,7 @@ type PropertiesDrawerProps = { readonly setShowConvertFileDialog: React.Dispatch< React.SetStateAction >; + readonly mode?: 'properties' | 'cart'; }; function CopyPathButton({ @@ -86,9 +89,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 +136,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} + )} + {proxiedPathByFspAndPathQuery.data?.sharing_key ? ( + ) : 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/components/ui/Views/CartList.tsx b/frontend/src/components/ui/Views/CartList.tsx new file mode 100644 index 00000000..76d76e96 --- /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 + +
+
+ ); +} diff --git a/frontend/src/hooks/useDataToolLinks.ts b/frontend/src/hooks/useDataToolLinks.ts index c0661179..9b766568 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/hooks/useLayoutPrefs.ts b/frontend/src/hooks/useLayoutPrefs.ts index 7ade9960..ff303a80 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 }; } diff --git a/frontend/src/layouts/BrowseLayout.tsx b/frontend/src/layouts/BrowseLayout.tsx index ecf0399c..c68c466c 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} + + )} +
+
); }; diff --git a/frontend/src/queries/proxiedPathQueries.ts b/frontend/src/queries/proxiedPathQueries.ts index 604ed1e5..1dfa1c2b 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) => { diff --git a/frontend/src/queries/viewQueries.ts b/frontend/src/queries/viewQueries.ts index 0f59a886..72ca593f 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 *