From dc273b2d637a5dd5a18dbf0fea587d8a7cd6cfeb Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Tue, 5 Aug 2025 18:53:41 +1200 Subject: [PATCH] fix(helm): don't block install with dry-run errors [r8s-454] (#976) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/portainer/react/components/index.ts | 2 +- app/react/components/Badge/Badge.tsx | 6 +- .../ExpandableMessageByLines.stories.tsx | 115 +++++++++++++++ .../ExpandableMessageByLines.test.tsx | 137 ++++++++++++++++++ .../components/ExpandableMessageByLines.tsx | 76 ++++++++++ .../ChartActions/UpgradeHelmModal.tsx | 16 +- .../helm/HelmTemplates/HelmInstallForm.tsx | 17 ++- .../HelmTemplates/HelmInstallInnerForm.tsx | 7 +- .../helm/HelmTemplates/HelmTemplatesList.tsx | 2 +- .../ManifestPreviewFormSection.test.tsx | 11 +- .../components/ManifestPreviewFormSection.tsx | 45 ++++-- 11 files changed, 408 insertions(+), 26 deletions(-) create mode 100644 app/react/components/ExpandableMessageByLines.stories.tsx create mode 100644 app/react/components/ExpandableMessageByLines.test.tsx create mode 100644 app/react/components/ExpandableMessageByLines.tsx diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts index 9b2f7325d..1b6d0df7a 100644 --- a/app/portainer/react/components/index.ts +++ b/app/portainer/react/components/index.ts @@ -98,7 +98,7 @@ export const ngModule = angular r2a(Tooltip, ['message', 'position', 'className', 'setHtmlMessage', 'size']) ) .component('terminalTooltip', r2a(TerminalTooltip, [])) - .component('badge', r2a(Badge, ['type', 'className'])) + .component('badge', r2a(Badge, ['type', 'className', 'data-cy'])) .component('fileUploadField', fileUploadField) .component('porSwitchField', switchField) .component( diff --git a/app/react/components/Badge/Badge.tsx b/app/react/components/Badge/Badge.tsx index 5babb5733..f0a4fc55c 100644 --- a/app/react/components/Badge/Badge.tsx +++ b/app/react/components/Badge/Badge.tsx @@ -1,6 +1,8 @@ import clsx from 'clsx'; import { PropsWithChildren } from 'react'; +import { AutomationTestingProps } from '@/types'; + export type BadgeType = | 'success' | 'danger' @@ -73,7 +75,8 @@ export function Badge({ type = 'info', className, children, -}: PropsWithChildren) { + 'data-cy': dataCy, +}: PropsWithChildren & Partial) { const baseClasses = 'inline-flex w-fit items-center !text-xs font-medium rounded-full px-2 py-0.5'; @@ -81,6 +84,7 @@ export function Badge({ {children} diff --git a/app/react/components/ExpandableMessageByLines.stories.tsx b/app/react/components/ExpandableMessageByLines.stories.tsx new file mode 100644 index 000000000..2a3c5a2a2 --- /dev/null +++ b/app/react/components/ExpandableMessageByLines.stories.tsx @@ -0,0 +1,115 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { ExpandableMessageByLines } from './ExpandableMessageByLines'; + +export default { + component: ExpandableMessageByLines, + title: 'Components/ExpandableMessageByLines', + argTypes: { + maxLines: { + control: { + type: 'select', + options: [2, 5, 10, 20, 50], + }, + description: 'Maximum number of lines to show before truncating', + }, + children: { + control: 'text', + description: 'The text content to display', + }, + }, +} as Meta; + +interface Args { + children: string; + maxLines?: 2 | 5 | 10 | 20 | 50; +} + +// Short text that won't be truncated +export const ShortText: StoryObj = { + args: { + children: 'This is a short message that should not be truncated.', + maxLines: 10, + }, +}; + +// Long text that will be truncated +export const LongText: StoryObj = { + args: { + children: `This is a very long message that should be truncated after the specified number of lines. +It contains multiple lines of text to demonstrate the expandable functionality. +The component will show a "Show more" button when the content exceeds the maxLines limit. +When clicked, it will expand to show the full content and change to "Show less". +This is useful for displaying long error messages, logs, or any text content that might be too long for the UI.`, + maxLines: 5, + }, +}; + +// Text with line breaks +export const TextWithLineBreaks: StoryObj = { + args: { + children: `Line 1: This is the first line +Line 2: This is the second line +Line 3: This is the third line +Line 4: This is the fourth line +Line 5: This is the fifth line +Line 6: This is the sixth line +Line 7: This is the seventh line +Line 8: This is the eighth line +Line 9: This is the ninth line +Line 10: This is the tenth line`, + maxLines: 5, + }, +}; + +// Very short maxLines +export const VeryShortMaxLines: StoryObj = { + args: { + children: `This text will be truncated after just 2 lines. +This is the second line. +This is the third line that should be hidden initially. +This is the fourth line that should also be hidden.`, + maxLines: 2, + }, +}; + +// Error message example +export const ErrorMessage: StoryObj = { + args: { + children: `Error: Failed to connect to the Docker daemon at unix:///var/run/docker.sock. +Is the docker daemon running? + +This error typically occurs when: +1. Docker daemon is not running +2. User doesn't have permission to access the Docker socket +3. Docker socket path is incorrect +4. Docker service has crashed + +To resolve this issue: +1. Start the Docker daemon: sudo systemctl start docker +2. Add user to docker group: sudo usermod -aG docker $USER +3. Verify Docker is running: docker ps +4. Check Docker socket permissions: ls -la /var/run/docker.sock`, + maxLines: 5, + }, +}; + +// Log output example +export const LogOutput: StoryObj = { + args: { + children: `2024-01-15T10:30:45.123Z INFO [ContainerService] Starting container nginx:latest +2024-01-15T10:30:45.234Z DEBUG [ContainerService] Container ID: abc123def456 +2024-01-15T10:30:45.345Z INFO [ContainerService] Container started successfully +2024-01-15T10:30:45.456Z DEBUG [NetworkService] Creating network bridge +2024-01-15T10:30:45.567Z INFO [NetworkService] Network created: portainer_network +2024-01-15T10:30:45.678Z DEBUG [VolumeService] Mounting volume /data +2024-01-15T10:30:45.789Z INFO [VolumeService] Volume mounted successfully +2024-01-15T10:30:45.890Z DEBUG [ContainerService] Setting up port mapping 80:80 +2024-01-15T10:30:45.901Z INFO [ContainerService] Port mapping configured +2024-01-15T10:30:45.912Z DEBUG [ContainerService] Setting environment variables +2024-01-15T10:30:45.923Z INFO [ContainerService] Environment variables set +2024-01-15T10:30:45.934Z DEBUG [ContainerService] Starting container process +2024-01-15T10:30:45.945Z INFO [ContainerService] Container process started`, + maxLines: 10, + }, +}; diff --git a/app/react/components/ExpandableMessageByLines.test.tsx b/app/react/components/ExpandableMessageByLines.test.tsx new file mode 100644 index 000000000..d403fcb9c --- /dev/null +++ b/app/react/components/ExpandableMessageByLines.test.tsx @@ -0,0 +1,137 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi } from 'vitest'; + +import { ExpandableMessageByLines } from '@@/ExpandableMessageByLines'; + +describe('ExpandableMessageByLines', () => { + // Mock scrollHeight and clientHeight for testing truncation + const mockScrollHeight = vi.fn(); + const mockClientHeight = vi.fn(); + + beforeEach(() => { + // Mock the properties on HTMLDivElement prototype + Object.defineProperty(HTMLDivElement.prototype, 'scrollHeight', { + get: mockScrollHeight, + configurable: true, + }); + + Object.defineProperty(HTMLDivElement.prototype, 'clientHeight', { + get: mockClientHeight, + configurable: true, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Basic Rendering', () => { + it('should render text content', () => { + const text = 'This is test content'; + // Mock non-truncated content (scrollHeight === clientHeight) + mockScrollHeight.mockReturnValue(100); + mockClientHeight.mockReturnValue(100); + + render({text}); + + expect(screen.getByText(text)).toBeInTheDocument(); + }); + + it('should show expand button only when text is truncated', () => { + const text = 'This is test content that should be truncated'; + // Mock truncated content (scrollHeight > clientHeight) + mockScrollHeight.mockReturnValue(300); + mockClientHeight.mockReturnValue(200); + + render({text}); + + expect( + screen.getByRole('button', { name: 'Show more' }) + ).toBeInTheDocument(); + expect( + screen.getByTestId('expandable-message-lines-button') + ).toBeInTheDocument(); + }); + + it('should hide expand button when text is not truncated', () => { + const text = 'Short text'; + // Mock non-truncated content (scrollHeight === clientHeight) + mockScrollHeight.mockReturnValue(50); + mockClientHeight.mockReturnValue(50); + + render({text}); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('expandable-message-lines-button') + ).not.toBeInTheDocument(); + }); + }); + + describe('Expand/Collapse Functionality', () => { + it('should toggle between Show more and Show less when button is clicked', async () => { + const user = userEvent.setup(); + const text = + 'This is a long text that should be truncated and show the expand button'; + + // Mock truncated content (scrollHeight > clientHeight) + mockScrollHeight.mockReturnValue(400); + mockClientHeight.mockReturnValue(200); + + render({text}); + + const button = screen.getByRole('button'); + + // Initially should show "Show more" + expect(screen.getByText('Show more')).toBeInTheDocument(); + + // Click to expand + await user.click(button); + expect(screen.getByText('Show less')).toBeInTheDocument(); + + // Click to collapse + await user.click(button); + expect(screen.getByText('Show more')).toBeInTheDocument(); + }); + }); + + describe('Text Content Handling', () => { + it('should not show button for single space strings when not truncated', () => { + // Mock non-truncated content (scrollHeight === clientHeight) + mockScrollHeight.mockReturnValue(20); + mockClientHeight.mockReturnValue(20); + + render( ); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('should show button for single space strings when truncated', () => { + // Mock truncated content (scrollHeight > clientHeight) + mockScrollHeight.mockReturnValue(100); + mockClientHeight.mockReturnValue(50); + + render( ); + + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('should handle different maxLines values', () => { + const longText = + 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10\nLine 11\nLine 12'; + // Mock truncated content for 5 lines (scrollHeight > clientHeight) + mockScrollHeight.mockReturnValue(240); + mockClientHeight.mockReturnValue(100); + + render( + + {longText} + + ); + + expect(screen.getByRole('button')).toBeInTheDocument(); + expect(screen.getByText('Show more')).toBeInTheDocument(); + }); + }); +}); diff --git a/app/react/components/ExpandableMessageByLines.tsx b/app/react/components/ExpandableMessageByLines.tsx new file mode 100644 index 000000000..36bf1035c --- /dev/null +++ b/app/react/components/ExpandableMessageByLines.tsx @@ -0,0 +1,76 @@ +import { useRef, useState, useEffect, useCallback } from 'react'; + +import { Button } from '@@/buttons'; + +// use enum so that the tailwind classes aren't interpolated +type MaxLines = 2 | 5 | 10 | 20 | 50; +const lineClampClasses: Record = { + 2: 'line-clamp-[2]', + 5: 'line-clamp-[5]', + 10: 'line-clamp-[10]', + 20: 'line-clamp-[20]', + 50: 'line-clamp-[50]', +}; + +interface LineBasedProps { + children: string; + maxLines?: MaxLines; +} + +export function ExpandableMessageByLines({ + children, + maxLines = 10, +}: LineBasedProps) { + const [isExpanded, setIsExpanded] = useState(false); + const [isTruncated, setIsTruncated] = useState(false); + const contentRef = useRef(null); + + const checkTruncation = useCallback(() => { + const el = contentRef.current; + if (el) { + setIsTruncated(el.scrollHeight > el.clientHeight); + } + }, []); + + useEffect(() => { + checkTruncation(); + + // Use requestAnimationFrame for better performance + let rafId: number; + function handleResize() { + if (rafId) cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(checkTruncation); + } + + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + if (rafId) cancelAnimationFrame(rafId); + }; + }, [children, maxLines, checkTruncation, isExpanded]); + + return ( +
+
+ {children} +
+ {(isTruncated || isExpanded) && ( + + )} +
+ ); +} diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeHelmModal.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeHelmModal.tsx index 7be9f0b9c..841dc2f46 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeHelmModal.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeHelmModal.tsx @@ -7,6 +7,7 @@ import { ChartVersion } from '@/react/kubernetes/helm/helmChartSourceQueries/use import { EnvironmentId } from '@/react/portainer/environments/types'; import { Modal, OnSubmit, openModal } from '@@/modals'; +import { confirm } from '@@/modals/confirm'; import { Button } from '@@/buttons'; import { Input } from '@@/form-components/Input'; import { FormControl } from '@@/form-components/FormControl'; @@ -181,8 +182,19 @@ export function UpgradeHelmModal({ Cancel