diff --git a/public/locales/en.json b/public/locales/en.json index b281794d..10e0524e 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -270,7 +270,8 @@ "properFormattingLowercase": "Use lowercase a-z, 0-9, hyphen (-), and period (.), but note that whitespace (spaces, tabs, etc.) is not allowed for proper compatibility.", "maxChars": "Max length is {{maxLength}} characters.", "userExists": "User with this email already exists!", - "atLeastOneUser": "You need to have at least one member assigned." + "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" }, "common": { "documentation": "Documentation", diff --git a/src/components/Dialogs/CreateProjectDialog.cy.tsx b/src/components/Dialogs/CreateProjectDialog.cy.tsx index 69188bbf..279e516e 100644 --- a/src/components/Dialogs/CreateProjectDialog.cy.tsx +++ b/src/components/Dialogs/CreateProjectDialog.cy.tsx @@ -1,8 +1,5 @@ import React, { useState, useRef } from 'react'; -import { - CreateProjectWorkspaceDialog, - OnCreatePayload, -} from './CreateProjectWorkspaceDialog'; +import { CreateProjectWorkspaceDialog, OnCreatePayload } from './CreateProjectWorkspaceDialog'; import { MemberRoles } from '../../lib/api/types/shared/members'; import { ErrorDialogHandle } from '../Shared/ErrorMessageBox'; @@ -31,18 +28,12 @@ export const CreateProjectWorkspaceDialogWrapper: React.FC<{ name: '', displayName: '', chargingTarget: '', - members: [ - { name: 'user1@example.com', roles: [MemberRoles.admin], kind: 'User' }, - ], + members: [{ name: 'user1@example.com', roles: [MemberRoles.admin], kind: 'User' }], + chargingTargetType: 'btp', }, }); - const handleCreate = async ({ - name, - displayName, - chargingTarget, - members, - }: OnCreatePayload) => { + const handleCreate = async ({ name, displayName, chargingTarget, members }: OnCreatePayload) => { const payload: OnCreatePayload = { name: name, displayName: displayName, @@ -55,6 +46,7 @@ export const CreateProjectWorkspaceDialogWrapper: React.FC<{ }; return ( { .contains('user1@example.com') .should('be.visible'); cy.get('ui5-button[icon="delete"]').find('button').click({ force: true }); - cy.get('span[id="members-error"]') - .contains('You need to have at least one member assigned.') - .should('be.visible'); + cy.get('span[id="members-error"]').contains('You need to have at least one member assigned.').should('be.visible'); }); it('should add a new member and display it in the table', () => { cy.mount(, {}); - cy.get('ui5-input[id*="member-email-input"]') - .find('input[id*="inner"]') - .type('user2@example.com', { force: true }); + cy.get('ui5-input[id*="member-email-input"]').find('input[id*="inner"]').type('user2@example.com', { force: true }); cy.get('ui5-button:contains("Add")').click({ force: true }); cy.get('div[data-component-name="AnalyticalTableContainerWithScrollbar"]') .contains('user2@example.com') @@ -96,25 +84,21 @@ describe('CreateProjectWorkspaceDialog', () => { const stubFn = cy.stub().as('stubFn'); cy.mount(, {}); - cy.get('ui5-input[id*="name"]') - .find('input[id*="inner"]') - .type('brand--01', { force: true }); + cy.get('ui5-input[id*="name"]').find('input[id*="inner"]').type('brand--01', { force: true }); cy.get('ui5-input[id*="displayName"]') .find('input[id*="inner"]') .type('Brand new workspace number one', { force: true }); cy.get('ui5-input[id*="chargingTarget"]') .find('input[id*="inner"]') - .type('Charging target 1000', { force: true }); - cy.get('ui5-input[id*="email"]') - .find('input[id*="inner"]') - .type('user2@example.com', { force: true }); + .type('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', { force: true }); + cy.get('ui5-input[id*="email"]').find('input[id*="inner"]').type('user2@example.com', { force: true }); cy.get('ui5-button:contains("Add")').click({ force: true }); cy.get('ui5-button:contains("Create")').click({ force: true }); cy.get('@stubFn').should('have.been.calledWith', { name: 'brand--01', displayName: 'Brand new workspace number one', - chargingTarget: 'Charging target 1000', + chargingTarget: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', members: [ { name: 'user1@example.com', @@ -143,9 +127,7 @@ describe('CreateProjectWorkspaceDialog', () => { }); newMembers.forEach((email) => { - cy.get('div[data-component-name="AnalyticalTableContainerWithScrollbar"]') - .contains(email) - .should('be.visible'); + cy.get('div[data-component-name="AnalyticalTableContainerWithScrollbar"]').contains(email).should('be.visible'); }); }); diff --git a/src/components/Dialogs/CreateProjectDialogContainer.tsx b/src/components/Dialogs/CreateProjectDialogContainer.tsx index 7aa2736b..4094ec03 100644 --- a/src/components/Dialogs/CreateProjectDialogContainer.tsx +++ b/src/components/Dialogs/CreateProjectDialogContainer.tsx @@ -2,10 +2,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { useApiResourceMutation } from '../../lib/api/useApiResource'; import { ErrorDialogHandle } from '../Shared/ErrorMessageBox.tsx'; import { APIError } from '../../lib/api/error'; -import { - CreateProjectWorkspaceDialog, - OnCreatePayload, -} from './CreateProjectWorkspaceDialog.tsx'; +import { CreateProjectWorkspaceDialog, OnCreatePayload } from './CreateProjectWorkspaceDialog.tsx'; import { useToast } from '../../context/ToastContext.tsx'; import { useAuthOnboarding } from '../../spaces/onboarding/auth/AuthContextOnboarding.tsx'; @@ -14,11 +11,7 @@ import { MemberRoles } from '../../lib/api/types/shared/members.ts'; import { useTranslation } from 'react-i18next'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; -import { - CreateProject, - CreateProjectResource, - CreateProjectType, -} from '../../lib/api/types/crate/createProject.ts'; +import { CreateProject, CreateProjectResource, CreateProjectType } from '../../lib/api/types/crate/createProject.ts'; import { validationSchemaProjectWorkspace } from '../../lib/api/validations/schemas.ts'; import { CreateDialogProps } from './CreateWorkspaceDialogContainer.tsx'; @@ -30,12 +23,12 @@ export function CreateProjectDialogContainer({ setIsOpen: (isOpen: boolean) => void; }) { const { + watch, register, handleSubmit, resetField, setValue, formState: { errors }, - watch, } = useForm({ resolver: zodResolver(validationSchemaProjectWorkspace), defaultValues: { @@ -59,9 +52,7 @@ export function CreateProjectDialogContainer({ useEffect(() => { if (username) { - setValue('members', [ - { name: username, roles: [MemberRoles.admin], kind: 'User' }, - ]); + setValue('members', [{ name: username, roles: [MemberRoles.admin], kind: 'User' }]); } if (!isOpen) { clearForm(); @@ -70,9 +61,7 @@ export function CreateProjectDialogContainer({ const toast = useToast(); - const { trigger } = useApiResourceMutation( - CreateProjectResource(), - ); + const { trigger } = useApiResourceMutation(CreateProjectResource()); const errorDialogRef = useRef(null); @@ -99,9 +88,7 @@ export function CreateProjectDialogContainer({ console.error(e); if (e instanceof APIError) { if (errorDialogRef.current) { - errorDialogRef.current.showErrorDialog( - `${e.message}: ${JSON.stringify(e.info)}`, - ); + errorDialogRef.current.showErrorDialog(`${e.message}: ${JSON.stringify(e.info)}`); } } return false; @@ -110,6 +97,7 @@ export function CreateProjectDialogContainer({ return ( ; projectName?: string; type: 'workspace' | 'project'; + watch: UseFormWatch; } export function CreateProjectWorkspaceDialog({ @@ -50,6 +51,7 @@ export function CreateProjectWorkspaceDialog({ setValue, projectName, type, + watch, }: CreateProjectWorkspaceDialogProps) { const { t } = useTranslation(); const [isKubectlDialogOpen, setIsKubectlDialogOpen] = useState(false); @@ -71,13 +73,9 @@ export function CreateProjectWorkspaceDialog({ +
- + @@ -88,19 +86,14 @@ export function CreateProjectWorkspaceDialog({ onClose={() => setIsOpen(false)} > - + + } /> @@ -110,10 +103,7 @@ export function CreateProjectWorkspaceDialog({ isOpen={isKubectlDialogOpen && !!projectName} onClose={closeKubectlDialog} /> - + ); diff --git a/src/components/Dialogs/CreateWorkspaceDialogContainer.tsx b/src/components/Dialogs/CreateWorkspaceDialogContainer.tsx index 3b794a92..6306343e 100644 --- a/src/components/Dialogs/CreateWorkspaceDialogContainer.tsx +++ b/src/components/Dialogs/CreateWorkspaceDialogContainer.tsx @@ -1,14 +1,8 @@ import { useCallback, useEffect, useRef } from 'react'; -import { - useApiResourceMutation, - useRevalidateApiResource, -} from '../../lib/api/useApiResource'; +import { useApiResourceMutation, useRevalidateApiResource } from '../../lib/api/useApiResource'; import { ErrorDialogHandle } from '../Shared/ErrorMessageBox.tsx'; import { APIError } from '../../lib/api/error'; -import { - CreateProjectWorkspaceDialog, - OnCreatePayload, -} from './CreateProjectWorkspaceDialog.tsx'; +import { CreateProjectWorkspaceDialog, OnCreatePayload } from './CreateProjectWorkspaceDialog.tsx'; import { CreateWorkspace, CreateWorkspaceResource, @@ -73,9 +67,7 @@ export function CreateWorkspaceDialogContainer({ useEffect(() => { if (username) { - setValue('members', [ - { name: username, roles: [MemberRoles.admin], kind: 'User' }, - ]); + setValue('members', [{ name: username, roles: [MemberRoles.admin], kind: 'User' }]); } if (!isOpen) { clearForm(); @@ -84,9 +76,7 @@ export function CreateWorkspaceDialogContainer({ const namespace = projectnameToNamespace(project); const toast = useToast(); - const { trigger } = useApiResourceMutation( - CreateWorkspaceResource(namespace), - ); + const { trigger } = useApiResourceMutation(CreateWorkspaceResource(namespace)); const revalidate = useRevalidateApiResource(ListWorkspaces(project)); const errorDialogRef = useRef(null); @@ -112,9 +102,7 @@ export function CreateWorkspaceDialogContainer({ console.error(e); if (e instanceof APIError) { if (errorDialogRef.current) { - errorDialogRef.current.showErrorDialog( - `${e.message}: ${JSON.stringify(e.info)}`, - ); + errorDialogRef.current.showErrorDialog(`${e.message}: ${JSON.stringify(e.info)}`); } } return false; @@ -123,6 +111,7 @@ export function CreateWorkspaceDialogContainer({ return ( ; sideFormContent?: React.ReactNode; requireChargingTarget?: boolean; + watch: UseFormWatch; } interface SelectOption { @@ -28,6 +20,7 @@ interface SelectOption { } export function MetadataForm({ + watch, register, errors, setValue, @@ -35,24 +28,20 @@ export function MetadataForm({ requireChargingTarget = false, }: MetadataFormProps) { const { t } = useTranslation(); - const handleChargingTargetTypeChange = ( - event: Ui5CustomEvent, - ) => { + const handleChargingTargetTypeChange = (event: Ui5CustomEvent) => { const selectedOption = event.detail.selectedOption as HTMLElement; setValue('chargingTargetType', selectedOption.dataset.value); + if (selectedOption.dataset.value === '') { + setValue('chargingTarget', ''); + } }; const chargingTypes: SelectOption[] = [ - ...(!requireChargingTarget - ? [{ label: t('common.notSelected'), value: '' }] - : []), + ...(!requireChargingTarget ? [{ label: t('common.notSelected'), value: '' }] : []), { label: t('common.btp'), value: 'btp' }, ]; return (
- + @@ -64,35 +53,30 @@ export function MetadataForm({ valueStateMessage={{errors.name?.message}} required /> - - - - + +
+ +
+ -
diff --git a/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx b/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx index ebef018e..adcfa51b 100644 --- a/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx +++ b/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx @@ -21,10 +21,7 @@ import { import { SummarizeStep } from './SummarizeStep.tsx'; import { useTranslation } from 'react-i18next'; import { useAuthOnboarding } from '../../../spaces/onboarding/auth/AuthContextOnboarding.tsx'; -import { - ErrorDialog, - ErrorDialogHandle, -} from '../../Shared/ErrorMessageBox.tsx'; +import { ErrorDialog, ErrorDialogHandle } from '../../Shared/ErrorMessageBox.tsx'; import { CreateDialogProps } from '../../Dialogs/CreateWorkspaceDialogContainer.tsx'; import { validationSchemaCreateManagedControlPlane } from '../../../lib/api/validations/schemas.ts'; import { Member, MemberRoles } from '../../../lib/api/types/shared/members.ts'; @@ -50,24 +47,16 @@ type CreateManagedControlPlaneWizardContainerProps = { workspaceName?: string; }; -type WizardStepType = - | 'metadata' - | 'members' - | 'componentSelection' - | 'summarize' - | 'success'; +type WizardStepType = 'metadata' | 'members' | 'componentSelection' | 'summarize' | 'success'; -const wizardStepOrder: WizardStepType[] = [ - 'metadata', - 'members', - 'componentSelection', - 'summarize', - 'success', -]; +const wizardStepOrder: WizardStepType[] = ['metadata', 'members', 'componentSelection', 'summarize', 'success']; -export const CreateManagedControlPlaneWizardContainer: FC< - CreateManagedControlPlaneWizardContainerProps -> = ({ isOpen, setIsOpen, projectName = '', workspaceName = '' }) => { +export const CreateManagedControlPlaneWizardContainer: FC = ({ + isOpen, + setIsOpen, + projectName = '', + workspaceName = '', +}) => { const { t } = useTranslation(); const { user } = useAuthOnboarding(); const errorDialogRef = useRef(null); @@ -80,9 +69,8 @@ export const CreateManagedControlPlaneWizardContainer: FC< resetField, setValue, reset, - getValues, - formState: { errors, isValid }, watch, + formState: { errors, isValid }, } = useForm({ resolver: zodResolver(validationSchemaCreateManagedControlPlane), defaultValues: { @@ -122,9 +110,7 @@ export const CreateManagedControlPlaneWizardContainer: FC< useEffect(() => { if (user?.email && isOpen) { - setValue('members', [ - { name: user.email, roles: [MemberRoles.admin], kind: 'User' }, - ]); + setValue('members', [{ name: user.email, roles: [MemberRoles.admin], kind: 'User' }]); } if (!isOpen) { clearFormFields(); @@ -137,12 +123,7 @@ export const CreateManagedControlPlaneWizardContainer: FC< const componentsList = watch('componentsList'); const handleCreateManagedControlPlane = useCallback( - async ({ - name, - displayName, - chargingTarget, - members, - }: OnCreatePayload): Promise => { + async ({ name, displayName, chargingTarget, members, chargingTargetType }: OnCreatePayload): Promise => { try { await trigger( CreateManagedControlPlane( @@ -151,6 +132,7 @@ export const CreateManagedControlPlaneWizardContainer: FC< { displayName, chargingTarget, + chargingTargetType, members, componentsList, }, @@ -161,9 +143,7 @@ export const CreateManagedControlPlaneWizardContainer: FC< return true; } catch (e) { if (e instanceof APIError && errorDialogRef.current) { - errorDialogRef.current.showErrorDialog( - `${e.message}: ${JSON.stringify(e.info)}`, - ); + errorDialogRef.current.showErrorDialog(`${e.message}: ${JSON.stringify(e.info)}`); } else { console.error(e); } @@ -173,13 +153,10 @@ export const CreateManagedControlPlaneWizardContainer: FC< [trigger, projectName, workspaceName, componentsList], ); - const handleStepChange = useCallback( - (e: Ui5CustomEvent) => { - const step = (e.detail.step.dataset.step ?? '') as WizardStepType; - setSelectedStep(step); - }, - [], - ); + const handleStepChange = useCallback((e: Ui5CustomEvent) => { + const step = (e.detail.step.dataset.step ?? '') as WizardStepType; + setSelectedStep(step); + }, []); const onNextClick = useCallback(() => { switch (selectedStep) { @@ -193,7 +170,7 @@ export const CreateManagedControlPlaneWizardContainer: FC< setSelectedStep('summarize'); break; case 'summarize': - handleCreateManagedControlPlane(getValues()); + handleCreateManagedControlPlane(watch()); break; case 'success': resetFormAndClose(); @@ -201,14 +178,7 @@ export const CreateManagedControlPlaneWizardContainer: FC< default: break; } - }, [ - selectedStep, - handleSubmit, - setSelectedStep, - handleCreateManagedControlPlane, - getValues, - resetFormAndClose, - ]); + }, [selectedStep, handleSubmit, setSelectedStep, handleCreateManagedControlPlane, watch, resetFormAndClose]); const setMembers = useCallback( (members: Member[]) => { @@ -232,11 +202,7 @@ export const CreateManagedControlPlaneWizardContainer: FC< case 'members': return selectedStep === 'metadata' || !isValid; case 'componentSelection': - return ( - selectedStep === 'metadata' || - selectedStep === 'members' || - !isValid - ); + return selectedStep === 'metadata' || selectedStep === 'members' || !isValid; case 'summarize': return ( selectedStep === 'metadata' || @@ -276,9 +242,7 @@ export const CreateManagedControlPlaneWizardContainer: FC<
{selectedStep !== 'success' && (selectedStep === 'metadata' ? ( - + ) : ( ))} @@ -301,11 +265,7 @@ export const CreateManagedControlPlaneWizardContainer: FC< selected={selectedStep === 'metadata'} data-step="metadata" > - + - + - + ; + watch: UseFormWatch; projectName: string; workspaceName: string; componentsList?: ComponentsListItem[]; } -export const SummarizeStep: React.FC = ({ - getValues, - projectName, - workspaceName, - componentsList, -}) => { +export const SummarizeStep: React.FC = ({ watch, projectName, workspaceName, componentsList }) => { const { t } = useTranslation(); return ( <> @@ -31,41 +26,24 @@ export const SummarizeStep: React.FC = ({
- - + + - +
- {getValues('members').map((member) => ( - + {watch('members').map((member) => ( + ))}
{getSelectedComponents(componentsList ?? []).map((component) => ( - + ))}
@@ -73,13 +51,14 @@ export const SummarizeStep: React.FC = ({ (); -export const validationSchemaProjectWorkspace = z.object({ - name: z - .string() - .min(1, t('validationErrors.required')) - .regex(projectWorkspaceNameRegex, t('validationErrors.properFormatting')) - .max(25, t('validationErrors.maxChars', { maxLength: 25 })), - displayName: z.string().optional(), - chargingTarget: z.string().optional(), - chargingTargetType: z.string().optional(), - members: z.array(member).refine((members) => members?.length > 0), -}); -export const validationSchemaCreateManagedControlPlane = z.object({ - name: z - .string() - .min(1, t('validationErrors.required')) - .regex(managedControlPlaneNameRegex, t('validationErrors.properFormattingLowercase')) - .max(36, t('validationErrors.maxChars', { maxLength: 36 })), - displayName: z.string().optional(), - chargingTarget: z.string().optional(), - chargingTargetType: z.string().optional(), - members: z.array(member), -}); +// Shared superRefine helper for charging target validation +function validateChargingTarget( + data: T, + ctx: z.RefinementCtx, +) { + if (data.chargingTargetType && data.chargingTarget && !btpChargingTargetRegex.test(data.chargingTarget ?? '')) { + ctx.addIssue({ + path: ['chargingTarget'], + code: z.ZodIssueCode.custom, + message: t('validationErrors.notValidChargingTargetFormat'), + }); + } else if (data.chargingTargetType && !data.chargingTarget) { + ctx.addIssue({ + path: ['chargingTarget'], + code: z.ZodIssueCode.custom, + message: t('validationErrors.required'), + }); + } +} + +export const validationSchemaProjectWorkspace = z + .object({ + name: z + .string() + .min(1, t('validationErrors.required')) + .regex(projectWorkspaceNameRegex, t('validationErrors.properFormatting')) + .max(25, t('validationErrors.maxChars', { maxLength: 25 })), + displayName: z.string().optional(), + chargingTarget: z.string().optional(), + chargingTargetType: z.string().optional(), + members: z.array(member).refine((members) => members?.length > 0), + }) + .superRefine(validateChargingTarget); + +export const validationSchemaCreateManagedControlPlane = z + .object({ + name: z + .string() + .min(1, t('validationErrors.required')) + .regex(managedControlPlaneNameRegex, t('validationErrors.properFormattingLowercase')) + .max(36, t('validationErrors.maxChars', { maxLength: 36 })), + displayName: z.string().optional(), + chargingTarget: z.string().optional(), + chargingTargetType: z.string().optional(), + members: z.array(member), + }) + .superRefine(validateChargingTarget);