mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix: leave editor after deleting the open stack (#1665)
* fix: leave editor after deleting the open stack Clear selection on delete-key identity match, navigate to dashboard only when the editor is visible, and clear mobile detail so the URL writer leaves the deleted stack route instead of a permanent skeleton. * fix: remove duplicate setIsFileLoading in stack actions test fixture
This commit is contained in:
@@ -208,6 +208,10 @@ export default function EditorLayout() {
|
||||
// useViewNavigationState needs onNavigateToDashboard -> resetEditorState
|
||||
// but stackActions isn't created until after navState
|
||||
const resetEditorStateRef = useRef<() => void>(() => {});
|
||||
// Mobile state is declared after useStackActions; this ref is assigned once
|
||||
// pendingDetailStack / mobileView exist so delete-of-open-stack can flip to
|
||||
// the list surface without reordering the hook graph.
|
||||
const onDeletedOpenStackRef = useRef<() => void>(() => {});
|
||||
|
||||
const navState = useViewNavigationState({
|
||||
onNavigateToDashboard: () => resetEditorStateRef.current(),
|
||||
@@ -282,6 +286,7 @@ export default function EditorLayout() {
|
||||
return can('stack:edit', 'stack', stackName);
|
||||
},
|
||||
canOfferVolumeRemoval,
|
||||
onDeletedOpenStack: () => onDeletedOpenStackRef.current(),
|
||||
});
|
||||
|
||||
// Wire the ref now that stackActions is available
|
||||
@@ -375,6 +380,10 @@ export default function EditorLayout() {
|
||||
const [pendingDetailStack, setPendingDetailStack] = useState<string | null>(null);
|
||||
const [pendingAnatomyTab, setPendingAnatomyTab] = useState<'networking' | 'doctor' | 'dossier' | 'drift' | undefined>();
|
||||
const [fleetUpdatesIntent, setFleetUpdatesIntent] = useState<{ tab: 'nodes' | 'changelog' } | null>(null);
|
||||
onDeletedOpenStackRef.current = () => {
|
||||
setPendingDetailStack(null);
|
||||
setMobileView('list');
|
||||
};
|
||||
|
||||
const handleFleetUpdatesIntentConsumed = useCallback(() => setFleetUpdatesIntent(null), []);
|
||||
|
||||
|
||||
@@ -102,6 +102,8 @@ function makeOverlay(over: Partial<OverlayState> = {}): OverlayState {
|
||||
setPreDeployAdvisory: vi.fn(),
|
||||
openSelfStackProtected: vi.fn(),
|
||||
setDiffPreview: vi.fn(),
|
||||
stackToDelete: null,
|
||||
closeDeleteDialog: vi.fn(),
|
||||
...over,
|
||||
} as unknown as OverlayState;
|
||||
}
|
||||
@@ -116,17 +118,24 @@ function setup(over: {
|
||||
editorState?: Partial<EditorState>;
|
||||
overlay?: Partial<OverlayState>;
|
||||
stackList?: Partial<StackListState>;
|
||||
navState?: Partial<NavState>;
|
||||
getLastDeployOutputLine?: (stackName: string) => string | undefined;
|
||||
hasUpdateGuard?: boolean;
|
||||
canEditStack?: (stackNameOrFilename: string) => boolean;
|
||||
activeNode?: Parameters<typeof useStackActions>[0]['activeNode'];
|
||||
setActiveNode?: Parameters<typeof useStackActions>[0]['setActiveNode'];
|
||||
onDeletedOpenStack?: () => void;
|
||||
} = {}) {
|
||||
const editorState = makeEditorState(over.editorState);
|
||||
const stackListState = makeStackListState(over.stackList);
|
||||
const navState = { setActiveView: vi.fn() } as unknown as NavState;
|
||||
const navState = {
|
||||
activeView: 'editor',
|
||||
setActiveView: vi.fn(),
|
||||
...over.navState,
|
||||
} as unknown as NavState;
|
||||
const overlayState = makeOverlay(over.overlay);
|
||||
const setActiveNode = over.setActiveNode ?? vi.fn();
|
||||
const onDeletedOpenStack = over.onDeletedOpenStack ?? vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useStackActions({
|
||||
@@ -142,9 +151,10 @@ function setup(over: {
|
||||
diffPreviewEnabled: false,
|
||||
hasUpdateGuard: over.hasUpdateGuard ?? false,
|
||||
canEditStack: over.canEditStack ?? (() => true),
|
||||
onDeletedOpenStack,
|
||||
}),
|
||||
);
|
||||
return { result, editorState, stackListState, overlayState, setActiveNode };
|
||||
return { result, editorState, stackListState, overlayState, navState, setActiveNode, onDeletedOpenStack };
|
||||
}
|
||||
|
||||
describe('useStackActions.saveFile', () => {
|
||||
@@ -182,7 +192,7 @@ describe('useStackActions.saveFile', () => {
|
||||
useStackActions({
|
||||
editorState,
|
||||
stackListState,
|
||||
navState: { setActiveView: vi.fn() } as unknown as NavState,
|
||||
navState: { activeView: 'editor', setActiveView: vi.fn() } as unknown as NavState,
|
||||
overlayState: makeOverlay(),
|
||||
activeNode: { id: 1, type: 'local' } as Parameters<typeof useStackActions>[0]['activeNode'],
|
||||
setActiveNode: vi.fn(),
|
||||
@@ -191,6 +201,7 @@ describe('useStackActions.saveFile', () => {
|
||||
getLastDeployOutputLine: () => undefined,
|
||||
diffPreviewEnabled: false,
|
||||
canEditStack: () => true,
|
||||
onDeletedOpenStack: vi.fn(),
|
||||
}),
|
||||
);
|
||||
const ok = await result.current.saveFile();
|
||||
@@ -1207,3 +1218,146 @@ describe('useStackActions.openStackApp', () => {
|
||||
expect(clickCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useStackActions.deleteStack', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
});
|
||||
|
||||
it('leaves the editor for dashboard when deleting the open stack by filename', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(new Response(null, { status: 200 }));
|
||||
const { result, stackListState, overlayState, navState, onDeletedOpenStack } = setup({
|
||||
overlay: { stackToDelete: 'web.yml' },
|
||||
stackList: { selectedFile: 'web.yml', files: ['web.yml'] },
|
||||
navState: { activeView: 'editor' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteStack(false);
|
||||
});
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledWith('/stacks/web.yml', { method: 'DELETE' });
|
||||
expect(stackListState.setSelectedFile).toHaveBeenCalledWith(null);
|
||||
expect(navState.setActiveView).toHaveBeenCalledWith('dashboard');
|
||||
expect(navState.setActiveView).toHaveBeenCalledTimes(1);
|
||||
expect(onDeletedOpenStack).toHaveBeenCalledTimes(1);
|
||||
expect(overlayState.closeDeleteDialog).toHaveBeenCalled();
|
||||
expect(stackListState.refreshStacks).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears isFileLoading on delete-leave so the URL writer is not blocked', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(new Response(null, { status: 200 }));
|
||||
const { result, editorState } = setup({
|
||||
overlay: { stackToDelete: 'web.yml' },
|
||||
stackList: { selectedFile: 'web.yml', files: ['web.yml'] },
|
||||
editorState: { isFileLoading: true },
|
||||
navState: { activeView: 'editor' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteStack(false);
|
||||
});
|
||||
|
||||
expect(editorState.setIsFileLoading).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('leaves the editor when sidebar delete passes a basename', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(new Response(null, { status: 200 }));
|
||||
const { result, stackListState, navState, onDeletedOpenStack } = setup({
|
||||
overlay: { stackToDelete: 'web' },
|
||||
stackList: { selectedFile: 'web.yml', files: ['web.yml'] },
|
||||
navState: { activeView: 'editor' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteStack(false);
|
||||
});
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledWith('/stacks/web', { method: 'DELETE' });
|
||||
expect(stackListState.setSelectedFile).toHaveBeenCalledWith(null);
|
||||
expect(navState.setActiveView).toHaveBeenCalledWith('dashboard');
|
||||
expect(onDeletedOpenStack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not navigate when deleting a different stack', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(new Response(null, { status: 200 }));
|
||||
const { result, stackListState, navState, onDeletedOpenStack } = setup({
|
||||
overlay: { stackToDelete: 'other.yml' },
|
||||
stackList: {
|
||||
selectedFile: 'web.yml',
|
||||
files: ['web.yml', 'other.yml'],
|
||||
},
|
||||
navState: { activeView: 'editor' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteStack(false);
|
||||
});
|
||||
|
||||
expect(stackListState.setSelectedFile).not.toHaveBeenCalledWith(null);
|
||||
expect(navState.setActiveView).not.toHaveBeenCalled();
|
||||
expect(onDeletedOpenStack).not.toHaveBeenCalled();
|
||||
expect(stackListState.refreshStacks).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears selection without navigating when the matching stack is hidden behind another view', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(new Response(null, { status: 200 }));
|
||||
const { result, stackListState, navState, onDeletedOpenStack } = setup({
|
||||
overlay: { stackToDelete: 'web.yml' },
|
||||
stackList: { selectedFile: 'web.yml', files: ['web.yml'] },
|
||||
navState: { activeView: 'resources' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteStack(false);
|
||||
});
|
||||
|
||||
expect(stackListState.setSelectedFile).toHaveBeenCalledWith(null);
|
||||
expect(navState.setActiveView).not.toHaveBeenCalled();
|
||||
expect(onDeletedOpenStack).not.toHaveBeenCalled();
|
||||
expect(stackListState.refreshStacks).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not reset or navigate on a non-OK delete response', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(new Response('boom', { status: 500 }));
|
||||
const { toast } = await import('@/components/ui/toast-store');
|
||||
const { result, stackListState, overlayState, navState, onDeletedOpenStack } = setup({
|
||||
overlay: { stackToDelete: 'web.yml' },
|
||||
stackList: { selectedFile: 'web.yml', files: ['web.yml'] },
|
||||
navState: { activeView: 'editor' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteStack(false);
|
||||
});
|
||||
|
||||
expect(stackListState.setSelectedFile).not.toHaveBeenCalledWith(null);
|
||||
expect(navState.setActiveView).not.toHaveBeenCalled();
|
||||
expect(onDeletedOpenStack).not.toHaveBeenCalled();
|
||||
expect(overlayState.closeDeleteDialog).not.toHaveBeenCalled();
|
||||
expect(stackListState.refreshStacks).not.toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not navigate on a self-stack-protected response', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: 'self_stack_protected' }), { status: 409 }),
|
||||
);
|
||||
const { result, stackListState, overlayState, navState, onDeletedOpenStack } = setup({
|
||||
overlay: { stackToDelete: 'web.yml' },
|
||||
stackList: { selectedFile: 'web.yml', files: ['web.yml'] },
|
||||
navState: { activeView: 'editor' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteStack(false);
|
||||
});
|
||||
|
||||
expect(overlayState.openSelfStackProtected).toHaveBeenCalled();
|
||||
expect(overlayState.closeDeleteDialog).toHaveBeenCalled();
|
||||
expect(stackListState.setSelectedFile).not.toHaveBeenCalledWith(null);
|
||||
expect(navState.setActiveView).not.toHaveBeenCalled();
|
||||
expect(onDeletedOpenStack).not.toHaveBeenCalled();
|
||||
expect(stackListState.refreshStacks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,6 +168,13 @@ interface UseStackActionsOptions {
|
||||
canEditStack: (stackNameOrFilename: string) => boolean;
|
||||
/** Fail-closed: true only when active node meta explicitly lists stack-down-remove-volumes. */
|
||||
canOfferVolumeRemoval?: boolean;
|
||||
/**
|
||||
* Mobile (and any shell-owned) cleanup after deleting the stack that is
|
||||
* currently open in the editor. EditorLayout clears pending detail and
|
||||
* flips to the stack list surface. Required: the sole production caller
|
||||
* owns that state, and an optional callback would silently skip it.
|
||||
*/
|
||||
onDeletedOpenStack: () => void;
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
@@ -390,6 +397,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
hasServiceScopedUpdate = false,
|
||||
canEditStack,
|
||||
canOfferVolumeRemoval = false,
|
||||
onDeletedOpenStack,
|
||||
} = options;
|
||||
|
||||
const pendingStackLoadRef = useRef<string | null>(null);
|
||||
@@ -493,6 +501,10 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
// previous node's data.
|
||||
loadFileAbortRef.current?.abort();
|
||||
loadFileAbortRef.current = null;
|
||||
// loadFileCore's finally skips clearing loading when the signal is aborted,
|
||||
// so clear it here. Otherwise useUrlSync's writer stays blocked after a
|
||||
// delete-leave (or any other reset) that aborts a mid-flight load.
|
||||
editorState.setIsFileLoading(false);
|
||||
stackListState.setSelectedFile(null);
|
||||
editorState.setContent('');
|
||||
editorState.setOriginalContent('');
|
||||
@@ -1767,8 +1779,17 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
}
|
||||
toast.success('Stack deleted successfully!');
|
||||
overlayState.closeDeleteDialog();
|
||||
if (stackListState.selectedFile === stackToDelete) {
|
||||
const selected = stackListState.selectedFile;
|
||||
const stripExt = (name: string) => name.replace(/\.(yml|yaml)$/, '');
|
||||
// Always clear a deleted selection, even when another top-level view is
|
||||
// visible (Resources, Networking, etc.). Leaving that view is gated on
|
||||
// the editor being the active surface below.
|
||||
if (selected != null && stripExt(selected) === stripExt(deleteKey)) {
|
||||
resetEditorState();
|
||||
if (navState.activeView === 'editor') {
|
||||
navState.setActiveView('dashboard');
|
||||
onDeletedOpenStack();
|
||||
}
|
||||
}
|
||||
await stackListState.refreshStacks();
|
||||
} catch (error) {
|
||||
|
||||
@@ -554,6 +554,76 @@ describe('useUrlSync', () => {
|
||||
pushSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('writes dashboard URL after leaving a deleted editor stack', () => {
|
||||
window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/local/stacks/radarr');
|
||||
const pushSpy = vi.spyOn(window.history, 'pushState');
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props) => useUrlSync(props),
|
||||
{
|
||||
initialProps: makeOpts({
|
||||
activeView: 'editor',
|
||||
selectedFile: 'radarr',
|
||||
files: ['radarr'],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
rerender(makeOpts({
|
||||
activeView: 'dashboard',
|
||||
selectedFile: null,
|
||||
files: [],
|
||||
}));
|
||||
});
|
||||
|
||||
const paths = [
|
||||
...pushSpy.mock.calls.map((call) => String(call[2] ?? '')),
|
||||
window.location.pathname,
|
||||
];
|
||||
expect(paths.some((p) => p === '/nodes/local/dashboard')).toBe(true);
|
||||
expect(window.location.pathname).not.toContain('/stacks/radarr');
|
||||
|
||||
pushSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('writes mobile stack-list URL after leaving a deleted editor stack', () => {
|
||||
window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/local/stacks/radarr');
|
||||
const pushSpy = vi.spyOn(window.history, 'pushState');
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props) => useUrlSync(props),
|
||||
{
|
||||
initialProps: makeOpts({
|
||||
activeView: 'editor',
|
||||
selectedFile: 'radarr',
|
||||
files: ['radarr'],
|
||||
isMobile: true,
|
||||
mobileSurface: 'detail',
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
rerender(makeOpts({
|
||||
activeView: 'dashboard',
|
||||
selectedFile: null,
|
||||
files: [],
|
||||
isMobile: true,
|
||||
mobileSurface: 'list',
|
||||
}));
|
||||
});
|
||||
|
||||
const paths = [
|
||||
...pushSpy.mock.calls.map((call) => String(call[2] ?? '')),
|
||||
window.location.pathname,
|
||||
];
|
||||
expect(paths.some((p) => p === '/nodes/local/stacks')).toBe(true);
|
||||
expect(window.location.pathname).not.toContain('/stacks/radarr');
|
||||
|
||||
pushSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('opens Monaco when hydrating /compose deep link', async () => {
|
||||
const loadFileForRoute = vi.fn().mockResolvedValue({ ok: true, envFiles: [] });
|
||||
const applyEditorRouteState = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user