test(stacks): cover orphaned branch; name the stack-container state; type link params (F1,F2,F4)

Maintainer pre-merge review follow-up:
F1: test the orphaned-stack breadcrumb branch (orphaned=true, no regular) —
    href carries stackId/orphaned, not external.
F2: extract STACK_CONTAINER_STATE_NAME so code + test share one literal.
F4: type buildStackLinkParams' return as StackLinkParams (documents the real
    shape; external stays boolean, serialized by ui-router — no runtime change).
F3 (legacy ?id= deep links) answered wontfix in the PR thread.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude code agent
2026-06-29 19:55:41 +03:00
parent a1851417d1
commit e9fae32b43
2 changed files with 66 additions and 6 deletions

View File

@@ -9,7 +9,7 @@ import { server } from '@/setup-tests/server';
import { User } from '@/portainer/users/types';
import { ContainerDetailsViewModel } from '@/docker/models/containerDetails';
import { ItemView } from './ItemView';
import { ItemView, STACK_CONTAINER_STATE_NAME } from './ItemView';
const { useCurrentStateAndParamsMock } = vi.hoisted(() => ({
useCurrentStateAndParamsMock: vi.fn(),
@@ -52,7 +52,7 @@ describe('ItemView', () => {
it('keeps the stack trail when the container is opened from a stack', async () => {
useCurrentStateAndParamsMock.mockReturnValue({
state: { name: 'docker.stacks.stack.container' },
state: { name: STACK_CONTAINER_STATE_NAME },
params: {
id: 'container-id-123',
endpointId: '1',
@@ -82,7 +82,7 @@ describe('ItemView', () => {
it('keeps the stack trail without a stack id for an external stack', async () => {
useCurrentStateAndParamsMock.mockReturnValue({
state: { name: 'docker.stacks.stack.container' },
state: { name: STACK_CONTAINER_STATE_NAME },
params: {
id: 'container-id-123',
endpointId: '1',
@@ -121,6 +121,45 @@ describe('ItemView', () => {
).not.toBeInTheDocument();
});
it('keeps the stack trail with the stack id for an orphaned stack', async () => {
useCurrentStateAndParamsMock.mockReturnValue({
state: { name: STACK_CONTAINER_STATE_NAME },
params: {
id: 'container-id-123',
endpointId: '1',
nodeName: undefined,
name: 'orphan-stack',
stackId: '7',
type: '2',
// Orphaned stacks are identified by orphaned=true and, unlike regular
// stacks, carry no `regular` flag. They still have a DB id (stackId),
// so the back-link must keep stackId and must NOT take the external
// (id-less) branch.
orphaned: 'true',
tab: 'logs',
},
});
renderComponent();
// Stack trail is shown instead of the global Containers crumb.
const stacksCrumb = await screen.findByRole('link', { name: 'Stacks' });
expect(stacksCrumb).toBeVisible();
const stackCrumb = screen.getByRole('link', { name: 'orphan-stack' });
expect(stackCrumb).toBeVisible();
const href = stackCrumb.getAttribute('href');
// Orphaned stacks keep their numeric stack id so the stack can reload,
// flag the orphaned state, and must not be treated as external.
expect(href).toContain('stackId=7');
expect(href).toContain('orphaned=true');
expect(href).not.toContain('external=');
expect(
screen.queryByRole('link', { name: 'Containers' })
).not.toBeInTheDocument();
});
it('renders health status section when container has health data', async () => {
renderComponent({
container: {

View File

@@ -22,6 +22,27 @@ import { VolumesSection } from './VolumesSection/VolumesSection';
import { ContainerNetworksDatatable } from './ContainerNetworksDatatable';
import { HealthStatus } from './HealthStatus';
// ui-router state name for a container opened from within a stack. Kept as a
// single source of truth so a future state rename stays in sync with the
// breadcrumb logic (and its test) instead of silently falling back to the
// default crumb.
export const STACK_CONTAINER_STATE_NAME = 'docker.stacks.stack.container';
// Shape of the params handed to the docker.stacks.stack route when building the
// back-to-stack breadcrumb link. Values are strings (inherited route params)
// except `external`, which is emitted as a boolean and serialized to
// `external=true` by ui-router.
type StackLinkParams = {
name?: string;
stackId?: string;
type?: string;
regular?: string;
orphaned?: string;
orphanedRunning?: string;
external?: boolean;
tab?: string;
};
export function ItemView() {
const environmentId = useEnvironmentId();
const { state, params } = useCurrentStateAndParams();
@@ -116,7 +137,7 @@ export function ItemView() {
);
}
// When a container is opened from a stack (state docker.stacks.stack.container),
// When a container is opened from a stack (state STACK_CONTAINER_STATE_NAME),
// keep the stack trail in the breadcrumbs so the user can navigate back to the
// stack. Otherwise fall back to the global containers list.
function getContainerBreadcrumbs(
@@ -124,7 +145,7 @@ function getContainerBreadcrumbs(
params: Record<string, string | undefined>,
containerName: string
): Array<Crumb | string> {
if (stateName === 'docker.stacks.stack.container') {
if (stateName === STACK_CONTAINER_STATE_NAME) {
return [
{ label: 'Stacks', link: 'docker.stacks' },
{
@@ -143,7 +164,7 @@ function getContainerBreadcrumbs(
// (container) route params, which are inherited from the parent stack state.
function buildStackLinkParams(
params: Record<string, string | undefined>
): Record<string, unknown> {
): StackLinkParams {
// External stacks have no DB id; they are identified by name/type only.
if (params.external === 'true') {
return {