diff --git a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx index 3fa357c7b..8135972ea 100644 --- a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx +++ b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx @@ -344,6 +344,34 @@ describe('DataToolLinks - Expected Behavior', () => { expect(onAddToCart).toHaveBeenCalled(); }); + it('routes the Neuroglancer button to onOpenNeuroglancer when given', async () => { + const onOpenNeuroglancer = vi.fn(); + const onToolClick = vi.fn(async () => {}); + render( + + + , + { initialEntries: ['/browse/test_fsp/test_file'] } + ); + + const logo = await screen.findByAltText( + 'View in Neuroglancer', + {}, + { timeout: 3000 } + ); + expect(screen.getByText('Neuroglancer View')).toBeInTheDocument(); + logo.click(); + + await waitFor(() => expect(onOpenNeuroglancer).toHaveBeenCalled()); + expect(onToolClick).not.toHaveBeenCalled(); + }); + it('should call onToolClick when copy icon is clicked', async () => { const onToolClick = vi.fn(async () => {}); renderDataToolLinks(undefined, onToolClick); diff --git a/frontend/src/__tests__/componentTests/NeuroglancerView.test.tsx b/frontend/src/__tests__/componentTests/NeuroglancerView.test.tsx index f3be13dd5..23990f838 100644 --- a/frontend/src/__tests__/componentTests/NeuroglancerView.test.tsx +++ b/frontend/src/__tests__/componentTests/NeuroglancerView.test.tsx @@ -36,7 +36,7 @@ const ngBase = vi.hoisted(() => ({ })); vi.mock('@/hooks/useDefaultNeuroglancerBaseUrl', () => ({ useInternalNeuroglancerBaseUrl: () => ngBase.current, - useDefaultNeuroglancerBaseUrl: () => 'https://ng-preferred.example/' + useViewsNeuroglancerBaseUrl: () => 'https://ng-preferred.example/' })); const navigate = vi.hoisted(() => vi.fn()); vi.mock('react-router', () => ({ diff --git a/frontend/src/components/NeuroglancerView.tsx b/frontend/src/components/NeuroglancerView.tsx index ae6b40290..cabf0c7de 100644 --- a/frontend/src/components/NeuroglancerView.tsx +++ b/frontend/src/components/NeuroglancerView.tsx @@ -16,8 +16,8 @@ import { import { useViewStateByReadKey } from '@/queries/viewQueries'; import { useViewsContext } from '@/contexts/ViewsContext'; import { - useDefaultNeuroglancerBaseUrl, - useInternalNeuroglancerBaseUrl + useInternalNeuroglancerBaseUrl, + useViewsNeuroglancerBaseUrl } from '@/hooks/useDefaultNeuroglancerBaseUrl'; import { useCanEditView } from '@/hooks/useCanEditView'; import { useNeuroglancerViewer } from '@/hooks/useNeuroglancerViewer'; @@ -96,8 +96,8 @@ export default function NeuroglancerView() { const { updateViewMutation } = useViewsContext(); const { canEdit, view: ownedView } = useCanEditView(readKey); const baseUrl = useInternalNeuroglancerBaseUrl(); - // The new tab isn't framed, so it can honor the Internal/External preference. - const preferredBaseUrl = useDefaultNeuroglancerBaseUrl(); + // The new tab isn't framed, so it can honor the Views Internal/Public preference. + const preferredBaseUrl = useViewsNeuroglancerBaseUrl(); const containerRef = useRef(null); const [iframe, setIframe] = useState(null); const bridge = useNeuroglancerViewer(iframe); diff --git a/frontend/src/components/Preferences.tsx b/frontend/src/components/Preferences.tsx index 4b2220c32..eb27d6851 100644 --- a/frontend/src/components/Preferences.tsx +++ b/frontend/src/components/Preferences.tsx @@ -36,7 +36,7 @@ export default function Preferences() {
- Neuroglancer links + Neuroglancer
diff --git a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx index 5a5508d2a..638fdaadf 100644 --- a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx +++ b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx @@ -65,6 +65,7 @@ export default function DataToolLinks({ dataLinkUrl, fspName, onAddToCart, + onOpenNeuroglancer, onToolClick, path, showCopiedTooltip, @@ -75,6 +76,8 @@ export default function DataToolLinks({ readonly dataLinkUrl?: string; readonly fspName?: string; readonly onAddToCart?: () => void; + // Replaces the Neuroglancer link (e.g. to create a View instead). + readonly onOpenNeuroglancer?: () => void; readonly onToolClick: (toolKey: PendingToolKey) => Promise; readonly path?: string; readonly showCopiedTooltip: boolean; @@ -103,15 +106,20 @@ export default function DataToolLinks({ return null; } + const opensView = viewer.key === 'neuroglancer' && onOpenNeuroglancer; return ( onOpenNeuroglancer() : onToolClick + } toolKey={viewer.key as PendingToolKey} - tooltipLabel={viewer.label} + tooltipLabel={ + opensView ? 'Create a View from this dataset' : viewer.label + } url={url} /> ); diff --git a/frontend/src/components/ui/BrowsePage/ZarrPreview.tsx b/frontend/src/components/ui/BrowsePage/ZarrPreview.tsx index 3ec904483..baa0856cd 100644 --- a/frontend/src/components/ui/BrowsePage/ZarrPreview.tsx +++ b/frontend/src/components/ui/BrowsePage/ZarrPreview.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { Typography } from '@material-tailwind/react'; import toast from 'react-hot-toast'; -import { useOutletContext } from 'react-router'; +import { useNavigate, useOutletContext } from 'react-router'; import type { UseQueryResult } from '@tanstack/react-query'; import zarrLogo from '@/assets/zarr.jpg'; @@ -15,7 +15,9 @@ import type { PendingToolKey } from '@/hooks/useZarrMetadata'; import useDataToolLinks from '@/hooks/useDataToolLinks'; +import { useCreateViewFlow } from '@/hooks/useCreateViewFlow'; import { useCartContext } from '@/contexts/CartContext'; +import { usePreferencesContext } from '@/contexts/PreferencesContext'; import { Metadata, getDatasetWarnings } from '@/omezarr-helper'; import type { OutletContextType } from '@/layouts/BrowseLayout'; @@ -65,6 +67,9 @@ export default function ZarrPreview({ ); const { addToCart } = useCartContext(); + const { neuroglancerButtonTarget } = usePreferencesContext(); + const { startCreateView, dialog: createViewDialog } = useCreateViewFlow(); + const navigate = useNavigate(); // ponytail: optional — component tests render without a router outlet. const outlet = useOutletContext(); @@ -138,6 +143,16 @@ export default function ZarrPreview({ dataLinkUrl={openWithToolUrls.copy || undefined} fspName={fspName} onAddToCart={() => void handleAddToCart()} + onOpenNeuroglancer={ + neuroglancerButtonTarget === 'views' + ? () => + startCreateView( + [{ fsp_name: fspName, path, label: datasetLabel }], + datasetLabel, + view => navigate(`/view/${view.read_key}`) + ) + : undefined + } onToolClick={handleToolClick} path={path} showCopiedTooltip={showCopiedTooltip} @@ -157,6 +172,7 @@ export default function ZarrPreview({ tools={true} /> ) : null} + {createViewDialog}
{zarrMetadataQuery.data?.metadata && 'arr' in zarrMetadataQuery.data.metadata ? ( diff --git a/frontend/src/components/ui/PreferencesPage/NeuroglancerOptions.tsx b/frontend/src/components/ui/PreferencesPage/NeuroglancerOptions.tsx index ca26e3a80..bbc3877fd 100644 --- a/frontend/src/components/ui/PreferencesPage/NeuroglancerOptions.tsx +++ b/frontend/src/components/ui/PreferencesPage/NeuroglancerOptions.tsx @@ -1,10 +1,16 @@ import toast from 'react-hot-toast'; +import { Typography } from '@material-tailwind/react'; import FgSwitch from '@/components/designSystem/atoms/formElements/FgSwitch'; import FgRadio from '@/components/designSystem/atoms/formElements/FgRadio'; import FgFieldSet from '@/components/designSystem/molecules/FgFieldSet'; import { usePreferencesContext } from '@/contexts/PreferencesContext'; +import type { + NeuroglancerButtonTarget, + ViewerUrlSource +} from '@/contexts/PreferencesContext'; import { useViewersContext } from '@/contexts/ViewersContext'; +import type { Result } from '@/shared.types'; /** Best-effort hostname for display; falls back to the raw template. */ function hostLabel(template: string): string { @@ -15,6 +21,52 @@ function hostLabel(template: string): string { } } +function BaseUrlChoice({ + hosts, + idPrefix, + legend, + onChange, + source +}: { + readonly hosts: { internal: string; public: string }; + readonly idPrefix: string; + readonly legend: string; + readonly onChange: (source: 'configured' | 'manifest') => void; + readonly source: ViewerUrlSource | undefined; +}) { + const current = source === 'manifest' ? 'manifest' : 'configured'; + return ( + + onChange('configured')} + value="configured" + /> + onChange('manifest')} + value="manifest" + /> + + ); +} + +function SubHeading({ children }: { readonly children: string }) { + return ( + + {children} + + ); +} + export default function NeuroglancerOptions() { const { useLegacyMultichannelApproach, @@ -24,7 +76,11 @@ export default function NeuroglancerOptions() { disableHeuristicalLayerTypeDetection, toggleDisableHeuristicalLayerTypeDetection, viewerUrlSources, - setViewerUrlSource + setViewerUrlSource, + neuroglancerButtonTarget, + setNeuroglancerButtonTarget, + viewsNeuroglancerUrlSource, + setViewsNeuroglancerUrlSource } = usePreferencesContext(); const { validViewers } = useViewersContext(); @@ -33,103 +89,147 @@ export default function NeuroglancerOptions() { // Only offer the URL choice when the deployment overrides the Neuroglancer URL // (i.e. the configured template differs from the manifest default). Otherwise // both options resolve to the same URL and the control is meaningless. - const showUrlSourceChoice = - !!neuroglancer && - !!neuroglancer.manifestTemplateUrl && - neuroglancer.manifestTemplateUrl !== neuroglancer.urlTemplate; + const hosts = + neuroglancer?.manifestTemplateUrl && + neuroglancer.manifestTemplateUrl !== neuroglancer.urlTemplate + ? { + internal: hostLabel(neuroglancer.urlTemplate), + public: hostLabel(neuroglancer.manifestTemplateUrl) + } + : null; - const currentSource = - viewerUrlSources['neuroglancer'] === 'manifest' ? 'manifest' : 'configured'; - - const handleUrlSourceChange = async (source: 'configured' | 'manifest') => { - const result = await setViewerUrlSource('neuroglancer', source); + const report = (result: Result, ok: string) => { if (result.success) { - toast.success('Neuroglancer URL preference updated'); + toast.success(ok); } else { toast.error(result.error); } }; + const handleButtonTargetChange = async (target: NeuroglancerButtonTarget) => + report( + await setNeuroglancerButtonTarget(target), + 'Neuroglancer button preference updated' + ); + return ( -
- {showUrlSourceChoice ? ( - - handleUrlSourceChange('configured')} - value="configured" - /> - handleUrlSourceChange('manifest')} - value="manifest" - /> - - ) : null} - - { - const result = await toggleUseLegacyMultichannelApproach(); - if (result.success) { - toast.success( - useLegacyMultichannelApproach - ? 'Disabled multichannel state generation for Neuroglancer' - : 'Enabled multichannel state generation for Neuroglancer' - ); - } else { - toast.error(result.error); - } - }} - showState +
+ + handleButtonTargetChange('views')} + value="views" /> - { - const result = await toggleDisableNeuroglancerStateGeneration(); - if (result.success) { - toast.success( - disableNeuroglancerStateGeneration - ? 'Neuroglancer state generation is now enabled' - : 'Neuroglancer state generation is now disabled' - ); - } else { - toast.error(result.error); - } - }} - showState - /> - { - const result = await toggleDisableHeuristicalLayerTypeDetection(); - if (result.success) { - toast.success( - disableHeuristicalLayerTypeDetection - ? 'Heuristical layer type determination is now enabled' - : 'Heuristical layer type determination is now disabled' - ); - } else { - toast.error(result.error); - } - }} - showState + handleButtonTargetChange('standalone')} + value="standalone" /> + + {hosts ? ( +
+ Views + + report( + await setViewsNeuroglancerUrlSource(source), + 'Views Neuroglancer URL preference updated' + ) + } + source={viewsNeuroglancerUrlSource} + /> +
+ ) : null} + +
+ Standalone Neuroglancer + + Used when the Neuroglancer button opens a standalone Neuroglancer, and + as the default for new Neuroglancer links. + + {hosts ? ( + + report( + await setViewerUrlSource('neuroglancer', source), + 'Neuroglancer URL preference updated' + ) + } + source={viewerUrlSources['neuroglancer']} + /> + ) : null} + + { + const result = await toggleUseLegacyMultichannelApproach(); + if (result.success) { + toast.success( + useLegacyMultichannelApproach + ? 'Disabled multichannel state generation for Neuroglancer' + : 'Enabled multichannel state generation for Neuroglancer' + ); + } else { + toast.error(result.error); + } + }} + showState + /> + { + const result = await toggleDisableNeuroglancerStateGeneration(); + if (result.success) { + toast.success( + disableNeuroglancerStateGeneration + ? 'Neuroglancer state generation is now enabled' + : 'Neuroglancer state generation is now disabled' + ); + } else { + toast.error(result.error); + } + }} + showState + /> + { + const result = await toggleDisableHeuristicalLayerTypeDetection(); + if (result.success) { + toast.success( + disableHeuristicalLayerTypeDetection + ? 'Heuristical layer type determination is now enabled' + : 'Heuristical layer type determination is now disabled' + ); + } else { + toast.error(result.error); + } + }} + showState + /> + +
); } diff --git a/frontend/src/contexts/PreferencesContext.tsx b/frontend/src/contexts/PreferencesContext.tsx index b868a7f69..848283ea3 100644 --- a/frontend/src/contexts/PreferencesContext.tsx +++ b/frontend/src/contexts/PreferencesContext.tsx @@ -51,6 +51,9 @@ export type FolderPreference = { export type ViewerUrlSource = 'configured' | 'manifest' | { custom: string }; export type ViewerUrlSources = Record; +/** Where the metadata panel's Neuroglancer button opens a dataset. */ +export type NeuroglancerButtonTarget = 'views' | 'standalone'; + type PreferencesContextType = { //Full query for accessing loading/error states preferenceQuery: ReturnType; @@ -65,6 +68,8 @@ type PreferencesContextType = { disableHeuristicalLayerTypeDetection: boolean; useLegacyMultichannelApproach: boolean; viewerUrlSources: ViewerUrlSources; + neuroglancerButtonTarget: NeuroglancerButtonTarget; + viewsNeuroglancerUrlSource: ViewerUrlSource; isFilteredByGroups: boolean; showTutorial: boolean; defaultExtraArgs: string; @@ -101,6 +106,12 @@ type PreferencesContextType = { viewerKey: string, source: ViewerUrlSource ) => Promise>; + setNeuroglancerButtonTarget: ( + target: NeuroglancerButtonTarget + ) => Promise>; + setViewsNeuroglancerUrlSource: ( + source: ViewerUrlSource + ) => Promise>; toggleFilterByGroups: () => Promise>; toggleShowTutorial: () => Promise>; updateDefaultExtraArgs: (args: string) => Promise>; @@ -293,6 +304,34 @@ export const PreferencesProvider = ({ } }; + const setNeuroglancerButtonTarget = async ( + target: NeuroglancerButtonTarget + ): Promise> => { + try { + await updatePreferenceMutation.mutateAsync({ + key: 'neuroglancerButtonTarget', + value: target + }); + return createSuccess(undefined); + } catch (error) { + return handleError(error); + } + }; + + const setViewsNeuroglancerUrlSource = async ( + source: ViewerUrlSource + ): Promise> => { + try { + await updatePreferenceMutation.mutateAsync({ + key: 'viewsNeuroglancerUrlSource', + value: source + }); + return createSuccess(undefined); + } catch (error) { + return handleError(error); + } + }; + const toggleShowTutorial = async (): Promise> => { return togglePreference( 'showTutorial', @@ -587,6 +626,10 @@ export const PreferencesProvider = ({ useLegacyMultichannelApproach: preferencesQuery.data?.useLegacyMultichannelApproach || false, viewerUrlSources: preferencesQuery.data?.viewerUrlSources || {}, + neuroglancerButtonTarget: + preferencesQuery.data?.neuroglancerButtonTarget ?? 'views', + viewsNeuroglancerUrlSource: + preferencesQuery.data?.viewsNeuroglancerUrlSource ?? 'configured', isFilteredByGroups: preferencesQuery.data?.isFilteredByGroups ?? true, showTutorial: preferencesQuery.data?.showTutorial ?? true, defaultExtraArgs: preferencesQuery.data?.defaultExtraArgs || '', @@ -617,6 +660,8 @@ export const PreferencesProvider = ({ toggleDisableHeuristicalLayerTypeDetection, toggleUseLegacyMultichannelApproach, setViewerUrlSource, + setNeuroglancerButtonTarget, + setViewsNeuroglancerUrlSource, toggleFilterByGroups, toggleShowTutorial, updateDefaultExtraArgs, diff --git a/frontend/src/hooks/useDefaultNeuroglancerBaseUrl.ts b/frontend/src/hooks/useDefaultNeuroglancerBaseUrl.ts index 32f7b24b2..da149bd18 100644 --- a/frontend/src/hooks/useDefaultNeuroglancerBaseUrl.ts +++ b/frontend/src/hooks/useDefaultNeuroglancerBaseUrl.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { usePreferencesContext } from '@/contexts/PreferencesContext'; +import type { ViewerUrlSource } from '@/contexts/PreferencesContext'; import { useViewersContext } from '@/contexts/ViewersContext'; import { resolveViewerTemplate } from '@/utils/viewerUrl'; @@ -9,16 +10,13 @@ export const DEFAULT_NEUROGLANCER_BASE_URL = 'https://neuroglancer-demo.appspot.com/'; /** - * The Neuroglancer base URL (the part before the '#!' state fragment) that new - * short links should default to, honoring the deployment's configured URL and - * the user's per-viewer URL preference. + * The Neuroglancer base URL (the part before the '#!' state fragment) for the + * given URL preference, honoring the deployment's configured URL. */ -export function useDefaultNeuroglancerBaseUrl(): string { +function useNeuroglancerBaseUrl(source: ViewerUrlSource | undefined): string { const { validViewers } = useViewersContext(); - const { viewerUrlSources } = usePreferencesContext(); return useMemo(() => { - const source = viewerUrlSources['neuroglancer']; const neuroglancer = validViewers.find(v => v.key === 'neuroglancer'); if (neuroglancer) { const template = resolveViewerTemplate(neuroglancer, source); @@ -33,7 +31,25 @@ export function useDefaultNeuroglancerBaseUrl(): string { return source.custom.split('#!')[0] || DEFAULT_NEUROGLANCER_BASE_URL; } return DEFAULT_NEUROGLANCER_BASE_URL; - }, [validViewers, viewerUrlSources]); + }, [validViewers, source]); +} + +/** + * The base URL for standalone Neuroglancer: the metadata panel's button and + * new short links. Follows the user's standalone Neuroglancer URL preference. + */ +export function useDefaultNeuroglancerBaseUrl(): string { + const { viewerUrlSources } = usePreferencesContext(); + return useNeuroglancerBaseUrl(viewerUrlSources['neuroglancer']); +} + +/** + * The base URL for a View's "Open in Neuroglancer" link-out. Follows its own + * preference, separate from standalone Neuroglancer. + */ +export function useViewsNeuroglancerBaseUrl(): string { + const { viewsNeuroglancerUrlSource } = usePreferencesContext(); + return useNeuroglancerBaseUrl(viewsNeuroglancerUrlSource); } /** diff --git a/frontend/src/queries/preferencesQueries.ts b/frontend/src/queries/preferencesQueries.ts index 149fcfed9..dd87efff8 100644 --- a/frontend/src/queries/preferencesQueries.ts +++ b/frontend/src/queries/preferencesQueries.ts @@ -18,6 +18,8 @@ import type { FileSharePathPreference, FolderPreference, FolderFavorite, + NeuroglancerButtonTarget, + ViewerUrlSource, ViewerUrlSources } from '@/contexts/PreferencesContext'; import { @@ -50,6 +52,8 @@ type PreferencesApiResponse = { disableHeuristicalLayerTypeDetection?: { value: boolean }; useLegacyMultichannelApproach?: { value: boolean }; viewerUrlSources?: { value: ViewerUrlSources }; + neuroglancerButtonTarget?: { value: NeuroglancerButtonTarget }; + viewsNeuroglancerUrlSource?: { value: ViewerUrlSource }; isFilteredByGroups?: { value: boolean }; showTutorial?: { value: boolean }; defaultExtraArgs?: { value: string }; @@ -85,6 +89,8 @@ export type PreferencesQueryData = { disableHeuristicalLayerTypeDetection: boolean; useLegacyMultichannelApproach: boolean; viewerUrlSources: ViewerUrlSources; + neuroglancerButtonTarget: NeuroglancerButtonTarget; + viewsNeuroglancerUrlSource: ViewerUrlSource; isFilteredByGroups: boolean; showTutorial: boolean; defaultExtraArgs: string; @@ -259,6 +265,10 @@ const createTransformPreferences = ( useLegacyMultichannelApproach: rawData.useLegacyMultichannelApproach?.value || false, viewerUrlSources: rawData.viewerUrlSources?.value || {}, + neuroglancerButtonTarget: + rawData.neuroglancerButtonTarget?.value ?? 'views', + viewsNeuroglancerUrlSource: + rawData.viewsNeuroglancerUrlSource?.value ?? 'configured', isFilteredByGroups: rawData.isFilteredByGroups?.value ?? true, showTutorial: rawData.showTutorial?.value ?? true, defaultExtraArgs: rawData.defaultExtraArgs?.value || '', diff --git a/frontend/ui-tests/tests/data-link-operations.spec.ts b/frontend/ui-tests/tests/data-link-operations.spec.ts index 7ecf90efe..981692c71 100644 --- a/frontend/ui-tests/tests/data-link-operations.spec.ts +++ b/frontend/ui-tests/tests/data-link-operations.spec.ts @@ -47,6 +47,14 @@ test.describe('Data Link Operations', () => { test.beforeEach( 'Wait for Zarr directories to load', async ({ fileglancerPage: page }) => { + // These tests cover the data link the standalone viewer needs; by + // default, the Neuroglancer button creates a View instead. + const response = await page.request.put( + '/api/preference/neuroglancerButtonTarget', + { data: { value: 'standalone' } } + ); + expect(response.ok()).toBe(true); + await page.reload(); await expect( page.getByRole('link', { name: ZARR_TEST_FILE_INFO.v3_ome.dirname }) ).toBeVisible();