diff --git a/app/react/docker/containers/ListView/ContainersDatatable/RowContext.ts b/app/react/docker/containers/ListView/ContainersDatatable/RowContext.ts index 66af35873..8081d5e59 100644 --- a/app/react/docker/containers/ListView/ContainersDatatable/RowContext.ts +++ b/app/react/docker/containers/ListView/ContainersDatatable/RowContext.ts @@ -4,6 +4,13 @@ import { createRowContext } from '@@/datatables/RowContext'; interface RowContextState { environment: Environment; + // When the containers datatable is rendered inside a stack, these are the + // current stack route params (name/stackId/type/regular/external/orphaned/ + // orphanedRunning/tab). They let the Quick Actions column build links to the + // stack-scoped container sub-tab states so the stack breadcrumb trail is + // preserved. Undefined for the global containers list (keeps global links). + // Raw route params (strings), consumed by buildStackLinkParams downstream. + stackRouteParams?: Record; } const { RowProvider, useRowContext } = createRowContext(); diff --git a/app/react/docker/containers/ListView/ContainersDatatable/columns/quick-actions.tsx b/app/react/docker/containers/ListView/ContainersDatatable/columns/quick-actions.tsx index 429ec2652..e21d662f9 100644 --- a/app/react/docker/containers/ListView/ContainersDatatable/columns/quick-actions.tsx +++ b/app/react/docker/containers/ListView/ContainersDatatable/columns/quick-actions.tsx @@ -3,10 +3,12 @@ import { CellContext } from '@tanstack/react-table'; import { useAuthorizations } from '@/react/hooks/useUser'; import { ContainerQuickActions } from '@/react/docker/containers/components/ContainerQuickActions'; import { ContainerListViewModel } from '@/react/docker/containers/types'; +import { buildStackContainerLinkParams } from '@/react/docker/containers/ItemView/containerBreadcrumbs'; import { useTableSettings } from '@@/datatables/useTableSettings'; import { TableSettings } from '../types'; +import { useRowContext } from '../RowContext'; import { columnHelper } from './helper'; @@ -20,9 +22,20 @@ function QuickActionsCell({ row: { original: container }, }: CellContext) { const settings = useTableSettings(); + const { stackRouteParams } = useRowContext(); const { hiddenQuickActions = [] } = settings; + // When rendered inside a stack, build the params for the stack-scoped + // container sub-tab states so the stack breadcrumb trail is preserved. + // Undefined in the global containers list, which keeps the global links. + const containerStackLinkParams = stackRouteParams + ? buildStackContainerLinkParams( + { ...stackRouteParams, nodeName: container.NodeName }, + container.Id + ) + : undefined; + const wrapperState = { showQuickActionAttach: !hiddenQuickActions.includes('attach'), showQuickActionExec: !hiddenQuickActions.includes('exec'), @@ -57,6 +70,7 @@ function QuickActionsCell({ nodeName={container.NodeName} status={container.Status} state={wrapperState} + stackLinkParams={containerStackLinkParams} /> ); } diff --git a/app/react/docker/containers/components/ContainerQuickActions/ContainerQuickActions.test.tsx b/app/react/docker/containers/components/ContainerQuickActions/ContainerQuickActions.test.tsx new file mode 100644 index 000000000..c13b4c07b --- /dev/null +++ b/app/react/docker/containers/components/ContainerQuickActions/ContainerQuickActions.test.tsx @@ -0,0 +1,129 @@ +import { render } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { vi } from 'vitest'; + +import { ContainerStatus } from '@/react/docker/containers/types'; +import { buildStackContainerLinkParams } from '@/react/docker/containers/ItemView/containerBreadcrumbs'; + +import { + ContainerQuickActions, + QuickActionsState, +} from './ContainerQuickActions'; + +// Render the Link's target state and params as data attributes so the tests can +// assert on them without a full ui-router state tree. +vi.mock('@@/Link', () => ({ + Link: ({ + children, + to, + params, + 'data-cy': dataCy, + }: { + children: ReactNode; + to: string; + params?: Record; + 'data-cy'?: string; + }) => ( + + {children} + + ), +})); + +// Authorizations are covered elsewhere; render the children unconditionally. +vi.mock('@/react/hooks/useUser', () => ({ + Authorized: ({ children }: { children: ReactNode }) => children, +})); + +const allOn: QuickActionsState = { + showQuickActionAttach: true, + showQuickActionExec: true, + showQuickActionInspect: true, + showQuickActionLogs: true, + showQuickActionStats: true, +}; + +const containerId = 'abc123'; +const nodeName = 'node-1'; + +const tabs = ['logs', 'inspect', 'stats', 'exec', 'attach'] as const; + +// Look the rendered Link up by the data-cy our Link mock sets. +function byDataCy(tab: (typeof tabs)[number]) { + return document.querySelector( + `[data-cy="container-${tab}-${containerId}"]` + ); +} + +test('without stack context, links target the global container states', () => { + render( + + ); + + tabs.forEach((tab) => { + const link = byDataCy(tab); + expect(link).not.toBeNull(); + expect(link?.getAttribute('data-to')).toBe( + `docker.containers.container.${tab}` + ); + expect(JSON.parse(link?.getAttribute('data-params') || '{}')).toEqual({ + id: containerId, + nodeName, + }); + }); +}); + +test('with stack context, links target the stack-scoped container states with stack params', () => { + const stackRouteParams = { + name: 'my-stack', + stackId: '7', + type: '2', + regular: 'true', + tab: 'containers', + }; + const stackLinkParams = buildStackContainerLinkParams( + { ...stackRouteParams, nodeName }, + containerId + ); + + render( + + ); + + tabs.forEach((tab) => { + const link = byDataCy(tab); + expect(link).not.toBeNull(); + expect(link?.getAttribute('data-to')).toBe( + `docker.stacks.stack.container.${tab}` + ); + // Every sub-tab shares the same stack params + container id/nodeName so the + // stack breadcrumb trail is preserved on navigation. + expect(JSON.parse(link?.getAttribute('data-params') || '{}')).toEqual({ + name: 'my-stack', + stackId: '7', + type: '2', + regular: 'true', + orphaned: undefined, + orphanedRunning: undefined, + tab: 'containers', + id: containerId, + nodeName, + }); + }); +}); diff --git a/app/react/docker/containers/components/ContainerQuickActions/ContainerQuickActions.tsx b/app/react/docker/containers/components/ContainerQuickActions/ContainerQuickActions.tsx index fe2da97d5..4d443688c 100644 --- a/app/react/docker/containers/components/ContainerQuickActions/ContainerQuickActions.tsx +++ b/app/react/docker/containers/components/ContainerQuickActions/ContainerQuickActions.tsx @@ -2,6 +2,10 @@ import clsx from 'clsx'; import { BarChart, FileText, Info, Paperclip, Terminal } from 'lucide-react'; import { ContainerStatus } from '@/react/docker/containers/types'; +import { + STACK_CONTAINER_STATE_NAME, + StackContainerLinkParams, +} from '@/react/docker/containers/ItemView/containerBreadcrumbs'; import { Authorized } from '@/react/hooks/useUser'; import { Icon } from '@@/Icon'; @@ -22,11 +26,16 @@ export function ContainerQuickActions({ containerId, nodeName, state, + stackLinkParams, }: { containerId: string; nodeName: string; status: ContainerStatus; state: QuickActionsState; + // When provided (container opened from a stack), the quick actions link to + // the stack-scoped container sub-tab states with these params so the stack + // breadcrumb trail is preserved. Otherwise they link to the global states. + stackLinkParams?: StackContainerLinkParams; }) { const isActive = !!status && @@ -37,13 +46,23 @@ export function ContainerQuickActions({ ContainerStatus.Unhealthy, ].includes(status); + // Build the target ui-router state for a given sub-tab: the stack-scoped + // state when inside a stack, the global container state otherwise. Both share + // the same tab suffix (logs/inspect/stats/exec/attach). + function linkTo(tab: string) { + return stackLinkParams + ? `${STACK_CONTAINER_STATE_NAME}.${tab}` + : `docker.containers.container.${tab}`; + } + const linkParams = stackLinkParams ?? { id: containerId, nodeName }; + return (
{state.showQuickActionLogs && ( @@ -55,8 +74,8 @@ export function ContainerQuickActions({ {state.showQuickActionInspect && ( @@ -68,8 +87,8 @@ export function ContainerQuickActions({ {state.showQuickActionStats && isActive && ( @@ -81,8 +100,8 @@ export function ContainerQuickActions({ {state.showQuickActionExec && isActive && ( @@ -94,8 +113,8 @@ export function ContainerQuickActions({ {state.showQuickActionAttach && isActive && ( diff --git a/app/react/docker/stacks/ItemView/StackContainersDatatable.tsx b/app/react/docker/stacks/ItemView/StackContainersDatatable.tsx index 0ac00f2a5..ade1a2cbc 100644 --- a/app/react/docker/stacks/ItemView/StackContainersDatatable.tsx +++ b/app/react/docker/stacks/ItemView/StackContainersDatatable.tsx @@ -1,4 +1,5 @@ import { Box } from 'lucide-react'; +import { useCurrentStateAndParams } from '@uirouter/react'; import { ContainerListViewModel } from '@/react/docker/containers/types'; import { createStore } from '@/react/docker/containers/ListView/ContainersDatatable/datatable-store'; @@ -42,6 +43,9 @@ export interface Props { export function StackContainersDatatable({ stackName }: Props) { const environmentQuery = useCurrentEnvironment(); const tableState = useTableState(settingsStore, storageKey); + // Current stack route params, forwarded to the Quick Actions column so it can + // link to the stack-scoped container sub-tab states and keep the stack trail. + const { params: stackRouteParams } = useCurrentStateAndParams(); const isGPUsColumnVisible = useShowGPUsColumn(environmentQuery.data); const columns = useColumns(false, isGPUsColumnVisible); @@ -60,7 +64,7 @@ export function StackContainersDatatable({ stackName }: Props) { const environment = environmentQuery.data; return ( - +