Skip to content

Commit f31463a

Browse files
committed
feat(KFLUXUI-1007): add versions tab to component page
Assisted-by: Cursor
1 parent 0fae599 commit f31463a

File tree

15 files changed

+681
-8
lines changed

15 files changed

+681
-8
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { createTableHeaders } from '~/shared/components/table/utils';
2+
3+
export const versionTableColumnClasses = {
4+
name: 'pf-m-width-20',
5+
description: 'pf-m-width-25',
6+
revision: 'pf-m-width-20',
7+
pipeline: 'pf-m-width-20',
8+
};
9+
10+
export const enum SortableHeaders {
11+
name = 0,
12+
revision = 2,
13+
pipeline = 3,
14+
}
15+
16+
const versionColumns = [
17+
{ title: 'Version name', className: versionTableColumnClasses.name, sortable: true },
18+
{ title: 'Description', className: versionTableColumnClasses.description },
19+
{ title: 'Git branch or tag', className: versionTableColumnClasses.revision, sortable: true },
20+
{ title: 'Pipeline', className: versionTableColumnClasses.pipeline, sortable: true },
21+
];
22+
23+
export default createTableHeaders(versionColumns);
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import * as React from 'react';
2+
import { Link } from 'react-router-dom';
3+
import { COMPONENT_VERSION_DETAILS_PATH } from '~/routes/paths';
4+
import { TableData } from '~/shared';
5+
import ExternalLink from '~/shared/components/links/ExternalLink';
6+
import { useNamespace } from '~/shared/providers/Namespace';
7+
import { ComponentBuildPipeline, ComponentVersion } from '~/types/component';
8+
import { createBranchUrl } from '~/utils/git-utils';
9+
import { versionTableColumnClasses } from './ComponentVersionListHeader';
10+
11+
export type VersionListRowCustomData = {
12+
repoUrl?: string;
13+
defaultPipeline?: ComponentBuildPipeline;
14+
componentName: string;
15+
};
16+
17+
const getPipelineName = (
18+
versionPipeline?: ComponentBuildPipeline,
19+
defaultPipeline?: ComponentBuildPipeline,
20+
): string => {
21+
const pipeline = versionPipeline ?? defaultPipeline;
22+
if (!pipeline) {
23+
return '-';
24+
}
25+
26+
const def = pipeline['pull-and-push'] ?? pipeline.push ?? pipeline.pull;
27+
if (!def) {
28+
return '-';
29+
}
30+
31+
return def['pipelineref-by-name'] ?? def['pipelinespec-from-bundle']?.name ?? '-';
32+
};
33+
34+
interface ComponentVersionListRowProps {
35+
obj: ComponentVersion;
36+
customData: VersionListRowCustomData;
37+
}
38+
39+
export const ComponentVersionListRow: React.FC<ComponentVersionListRowProps> = ({
40+
obj,
41+
customData,
42+
}) => {
43+
const namespace = useNamespace();
44+
const { repoUrl, defaultPipeline, componentName } = customData;
45+
const branchUrl = createBranchUrl(repoUrl, obj.revision);
46+
const pipelineName = getPipelineName(obj['build-pipeline'], defaultPipeline);
47+
48+
return (
49+
<>
50+
<TableData className={versionTableColumnClasses.name}>
51+
<Link
52+
to={COMPONENT_VERSION_DETAILS_PATH.createPath({
53+
workspaceName: namespace,
54+
componentName,
55+
versionRevision: obj.revision,
56+
})}
57+
>
58+
{obj.name}
59+
</Link>
60+
</TableData>
61+
<TableData className={versionTableColumnClasses.description}>{obj.context || '-'}</TableData>
62+
<TableData className={versionTableColumnClasses.revision}>
63+
{branchUrl ? <ExternalLink href={branchUrl} text={obj.revision} /> : obj.revision || '-'}
64+
</TableData>
65+
<TableData className={versionTableColumnClasses.pipeline}>{pipelineName}</TableData>
66+
</>
67+
);
68+
};
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import React from 'react';
2+
import { SortByDirection } from '@patternfly/react-table';
3+
import { FilterContext } from '~/components/Filter/generic/FilterContext';
4+
import { BaseTextFilterToolbar } from '~/components/Filter/toolbars/BaseTextFIlterToolbar';
5+
import { useComponent } from '~/hooks/useComponents';
6+
import { useSortedResources } from '~/hooks/useSortedResources';
7+
import { Table } from '~/shared';
8+
import FilteredEmptyState from '~/shared/components/empty-state/FilteredEmptyState';
9+
import { useNamespace } from '~/shared/providers/Namespace';
10+
import { getErrorState } from '~/shared/utils/error-utils';
11+
import { ComponentVersion } from '~/types/component';
12+
import getVersionListHeader, { SortableHeaders } from './ComponentVersionListHeader';
13+
import { ComponentVersionListRow, VersionListRowCustomData } from './ComponentVersionListRow';
14+
15+
type ComponentVersionListViewProps = {
16+
componentName: string;
17+
};
18+
19+
const sortPaths: Record<SortableHeaders, string> = {
20+
[SortableHeaders.name]: 'name',
21+
[SortableHeaders.revision]: 'revision',
22+
[SortableHeaders.pipeline]: 'build-pipeline',
23+
};
24+
25+
const ComponentVersionListView: React.FC<
26+
React.PropsWithChildren<ComponentVersionListViewProps>
27+
> = ({ componentName }) => {
28+
const namespace = useNamespace();
29+
const { filters: unparsedFilters, setFilters, onClearFilters } = React.useContext(FilterContext);
30+
const nameFilter = unparsedFilters.name ? (unparsedFilters.name as string) : '';
31+
32+
const [component, compLoaded, compError] = useComponent(namespace, componentName);
33+
34+
const [activeSortIndex, setActiveSortIndex] = React.useState<number>(SortableHeaders.name);
35+
const [activeSortDirection, setActiveSortDirection] = React.useState<SortByDirection>(
36+
SortByDirection.asc,
37+
);
38+
39+
const versions = React.useMemo(
40+
() => component?.spec?.source?.versions ?? [],
41+
[component?.spec?.source?.versions],
42+
);
43+
44+
const filteredVersions = React.useMemo(
45+
() => versions.filter((v) => v.name.toLowerCase().includes(nameFilter.trim().toLowerCase())),
46+
[versions, nameFilter],
47+
);
48+
49+
const sortedVersions = useSortedResources(
50+
filteredVersions,
51+
activeSortIndex,
52+
activeSortDirection,
53+
sortPaths,
54+
);
55+
56+
const repoUrl = component?.spec?.source?.url;
57+
const defaultPipeline = component?.spec?.['default-build-pipeline'];
58+
59+
const customData = React.useMemo<VersionListRowCustomData>(
60+
() => ({ repoUrl, defaultPipeline, componentName }),
61+
[repoUrl, defaultPipeline, componentName],
62+
);
63+
64+
const Header = React.useMemo(
65+
() =>
66+
getVersionListHeader(activeSortIndex, activeSortDirection, (_, index, direction) => {
67+
setActiveSortIndex(index);
68+
setActiveSortDirection(direction);
69+
}),
70+
[activeSortIndex, activeSortDirection],
71+
);
72+
73+
const EmptyMsg = () => <FilteredEmptyState onClearFilters={() => onClearFilters()} />;
74+
75+
if (compError) {
76+
return getErrorState(compError, compLoaded, 'Component versions');
77+
}
78+
79+
const isFiltered = nameFilter.length > 0;
80+
81+
return (
82+
<>
83+
{(isFiltered || versions.length > 0) && (
84+
<BaseTextFilterToolbar
85+
text={nameFilter}
86+
label="name"
87+
setText={(newName) => setFilters({ name: newName })}
88+
onClearFilters={onClearFilters}
89+
data-test="version-list-toolbar"
90+
/>
91+
)}
92+
<Table
93+
data={sortedVersions}
94+
unfilteredData={versions}
95+
EmptyMsg={EmptyMsg}
96+
aria-label="Component Version List"
97+
Header={Header}
98+
Row={(props) => (
99+
<ComponentVersionListRow obj={props.obj as ComponentVersion} customData={customData} />
100+
)}
101+
loaded={compLoaded}
102+
/>
103+
</>
104+
);
105+
};
106+
107+
export default ComponentVersionListView;
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { screen } from '@testing-library/react';
2+
import { ComponentVersion } from '~/types/component';
3+
import { mockUseNamespaceHook } from '~/unit-test-utils/mock-namespace';
4+
import { renderWithQueryClient } from '~/unit-test-utils/mock-react-query';
5+
import { ComponentVersionListRow, VersionListRowCustomData } from '../ComponentVersionListRow';
6+
7+
jest.mock('react-router-dom', () => ({
8+
Link: (props) => <a href={props.to}>{props.children}</a>,
9+
}));
10+
11+
const mockVersion: ComponentVersion = {
12+
name: 'Version 1.0',
13+
revision: 'ver-1.0',
14+
context: './frontend',
15+
};
16+
17+
const mockVersionNoPipeline: ComponentVersion = {
18+
name: 'Test',
19+
revision: 'test',
20+
};
21+
22+
const mockVersionWithPipeline: ComponentVersion = {
23+
name: 'Custom Pipeline',
24+
revision: 'custom-branch',
25+
context: './backend',
26+
'build-pipeline': {
27+
'pull-and-push': {
28+
'pipelineref-by-name': 'my-custom-pipeline',
29+
},
30+
},
31+
};
32+
33+
const defaultCustomData: VersionListRowCustomData = {
34+
repoUrl: 'https://github.com/org/repo',
35+
componentName: 'my-component',
36+
};
37+
38+
describe('ComponentVersionListRow', () => {
39+
mockUseNamespaceHook('test-ns');
40+
41+
it('should render version name as a link', () => {
42+
renderWithQueryClient(
43+
<ComponentVersionListRow obj={mockVersion} customData={defaultCustomData} />,
44+
);
45+
const link = screen.getByText('Version 1.0');
46+
expect(link).toBeInTheDocument();
47+
expect(link.closest('a')).toHaveAttribute('href', expect.stringContaining('ver-1.0'));
48+
});
49+
50+
it('should render description from context field', () => {
51+
renderWithQueryClient(
52+
<ComponentVersionListRow obj={mockVersion} customData={defaultCustomData} />,
53+
);
54+
expect(screen.getByText('./frontend')).toBeInTheDocument();
55+
});
56+
57+
it('should render "-" when context is not set', () => {
58+
renderWithQueryClient(
59+
<ComponentVersionListRow obj={mockVersionNoPipeline} customData={defaultCustomData} />,
60+
);
61+
expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(1);
62+
});
63+
64+
it('should render revision as an external link for GitHub repos', () => {
65+
renderWithQueryClient(
66+
<ComponentVersionListRow obj={mockVersion} customData={defaultCustomData} />,
67+
);
68+
const externalLink = screen.getByText('ver-1.0');
69+
expect(externalLink.closest('a')).toHaveAttribute(
70+
'href',
71+
'https://github.com/org/repo/tree/ver-1.0',
72+
);
73+
});
74+
75+
it('should render revision as plain text when repo URL is unknown provider', () => {
76+
const customData: VersionListRowCustomData = {
77+
repoUrl: 'https://unknown-git.example.com/org/repo',
78+
componentName: 'my-component',
79+
};
80+
renderWithQueryClient(<ComponentVersionListRow obj={mockVersion} customData={customData} />);
81+
expect(screen.getByText('ver-1.0')).toBeInTheDocument();
82+
});
83+
84+
it('should render revision as plain text when repo URL is not set', () => {
85+
const customData: VersionListRowCustomData = {
86+
componentName: 'my-component',
87+
};
88+
renderWithQueryClient(<ComponentVersionListRow obj={mockVersion} customData={customData} />);
89+
expect(screen.getByText('ver-1.0')).toBeInTheDocument();
90+
});
91+
92+
it('should render pipeline name from version build-pipeline', () => {
93+
renderWithQueryClient(
94+
<ComponentVersionListRow obj={mockVersionWithPipeline} customData={defaultCustomData} />,
95+
);
96+
expect(screen.getByText('my-custom-pipeline')).toBeInTheDocument();
97+
});
98+
99+
it('should render pipeline name from default-build-pipeline when version has none', () => {
100+
const customData: VersionListRowCustomData = {
101+
repoUrl: 'https://github.com/org/repo',
102+
componentName: 'my-component',
103+
defaultPipeline: {
104+
'pull-and-push': {
105+
'pipelinespec-from-bundle': {
106+
name: 'docker-build-oci-ta',
107+
bundle: 'latest',
108+
},
109+
},
110+
},
111+
};
112+
renderWithQueryClient(
113+
<ComponentVersionListRow obj={mockVersionNoPipeline} customData={customData} />,
114+
);
115+
expect(screen.getByText('docker-build-oci-ta')).toBeInTheDocument();
116+
});
117+
118+
it('should render "-" when no pipeline is configured', () => {
119+
renderWithQueryClient(
120+
<ComponentVersionListRow obj={mockVersionNoPipeline} customData={defaultCustomData} />,
121+
);
122+
// "-" for pipeline and "-" for context
123+
const dashes = screen.getAllByText('-');
124+
expect(dashes.length).toBeGreaterThanOrEqual(2);
125+
});
126+
127+
it('should prefer version pipeline over default pipeline', () => {
128+
const customData: VersionListRowCustomData = {
129+
repoUrl: 'https://github.com/org/repo',
130+
componentName: 'my-component',
131+
defaultPipeline: {
132+
'pull-and-push': {
133+
'pipelinespec-from-bundle': {
134+
name: 'default-pipeline',
135+
bundle: 'latest',
136+
},
137+
},
138+
},
139+
};
140+
renderWithQueryClient(
141+
<ComponentVersionListRow obj={mockVersionWithPipeline} customData={customData} />,
142+
);
143+
expect(screen.getByText('my-custom-pipeline')).toBeInTheDocument();
144+
expect(screen.queryByText('default-pipeline')).not.toBeInTheDocument();
145+
});
146+
});

0 commit comments

Comments
 (0)