From 993f69db37ccb6929937472eeb93b98e26b20a19 Mon Sep 17 00:00:00 2001 From: James Player Date: Fri, 14 Mar 2025 10:37:14 +1300 Subject: [PATCH] chore(app): Migrate helm templates list to react (#492) --- .../helm-templates-list-item.css | 9 - .../helm-templates-list-item.html | 40 ---- .../helm-templates-list-item.js | 17 -- .../helm-templates-list.controller.js | 43 ---- .../helm-templates-list.html | 79 -------- .../helm-templates-list.js | 14 -- .../helm/helm-templates/helm-templates.html | 2 +- app/kubernetes/react/components/index.ts | 15 ++ .../HelmTemplates/HelmTemplatesList.test.tsx | 190 ++++++++++++++++++ .../helm/HelmTemplates/HelmTemplatesList.tsx | 179 +++++++++++++++++ .../HelmTemplates/HelmTemplatesListItem.tsx | 74 +++++++ 11 files changed, 459 insertions(+), 203 deletions(-) delete mode 100644 app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.css delete mode 100644 app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.html delete mode 100644 app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.js delete mode 100644 app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.controller.js delete mode 100644 app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html delete mode 100644 app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.js create mode 100644 app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx create mode 100644 app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx create mode 100644 app/react/kubernetes/helm/HelmTemplates/HelmTemplatesListItem.tsx diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.css b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.css deleted file mode 100644 index a618dc68b..000000000 --- a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.css +++ /dev/null @@ -1,9 +0,0 @@ -.helm-template-item-details { - display: flex; - justify-content: space-between; - flex-wrap: wrap; -} - -.helm-template-item-details .helm-template-item-details-sub { - width: 100%; -} diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.html b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.html deleted file mode 100644 index 43658b833..000000000 --- a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.html +++ /dev/null @@ -1,40 +0,0 @@ - -
-
- - - - - -
- -
- - - {{ $ctrl.model.name }} - - - - - - Helm - - -
- - - -
- - {{ $ctrl.model.description }} - - - {{ $ctrl.model.annotations.category }} - -
- -
- -
- -
diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.js b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.js deleted file mode 100644 index adde64a03..000000000 --- a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.js +++ /dev/null @@ -1,17 +0,0 @@ -import angular from 'angular'; -import './helm-templates-list-item.css'; -import { HelmIcon } from '../../HelmIcon'; - -angular.module('portainer.kubernetes').component('helmTemplatesListItem', { - templateUrl: './helm-templates-list-item.html', - bindings: { - model: '<', - onSelect: '<', - }, - transclude: { - actions: '?templateItemActions', - }, - controller() { - this.fallbackIcon = HelmIcon; - }, -}); diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.controller.js b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.controller.js deleted file mode 100644 index 9ba2a579d..000000000 --- a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.controller.js +++ /dev/null @@ -1,43 +0,0 @@ -export default class HelmTemplatesListController { - /* @ngInject */ - constructor($async, $scope, HelmService, Notifications) { - this.$async = $async; - this.$scope = $scope; - this.HelmService = HelmService; - this.Notifications = Notifications; - - this.state = { - textFilter: '', - selectedCategory: '', - categories: [], - }; - - this.updateCategories = this.updateCategories.bind(this); - this.onCategoryChange = this.onCategoryChange.bind(this); - } - - async updateCategories() { - try { - const annotationCategories = this.charts - .map((t) => t.annotations) // get annotations - .filter((a) => a) // filter out undefined/nulls - .map((c) => c.category); // get annotation category - const availableCategories = [...new Set(annotationCategories)].sort(); // unique and sort - this.state.categories = availableCategories.map((cat) => ({ label: cat, value: cat })); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to retrieve helm charts categories'); - } - } - - onCategoryChange(value) { - return this.$scope.$evalAsync(() => { - this.state.selectedCategory = value || ''; - }); - } - - $onChanges() { - if (this.charts.length > 0) { - this.updateCategories(); - } - } -} diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html deleted file mode 100644 index cd2c1131d..000000000 --- a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html +++ /dev/null @@ -1,79 +0,0 @@ -
-
-
{{ $ctrl.titleText }}
- - -
- -
-
-
-
Select the Helm chart to use. Bring further Helm charts into your selection list via - User settings - Helm repositories.
-
-
- - - - - -
-
-

Disclaimer

-
- At present Portainer does not support OCI format Helm charts. Support for OCI charts will be available in a future release.
- If you would like to provide feedback on OCI support or get access to early releases to test this functionality, - please get in touch. -
-
-
-
- -
- - -
No Helm charts found
-
- Loading... -
Initial download of Helm charts can take a few minutes
-
-
No helm charts available.
-
-
diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.js b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.js deleted file mode 100644 index 2366e8d5a..000000000 --- a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.js +++ /dev/null @@ -1,14 +0,0 @@ -import angular from 'angular'; -import controller from './helm-templates-list.controller'; - -angular.module('portainer.kubernetes').component('helmTemplatesList', { - templateUrl: './helm-templates-list.html', - controller, - bindings: { - loading: '<', - titleText: '@', - charts: '<', - tableKey: '@', - selectAction: '<', - }, -}); diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates.html b/app/kubernetes/components/helm/helm-templates/helm-templates.html index a5f8dc960..4b998cf76 100644 --- a/app/kubernetes/components/helm/helm-templates/helm-templates.html +++ b/app/kubernetes/components/helm/helm-templates/helm-templates.html @@ -101,7 +101,7 @@
( + + )), + user + ) + ); + return { ...render(), 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]); + }); +}); diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx new file mode 100644 index 000000000..5cfdb6953 --- /dev/null +++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx @@ -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(null); + + const categories = useMemo(() => getCategories(charts), [charts]); + + const filteredCharts = useMemo( + () => getFilteredCharts(charts, textFilter, selectedCategory), + [charts, textFilter, selectedCategory] + ); + + return ( +
+
+
{titleText}
+ + setTextFilter(value)} + placeholder="Search..." + data-cy="helm-templates-search" + className="!mr-0 h-9" + /> + +
+ setSelectedCategory(value)} + isClearable + bindToBody + data-cy="helm-category-select" + /> +
+
+
+
+ Select the Helm chart to use. Bring further Helm charts into your + selection list via{' '} + + User settings - Helm repositories + + . +
+ + + At present Portainer does not support OCI format Helm charts. + Support for OCI charts will be available in a future release. +
+ If you would like to provide feedback on OCI support or get access + to early releases to test this functionality,{' '} + + please get in touch + + . + + } + /> +
+ +
+ {filteredCharts.map((chart) => ( + + ))} + + {filteredCharts.length === 0 && textFilter && ( +
No Helm charts found
+ )} + + {loading && ( +
+ Loading... +
+ Initial download of Helm charts can take a few minutes +
+
+ )} + + {!loading && charts.length === 0 && ( +
+ No helm charts available. +
+ )} +
+
+ ); +} diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesListItem.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesListItem.tsx new file mode 100644 index 000000000..b51fb5cdd --- /dev/null +++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesListItem.tsx @@ -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 ( + + ); +}