1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-25 00:09:40 +02:00

chore(app): Migrate helm templates list to react (#492)

This commit is contained in:
James Player 2025-03-14 10:37:14 +13:00 committed by GitHub
parent 58317edb6d
commit 993f69db37
11 changed files with 459 additions and 203 deletions

View file

@ -0,0 +1,190 @@
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { UserViewModel } from '@/portainer/models/user';
import { HelmTemplatesList } from './HelmTemplatesList';
import { Chart } from './HelmTemplatesListItem';
// Sample test data
const mockCharts: Chart[] = [
{
name: 'test-chart-1',
description: 'Test Chart 1 Description',
annotations: {
category: 'database',
},
},
{
name: 'test-chart-2',
description: 'Test Chart 2 Description',
annotations: {
category: 'database',
},
},
{
name: 'nginx-chart',
description: 'Nginx Web Server',
annotations: {
category: 'web',
},
},
];
const selectActionMock = vi.fn();
function renderComponent({
loading = false,
titleText = 'Test Helm Templates',
charts = mockCharts,
selectAction = selectActionMock,
} = {}) {
const user = new UserViewModel({ Username: 'user' });
const Wrapped = withTestQueryProvider(
withUserProvider(
withTestRouter(() => (
<HelmTemplatesList
loading={loading}
titleText={titleText}
charts={charts}
selectAction={selectAction}
/>
)),
user
)
);
return { ...render(<Wrapped />), user };
}
describe('HelmTemplatesList', () => {
beforeEach(() => {
selectActionMock.mockClear();
});
it('should display title and charts list', async () => {
renderComponent();
// Check for the title
expect(screen.getByText('Test Helm Templates')).toBeInTheDocument();
// Check for charts
expect(screen.getByText('test-chart-1')).toBeInTheDocument();
expect(screen.getByText('Test Chart 1 Description')).toBeInTheDocument();
expect(screen.getByText('nginx-chart')).toBeInTheDocument();
expect(screen.getByText('Nginx Web Server')).toBeInTheDocument();
});
it('should call selectAction when a chart is clicked', async () => {
renderComponent();
// Find the first chart item
const firstChartItem = screen.getByText('test-chart-1').closest('button');
expect(firstChartItem).not.toBeNull();
// Click on the chart item
if (firstChartItem) {
fireEvent.click(firstChartItem);
}
// Check if selectAction was called with the correct chart
expect(selectActionMock).toHaveBeenCalledWith(mockCharts[0]);
});
it('should filter charts by text search', async () => {
renderComponent();
const user = userEvent.setup();
// Find search input and type "nginx"
const searchInput = screen.getByPlaceholderText('Search...');
await user.type(searchInput, 'nginx');
// Wait 300ms for debounce
await new Promise((resolve) => {
setTimeout(() => {
resolve(undefined);
}, 300);
});
// Should show only nginx chart
expect(screen.getByText('nginx-chart')).toBeInTheDocument();
expect(screen.queryByText('test-chart-1')).not.toBeInTheDocument();
expect(screen.queryByText('test-chart-2')).not.toBeInTheDocument();
});
it('should filter charts by category', async () => {
renderComponent();
const user = userEvent.setup();
// Find the category select
const categorySelect = screen.getByText('Select a category');
await user.click(categorySelect);
// Select "web" category
const webCategory = screen.getByText('web', {
selector: '[tabindex="-1"]',
});
await user.click(webCategory);
// Should show only web category charts
expect(screen.queryByText('nginx-chart')).toBeInTheDocument();
expect(screen.queryByText('test-chart-1')).not.toBeInTheDocument();
expect(screen.queryByText('test-chart-2')).not.toBeInTheDocument();
});
it('should show loading message when loading prop is true', async () => {
renderComponent({ loading: true });
// Check for loading message
expect(screen.getByText('Loading...')).toBeInTheDocument();
expect(
screen.getByText('Initial download of Helm charts can take a few minutes')
).toBeInTheDocument();
});
it('should show empty message when no charts are available', async () => {
renderComponent({ charts: [] });
// Check for empty message
expect(screen.getByText('No helm charts available.')).toBeInTheDocument();
});
it('should show no results message when search has no matches', async () => {
renderComponent();
const user = userEvent.setup();
// Find search input and type text that won't match any charts
const searchInput = screen.getByPlaceholderText('Search...');
await user.type(searchInput, 'nonexistent chart');
// Wait 300ms for debounce
await new Promise((resolve) => {
setTimeout(() => {
resolve(undefined);
}, 300);
});
// Check for no results message
expect(screen.getByText('No Helm charts found')).toBeInTheDocument();
});
it('should handle keyboard navigation and selection', async () => {
renderComponent();
const user = userEvent.setup();
// Find the first chart item
const firstChartItem = screen.getByText('test-chart-1').closest('button');
expect(firstChartItem).not.toBeNull();
// Focus and press Enter
if (firstChartItem) {
(firstChartItem as HTMLElement).focus();
await user.keyboard('{Enter}');
}
// Check if selectAction was called with the correct chart
expect(selectActionMock).toHaveBeenCalledWith(mockCharts[0]);
});
});

View file

@ -0,0 +1,179 @@
import { useState, useMemo } from 'react';
import { PortainerSelect } from '@/react/components/form-components/PortainerSelect';
import { Link } from '@/react/components/Link';
import { InsightsBox } from '@@/InsightsBox';
import { SearchBar } from '@@/datatables/SearchBar';
import { Chart, HelmTemplatesListItem } from './HelmTemplatesListItem';
interface Props {
loading: boolean;
titleText: string;
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;
});
}
export function HelmTemplatesList({
loading,
titleText,
charts = [],
selectAction,
}: Props) {
const [textFilter, setTextFilter] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const categories = useMemo(() => getCategories(charts), [charts]);
const filteredCharts = useMemo(
() => getFilteredCharts(charts, textFilter, selectedCategory),
[charts, textFilter, selectedCategory]
);
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="toolBarTitle vertical-center">{titleText}</div>
<SearchBar
value={textFilter}
onChange={(value) => setTextFilter(value)}
placeholder="Search..."
data-cy="helm-templates-search"
className="!mr-0 h-9"
/>
<div className="w-full sm:w-1/5">
<PortainerSelect
placeholder="Select a category"
value={selectedCategory}
options={categories}
onChange={(value) => setSelectedCategory(value)}
isClearable
bindToBody
data-cy="helm-category-select"
/>
</div>
</div>
<div className="w-fit">
<div className="small text-muted mb-2">
Select the Helm chart to use. Bring further Helm charts into your
selection list via{' '}
<Link
to="portainer.account"
params={{ '#': 'helm-repositories' }}
data-cy="helm-repositories-link"
>
User settings - Helm repositories
</Link>
.
</div>
<InsightsBox
header="Disclaimer"
type="slim"
content={
<>
At present Portainer does not support OCI format Helm charts.
Support for OCI charts will be available in a future release.
<br />
If you would like to provide feedback on OCI support or get access
to early releases to test this functionality,{' '}
<a
href="https://bit.ly/3WVkayl"
target="_blank"
rel="noopener noreferrer"
>
please get in touch
</a>
.
</>
}
/>
</div>
<div className="blocklist !px-0" role="list">
{filteredCharts.map((chart) => (
<HelmTemplatesListItem
key={chart.name}
model={chart}
onSelect={selectAction}
/>
))}
{filteredCharts.length === 0 && textFilter && (
<div className="text-muted small mt-4">No Helm charts found</div>
)}
{loading && (
<div className="text-muted text-center">
Loading...
<div className="text-muted text-center">
Initial download of Helm charts can take a few minutes
</div>
</div>
)}
{!loading && charts.length === 0 && (
<div className="text-muted text-center">
No helm charts available.
</div>
)}
</div>
</section>
);
}

View file

@ -0,0 +1,74 @@
import React from 'react';
import { HelmIcon } from '@/kubernetes/components/helm/helm-templates/HelmIcon';
import { FallbackImage } from '@/react/components/FallbackImage';
import Svg from '@@/Svg';
export interface Chart {
name: string;
description: string;
icon?: string;
annotations?: {
category?: string;
};
}
interface HelmTemplatesListItemProps {
model: Chart;
onSelect: (model: Chart) => void;
actions?: React.ReactNode;
}
export function HelmTemplatesListItem(props: HelmTemplatesListItemProps) {
const { model, onSelect, actions } = props;
function handleSelect() {
onSelect(model);
}
return (
<button
type="button"
className="blocklist-item mx-0 bg-inherit text-start"
onClick={handleSelect}
tabIndex={0}
>
<div className="blocklist-item-box">
<span className="shrink-0">
<FallbackImage
src={model.icon}
fallbackIcon={HelmIcon}
className="blocklist-item-logo h-16 w-auto"
alt="Helm chart icon"
/>
</span>
<div className="col-sm-12 flex flex-wrap justify-between gap-2">
<div className="blocklist-item-line">
<span>
<span className="blocklist-item-title">{model.name}</span>
<span className="space-left blocklist-item-subtitle">
<span className="vertical-center">
<Svg icon="helm" className="icon icon-primary" />
</span>
<span> Helm </span>
</span>
</span>
</div>
<span className="blocklist-item-actions">{actions}</span>
<div className="blocklist-item-line w-full">
<span className="blocklist-item-desc">{model.description}</span>
{model.annotations?.category && (
<span className="small text-muted">
{model.annotations.category}
</span>
)}
</div>
</div>
</div>
</button>
);
}