diff --git a/src/components/Catalog.tsx b/src/components/Catalog.tsx index b4d81d7e0..d6e495dcb 100644 --- a/src/components/Catalog.tsx +++ b/src/components/Catalog.tsx @@ -25,7 +25,7 @@ import { useTranslation } from 'react-i18next' import { useHistory, useLocation } from 'react-router-dom' import { useSession } from 'providers/Session' import { applyAclToUiSchema, getSpec } from 'common/api-spec' -import { useAppDispatch, useAppSelector } from 'redux/hooks' +import { useAppDispatch } from 'redux/hooks' import { setError } from 'redux/reducers' import { makeStyles } from 'tss-react/mui' import { cleanLink } from 'utils/data' @@ -135,7 +135,6 @@ export default function ({ const { classes } = useStyles() const dispatch = useAppDispatch() const { user } = useSession() - const globalError = useAppSelector(({ global: { error } }) => error) const hash = location.hash.substring(1) const hashMap = { info: 0, @@ -150,20 +149,8 @@ export default function ({ } const [data, setData] = useState(workload) const [workloadValues, setWorkloadValues] = useState(values) - const [scrollPosition, setScrollPosition] = useState(0) const icon = data?.icon || '/logos/akamai_logo.svg' - const handleScroll = () => { - const position = window.scrollY - setScrollPosition(position) - } - useEffect(() => { - window.addEventListener('scroll', handleScroll, { passive: true }) - return () => { - window.removeEventListener('scroll', handleScroll) - } - }, []) - useEffect(() => { if (!workload) return setWorkloadValues(values) diff --git a/src/components/Catalogs.tsx b/src/components/Catalogs.tsx index 070345908..c751e6231 100644 --- a/src/components/Catalogs.tsx +++ b/src/components/Catalogs.tsx @@ -53,19 +53,12 @@ interface Props { // TODO: this needs to be fetched from APL Api interface NewChartValues { - url: string - chartName: string + gitRepositoryUrl: string + chartTargetDirName: string chartIcon?: string - chartPath: string - revision: string allowTeams: boolean } -interface NewChartPayload extends NewChartValues { - teamId: string - userSub: string -} - export default function ({ teamId, catalogs, fetchCatalog }: Props): React.ReactElement { const { classes, cx } = useStyles() const [filterName, setFilterName] = useState('') @@ -88,25 +81,9 @@ export default function ({ teamId, catalogs, fetchCatalog }: Props): React.React setFilteredCatalog(filtered) } - const addChart = async (values: NewChartValues) => { - let finalUrl = '' - - try { - const parsedUrl = new URL(values.url) - // Split the pathname into segments and filter out empty values. - const segments = parsedUrl.pathname.split('/').filter(Boolean) - if (segments.length < 2) throw new Error('Invalid repository URL: not enough segments.') - - // Construct the base URL using only the first two segments. - // This gives you: https://github.com/{company}/{project}.git - finalUrl = `${parsedUrl.protocol}//${parsedUrl.hostname}/${segments[0]}/${segments[1]}.git` - } catch (error) { - return - } - - const payload: NewChartPayload = { ...values, teamId, userSub: user.sub, url: finalUrl } + const addChart = async (data: NewChartValues) => { try { - const result = await createWorkloadCatalog({ body: payload }).unwrap() + const result = await createWorkloadCatalog({ body: data }).unwrap() fetchCatalog() if (result) enqueueSnackbar('Chart successfully added', { variant: 'success' }) else enqueueSnackbar('Error adding chart', { variant: 'error' }) @@ -160,11 +137,12 @@ export default function ({ teamId, catalogs, fetchCatalog }: Props): React.React addChart(handleActionValues)} handleClose={() => setOpenNewChartModal(false)} + chartDirectories={filteredCatalog.map((item) => item.name) || []} /> ) diff --git a/src/components/NewChartModal.test.tsx b/src/components/NewChartModal.test.tsx index 6f4eda8e0..11c10830e 100644 --- a/src/components/NewChartModal.test.tsx +++ b/src/components/NewChartModal.test.tsx @@ -1,72 +1,370 @@ -// NewChartModal.test.tsx import React from 'react' -import { render, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' -import NewChartModal from './NewChartModal' - -// Provide required props for NewChartModal -const defaultProps = { - open: true, - handleClose: jest.fn(), - handleAction: jest.fn(), - title: 'Add Helm Chart', - cancelButtonText: 'Cancel', - actionButtonText: 'Add Chart', -} - -describe('NewChartModal', () => { - test('submit button is disabled if required fields are empty', () => { - render() - - // Initially, the connection is not tested so the form is invalid. - const addChartButton = screen.getByRole('button', { name: /add chart/i }) - expect(addChartButton).toBeDisabled() +import userEvent from '@testing-library/user-event' +import { useSession } from 'providers/Session' +import { useGetHelmChartContentQuery } from 'redux/otomiApi' +import NewChartModal, { checkDirectoryName } from './NewChartModal' + +// Mock the redux hook +jest.mock('redux/otomiApi', () => ({ + useGetHelmChartContentQuery: jest.fn(), +})) +const mockedUseGetHelmChartContentQuery = useGetHelmChartContentQuery as jest.Mock + +// Mock the session provider +jest.mock('providers/Session', () => ({ + useSession: jest.fn(), +})) +const mockedUseSession = useSession as jest.Mock + +// Mock the DefaultLogo component +jest.mock('../assets/akamai-logo-rgb-waveOnly', () => ({ + __esModule: true, + default: () =>
, +})) + +describe('NewChartModal Component', () => { + const mockChartDirectories = ['existing-chart'] + const mockHandleClose = jest.fn() + const mockHandleCancel = jest.fn() + const mockHandleAction = jest.fn() + + const mockProps = { + title: 'Add New Chart', + open: true, + handleClose: mockHandleClose, + handleCancel: mockHandleCancel, + cancelButtonText: 'Cancel', + handleAction: mockHandleAction, + actionButtonText: 'Submit', + chartDirectories: mockChartDirectories, + } + + const mockHelmChartData = { + values: { + name: 'test-chart', + description: 'A test chart', + version: '1.0.0', + appVersion: '1.0.0', + icon: 'https://example.com/icon.png', + }, + } + + beforeEach(() => { + jest.clearAllMocks() + // Default mock implementation + mockedUseSession.mockReturnValue({ + settings: { cluster: { domainSuffix: 'example.com' } }, + }) + const mockUseGetHelmChartContentQuery = jest.fn().mockReturnValue({ + data: null, + isLoading: false, + isFetching: false, + }) + mockedUseGetHelmChartContentQuery.mockImplementation(mockUseGetHelmChartContentQuery) }) - test('submit button is enabled when a chart is succesfully fetched', async () => { - render() - // Paste in a valid GitHub URL (ending with chart.yaml) - const urlInput = screen.getByLabelText(/github url/i) - userEvent.clear(urlInput) - const clipboardData = 'https://github.com/nats-io/k8s/blob/main/helm/charts/nats/Chart.yaml' - userEvent.paste(clipboardData) + describe('checkDirectoryName helper function', () => { + it('should return error message if directory name already exists', () => { + const result = checkDirectoryName('existing-chart', mockChartDirectories) + expect(result).toBe('Directory name already exists.') + }) + + it('should return error message if directory name contains invalid characters', () => { + const invalidNames = [ + 'test chart', // space + 'test/chart', // slash + 'test:chart', // colon + 'test&chart', // ampersand + '..', // just dots + '-test', // leading dash + 'test-', // trailing dash + ] + + invalidNames.forEach((name) => { + const result = checkDirectoryName(name, mockChartDirectories) + expect(result).toBe( + 'Invalid directory name. Avoid spaces, special characters or leading, trailing dots and dashes.', + ) + }) + }) + + it('should return empty string for valid directory name', () => { + const result = checkDirectoryName('valid-chart-name', mockChartDirectories) + expect(result).toBe('') + }) + }) + + describe('Component Rendering', () => { + it('should render the modal with header when noHeader is not provided', () => { + render() + expect(screen.getByText('Add New Chart')).toBeInTheDocument() + }) + + it('should not render header when noHeader is true', () => { + render() + expect(screen.queryByText('Add New Chart')).not.toBeInTheDocument() + }) + + it('should render the form fields', () => { + render() + + expect(screen.getByLabelText('Git Repository URL')).toBeInTheDocument() + expect(screen.getByLabelText('Name')).toBeInTheDocument() + expect(screen.getByLabelText('Description')).toBeInTheDocument() + expect(screen.getByLabelText('App Version')).toBeInTheDocument() + expect(screen.getByLabelText('Version')).toBeInTheDocument() + expect(screen.getByLabelText('Icon URL (optional)')).toBeInTheDocument() + expect(screen.getByLabelText('Target Directory Name')).toBeInTheDocument() + expect(screen.getByLabelText('Allow teams to use this chart')).toBeInTheDocument() + }) + + it('should render the default logo when no icon is provided', () => { + render() + expect(screen.getByTestId('default-logo')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should update gitRepositoryUrl when user types in the URL field', async () => { + render() + + const urlInput = screen.getByLabelText('Git Repository URL') + await userEvent.type(urlInput, 'https://github.com/test/repo/blob/main/Chart.yaml') + + expect(urlInput).toHaveValue('https://github.com/test/repo/blob/main/Chart.yaml') + }) + + it('should trigger handleCancel when clicking the cancel button', () => { + render() + + const cancelButton = screen.getByText('Cancel') + fireEvent.click(cancelButton) + + expect(mockHandleCancel).toHaveBeenCalledTimes(1) + }) + + it('should set helm chart URL when clicking Get details button', async () => { + mockedUseGetHelmChartContentQuery.mockImplementation(({ url }) => ({ + data: url ? mockHelmChartData : null, + isLoading: false, + isFetching: false, + })) + + render() + + const urlInput = screen.getByLabelText('Git Repository URL') + await userEvent.type(urlInput, 'https://github.com/test/repo/blob/main/Chart.yaml') + + const getDetailsButton = screen.getByText('Get details') + fireEvent.click(getDetailsButton) + + // Wait for the useEffect to update the form fields + await waitFor(() => { + expect(screen.getByLabelText('Name')).toHaveValue('test-chart') + expect(screen.getByLabelText('Description')).toHaveValue('A test chart') + expect(screen.getByLabelText('Version')).toHaveValue('1.0.0') + expect(screen.getByLabelText('App Version')).toHaveValue('1.0.0') + expect(screen.getByLabelText('Icon URL (optional)')).toHaveValue('https://example.com/icon.png') + expect(screen.getByLabelText('Target Directory Name')).toHaveValue('test-chart') + }) + }) + + it('should show error when helm chart data has an error', async () => { + const mockError = 'Error fetching helm chart content.' + mockedUseGetHelmChartContentQuery.mockImplementation(({ url }) => ({ + data: url ? { error: mockError } : null, + isLoading: false, + isFetching: false, + })) - // Click "Get details" to simulate a successful test connection. - const getDetailsButton = screen.getByRole('button', { name: /get details/i }) - userEvent.click(getDetailsButton) + render() - // Wait for the Chart Name field to become enabled (indicating connectionTested is finished). - await waitFor(() => { - expect(screen.getByLabelText(/chart name/i)).not.toBeDisabled() + const urlInput = screen.getByLabelText('Git Repository URL') + await userEvent.type(urlInput, 'https://github.com/invalid/invalid/blob/invalid/Chart.yaml') + + const getDetailsButton = screen.getByText('Get details') + fireEvent.click(getDetailsButton) + + await waitFor(() => { + expect(screen.getByText(mockError)).toBeInTheDocument() + }) + }) + + it('should toggle allowTeams when checkbox is clicked', async () => { + render() + + const checkbox = screen.getByLabelText('Allow teams to use this chart') + + // Default is checked + expect(checkbox).toBeChecked() + + // Click to uncheck + await userEvent.click(checkbox) + expect(checkbox).not.toBeChecked() + + // Click to check again + await userEvent.click(checkbox) + expect(checkbox).toBeChecked() + }) + + it('should display directory name error when invalid directory name is entered', async () => { + // Set up with successful chart data retrieval + mockedUseGetHelmChartContentQuery.mockImplementation(({ url }) => ({ + data: url ? mockHelmChartData : null, + isLoading: false, + isFetching: false, + })) + + render() + + // First get chart details to enable the field + const urlInput = screen.getByLabelText('Git Repository URL') + await userEvent.type(urlInput, 'https://github.com/test/repo/blob/main/Chart.yaml') + + const getDetailsButton = screen.getByText('Get details') + fireEvent.click(getDetailsButton) + + // Wait for fields to update + await waitFor(() => { + expect(screen.getByLabelText('Target Directory Name')).toHaveValue('test-chart') + }) + + // Now change to an invalid directory name + const dirNameInput = screen.getByLabelText('Target Directory Name') + await userEvent.clear(dirNameInput) + await userEvent.type(dirNameInput, 'test chart') + + expect( + screen.getByText( + 'Invalid directory name. Avoid spaces, special characters or leading, trailing dots and dashes.', + ), + ).toBeInTheDocument() }) - // Expect the 'add chart' button to be enabled as all fields should be populated - const addChartButton = screen.getByRole('button', { name: /add chart/i }) - expect(addChartButton).toBeEnabled() + it('should display directory exists error when using an existing directory name', async () => { + // Set up with successful chart data retrieval + mockedUseGetHelmChartContentQuery.mockImplementation(({ url }) => ({ + data: url ? mockHelmChartData : null, + isLoading: false, + isFetching: false, + })) + + render() + + // First get chart details to enable the field + const urlInput = screen.getByLabelText('Git Repository URL') + await userEvent.type(urlInput, 'https://github.com/test/repo/blob/main/Chart.yaml') + + const getDetailsButton = screen.getByText('Get details') + fireEvent.click(getDetailsButton) + + // Wait for fields to update + await waitFor(() => { + expect(screen.getByLabelText('Target Directory Name')).toHaveValue('test-chart') + }) + + // Now change to an existing directory name + const dirNameInput = screen.getByLabelText('Target Directory Name') + await userEvent.clear(dirNameInput) + await userEvent.type(dirNameInput, 'existing-chart') + + expect(screen.getByText('Directory name already exists.')).toBeInTheDocument() + }) }) - test('shows error when URL is not a GitHub URL', async () => { - render() - // Paste in an invalid GitHub URL (URL does not contain github) - const urlInput = screen.getByLabelText(/github url/i) - userEvent.clear(urlInput) - const clipboardData = 'https://ithub.com/blob/main/helm/charts/nats/Chart.yaml' - userEvent.paste(clipboardData) + describe('Form Submission', () => { + it('should call handleAction with correct values when form is valid and submitted', async () => { + // Set up with successful chart data retrieval + mockedUseGetHelmChartContentQuery.mockImplementation(({ url }) => ({ + data: url ? mockHelmChartData : null, + isLoading: false, + isFetching: false, + })) + + render() + + // Fill form + const urlInput = screen.getByLabelText('Git Repository URL') + await userEvent.type(urlInput, 'https://github.com/test/repo/blob/main/Chart.yaml') + + const getDetailsButton = screen.getByText('Get details') + fireEvent.click(getDetailsButton) + + // Wait for fields to update and form to be valid + await waitFor(() => { + expect(screen.getByLabelText('Name')).toHaveValue('test-chart') + }) + + // Submit form + const submitButton = screen.getByText('Submit') + expect(submitButton).not.toBeDisabled() + fireEvent.click(submitButton) - const errorMsg = await screen.findByText(/URL must be a valid GitHub URL/i) - expect(errorMsg).toBeInTheDocument() + expect(mockHandleAction).toHaveBeenCalledWith({ + gitRepositoryUrl: 'https://github.com/test/repo/blob/main/Chart.yaml', + chartTargetDirName: 'test-chart', + chartIcon: 'https://example.com/icon.png', + allowTeams: true, + }) + }) + + it('should disable submit button when form is invalid', () => { + render() + + // Form is invalid initially without any data + const submitButton = screen.getByText('Submit') + expect(submitButton).toBeDisabled() + }) }) - test('shows error when URL does not end with chart.yaml', async () => { - render() - // Paste in an invalid GitHub URL (URL is a valid github URL but does not end with Chart.yaml) - const urlInput = screen.getByLabelText(/github url/i) - userEvent.clear(urlInput) - const clipboardData = 'https://github.com/bitnami/charts/blob/main/bitnami/cassandra/Chart.txt' - userEvent.paste(clipboardData) + describe('Loading States', () => { + it('should show loading state for the Get details button', () => { + mockedUseGetHelmChartContentQuery.mockImplementation(() => ({ + data: null, + isLoading: true, + isFetching: false, + })) + + render() - const errorMsg = await screen.findByText(/his is a valid GitHub URL but does not end with/i) - expect(errorMsg).toBeInTheDocument() + const getDetailsButton = screen.getByText('Get details') + fireEvent.click(getDetailsButton) + + // Check loading state logic - the actual visual component might be implemented differently + // depending on how LoadingButton works in the codebase + expect(mockedUseGetHelmChartContentQuery).toHaveBeenCalled() + }) + + it('should show loading state for the Submit button when submitting', async () => { + // Set up with successful chart data retrieval + mockedUseGetHelmChartContentQuery.mockImplementation(({ url }) => ({ + data: url ? mockHelmChartData : null, + isLoading: false, + isFetching: false, + })) + + render() + + // Fill form + const urlInput = screen.getByLabelText('Git Repository URL') + await userEvent.type(urlInput, 'https://github.com/test/repo/blob/main/Chart.yaml') + + const getDetailsButton = screen.getByText('Get details') + fireEvent.click(getDetailsButton) + + // Wait for fields to update + await waitFor(() => { + expect(screen.getByLabelText('Name')).toHaveValue('test-chart') + }) + + // Submit form + const submitButton = screen.getByText('Submit') + fireEvent.click(submitButton) + + // Button should be disabled during submission + expect(submitButton).toBeDisabled() + }) }) }) diff --git a/src/components/NewChartModal.tsx b/src/components/NewChartModal.tsx index 59c27ba7d..67747df31 100644 --- a/src/components/NewChartModal.tsx +++ b/src/components/NewChartModal.tsx @@ -1,11 +1,10 @@ -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import { Box, Button, ButtonPropsColorOverrides, Checkbox, FormControlLabel, - IconButton, Modal, TextField, Typography, @@ -14,7 +13,9 @@ import { import { LoadingButton } from '@mui/lab' // eslint-disable-next-line import/no-unresolved import { OverridableStringUnion } from '@mui/types' -import yaml from 'js-yaml' +import { useGetHelmChartContentQuery } from 'redux/otomiApi' +import { isEmpty } from 'lodash' +import { useSession } from 'providers/Session' import DefaultLogo from '../assets/akamai-logo-rgb-waveOnly' // styles ---------------------------------------------------------------- @@ -24,11 +25,19 @@ const ModalBox = styled(Box)(({ theme }) => ({ left: '50%', transform: 'translate(-50%, -50%)', width: 700, + maxHeight: '90%', + overflowY: 'auto', backgroundColor: theme.palette.background.paper, boxShadow: 'rgb(0 0 0 / 20%) 0px 11px 15px -7px, rgb(0 0 0 / 14%) 0px 24px 38px 3px, rgb(0 0 0 / 12%) 0px 9px 46px 8px', borderRadius: 16, padding: 0, + // Hide scrollbar + '-ms-overflow-style': 'none' /* Internet Explorer 10+ */, + 'scrollbar-width': 'none' /* Firefox */, + '&::-webkit-scrollbar': { + display: 'none' /* Safari and Chrome */, + }, })) const ModalHeader = styled('div')({ @@ -54,6 +63,35 @@ const ModalFooter = styled('div')({ paddingRight: '30px', }) +// helper functions ----------------------------------------------------- +export const checkDirectoryName = (directoryName: string, chartDirectories: string[]) => { + if (!directoryName) return 'Directory name is required.' + if (chartDirectories.includes(directoryName.toLowerCase())) return 'Directory name already exists.' + // Regex to validate directory names by checking: + // 1. Names consisting **only** of dots (`.`) → Invalid (e.g., "..", "..."). + // 2. Presence of special characters: `\ / : * ? " < > | # % & { } $ ! ` ~` or whitespace → Invalid. + // 3. Names starting (`^-`) or ending (`-$`) with a hyphen → Invalid. + const invalidDirNamePattern = /[\\/:*?"<>|#%&{}$!`~\s]|^\.+$|^-|-$/ + if (invalidDirNamePattern.test(directoryName)) + return 'Invalid directory name. Avoid spaces, special characters or leading, trailing dots and dashes.' + return '' +} + +const checkGitRepositoryUrl = (url: string, urlError: string) => { + // Regex to validate Git URLs by checking: + // 1. Optional "http(s)://". + // 2. Matches "github.com", "gitlab.com", or "bitbucket.org". + // 3. Ensures repository owner and name in the URL path. + // 4. Matches "blob", "raw", or "src" for content retrieval. + // 5. Validates file path after content type. + // 6. Ensures ending with "Chart.yaml". + const gitRepositoryUrlRegex = + /^(https?:\/\/)?(github\.com|gitlab\.com|bitbucket\.org)\/.+\/.+\/(blob|raw|src|-\/(?:blob|raw))\/.+\/Chart\.yaml$/ + + const errorText = urlError || (url && !url.match(gitRepositoryUrlRegex) ? 'Invalid URL format.' : '') + return errorText +} + // interface and component ----------------------------------------------- interface Props { title?: string @@ -70,14 +108,13 @@ interface Props { > actionButtonEndIcon?: React.ReactElement actionButtonFrontIcon?: React.ReactElement + chartDirectories: string[] } interface NewChartValues { - url: string - chartName: string + gitRepositoryUrl: string + chartTargetDirName: string chartIcon?: string - chartPath: string - revision: string allowTeams: boolean } @@ -93,76 +130,82 @@ export default function NewChartModal({ actionButtonColor, actionButtonEndIcon, actionButtonFrontIcon, + chartDirectories, }: Props) { - // State for the GitHub URL and chart fields - const [githubUrl, setGithubUrl] = useState('') + const { + settings: { cluster }, + } = useSession() + const [helmChartUrl, setHelmChartUrl] = useState('') + const [gitRepositoryUrl, setGitRepositoryUrl] = useState('') const [chartName, setChartName] = useState('') + const [chartDescription, setChartDescription] = useState('') + const [chartAppVersion, setChartAppVersion] = useState('') + const [chartVersion, setChartVersion] = useState('') const [chartIcon, setChartIcon] = useState('') - const [chartPath, setChartPath] = useState('') - const [revision, setRevision] = useState('') + const [chartTargetDirName, setChartTargetDirName] = useState('') const [allowTeams, setAllowTeams] = useState(true) // Indicates that Get details passed. const [connectionTested, setConnectionTested] = useState(false) // Error state for the URL input. - const [urlError, setUrlError] = useState(null) + const [urlError, setUrlError] = useState('') // Loading state for the Add Chart button. const [isLoading, setIsLoading] = useState(false) - // Validate the URL whenever it changes. + const { + data: helmChartData, + isLoading: isLoadingHelmChartContent, + isFetching: isFetchingHelmChartContent, + } = useGetHelmChartContentQuery({ url: helmChartUrl }, { skip: !helmChartUrl }) + + useEffect(() => { + if (helmChartData?.error) { + const { error } = helmChartData as { error: string } + setConnectionTested(false) + setUrlError(error) + } else { + setConnectionTested(true) + setUrlError(null) + } + if (!isEmpty(helmChartData?.values)) { + const { values } = helmChartData as { + values: { name: string; description: string; version: string; appVersion: string; icon: string } + } + setChartName(values.name) + setChartDescription(values.description) + setChartVersion(values.version) + setChartAppVersion(values.appVersion) + setChartIcon(values.icon || '') + setChartTargetDirName(values.name) + setConnectionTested(true) + } else setConnectionTested(false) + }, [helmChartData]) + const handleUrlChange = (e: React.ChangeEvent) => { - const val = e.target.value - setGithubUrl(val) + const repoUrl = e.target.value + setHelmChartUrl('') + setGitRepositoryUrl(repoUrl) setConnectionTested(false) - try { - const parsedUrl = new URL(val) - if (!parsedUrl.hostname.includes('github.com') || !parsedUrl.pathname.includes('/blob/')) - setUrlError('URL must be a valid GitHub URL (containing github.com and /blob/).') - else if (!val.toLowerCase().endsWith('chart.yaml')) - setUrlError("This is a valid GitHub URL but does not end with 'chart.yaml'.") - else setUrlError(null) - } catch (error) { - setUrlError('Invalid URL format.') - } + setUrlError(null) } - const getChart = async () => { - if (!githubUrl || urlError) return - - try { - const parsedUrl = new URL(githubUrl) - if (!parsedUrl.hostname.includes('github.com')) return - - const rawUrl = githubUrl.replace('github.com', 'raw.githubusercontent.com').replace('/blob', '') - const response = await fetch(rawUrl) - if (!response.ok) return - - const yamlText = await response.text() - const chartData = yaml.load(yamlText) as any - - // Set chart fields (icon is optional) - setChartName((chartData.name as string) || '') - setChartIcon((chartData.icon as string) || '') - const pathSegments = parsedUrl.pathname.split('/').filter(Boolean) - if (pathSegments.length < 5 || pathSegments[2] !== 'blob') return - - const rev = pathSegments[3] - const chartPathSegments = pathSegments.slice(4, pathSegments.length - 1) - const cp = chartPathSegments.join('/') - setRevision(rev) - setChartPath(cp) - setConnectionTested(true) - } catch (error) { - setConnectionTested(false) - } + const handleSubmit = () => { + setIsLoading(true) + handleAction({ + gitRepositoryUrl: helmChartUrl, + chartTargetDirName, + chartIcon, + allowTeams, + }) } - // Form is valid when connection is tested, required fields are filled, and no URL error exists. + // Form is valid when connection is tested, required fields are filled, and no error exists. const isFormValid = connectionTested && - chartName.trim() !== '' && - chartPath.trim() !== '' && - revision.trim() !== '' && - githubUrl.trim() !== '' && + chartName?.trim() !== '' && + chartAppVersion?.trim() !== '' && + chartVersion?.trim() !== '' && + gitRepositoryUrl?.trim() !== '' && + !checkDirectoryName(chartTargetDirName, chartDirectories) && !urlError // Temp solution to style disabled state, cannot be done with styled components. @@ -181,41 +224,40 @@ export default function NewChartModal({ {!noHeader && ( {title} - - X - )} {/* Helper text */} - Please provide a valid GitHub URL pointing to a Chart.yaml file + Provide a git repository URL pointing to a Chart.yaml file. {/* Row for the GitHub URL input and Get details button */} - + - {/* Editable fields for the fetched chart data. They are enabled only if connectionTested is true. */} - setChartName(e.target.value)} - fullWidth - disabled={!connectionTested} - sx={disabledSx} - /> + {/* Read-only fields for the fetched chart data. */} + + + + {/* Icon URL field with preview image next to it */} setChartPath(e.target.value)} - fullWidth - disabled={!connectionTested} - sx={disabledSx} - /> - setRevision(e.target.value)} + label='Target Directory Name' + value={chartTargetDirName} + onChange={(e) => setChartTargetDirName(e.target.value)} fullWidth disabled={!connectionTested} sx={disabledSx} + error={Boolean(checkDirectoryName(chartTargetDirName, chartDirectories))} + helperText={helmChartUrl && checkDirectoryName(chartTargetDirName, chartDirectories)} /> + + {`The Helm chart will be added at: `} + + {`https://gitea.${cluster.domainSuffix}/otomi/charts/${chartTargetDirName}`} + + setAllowTeams(e.target.checked)} color='primary' /> @@ -278,17 +320,7 @@ export default function NewChartModal({ variant='contained' color={actionButtonColor || 'error'} sx={{ ml: 1, bgcolor: actionButtonColor }} - onClick={() => { - setIsLoading(true) - handleAction({ - url: githubUrl, - chartName, - chartIcon, - chartPath, - revision, - allowTeams, - }) - }} + onClick={handleSubmit} disabled={!isFormValid || isLoading} loading={isLoading} startIcon={actionButtonFrontIcon} diff --git a/src/redux/otomiApi.ts b/src/redux/otomiApi.ts index 13f79b05e..60a4e69e0 100644 --- a/src/redux/otomiApi.ts +++ b/src/redux/otomiApi.ts @@ -263,6 +263,9 @@ const injectedRtkApi = api.injectEndpoints({ workloadCatalog: build.mutation({ query: (queryArg) => ({ url: `/workloadCatalog`, method: 'POST', body: queryArg.body }), }), + getHelmChartContent: build.query({ + query: (queryArg) => ({ url: `/helmChartContent`, params: { url: queryArg.url } }), + }), createWorkloadCatalog: build.mutation({ query: (queryArg) => ({ url: `/createWorkloadCatalog`, method: 'POST', body: queryArg.body }), }), @@ -3499,6 +3502,14 @@ export type WorkloadCatalogApiArg = { /** Project object that contains updated values */ body: object } +export type GetHelmChartContentApiResponse = /** status 200 Successfully obtained helm chart content */ { + values?: object + error?: string +} +export type GetHelmChartContentApiArg = { + /** URL of the helm chart */ + url?: string +} export type CreateWorkloadCatalogApiResponse = /** status 200 Successfully updated a team project */ object export type CreateWorkloadCatalogApiArg = { /** Project object that contains updated values */ @@ -4571,6 +4582,7 @@ export const { useDeleteCoderepoMutation, useGetAllWorkloadsQuery, useWorkloadCatalogMutation, + useGetHelmChartContentQuery, useCreateWorkloadCatalogMutation, useGetTeamWorkloadsQuery, useCreateWorkloadMutation,