diff --git a/public/locales/en.json b/public/locales/en.json index 8ce4fd97..0e145c5c 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -364,7 +364,8 @@ "maxChars": "Max length is {{maxLength}} characters.", "userExists": "User with this name already exists!", "atLeastOneUser": "You need to have at least one member assigned.", - "notValidChargingTargetFormat": "Use lowercase letters a-f, numbers 0-9, and hyphens (-) in the format: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + "notValidChargingTargetFormat": "Use lowercase letters a-f, numbers 0-9, and hyphens (-) in the format: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "urlFormat": "Must be a valid HTTPS URL" }, "common": { "all": "All", @@ -510,5 +511,17 @@ "addMembersButton0": "Add members", "addMembersButton1": "Add member", "addMembersButtonN": "Add {{count}} members" + }, + "CreateGitRepositoryDialog": { + "dialogTitle": "Create Git Repository", + "metadataTitle": "Metadata", + "urlTitle": "URL", + "secretRefTitle": "SecretRef", + "nameTitle": "Name", + "intervalTitle": "Interval", + "specTitle": "Spec", + "branchTitle": "Branch", + "gitRepositoryCreated": "Git Repository created successfully", + "gitRepositoryCreationFailed": "Failed to create Git Repository: {{error}}" } } diff --git a/src/components/ControlPlane/GitRepositories.tsx b/src/components/ControlPlane/GitRepositories.tsx index 15beca49..c76d2d51 100644 --- a/src/components/ControlPlane/GitRepositories.tsx +++ b/src/components/ControlPlane/GitRepositories.tsx @@ -1,4 +1,11 @@ import ConfiguredAnalyticstable from '../Shared/ConfiguredAnalyticsTable.tsx'; +import { useApiResource } from '../../lib/api/useApiResource'; +import { FluxRequest } from '../../lib/api/types/flux/listGitRepo'; +import { useTranslation } from 'react-i18next'; +import { formatDateAsTimeAgo } from '../../utils/i18n/timeAgo.ts'; + +import { YamlViewButton } from '../Yaml/YamlViewButton.tsx'; +import { Fragment, useCallback, useContext, useMemo, useRef, useState } from 'react'; import { AnalyticalTableColumnDefinition, Panel, @@ -7,15 +14,8 @@ import { ToolbarSpacer, Button, } from '@ui5/webcomponents-react'; +import '@ui5/webcomponents-icons/dist/add'; import IllustratedError from '../Shared/IllustratedError.tsx'; -import { useApiResource } from '../../lib/api/useApiResource'; -import { FluxRequest } from '../../lib/api/types/flux/listGitRepo'; -import { useTranslation } from 'react-i18next'; -import { formatDateAsTimeAgo } from '../../utils/i18n/timeAgo.ts'; - -import { YamlViewButton } from '../Yaml/YamlViewButton.tsx'; -import { Fragment, useCallback, useContext, useMemo, useRef } from 'react'; -import StatusFilter from '../Shared/StatusFilter/StatusFilter.tsx'; import { ResourceStatusCell } from '../Shared/ResourceStatusCell.tsx'; import { Resource } from '../../utils/removeManagedFieldsAndFilterData.ts'; import { useSplitter } from '../Splitter/SplitterContext.tsx'; @@ -27,6 +27,8 @@ import { ActionsMenu, type ActionItem } from './ActionsMenu'; import { ApiConfigContext } from '../Shared/k8s'; import { useHasMcpAdminRights } from '../../spaces/mcp/auth/useHasMcpAdminRights.ts'; +import StatusFilter from '../Shared/StatusFilter/StatusFilter.tsx'; +import { CreateGitRepositoryDialog } from '../Dialogs/CreateGitRepositoryDialog.tsx'; export type GitRepoItem = GitReposResponse['items'][0] & { apiVersion?: string; @@ -39,6 +41,7 @@ export function GitRepositories() { const { openInAsideWithApiConfig } = useSplitter(); const errorDialogRef = useRef(null); const handlePatch = useHandleResourcePatch(errorDialogRef); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); type FluxRow = { name: string; @@ -186,21 +189,28 @@ export function GitRepositories() { }) ?? []; return ( - - {t('common.resourcesCount', { count: rows.length })} - - - - } - > - <> - - - - + <> + + {t('common.resourcesCount', { count: rows.length })} + + + + + } + > + <> + + + + + + setIsCreateDialogOpen(false)} /> + ); } @@ -211,7 +221,6 @@ function shortenCommitHash(commitHash: string): string { if (match && match[2]) { return `${match[1]}@${match[2].slice(0, 7)}`; } - //example output : master@b3396ad return commitHash; } diff --git a/src/components/Dialogs/CreateGitRepositoryDialog.cy.tsx b/src/components/Dialogs/CreateGitRepositoryDialog.cy.tsx new file mode 100644 index 00000000..9629633f --- /dev/null +++ b/src/components/Dialogs/CreateGitRepositoryDialog.cy.tsx @@ -0,0 +1,193 @@ +import { CreateGitRepositoryDialog } from './CreateGitRepositoryDialog'; +import { CreateGitRepositoryParams } from '../../hooks/useCreateGitRepository'; + +describe('CreateGitRepositoryDialog', () => { + let capturedData: CreateGitRepositoryParams | null = null; + + const fakeUseCreateGitRepository = () => ({ + createGitRepository: async (data: CreateGitRepositoryParams): Promise => { + capturedData = data; + }, + isLoading: false, + }); + + beforeEach(() => { + capturedData = null; + }); + + it('creates a git repository with valid data', () => { + const onClose = cy.stub(); + + cy.mount( + , + ); + + const expectedPayload = { + namespace: 'default', + name: 'test-repo', + interval: '5m0s', + url: 'https://github.com/test/repo', + branch: 'develop', + secretRef: '', + }; + + // Fill in the form + cy.get('[name="name"]').typeIntoUi5Input('test-repo'); + cy.get('[name="interval"]').find('input').clear().type('5m0s'); + + cy.get('[name="url"]').typeIntoUi5Input('https://github.com/test/repo'); + cy.get('[name="branch"]').find('input').clear().type('develop'); + + // Submit the form + cy.get('ui5-button').contains('Create').click(); + + // Verify the hook was called with correct data + cy.then(() => cy.wrap(capturedData).deepEqualJson(expectedPayload)); + + // Dialog should close on success + cy.wrap(onClose).should('have.been.called'); + }); + + it('includes secretRef when provided', () => { + const onClose = cy.stub(); + + cy.mount( + , + ); + + const expectedPayload = { + namespace: 'default', + name: 'test-repo', + interval: '1m0s', + url: 'https://github.com/test/repo', + branch: 'main', + secretRef: 'my-git-secret', + }; + + // Fill in the form + cy.get('[name="name"]').typeIntoUi5Input('test-repo'); + cy.get('[name="url"]').typeIntoUi5Input('https://github.com/test/repo'); + cy.get('[name="secretRef"]').typeIntoUi5Input('my-git-secret'); + + // Submit the form + cy.get('ui5-button').contains('Create').click(); + + // Verify the hook was called with correct data + cy.then(() => cy.wrap(capturedData).deepEqualJson(expectedPayload)); + + // Dialog should close on success + cy.wrap(onClose).should('have.been.called'); + }); + + it('validates required fields', () => { + const onClose = cy.stub(); + + cy.mount( + , + ); + + // Try to submit without filling required fields + cy.get('ui5-button').contains('Create').click(); + + // Should show validation errors + cy.get('[name="name"]').should('have.attr', 'value-state', 'Negative'); + cy.contains('This field is required').should('exist'); + + // Dialog should not close + cy.wrap(onClose).should('not.have.been.called'); + }); + + it('validates URL format', () => { + const onClose = cy.stub(); + + cy.mount( + , + ); + + cy.get('[name="name"]').typeIntoUi5Input('test-repo'); + cy.get('[name="interval"]').find('input').clear().type('1m0s'); + cy.get('[name="branch"]').find('input').clear().type('main'); + + // Test 1: Invalid string + cy.get('[name="url"]').find('input').clear().type('not-a-valid-url'); + cy.get('ui5-button').contains('Create').click(); + cy.get('[name="url"]').should('have.attr', 'value-state', 'Negative'); + cy.contains('Must be a valid HTTPS URL').should('exist'); + + // Test 2: HTTP protocol (should fail if we require HTTPS) + cy.get('[name="url"]').find('input').clear().type('http://github.com/test/repo'); + cy.get('ui5-button').contains('Create').click(); + cy.get('[name="url"]').should('have.attr', 'value-state', 'Negative'); + cy.contains('Must be a valid HTTPS URL').should('exist'); + + // Test 3: Valid HTTPS URL (should pass) + cy.get('[name="url"]').find('input').clear().type('https://github.com/test/repo'); + cy.get('ui5-button').contains('Create').click(); + + // Dialog should close on success + cy.wrap(onClose).should('have.been.called'); + }); + + it('closes dialog when cancel is clicked', () => { + const onClose = cy.stub(); + + cy.mount( + , + ); + + // Fill in some data + cy.get('[name="name"]').typeIntoUi5Input('test-repo'); + + // Click cancel + cy.get('ui5-button').contains('Cancel').click(); + + // Dialog should close without calling onSuccess + cy.wrap(onClose).should('have.been.called'); + }); + + it('uses default values for interval and branch', () => { + const onClose = cy.stub(); + + cy.mount( + , + ); + + // Check default values + cy.get('[name="interval"]').find('input').should('have.value', '1m0s'); + cy.get('[name="branch"]').find('input').should('have.value', 'main'); + }); + + it('should not close dialog when creation fails', () => { + const failingUseCreateGitRepository = () => ({ + createGitRepository: async (): Promise => { + throw new Error('Creation failed'); + }, + isLoading: false, + }); + + const onClose = cy.stub(); + + cy.mount( + , + ); + + // Fill in the form + cy.get('[name="name"]').typeIntoUi5Input('test-repo'); + cy.get('[name="interval"]').find('input').clear().type('1m0s'); + cy.get('[name="url"]').find('input').type('https://github.com/test/repo'); + cy.get('[name="branch"]').find('input').clear().type('main'); + + // Submit the form + cy.get('ui5-button').contains('Create').click(); + + // Dialog should NOT close on failure + cy.wrap(onClose).should('not.have.been.called'); + + // Dialog should still be visible + cy.contains('Create Git Repository').should('be.visible'); + }); +}); diff --git a/src/components/Dialogs/CreateGitRepositoryDialog.module.css b/src/components/Dialogs/CreateGitRepositoryDialog.module.css new file mode 100644 index 00000000..e30747d5 --- /dev/null +++ b/src/components/Dialogs/CreateGitRepositoryDialog.module.css @@ -0,0 +1,41 @@ +.grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 1rem; + padding: 1rem; + align-items: center; +} + +.gridColumnLabel { + justify-self: end; +} + +.inputField { + width: 25rem; +} + +.sectionHeader { + grid-column: 1 / -1; + font-weight: bold; + font-size: var(--sapFontHeader6Size); + padding-top: 0.5rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--sapGroup_Title_BorderColor); + margin-bottom: 0.5rem; +} + +.sectionHeader:first-child { + padding-top: 0; +} + +.form { + width: 30rem; +} + +.formField { + margin-bottom: 1.25rem; +} + +.input { + width: 100%; +} diff --git a/src/components/Dialogs/CreateGitRepositoryDialog.tsx b/src/components/Dialogs/CreateGitRepositoryDialog.tsx new file mode 100644 index 00000000..36ac08e0 --- /dev/null +++ b/src/components/Dialogs/CreateGitRepositoryDialog.tsx @@ -0,0 +1,209 @@ +import { Dialog, Bar, Label, Input, Button, Form, FormGroup } from '@ui5/webcomponents-react'; +import { useForm, Controller } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { useId } from 'react'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useCreateGitRepository as _useCreateGitRepository } from '../../hooks/useCreateGitRepository'; +import styles from './CreateGitRepositoryDialog.module.css'; + +interface CreateGitRepositoryDialogProps { + isOpen: boolean; + onClose: () => void; + useCreateGitRepository?: typeof _useCreateGitRepository; +} + +export function CreateGitRepositoryDialog({ + isOpen, + onClose, + useCreateGitRepository = _useCreateGitRepository, +}: CreateGitRepositoryDialogProps) { + const { t } = useTranslation(); + const { createGitRepository, isLoading } = useCreateGitRepository(); + const namespaceId = useId(); + const nameId = useId(); + const intervalId = useId(); + const urlId = useId(); + const branchId = useId(); + const secretRefId = useId(); + + const validationSchema = z.object({ + namespace: z.string().min(1, { message: t('validationErrors.required') }), + name: z.string().min(1, { message: t('validationErrors.required') }), + interval: z.string().min(1, { message: t('validationErrors.required') }), + url: z.url({ protocol: /^https$/, message: t('validationErrors.urlFormat') }), + branch: z.string().min(1, { message: t('validationErrors.required') }), + secretRef: z.string().optional(), + }); + + type FormSchema = z.infer; + + const { + control, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + defaultValues: { + namespace: 'default', + name: '', + interval: '1m0s', + url: '', + branch: 'main', + secretRef: '', + }, + resolver: zodResolver(validationSchema), + }); + + const handleClose = () => { + reset(); + onClose(); + }; + + const handleCreate = () => { + void handleSubmit(async (data) => { + try { + await createGitRepository(data); + reset(); + onClose(); + } catch { + // Error handled by hook + } + })(); + }; + + return ( + + + + + } + /> + } + onClose={handleClose} + > +
+ +
+ + ( + {errors.namespace?.message}} + className={styles.input} + /> + )} + /> +
+ +
+ + ( + {errors.name?.message}} + className={styles.input} + /> + )} + /> +
+
+ + +
+ + ( + {errors.interval?.message}} + placeholder="1m0s" + className={styles.input} + /> + )} + /> +
+ +
+ + ( + {errors.url?.message}} + placeholder="https://github.com/owner/repo" + className={styles.input} + /> + )} + /> +
+ +
+ + ( + {errors.branch?.message}} + placeholder="main" + className={styles.input} + /> + )} + /> +
+ +
+ + } + /> +
+
+
+
+ ); +} diff --git a/src/hooks/useCreateGitRepository.spec.ts b/src/hooks/useCreateGitRepository.spec.ts new file mode 100644 index 00000000..29dec00e --- /dev/null +++ b/src/hooks/useCreateGitRepository.spec.ts @@ -0,0 +1,227 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; +import { useCreateGitRepository } from './useCreateGitRepository'; +import { fetchApiServerJson } from '../lib/api/fetch'; +import { assertNonNullish } from '../utils/test/vitest-utils'; + +vi.mock('../lib/api/fetch'); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string) => fallback || key, + }), +})); + +vi.mock('../context/ToastContext', () => ({ + useToast: () => ({ + show: vi.fn(), + }), +})); + +vi.mock('../components/Shared/k8s/index', () => ({ + ApiConfigContext: { + Provider: ({ children }: { children: React.ReactNode }) => children, + }, +})); + +describe('useCreateGitRepository', () => { + let fetchMock: Mock; + + beforeEach(() => { + fetchMock = vi.mocked(fetchApiServerJson); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should successfully create a git repository', async () => { + // ARRANGE + fetchMock.mockResolvedValue(undefined); + + const testData = { + name: 'test-repo', + namespace: 'default', + interval: '1m0s', + url: 'https://github.com/test/repo', + branch: 'main', + }; + + // ACT + const renderHookResult = renderHook(() => useCreateGitRepository()); + const { createGitRepository } = renderHookResult.result.current; + + await act(async () => { + await createGitRepository(testData); + }); + + // ASSERT + expect(fetchMock).toHaveBeenCalledTimes(1); + + const call = fetchMock.mock.calls[0]; + assertNonNullish(call); + const [url, _config, _excludeMcpConfig, method, body] = call; + + expect(url).toBe('/apis/source.toolkit.fluxcd.io/v1/namespaces/default/gitrepositories'); + expect(method).toBe('POST'); + + const payload = JSON.parse(body as string); + expect(payload).toEqual({ + apiVersion: 'source.toolkit.fluxcd.io/v1', + kind: 'GitRepository', + metadata: { + name: 'test-repo', + namespace: 'default', + }, + spec: { + interval: '1m0s', + url: 'https://github.com/test/repo', + ref: { + branch: 'main', + }, + }, + }); + }); + + it('should include secretRef when provided', async () => { + // ARRANGE + fetchMock.mockResolvedValue(undefined); + + const testData = { + name: 'test-repo', + namespace: 'default', + interval: '1m0s', + url: 'https://github.com/test/repo', + branch: 'main', + secretRef: 'my-secret', + }; + + // ACT + const renderHookResult = renderHook(() => useCreateGitRepository()); + const { createGitRepository } = renderHookResult.result.current; + + await act(async () => { + await createGitRepository(testData); + }); + + // ASSERT + expect(fetchMock).toHaveBeenCalledTimes(1); + + const call = fetchMock.mock.calls[0]; + assertNonNullish(call); + const [, , , , body] = call; + + const payload = JSON.parse(body as string); + expect(payload.spec.secretRef).toEqual({ name: 'my-secret' }); + }); + + it('should use custom namespace when provided', async () => { + // ARRANGE + fetchMock.mockResolvedValue(undefined); + + const testData = { + name: 'test-repo', + namespace: 'default', + interval: '1m0s', + url: 'https://github.com/test/repo', + branch: 'main', + }; + + // ACT + const renderHookResult = renderHook(() => useCreateGitRepository()); + const { createGitRepository } = renderHookResult.result.current; + + await act(async () => { + await createGitRepository(testData); + }); + + // ASSERT + expect(fetchMock).toHaveBeenCalledTimes(1); + + const call = fetchMock.mock.calls[0]; + assertNonNullish(call); + const [url, , , , body] = call; + + expect(url).toBe('/apis/source.toolkit.fluxcd.io/v1/namespaces/default/gitrepositories'); + + const payload = JSON.parse(body as string); + expect(payload.metadata.namespace).toBe('default'); + }); + + it('should handle creation failure', async () => { + // ARRANGE + fetchMock.mockRejectedValue(new Error('Network error')); + + const testData = { + name: 'test-repo', + namespace: 'default', + interval: '1m0s', + url: 'https://github.com/test/repo', + branch: 'main', + }; + + // ACT + const renderHookResult = renderHook(() => useCreateGitRepository()); + const { createGitRepository } = renderHookResult.result.current; + + let errorThrown = false; + await act(async () => { + try { + await createGitRepository(testData); + } catch { + errorThrown = true; + } + }); + + // ASSERT + expect(errorThrown).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should build correct payload with all fields', async () => { + // ARRANGE + fetchMock.mockResolvedValue(undefined); + + const testData = { + name: 'my-app-repo', + namespace: 'default', + interval: '5m0s', + url: 'https://github.com/org/app', + branch: 'develop', + secretRef: 'git-credentials', + }; + + // ACT + const renderHookResult = renderHook(() => useCreateGitRepository()); + const { createGitRepository } = renderHookResult.result.current; + + await act(async () => { + await createGitRepository(testData); + }); + + // ASSERT + const call = fetchMock.mock.calls[0]; + assertNonNullish(call); + const [, , , , body] = call; + + const payload = JSON.parse(body as string); + expect(payload).toEqual({ + apiVersion: 'source.toolkit.fluxcd.io/v1', + kind: 'GitRepository', + metadata: { + name: 'my-app-repo', + namespace: 'default', + }, + spec: { + interval: '5m0s', + url: 'https://github.com/org/app', + ref: { + branch: 'develop', + }, + secretRef: { + name: 'git-credentials', + }, + }, + }); + }); +}); diff --git a/src/hooks/useCreateGitRepository.ts b/src/hooks/useCreateGitRepository.ts new file mode 100644 index 00000000..e81590d9 --- /dev/null +++ b/src/hooks/useCreateGitRepository.ts @@ -0,0 +1,67 @@ +import { useState, useContext, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useToast } from '../context/ToastContext'; +import { fetchApiServerJson } from '../lib/api/fetch'; +import { ApiConfigContext } from '../components/Shared/k8s/index'; +import { useRevalidateApiResource } from '../lib/api/useApiResource'; +import { FluxRequest } from '../lib/api/types/flux/listGitRepo'; +import { CreateGitRepositoryType } from '../lib/api/types/flux/createGitRepository'; + +export interface CreateGitRepositoryParams { + namespace: string; + name: string; + interval: string; + url: string; + branch: string; + secretRef?: string; +} + +export const useCreateGitRepository = () => { + const [isLoading, setIsLoading] = useState(false); + const { t } = useTranslation(); + const toast = useToast(); + const apiConfig = useContext(ApiConfigContext); + const revalidate = useRevalidateApiResource(FluxRequest); + + const createGitRepository = useCallback( + async (data: CreateGitRepositoryParams) => { + setIsLoading(true); + try { + const targetNamespace = data.namespace; + const payload: CreateGitRepositoryType = { + apiVersion: 'source.toolkit.fluxcd.io/v1', + kind: 'GitRepository', + metadata: { + name: data.name, + namespace: targetNamespace, + }, + spec: { + interval: data.interval, + url: data.url, + ref: { branch: data.branch }, + ...(data.secretRef ? { secretRef: { name: data.secretRef } } : {}), + }, + }; + + const path = `/apis/source.toolkit.fluxcd.io/v1/namespaces/${targetNamespace}/gitrepositories`; + + await fetchApiServerJson(path, apiConfig, undefined, 'POST', JSON.stringify(payload)); + await revalidate(); + + toast.show(t('CreateGitRepositoryDialog.gitRepositoryCreated')); + } catch (error) { + toast.show( + t('CreateGitRepositoryDialog.gitRepositoryCreationFailed', { + error: error instanceof Error ? error.message : 'Unknown error', + }), + ); + throw error; + } finally { + setIsLoading(false); + } + }, + [apiConfig, revalidate, t, toast], + ); + + return { createGitRepository, isLoading }; +}; diff --git a/src/lib/api/types/flux/createGitRepository.ts b/src/lib/api/types/flux/createGitRepository.ts new file mode 100644 index 00000000..a4ef2b46 --- /dev/null +++ b/src/lib/api/types/flux/createGitRepository.ts @@ -0,0 +1,18 @@ +export type CreateGitRepositoryType = { + apiVersion: 'source.toolkit.fluxcd.io/v1'; + kind: 'GitRepository'; + metadata: { + name: string; + namespace: string; + }; + spec: { + interval: string; + url: string; + ref: { + branch: string; + }; + secretRef?: { + name: string; + }; + }; +};