Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions src/components/ComponentVersion/ComponentVersionDetailsView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import React from 'react';
import { useParams } from 'react-router-dom';
import { Bullseye, EmptyStateBody, Spinner, Text, TextVariants } from '@patternfly/react-core';
import emptyStateImgUrl from '~/assets/Components.svg';
import { FeatureFlagIndicator } from '~/feature-flags/FeatureFlagIndicator';
import { FLAGS } from '~/feature-flags/flags';
import { IfFeature } from '~/feature-flags/hooks';
import { useComponent } from '~/hooks/useComponents';
import {
COMPONENTS_PATH,
COMPONENT_DETAILS_V2_PATH,
COMPONENT_VERSIONS_PATH,
COMPONENT_VERSION_DETAILS_PATH,
} from '~/routes/paths';
import { RouterParams } from '~/routes/utils';
import AppEmptyState from '~/shared/components/empty-state/AppEmptyState';
import { useNamespace } from '~/shared/providers/Namespace/useNamespaceInfo';
import { getErrorState } from '~/shared/utils/error-utils';
import { getComponentVersion } from '~/utils/version-utils';
import { DetailsPage } from '../DetailsPage';

const ComponentVersionDetailsView: React.FC = () => {
const { componentName, versionRevision } = useParams<RouterParams>();
const namespace = useNamespace();
const [component, loaded, componentError] = useComponent(namespace, componentName);

if (!loaded) {
return (
<Bullseye>
<Spinner data-test="spinner" />
</Bullseye>
);
}

if (componentError) {
return getErrorState(componentError, loaded, 'Component version');
}

const version = getComponentVersion(component, versionRevision);

if (!version) {
return getErrorState({ code: 404 }, true, `Component version '${versionRevision}'`);
}

return (
<IfFeature
flag="components-page"
fallback={
<AppEmptyState emptyStateImg={emptyStateImgUrl} title="Feature flag disabled">
<EmptyStateBody>
{`To view this page, enable the "${FLAGS['components-page'].description}" feature flag.`}
</EmptyStateBody>
</AppEmptyState>
}
>
<DetailsPage
data-test="version-details-test-id"
headTitle={versionRevision}
title={
<Text component={TextVariants.h2}>
{component.metadata.name} <FeatureFlagIndicator flags={['components-page']} fullLabel />
</Text>
}
breadcrumbs={[
{
path: COMPONENTS_PATH.createPath({ workspaceName: namespace }),
name: 'Components',
},
{
path: COMPONENT_DETAILS_V2_PATH.createPath({
workspaceName: namespace,
componentName,
}),
name: component.spec.componentName || componentName,
},
{
path: COMPONENT_VERSIONS_PATH.createPath({ workspaceName: namespace, componentName }),
name: 'Versions',
},
{
path: COMPONENT_VERSION_DETAILS_PATH.createPath({
workspaceName: namespace,
componentName,
versionRevision,
}),
name: version.name,
},
]}
baseURL={COMPONENT_VERSION_DETAILS_PATH.createPath({
workspaceName: namespace,
componentName,
versionRevision,
})}
tabs={[
{
key: 'index',
label: 'Overview',
isFilled: true,
},
{
key: 'activity',
label: 'Activity',
},
]}
/>
</IfFeature>
);
};

export default ComponentVersionDetailsView;
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import * as React from 'react';
import { useParams } from 'react-router-dom';
import { screen } from '@testing-library/react';
import { FLAGS } from '~/feature-flags/flags';
import { useIsOnFeatureFlag, useFeatureFlags } from '~/feature-flags/hooks';
import { useComponent } from '~/hooks/useComponents';
import { ComponentKind, ComponentSpecs } from '~/types';
import { mockUseNamespaceHook } from '~/unit-test-utils/mock-namespace';
import { renderWithQueryClientAndRouter } from '~/unit-test-utils/rendering-utils';
import ComponentVersionDetailsView from '../ComponentVersionDetailsView';

jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: jest.fn(),
Outlet: () => <div data-test="outlet" />,
}));

jest.mock('~/hooks/useComponents', () => ({
useComponent: jest.fn(),
}));

jest.mock('~/feature-flags/hooks', () => {
const useIsOnFeatureFlagMock = jest.fn();
const useFeatureFlagsMock = jest.fn();
return {
useIsOnFeatureFlag: useIsOnFeatureFlagMock,
useFeatureFlags: useFeatureFlagsMock,
IfFeature: ({
flag,
children,
fallback,
}: {
flag: string;
children: React.ReactNode;
fallback?: React.ReactNode;
}) => {
const isEnabled = useIsOnFeatureFlagMock(flag);
return isEnabled ? children : fallback ?? null;
},
};
});

jest.mock('~/hooks/useDocumentTitle', () => ({
useDocumentTitle: jest.fn(),
}));

const useParamsMock = useParams as jest.Mock;
const useComponentMock = useComponent as jest.Mock;
const mockUseIsOnFeatureFlag = useIsOnFeatureFlag as jest.Mock;
const mockUseFeatureFlags = useFeatureFlags as jest.Mock;

const mockComponent: Partial<ComponentKind> = {
metadata: {
name: 'my-component',
namespace: 'test-ns',
uid: 'uid-1',
creationTimestamp: '2024-01-01T00:00:00Z',
},
spec: {
componentName: 'my-component',
source: {
url: 'https://github.com/org/repo',
versions: [
{ name: 'Version 1.0', revision: 'ver-1.0', context: './frontend' },
{ name: 'Main', revision: 'main' },
],
},
containerImage: 'quay.io/org/repo',
} as ComponentSpecs,
};

describe('ComponentVersionDetailsView', () => {
mockUseNamespaceHook('test-ns');

beforeEach(() => {
useParamsMock.mockReturnValue({
componentName: 'my-component',
versionRevision: 'ver-1.0',
});
useComponentMock.mockReturnValue([mockComponent, true, undefined]);
mockUseIsOnFeatureFlag.mockReturnValue(true);
mockUseFeatureFlags.mockReturnValue([{ 'components-page': true }, jest.fn()]);
});

afterEach(() => {
jest.clearAllMocks();
});

it('should render spinner while loading', () => {
useComponentMock.mockReturnValue([undefined, false, undefined]);
renderWithQueryClientAndRouter(<ComponentVersionDetailsView />);
expect(screen.getByTestId('spinner')).toBeInTheDocument();
});

it('should render error state when component fails to load', () => {
useComponentMock.mockReturnValue([undefined, true, { code: 500 }]);
renderWithQueryClientAndRouter(<ComponentVersionDetailsView />);
expect(screen.getByText('Unable to load Component version')).toBeInTheDocument();
});

it('should render 404 when version is not found', () => {
useParamsMock.mockReturnValue({
componentName: 'my-component',
versionRevision: 'nonexistent',
});
renderWithQueryClientAndRouter(<ComponentVersionDetailsView />);
expect(screen.getByText('404: Page not found')).toBeInTheDocument();
});

it('should render the version details page with tabs', () => {
renderWithQueryClientAndRouter(<ComponentVersionDetailsView />);
expect(screen.getByText('Overview')).toBeInTheDocument();
expect(screen.getByText('Activity')).toBeInTheDocument();
});

it('should render breadcrumbs including component and version names', () => {
renderWithQueryClientAndRouter(<ComponentVersionDetailsView />);
expect(screen.getByText('Components')).toBeInTheDocument();
// "my-component" appears in both breadcrumbs and heading
expect(screen.getAllByText('my-component').length).toBeGreaterThanOrEqual(2);
expect(screen.getByText('Versions')).toBeInTheDocument();
expect(screen.getByText('Version 1.0')).toBeInTheDocument();
});

it('should render the component name as the heading', () => {
renderWithQueryClientAndRouter(<ComponentVersionDetailsView />);
expect(screen.getByRole('heading', { name: /my-component/ })).toBeInTheDocument();
});

it('should show fallback when feature flag is disabled', () => {
mockUseIsOnFeatureFlag.mockReturnValue(false);
renderWithQueryClientAndRouter(<ComponentVersionDetailsView />);
expect(screen.getByText('Feature flag disabled')).toBeInTheDocument();
expect(
screen.getByText(
`To view this page, enable the "${FLAGS['components-page'].description}" feature flag.`,
),
).toBeInTheDocument();
});
});
23 changes: 23 additions & 0 deletions src/components/ComponentVersion/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { k8sQueryGetResource } from '../../k8s';
import { ComponentModel } from '../../models';
import { RouterParams } from '../../routes/utils';
import { createLoaderWithAccessCheck } from '../../utils/rbac';

export const componentVersionDetailsViewLoader = createLoaderWithAccessCheck(
async ({ params }) => {
const ns = params[RouterParams.workspaceName];
return k8sQueryGetResource({
model: ComponentModel,
queryOptions: {
ns,
name: params[RouterParams.componentName],
},
});
},
{
model: ComponentModel,
verb: 'get',
},
);

export { default as ComponentVersionDetailsViewLayout } from './ComponentVersionDetailsView';
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import * as React from 'react';
import { useParams } from 'react-router-dom';
import {
Bullseye,
DescriptionList,
DescriptionListDescription,
DescriptionListGroup,
DescriptionListTerm,
Spinner,
} from '@patternfly/react-core';
import GitRepoLink from '~/components/GitLink/GitRepoLink';
import LatestBuildSection from '~/components/LatestBuild/LatestBuildSection';
import { useComponent } from '~/hooks/useComponents';
import { RouterParams } from '~/routes/utils';
import { useNamespace } from '~/shared/providers/Namespace';
import { getErrorState } from '~/shared/utils/error-utils';
import { getComponentVersion } from '~/utils/version-utils';
import { DetailsSection } from '../../DetailsPage';

const ComponentVersionDetailsTab: React.FC = () => {
const namespace = useNamespace();
const { componentName, versionRevision } = useParams<RouterParams>();
const [component, loaded, componentError] = useComponent(namespace, componentName);

if (!loaded) {
return (
<Bullseye>
<Spinner data-test="spinner" />
</Bullseye>
);
}

if (componentError) {
return getErrorState(componentError, loaded, 'Component version');
}

const version = getComponentVersion(component, versionRevision);

if (!version) {
return getErrorState({ code: 404 }, true, `Component version '${versionRevision}'`);
}

const repoUrl = component.spec.source?.url;
const pipelineDef = version['build-pipeline'] ?? component.spec['default-build-pipeline'];
const pipelineEntry = pipelineDef?.['pull-and-push'] ?? pipelineDef?.push ?? pipelineDef?.pull;
const pipelineName =
pipelineEntry?.['pipelineref-by-name'] ?? pipelineEntry?.['pipelinespec-from-bundle']?.name;

return (
<>
<DetailsSection title="Version details">
<DescriptionList>
<DescriptionListGroup>
<DescriptionListTerm>Name</DescriptionListTerm>
<DescriptionListDescription data-test="version-name">
{version.name}
</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>Git branch or tag</DescriptionListTerm>
<DescriptionListDescription data-test="version-branch">
{repoUrl ? (
<GitRepoLink url={repoUrl} revision={version.revision} />
) : (
version.revision || '-'
)}
</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>Pipeline</DescriptionListTerm>
<DescriptionListDescription data-test="version-pipeline">
{pipelineName || '-'}
</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<LatestBuildSection component={component} version={versionRevision} />
</DescriptionListGroup>
</DescriptionList>
</DetailsSection>
</>
);
};

export default ComponentVersionDetailsTab;
Loading
Loading