0bf4e71b79
When a container is opened from a stack, the detail tab kept the stack trail (PR #7) but the attribute sub-tabs (Logs, Stats, Inspect, Console, Attach) dropped it: those tabs were registered only under the global docker.containers.container.* tree, so navigating to one left the stack state (and its inherited params) behind, and each sub-view set a hardcoded "Containers > ..." breadcrumb. - Register stack-scoped child states docker.stacks.stack.container.{attach, exec,inspect,logs,stats} mirroring the global ones, so the inherited stack params survive and the trail can be kept. - Centralize the breadcrumb logic in containerBreadcrumbs.ts (moved out of ItemView, which re-exports it) and add isStackContainerState + getContainerSubTabBreadcrumbs + buildStackContainerLinkParams. - ActionLinksRow links sub-tabs into the stack tree (with stack+container params) when opened from a stack, else the global states unchanged. - InspectView + the logs/stats/console controllers render the stack-aware trail; set up-front (no name) so it survives the load window and errors. Covers regular/external/orphaned stacks and the non-stack fallback, matching the existing ItemView breadcrumb behavior. New unit tests in containerBreadcrumbs.test.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
190 lines
6.0 KiB
TypeScript
190 lines
6.0 KiB
TypeScript
import { Crumb } from '@@/PageHeader/Breadcrumbs/Breadcrumbs';
|
|
|
|
import {
|
|
STACK_CONTAINER_STATE_NAME,
|
|
getContainerBreadcrumbs,
|
|
getContainerSubTabBreadcrumbs,
|
|
isStackContainerState,
|
|
} from './containerBreadcrumbs';
|
|
|
|
// Narrow a crumb to the object form so the tests can assert on its link/params.
|
|
function asCrumb(value: Crumb | string): Crumb {
|
|
if (typeof value === 'string') {
|
|
throw new Error(`expected an object crumb but got the string "${value}"`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
// Find a crumb by its visible label.
|
|
function findCrumb(
|
|
crumbs: Array<Crumb | string>,
|
|
label: string
|
|
): Crumb | undefined {
|
|
return crumbs
|
|
.filter((c): c is Crumb => typeof c !== 'string')
|
|
.find((c) => c.label === label);
|
|
}
|
|
|
|
describe('isStackContainerState', () => {
|
|
it('matches the stack container detail state and its sub-tab children', () => {
|
|
expect(isStackContainerState(STACK_CONTAINER_STATE_NAME)).toBe(true);
|
|
expect(isStackContainerState(`${STACK_CONTAINER_STATE_NAME}.logs`)).toBe(
|
|
true
|
|
);
|
|
expect(isStackContainerState(`${STACK_CONTAINER_STATE_NAME}.exec`)).toBe(
|
|
true
|
|
);
|
|
});
|
|
|
|
it('does not match the global container states or undefined', () => {
|
|
expect(isStackContainerState('docker.containers.container')).toBe(false);
|
|
expect(isStackContainerState('docker.containers.container.logs')).toBe(
|
|
false
|
|
);
|
|
expect(isStackContainerState(undefined)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('getContainerBreadcrumbs', () => {
|
|
it('returns the global Containers trail when not opened from a stack', () => {
|
|
const crumbs = getContainerBreadcrumbs(
|
|
'docker.containers.container',
|
|
{ id: 'c1' },
|
|
'web'
|
|
);
|
|
|
|
expect(asCrumb(crumbs[0]).link).toBe('docker.containers');
|
|
expect(crumbs[crumbs.length - 1]).toBe('web');
|
|
expect(findCrumb(crumbs, 'Stacks')).toBeUndefined();
|
|
});
|
|
|
|
it('returns the stack trail when opened from a stack', () => {
|
|
const crumbs = getContainerBreadcrumbs(
|
|
STACK_CONTAINER_STATE_NAME,
|
|
{ id: 'c1', name: 'my-stack', stackId: '7', type: '2', regular: 'true' },
|
|
'web'
|
|
);
|
|
|
|
expect(asCrumb(crumbs[0]).link).toBe('docker.stacks');
|
|
const stackCrumb = asCrumb(crumbs[1]);
|
|
expect(stackCrumb.label).toBe('my-stack');
|
|
expect(stackCrumb.link).toBe('docker.stacks.stack');
|
|
expect(stackCrumb.linkParams).toMatchObject({ stackId: '7' });
|
|
expect(findCrumb(crumbs, 'Containers')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('getContainerSubTabBreadcrumbs', () => {
|
|
it('falls back to the global Containers trail for a non-stack sub-tab', () => {
|
|
const crumbs = getContainerSubTabBreadcrumbs(
|
|
'docker.containers.container.logs',
|
|
{ id: 'c1' },
|
|
'web',
|
|
'Logs'
|
|
);
|
|
|
|
expect(crumbs).toHaveLength(3);
|
|
expect(asCrumb(crumbs[0]).link).toBe('docker.containers');
|
|
const containerCrumb = asCrumb(crumbs[1]);
|
|
expect(containerCrumb.label).toBe('web');
|
|
expect(containerCrumb.link).toBe('docker.containers.container');
|
|
expect(containerCrumb.linkParams).toEqual({ id: 'c1' });
|
|
expect(crumbs[2]).toBe('Logs');
|
|
expect(findCrumb(crumbs, 'Stacks')).toBeUndefined();
|
|
});
|
|
|
|
it('keeps the stack trail for a regular stack sub-tab', () => {
|
|
const crumbs = getContainerSubTabBreadcrumbs(
|
|
`${STACK_CONTAINER_STATE_NAME}.logs`,
|
|
{ id: 'c1', name: 'my-stack', stackId: '7', type: '2', regular: 'true' },
|
|
'web',
|
|
'Logs'
|
|
);
|
|
|
|
// Stacks > my-stack > web > Logs, and never the global Containers crumb.
|
|
expect(asCrumb(crumbs[0]).link).toBe('docker.stacks');
|
|
const stackCrumb = asCrumb(crumbs[1]);
|
|
expect(stackCrumb.label).toBe('my-stack');
|
|
expect(stackCrumb.link).toBe('docker.stacks.stack');
|
|
expect(stackCrumb.linkParams).toMatchObject({
|
|
stackId: '7',
|
|
regular: 'true',
|
|
});
|
|
|
|
const containerCrumb = asCrumb(crumbs[2]);
|
|
expect(containerCrumb.label).toBe('web');
|
|
// The container crumb links back into the stack tree, carrying both the
|
|
// container id and the stack params so the trail survives a reload.
|
|
expect(containerCrumb.link).toBe(STACK_CONTAINER_STATE_NAME);
|
|
expect(containerCrumb.linkParams).toMatchObject({
|
|
id: 'c1',
|
|
stackId: '7',
|
|
});
|
|
|
|
expect(crumbs[crumbs.length - 1]).toBe('Logs');
|
|
expect(findCrumb(crumbs, 'Containers')).toBeUndefined();
|
|
});
|
|
|
|
it('omits the stack id and regular flag for an external stack sub-tab', () => {
|
|
const crumbs = getContainerSubTabBreadcrumbs(
|
|
`${STACK_CONTAINER_STATE_NAME}.stats`,
|
|
{
|
|
id: 'c1',
|
|
name: 'ext-stack',
|
|
type: '2',
|
|
external: 'true',
|
|
// Present in the inherited params but must NOT propagate via the
|
|
// external branch.
|
|
stackId: '7',
|
|
regular: 'true',
|
|
},
|
|
'web',
|
|
'Stats'
|
|
);
|
|
|
|
const stackCrumb = asCrumb(crumbs[1]);
|
|
expect(stackCrumb.linkParams).toMatchObject({ external: true, type: '2' });
|
|
expect(stackCrumb.linkParams).not.toHaveProperty('stackId');
|
|
expect(stackCrumb.linkParams).not.toHaveProperty('regular');
|
|
|
|
const containerCrumb = asCrumb(crumbs[2]);
|
|
expect(containerCrumb.linkParams).toMatchObject({
|
|
id: 'c1',
|
|
external: true,
|
|
});
|
|
expect(containerCrumb.linkParams).not.toHaveProperty('stackId');
|
|
expect(findCrumb(crumbs, 'Containers')).toBeUndefined();
|
|
});
|
|
|
|
it('keeps the stack id and flags an orphaned stack sub-tab', () => {
|
|
const crumbs = getContainerSubTabBreadcrumbs(
|
|
`${STACK_CONTAINER_STATE_NAME}.exec`,
|
|
{
|
|
id: 'c1',
|
|
name: 'orphan-stack',
|
|
stackId: '7',
|
|
type: '2',
|
|
orphaned: 'true',
|
|
},
|
|
'web',
|
|
'Console'
|
|
);
|
|
|
|
const stackCrumb = asCrumb(crumbs[1]);
|
|
expect(stackCrumb.linkParams).toMatchObject({
|
|
stackId: '7',
|
|
orphaned: 'true',
|
|
});
|
|
expect(stackCrumb.linkParams).not.toHaveProperty('external');
|
|
|
|
const containerCrumb = asCrumb(crumbs[2]);
|
|
expect(containerCrumb.linkParams).toMatchObject({
|
|
id: 'c1',
|
|
stackId: '7',
|
|
orphaned: 'true',
|
|
});
|
|
expect(crumbs[crumbs.length - 1]).toBe('Console');
|
|
expect(findCrumb(crumbs, 'Containers')).toBeUndefined();
|
|
});
|
|
});
|