1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-02 20:35:25 +02:00

feat(helm): add registry dropdown [r8s-340] (#779)

This commit is contained in:
Ali 2025-06-09 20:08:50 +12:00 committed by GitHub
parent c9e3717ce3
commit 1963edda66
16 changed files with 288 additions and 190 deletions

View file

@ -43,7 +43,7 @@ vi.mock('../queries/useUpdateHelmReleaseMutation', () => ({
})),
}));
vi.mock('../queries/useHelmRepositories', () => ({
vi.mock('../queries/useHelmRepoVersions', () => ({
useHelmRepoVersions: vi.fn(() => ({
data: [
{ Version: '1.0.0', AppVersion: '1.0.0' },
@ -75,6 +75,8 @@ const mockChart: Chart = {
annotations: {
category: 'database',
},
version: '1.0.1',
versions: ['1.0.0', '1.0.1'],
};
const mockRouterStateService = {

View file

@ -11,10 +11,6 @@ import { confirmGenericDiscard } from '@@/modals/confirm';
import { Option } from '@@/form-components/PortainerSelect';
import { Chart } from '../types';
import {
ChartVersion,
useHelmRepoVersions,
} from '../queries/useHelmRepositories';
import { useUpdateHelmReleaseMutation } from '../queries/useUpdateHelmReleaseMutation';
import { HelmInstallInnerForm } from './HelmInstallInnerForm';
@ -30,23 +26,16 @@ export function HelmInstallForm({ selectedChart, namespace, name }: Props) {
const environmentId = useEnvironmentId();
const router = useRouter();
const analytics = useAnalytics();
const helmRepoVersionsQuery = useHelmRepoVersions(
selectedChart.name,
60 * 60 * 1000, // 1 hour
[selectedChart.repo],
false
);
const versions = helmRepoVersionsQuery.data;
const versionOptions: Option<ChartVersion>[] = versions.map(
const versionOptions: Option<string>[] = selectedChart.versions.map(
(version, index) => ({
label: index === 0 ? `${version.Version} (latest)` : version.Version,
label: index === 0 ? `${version} (latest)` : version,
value: version,
})
);
const defaultVersion = versionOptions[0]?.value;
const initialValues: HelmInstallFormValues = {
values: '',
version: defaultVersion?.Version ?? '',
version: defaultVersion ?? '',
};
const installHelmChartMutation = useUpdateHelmReleaseMutation(environmentId);
@ -66,7 +55,6 @@ export function HelmInstallForm({ selectedChart, namespace, name }: Props) {
namespace={namespace}
name={name}
versionOptions={versionOptions}
isLoadingVersions={helmRepoVersionsQuery.isInitialLoading}
/>
</Formik>
);

View file

@ -6,7 +6,6 @@ import { FormControl } from '@@/form-components/FormControl';
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
import { FormSection } from '@@/form-components/FormSection';
import { ChartVersion } from '../queries/useHelmRepositories';
import { Chart } from '../types';
import { useHelmChartValues } from '../queries/useHelmChartValues';
import { HelmValuesInput } from '../components/HelmValuesInput';
@ -17,8 +16,7 @@ type Props = {
selectedChart: Chart;
namespace?: string;
name?: string;
versionOptions: Option<ChartVersion>[];
isLoadingVersions: boolean;
versionOptions: Option<string>[];
};
export function HelmInstallInnerForm({
@ -26,7 +24,6 @@ export function HelmInstallInnerForm({
namespace,
name,
versionOptions,
isLoadingVersions,
}: Props) {
const { values, setFieldValue, isSubmitting } =
useFormikContext<HelmInstallFormValues>();
@ -39,7 +36,7 @@ export function HelmInstallInnerForm({
const selectedVersion = useMemo(
() =>
versionOptions.find((v) => v.value.Version === values.version)?.value ??
versionOptions.find((v) => v.value === values.version)?.value ??
versionOptions[0]?.value,
[versionOptions, values.version]
);
@ -51,15 +48,14 @@ export function HelmInstallInnerForm({
<FormControl
label="Version"
inputId="version-input"
isLoading={isLoadingVersions}
loadingText="Loading versions..."
>
<PortainerSelect<ChartVersion>
<PortainerSelect<string>
value={selectedVersion}
options={versionOptions}
onChange={(version) => {
if (version) {
setFieldValue('version', version.Version);
setFieldValue('version', version);
}
}}
data-cy="helm-version-input"

View file

@ -1,12 +1,11 @@
import { useState } from 'react';
import { compact } from 'lodash';
import { useCurrentUser } from '@/react/hooks/useUser';
import { Chart } from '../types';
import {
useHelmChartList,
useHelmRepositories,
} from '../queries/useHelmChartList';
import { useHelmChartList } from '../queries/useHelmChartList';
import { useHelmRegistries } from '../queries/useHelmRegistries';
import { HelmTemplatesList } from './HelmTemplatesList';
import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem';
@ -20,10 +19,11 @@ interface Props {
export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) {
const [selectedChart, setSelectedChart] = useState<Chart | null>(null);
const [selectedRegistry, setSelectedRegistry] = useState<string | null>(null);
const { user } = useCurrentUser();
const helmReposQuery = useHelmRepositories(user.Id);
const chartListQuery = useHelmChartList(user.Id, helmReposQuery.data ?? []);
const helmReposQuery = useHelmRegistries();
const chartListQuery = useHelmChartList(user.Id, compact([selectedRegistry]));
function clearHelmChart() {
setSelectedChart(null);
onSelectHelmChart('');
@ -54,6 +54,9 @@ export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) {
charts={chartListQuery.data}
selectAction={handleChartSelection}
isLoading={chartListQuery.isInitialLoading}
registries={helmReposQuery.data ?? []}
selectedRegistry={selectedRegistry}
setSelectedRegistry={setSelectedRegistry}
/>
)}
</div>

View file

@ -19,6 +19,8 @@ const mockCharts: Chart[] = [
annotations: {
category: 'database',
},
version: '1.0.0',
versions: ['1.0.0', '1.0.1'],
},
{
name: 'test-chart-2',
@ -27,14 +29,18 @@ const mockCharts: Chart[] = [
annotations: {
category: 'database',
},
version: '1.0.0',
versions: ['1.0.0', '1.0.1'],
},
{
name: 'nginx-chart',
description: 'Nginx Web Server',
repo: 'https://example.com',
repo: 'https://example.com/2',
annotations: {
category: 'web',
},
version: '1.0.0',
versions: ['1.0.0', '1.0.1'],
},
];
@ -44,8 +50,11 @@ function renderComponent({
loading = false,
charts = mockCharts,
selectAction = selectActionMock,
selectedRegistry = '',
} = {}) {
const user = new UserViewModel({ Username: 'user' });
const registries = ['https://example.com', 'https://example.com/2'];
const Wrapped = withTestQueryProvider(
withUserProvider(
withTestRouter(() => (
@ -53,6 +62,9 @@ function renderComponent({
isLoading={loading}
charts={charts}
selectAction={selectAction}
registries={registries}
selectedRegistry={selectedRegistry}
setSelectedRegistry={() => {}}
/>
)),
user
@ -77,6 +89,7 @@ describe('HelmTemplatesList', () => {
expect(screen.getByText('Test Chart 1 Description')).toBeInTheDocument();
expect(screen.getByText('nginx-chart')).toBeInTheDocument();
expect(screen.getByText('Nginx Web Server')).toBeInTheDocument();
expect(screen.getByText('https://example.com/2')).toBeInTheDocument();
});
it('should call selectAction when a chart is clicked', async () => {
@ -146,11 +159,24 @@ describe('HelmTemplatesList', () => {
).toBeInTheDocument();
});
it('should show empty message when no charts are available', async () => {
renderComponent({ charts: [] });
it('should show empty message when no charts are available and a registry is selected', async () => {
renderComponent({ charts: [], selectedRegistry: 'https://example.com' });
// Check for empty message
expect(screen.getByText('No helm charts available.')).toBeInTheDocument();
expect(
screen.getByText('No helm charts available in this registry.')
).toBeInTheDocument();
});
it("should show 'select registry' message when no charts are available and no registry is selected", async () => {
renderComponent({ charts: [] });
// Check for message
expect(
screen.getByText(
'Please select a registry to view available Helm charts.'
)
).toBeInTheDocument();
});
it('should show no results message when search has no matches', async () => {

View file

@ -1,6 +1,10 @@
import { useState, useMemo } from 'react';
import { components, OptionProps } from 'react-select';
import { PortainerSelect } from '@/react/components/form-components/PortainerSelect';
import {
PortainerSelect,
Option,
} from '@/react/components/form-components/PortainerSelect';
import { Link } from '@/react/components/Link';
import { InsightsBox } from '@@/InsightsBox';
@ -15,70 +19,31 @@ interface Props {
isLoading: boolean;
charts?: Chart[];
selectAction: (chart: Chart) => void;
}
/**
* Get categories from charts
* @param charts - The charts to get the categories from
* @returns Categories
*/
function getCategories(charts: Chart[]) {
const annotationCategories = charts
.map((chart) => chart.annotations?.category) // get category
.filter((c): c is string => !!c); // filter out nulls/undefined
const availableCategories = [...new Set(annotationCategories)].sort(); // unique and sort
// Create options array in the format expected by PortainerSelect
return availableCategories.map((cat) => ({
label: cat,
value: cat,
}));
}
/**
* Get filtered charts
* @param charts - The charts to get the filtered charts from
* @param textFilter - The text filter
* @param selectedCategory - The selected category
* @returns Filtered charts
*/
function getFilteredCharts(
charts: Chart[],
textFilter: string,
selectedCategory: string | null
) {
return charts.filter((chart) => {
// Text filter
if (
textFilter &&
!chart.name.toLowerCase().includes(textFilter.toLowerCase()) &&
!chart.description.toLowerCase().includes(textFilter.toLowerCase())
) {
return false;
}
// Category filter
if (
selectedCategory &&
(!chart.annotations || chart.annotations.category !== selectedCategory)
) {
return false;
}
return true;
});
registries: string[];
selectedRegistry: string | null;
setSelectedRegistry: (registry: string | null) => void;
}
export function HelmTemplatesList({
isLoading,
charts = [],
selectAction,
registries,
selectedRegistry,
setSelectedRegistry,
}: Props) {
const [textFilter, setTextFilter] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const categories = useMemo(() => getCategories(charts), [charts]);
const registryOptions = useMemo(
() =>
registries.map((registry) => ({
label: registry,
value: registry,
})),
[registries]
);
const filteredCharts = useMemo(
() => getFilteredCharts(charts, textFilter, selectedCategory),
@ -87,7 +52,7 @@ export function HelmTemplatesList({
return (
<section className="datatable" aria-label="Helm charts">
<div className="toolBar vertical-center relative w-full flex-wrap !gap-x-5 !gap-y-1 !px-0">
<div className="toolBar vertical-center relative w-full flex-wrap !gap-x-5 !gap-y-1 !px-0 !overflow-visible">
<div className="toolBarTitle vertical-center">Helm chart</div>
<SearchBar
@ -98,12 +63,25 @@ export function HelmTemplatesList({
className="!mr-0 h-9"
/>
<div className="w-full sm:w-1/5">
<div className="w-full sm:w-1/4">
<PortainerSelect
placeholder="Select a registry"
value={selectedRegistry ?? ''}
options={registryOptions}
onChange={setSelectedRegistry}
isClearable
bindToBody
components={{ Option: RegistryOption }}
data-cy="helm-registry-select"
/>
</div>
<div className="w-full sm:w-1/4">
<PortainerSelect
placeholder="Select a category"
value={selectedCategory}
options={categories}
onChange={(value) => setSelectedCategory(value)}
onChange={setSelectedCategory}
isClearable
bindToBody
data-cy="helm-category-select"
@ -173,12 +151,85 @@ export function HelmTemplatesList({
</div>
)}
{!isLoading && charts.length === 0 && (
{!isLoading && charts.length === 0 && selectedRegistry && (
<div className="text-muted text-center">
No helm charts available.
No helm charts available in this registry.
</div>
)}
{!selectedRegistry && (
<div className="text-muted text-center">
Please select a registry to view available Helm charts.
</div>
)}
</div>
</section>
);
}
// truncate the registry text, because some registry names are urls, which are too long
function RegistryOption(props: OptionProps<Option<string>>) {
const { data: registry } = props;
return (
<div title={registry.value}>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<components.Option {...props} className="whitespace-nowrap truncate">
{registry.value}
</components.Option>
</div>
);
}
/**
* Get categories from charts
* @param charts - The charts to get the categories from
* @returns Categories
*/
function getCategories(charts: Chart[]) {
const annotationCategories = charts
.map((chart) => chart.annotations?.category) // get category
.filter((c): c is string => !!c); // filter out nulls/undefined
const availableCategories = [...new Set(annotationCategories)].sort(); // unique and sort
// Create options array in the format expected by PortainerSelect
return availableCategories.map((cat) => ({
label: cat,
value: cat,
}));
}
/**
* Get filtered charts
* @param charts - The charts to get the filtered charts from
* @param textFilter - The text filter
* @param selectedCategory - The selected category
* @returns Filtered charts
*/
function getFilteredCharts(
charts: Chart[],
textFilter: string,
selectedCategory: string | null
) {
return charts.filter((chart) => {
// Text filter
if (
textFilter &&
!chart.name.toLowerCase().includes(textFilter.toLowerCase()) &&
!chart.description.toLowerCase().includes(textFilter.toLowerCase())
) {
return false;
}
// Category filter
if (
selectedCategory &&
(!chart.annotations || chart.annotations.category !== selectedCategory)
) {
return false;
}
return true;
});
}

View file

@ -19,6 +19,8 @@ const mockChart: Chart = {
annotations: {
category: 'database',
},
version: '1.0.1',
versions: ['1.0.0', '1.0.1'],
};
const clearHelmChartMock = vi.fn();