diff --git a/graph-ui/src/galaxy/GalaxyPanel.stability.test.tsx b/graph-ui/src/galaxy/GalaxyPanel.stability.test.tsx index 08f05fcc0..be0518fc0 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.stability.test.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.stability.test.tsx @@ -159,3 +159,89 @@ it('highlights a path and the call order of the root inside the loaded scope, an await direction('outbound'); expect(host.querySelector('[data-testid="atlas-galaxy-path-panel"]')).toBeNull(); }); + +const panel = () => host.querySelector('[data-testid="atlas-galaxy-path-panel"]'); +const traceDirection = (value: string) => act(async () => { + const select = host.querySelector('select[aria-label="Trace direction"]')!; + select.value = value; select.dispatchEvent(new Event('change', { bubbles: true })); +}); +const pickPath = (name: string) => act(async () => { + host.querySelector('.atlas-graph-path-picker')!.open = true; + [...host.querySelectorAll('.atlas-graph-path-menu li button')] + .find(entry => entry.querySelector('strong')?.textContent === name)!.click(); +}); + +it('drops a path on a new scope key, so returning to the old key does not bring it back', async () => { + await act(async () => root.render()); + await settle(() => expect(seam().nodes).toBe(5)); + await act(async () => { seam().clickNode('sample.n1'); }); + await settle(() => expect(seam().nodes).toBe(4)); + await pickPath('n2'); + expect(heading()).toBe('Path to n2 · 1 hop'); + + // Incoming and back to both directions: the same key as before, but the path is gone. + await traceDirection('inbound'); + await settle(() => expect(seam().nodes).toBe(2)); + expect(panel()).toBeNull(); + await traceDirection('both'); + await settle(() => expect(seam().nodes).toBe(4)); + expect(panel()).toBeNull(); + expect(scene.path).toBeUndefined(); + + // The whole graph and the same root again: a fresh picture without the old path. + await act(async () => button('Call order').click()); + expect(heading()).toBe('Calls of n1 · 2 calls'); + await act(async () => button('All graph').click()); + await settle(() => expect(seam().nodes).toBe(5)); + await act(async () => { seam().clickNode('sample.n1'); }); + await settle(() => expect(seam().nodes).toBe(4)); + expect(panel()).toBeNull(); + expect(button('Call order').getAttribute('aria-pressed')).toBe('false'); +}); + +it('leaves Escape to an overlay above the galaxy and to a field being typed in', async () => { + const fetch = graphFetch(); + const panelWith = (escapeTaken: boolean) => ; + await act(async () => root.render(panelWith(false))); + await settle(() => expect(seam().nodes).toBe(5)); + await act(async () => { seam().clickNode('sample.n1'); }); + await settle(() => expect(seam().nodes).toBe(4)); + await pickPath('n2'); + expect(panel()).not.toBeNull(); + const escape = (target: EventTarget) => { + const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }); + act(() => { target.dispatchEvent(event); }); + return event; + }; + + // Help, settings or projects lie above: the key is theirs. + await act(async () => root.render(panelWith(true))); + expect(escape(window).defaultPrevented).toBe(false); + expect(panel()).not.toBeNull(); + + // Typing in a field: Escape leaves the field, not the path. + await act(async () => root.render(panelWith(false))); + const field = host.querySelector('input[aria-label="Find a graph node"]')!; + expect(escape(field).defaultPrevented).toBe(false); + expect(panel()).not.toBeNull(); + + expect(escape(window).defaultPrevented).toBe(true); + expect(panel()).toBeNull(); +}); + +it('keeps the picked name when a removed layer takes the path target out of the picture', async () => { + await act(async () => root.render()); + await settle(() => expect(seam().nodes).toBe(5)); + await act(async () => { seam().clickNode('sample.n1'); }); + await settle(() => expect(seam().nodes).toBe(4)); + await act(async () => button('Expand +1').click()); + await settle(() => expect(seam().nodes).toBe(5)); + await pickPath('n4'); + expect(heading()).toBe('Path to n4 · 2 hops'); + + await act(async () => host.querySelector('button[aria-label="Remove graph layer"]')!.click()); + await settle(() => expect(seam().nodes).toBe(4)); + expect(heading()).toBe('Path to n4 · 0 hops'); + expect(panel()?.textContent).toContain('No path to n4 over the loaded relationships'); + expect(panel()?.textContent).not.toContain('#4'); +}); diff --git a/graph-ui/src/galaxy/GalaxyPanel.tsx b/graph-ui/src/galaxy/GalaxyPanel.tsx index 5b5f90a35..dc98d4584 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.tsx @@ -215,6 +215,7 @@ import type { AgentsRuntime } from '../agents/agent-source'; import { loadAgentsPreference, saveAgentsPreference } from '../agents/agent-preference'; import type { AgentsPreference } from '../agents/agent-preference'; import { fullscreenIsolationRequired, isolateFullscreenBackground } from './fullscreen-isolation'; +import { isTypingTarget } from '../app/keyboard'; /** * Wie lange die Ereigniszeile des FOLLOW-Modus stehen bleibt. @@ -1207,10 +1208,17 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { * nicht". Der Abstand ist hier die Zahl der Hops und nur hier (G6); die * Wolke selbst behauptet keinen. Die Wahl haengt am Scope (`organicKey`): * eine neue Wurzel, Richtung oder Kantenart verwirft sie, ein Expand - * rechnet sie auf dem groesseren Bild neu. + * rechnet sie auf dem groesseren Bild neu. Verworfen heisst verworfen: wer + * die Richtung zurueckstellt oder dieselbe Wurzel neu waehlt, bekommt den + * alten Pfad nicht ungefragt wieder. Das Ziel behaelt seinen Namen, auch + * wenn ein entfernter Layer es aus dem Bild nimmt. */ - const [trail, setTrail] = useState<{ key: string; kind: 'path'; target: number } | { key: string; kind: 'calls' }>(); + const [trail, setTrail] = useState<{ key: string; kind: 'path'; target: number; name: string } | { key: string; kind: 'calls' }>(); const [trailStep, setTrailStep] = useState(0); + useEffect(() => { + setTrail(current => (current && current.key !== organicKey ? undefined : current)); + setTrailStep(0); + }, [organicKey]); const trailRoot = scope.result?.roots.size === 1 ? [...scope.result.roots][0] : undefined; const rootCalls = useMemo(() => (data && trailRoot !== undefined ? callOrder(data.edges, trailRoot) : []), [data, trailRoot]); const pathCandidates = useMemo(() => data?.nodes.filter(node => !scope.result?.roots.has(node.id)) ?? [], [data, scope.result]); @@ -1223,7 +1231,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { heading: galaxyPathText.callsHeading(trailRoot === undefined ? '' : nameOf(trailRoot), rootCalls.length) }; } const steps: ScopePathStep[] | undefined = shortestScopePath(data.edges, scope.result.roots, trail.target, scope.direction); - const target = nameOf(trail.target); + const target = names.get(trail.target) ?? trail.name; return { nameOf, lines: false, labels: 'all' as const, steps: steps ?? [], heading: galaxyPathText.pathHeading(target, steps?.length ?? 0), note: steps === undefined ? galaxyPathText.noPath(target) : steps.length === 0 ? galaxyPathText.isRoot(target) : undefined }; @@ -1232,16 +1240,24 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { const trailIds = useMemo(() => trailView?.steps.length && scope.result ? pathNodes(trailView.steps, scope.result.roots) : undefined, [trailView, scope.result]); const clearTrail = useCallback(() => { setTrail(undefined); setTrailStep(0); }, []); + /* + * Escape gibt den Pfad frei, aber erst nach allen, die vorgehen: liegt eine + * Flaeche darueber (`escapeTaken`, dieselbe Reihenfolge wie beim + * Vollbild), gehoert die Taste ihr, und wer in einem Feld oder im Editor + * tippt, verlaesst mit Escape das Feld und nicht den Pfad. + */ + const escapeTaken = props.escapeTaken === true; useEffect(() => { - if (!trailView) return; + if (!trailView || escapeTaken) return; const onKey = (event: globalThis.KeyboardEvent): void => { if (event.key !== 'Escape' || event.defaultPrevented) return; + if (isTypingTarget(event.target instanceof Element ? event.target : null)) return; event.preventDefault(); clearTrail(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [trailView, clearTrail]); + }, [trailView, escapeTaken, clearTrail]); const index = useMemo( () => (picture === undefined ? new Map() : nodesByQualifiedName(picture.nodes)), @@ -1727,9 +1743,8 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { * die Oberflaeche legt, und zwar ZULETZT: liegt eine andere Flaeche darueber * (Hilfe, Einstiegsdialog, Suchfenster, Einstellungen), gehoert die Taste * ihr. Der Vollbildmodus reiht sich in die bestehende Reihenfolge ein, er - * draengt sich nicht vor. + * draengt sich nicht vor (`escapeTaken` steht beim Pfad weiter oben). */ - const escapeTaken = props.escapeTaken === true; useEffect(() => { const node = panel.current; if (node === null || !fullscreenIsolationRequired(fullscreen, escapeTaken)) { @@ -1810,6 +1825,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { const selected = layoutNodeForSelection(layout, node) ?? layoutNodeForSelection(data, node) ?? node; props.onSelectNode?.(selected); if (props.workspaceExpanded) { + clearTrail(); scope.select({ kind: 'node', id: selected.id, name: selected.name, qualifiedName: selected.qualified_name }); setNote(''); return; @@ -1821,19 +1837,20 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { setNote(''); onOpenNode(selected); }, - [layout, data, picture, mode, flyTo, onOpenNode, props.onSelectNode, props.workspaceExpanded, scope.select], + [layout, data, picture, mode, flyTo, onOpenNode, props.onSelectNode, props.workspaceExpanded, scope.select, clearTrail], ); const handleBackgroundClick = useCallback(() => { setBackgroundCleared(true); if (props.workspaceExpanded) setChosenMode('galaxy'); + clearTrail(); scope.reset(); changeTraceTypes(undefined); setHighlighted(null); setNote(props.workspaceExpanded ? '' : mode === 'hierarchy' ? readerProjection?.message ?? HIERARCHY_NO_FOCUS_NOTE : GALAXY_NO_FOCUS_NOTE); refitNow(); props.onClearSelection?.(); - }, [mode, refitNow, props.onClearSelection, readerProjection, scope.reset, props.workspaceExpanded, changeTraceTypes]); + }, [mode, refitNow, props.onClearSelection, readerProjection, scope.reset, props.workspaceExpanded, changeTraceTypes, clearTrail]); /* * Die Vorgabe der Ansicht, mit der Wahl des Lesers darauf. @@ -2312,7 +2329,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { )} {(props.workspaceExpanded || scope.scope) &&
- {props.workspaceExpanded && { props.onClearSelection?.(); setBackgroundCleared(false); scope.select(next); }} />} + {props.workspaceExpanded && { props.onClearSelection?.(); setBackgroundCleared(false); clearTrail(); scope.select(next); }} />} {scope.scope ? <> {props.workspaceExpanded && } {scope.scope.name} @@ -2328,7 +2345,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { {props.workspaceExpanded && mode === 'galaxy' && <> - { setTrail({ key: organicKey, kind: 'path', target: node.id }); setTrailStep(0); }} /> + { setTrail({ key: organicKey, kind: 'path', target: node.id, name: node.name }); setTrailStep(0); }} />