This is an automated email from the ASF dual-hosted git repository. marat pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/camel-karavan.git
The following commit(s) were added to refs/heads/main by this push: new da5a7ddb Import file validation (#1085) da5a7ddb is described below commit da5a7ddb8a5bbc623e8c3a7fd9117d8abab013a0 Author: Mario Volf <mv...@users.noreply.github.com> AuthorDate: Mon Jan 29 15:42:24 2024 +0100 Import file validation (#1085) * mvolf - Validate file existence on file import. Use the same validation on file create. Fix for case when email doesn't exist. Fix for quarkus quinoa properties after upgrade. * mvolf - Revert changes to TopologyToolbar made by build --- .../org/apache/camel/karavan/git/GitService.java | 7 +- .../karavan/infinispan/InfinispanService.java | 7 ++ .../project/ProjectFileCreateValidator.java | 35 ++++++ .../src/main/resources/application.properties | 3 +- .../src/main/webui/src/api/KaravanApi.tsx | 15 ++- .../src/main/webui/src/api/ProjectService.ts | 17 ++- .../webui/src/project/files/CreateFileModal.tsx | 131 +++++++++++++++------ .../webui/src/project/files/UploadFileModal.tsx | 130 ++++++++++++++------ 8 files changed, 253 insertions(+), 92 deletions(-) diff --git a/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/git/GitService.java b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/git/GitService.java index da93a157..25e37750 100644 --- a/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/git/GitService.java +++ b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/git/GitService.java @@ -309,11 +309,14 @@ public class GitService { } private PersonIdent getPersonIdent() { + String defaultEmailAddress = "kara...@test.org"; + if (securityIdentity != null && securityIdentity.getAttributes().get("userinfo") != null) { UserInfo userInfo = (UserInfo) securityIdentity.getAttributes().get("userinfo"); - return new PersonIdent(securityIdentity.getPrincipal().getName(), userInfo.getEmail()); + String email = Objects.isNull(userInfo.getEmail()) || userInfo.getEmail().isBlank() ? defaultEmailAddress : userInfo.getEmail(); + return new PersonIdent(securityIdentity.getPrincipal().getName(), email); } - return new PersonIdent("karavan", "kara...@test.org"); + return new PersonIdent("karavan", defaultEmailAddress); } public Git init(String dir, String uri, String branch) throws GitAPIException, IOException, URISyntaxException { diff --git a/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/infinispan/InfinispanService.java b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/infinispan/InfinispanService.java index fca6c20e..594dde7b 100644 --- a/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/infinispan/InfinispanService.java +++ b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/infinispan/InfinispanService.java @@ -17,8 +17,10 @@ package org.apache.camel.karavan.infinispan; import jakarta.enterprise.inject.Default; +import jakarta.inject.Inject; import jakarta.inject.Singleton; import org.apache.camel.karavan.infinispan.model.*; +import org.apache.camel.karavan.validation.project.ProjectFileCreateValidator; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.faulttolerance.Retry; import org.eclipse.microprofile.health.HealthCheck; @@ -63,6 +65,9 @@ public class InfinispanService implements HealthCheck { @ConfigProperty(name = "karavan.infinispan.password") String infinispanPassword; + @Inject + ProjectFileCreateValidator projectFileCreateValidator; + private RemoteCache<GroupedKey, Project> projects; private RemoteCache<GroupedKey, ProjectFile> files; private RemoteCache<GroupedKey, DeploymentStatus> deploymentStatuses; @@ -174,6 +179,8 @@ public class InfinispanService implements HealthCheck { } public void saveProjectFile(ProjectFile file) { + projectFileCreateValidator.validate(file).failOnError(); + files.put(GroupedKey.create(file.getProjectId(), DEFAULT_ENVIRONMENT, file.getName()), file); } diff --git a/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/validation/project/ProjectFileCreateValidator.java b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/validation/project/ProjectFileCreateValidator.java new file mode 100644 index 00000000..686c3db1 --- /dev/null +++ b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/validation/project/ProjectFileCreateValidator.java @@ -0,0 +1,35 @@ +package org.apache.camel.karavan.validation.project; + +import java.util.List; + +import org.apache.camel.karavan.infinispan.InfinispanService; +import org.apache.camel.karavan.infinispan.model.ProjectFile; +import org.apache.camel.karavan.shared.validation.SimpleValidator; +import org.apache.camel.karavan.shared.validation.ValidationError; +import org.apache.camel.karavan.shared.validation.Validator; + +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class ProjectFileCreateValidator extends Validator<ProjectFile> { + + private final SimpleValidator simpleValidator; + private final InfinispanService infinispanService; + + public ProjectFileCreateValidator(SimpleValidator simpleValidator, InfinispanService infinispanService) { + this.simpleValidator = simpleValidator; + this.infinispanService = infinispanService; + } + + + @Override + protected void validationRules(ProjectFile value, List<ValidationError> errors) { + simpleValidator.validate(value, errors); + + boolean projectFileExists = infinispanService.getProjectFile(value.getProjectId(), value.getName()) != null; + + if(projectFileExists) { + errors.add(new ValidationError("name", "File with given name already exists")); + } + } +} diff --git a/karavan-web/karavan-app/src/main/resources/application.properties b/karavan-web/karavan-app/src/main/resources/application.properties index 553da3ce..2efec5fd 100644 --- a/karavan-web/karavan-app/src/main/resources/application.properties +++ b/karavan-web/karavan-app/src/main/resources/application.properties @@ -145,8 +145,9 @@ quarkus.kubernetes-client.devservices.enabled=false quarkus.swagger-ui.always-include=true -quarkus.quinoa.frozen-lockfile=false +quarkus.quinoa.ci=false quarkus.quinoa.package-manager-install=false quarkus.quinoa.package-manager-install.node-version=18.12.1 quarkus.quinoa.dev-server.port=3003 quarkus.quinoa.dev-server.check-timeout=60000 +quarkus.quinoa.ignored-path-prefixes=/api,/public diff --git a/karavan-web/karavan-app/src/main/webui/src/api/KaravanApi.tsx b/karavan-web/karavan-app/src/main/webui/src/api/KaravanApi.tsx index 95ad0698..840e0234 100644 --- a/karavan-web/karavan-app/src/main/webui/src/api/KaravanApi.tsx +++ b/karavan-web/karavan-app/src/main/webui/src/api/KaravanApi.tsx @@ -267,12 +267,16 @@ export class KaravanApi { }); } + static async saveProjectFile(file: ProjectFile) { + return instance.post('/api/file', file); + } + static async postProjectFile(file: ProjectFile, after: (res: AxiosResponse<any>) => void) { instance.post('/api/file', file) .then(res => { after(res); }).catch(err => { - after(err); + after(err); }); } @@ -621,14 +625,9 @@ export class KaravanApi { }); } - static async postOpenApi(file: ProjectFile, generateRest: boolean, generateRoutes: boolean, integrationName: string, after: (res: AxiosResponse<any>) => void) { + static async postOpenApi(file: ProjectFile, generateRest: boolean, generateRoutes: boolean, integrationName: string) { const uri = `/api/file/openapi/${generateRest}/${generateRoutes}/${integrationName}`; - instance.post(encodeURI(uri), file) - .then(res => { - after(res); - }).catch(err => { - after(err); - }); + return instance.post(encodeURI(uri), file); } static async fetchData(type: 'container' | 'build' | 'none', podName: string, controller: AbortController) { diff --git a/karavan-web/karavan-app/src/main/webui/src/api/ProjectService.ts b/karavan-web/karavan-app/src/main/webui/src/api/ProjectService.ts index b3d925d8..d0d03034 100644 --- a/karavan-web/karavan-app/src/main/webui/src/api/ProjectService.ts +++ b/karavan-web/karavan-app/src/main/webui/src/api/ProjectService.ts @@ -245,15 +245,14 @@ export class ProjectService { return result.data; } - public static createFile(file: ProjectFile) { - KaravanApi.postProjectFile(file, res => { - if (res.status === 200) { - // console.log(res) //TODO show notification - ProjectService.refreshProjectData(file.projectId); - } else { - // console.log(res) //TODO show notification - } - }) + public static async createFile(file: ProjectFile) { + const result = await KaravanApi.saveProjectFile(file); + return result.data; + } + + public static async createOpenApiFile(file: ProjectFile, generateRest: boolean, generateRoutes: boolean, integrationName: string) { + const result = await KaravanApi.postOpenApi(file, generateRest, generateRoutes, integrationName); + return result.data; } public static deleteFile(file: ProjectFile) { diff --git a/karavan-web/karavan-app/src/main/webui/src/project/files/CreateFileModal.tsx b/karavan-web/karavan-app/src/main/webui/src/project/files/CreateFileModal.tsx index c5c99037..8c08be7f 100644 --- a/karavan-web/karavan-app/src/main/webui/src/project/files/CreateFileModal.tsx +++ b/karavan-web/karavan-app/src/main/webui/src/project/files/CreateFileModal.tsx @@ -22,12 +22,12 @@ import { FormGroup, ModalVariant, Form, - ToggleGroupItem, ToggleGroup, FormHelperText, HelperText, HelperTextItem, TextInput, + ToggleGroupItem, ToggleGroup, TextInput, Alert, Divider, Grid, Text, } from '@patternfly/react-core'; import '../../designer/karavan.css'; import {Integration, KameletTypes, MetadataLabels} from "karavan-core/lib/model/IntegrationDefinition"; import {CamelDefinitionYaml} from "karavan-core/lib/api/CamelDefinitionYaml"; -import {useFilesStore, useFileStore, useProjectStore} from "../../api/ProjectStore"; +import {useFileStore, useProjectStore} from "../../api/ProjectStore"; import {ProjectFile, ProjectFileTypes} from "../../api/ProjectModels"; import {CamelUi} from "../../designer/utils/CamelUi"; import {ProjectService} from "../../api/ProjectService"; @@ -35,6 +35,12 @@ import {shallow} from "zustand/shallow"; import {CamelUtil} from "karavan-core/lib/api/CamelUtil"; import {KameletApi} from "karavan-core/lib/api/KameletApi"; import {TypeaheadSelect, Value} from "../../designer/ui/TypeaheadSelect"; +import * as yup from "yup"; +import {useForm} from "react-hook-form"; +import {yupResolver} from "@hookform/resolvers/yup"; +import {useResponseErrorHandler} from "../../shared/error/UseResponseErrorHandler"; +import {EventBus} from "../../designer/utils/EventBus"; +import {AxiosError} from "axios"; interface Props { types: string[], @@ -43,14 +49,44 @@ interface Props { export function CreateFileModal(props: Props) { + const formValidationSchema = yup.object().shape({ + name: yup + .string() + .required("File name is required"), + }); + + const defaultFormValues = { + name: "" + }; + + const responseToFormErrorFields = new Map<string, string>([ + ["name", "name"] + ]); + + const { + register, + setError, + handleSubmit, + formState: { errors }, + reset, + clearErrors + } = useForm({ + resolver: yupResolver(formValidationSchema), + mode: "onChange", + defaultValues: defaultFormValues + }); + + const [project] = useProjectStore((s) => [s.project], shallow); - const [files] = useFilesStore((s) => [s.files], shallow); const [operation, setFile, designerTab] = useFileStore((s) => [s.operation, s.setFile, s.designerTab], shallow); const [name, setName] = useState<string>(''); - const [nameAvailable, setNameAvailable] = useState<boolean>(true); const [fileType, setFileType] = useState<string>(); const [kameletType, setKameletType] = useState<KameletTypes>('source'); const [selectedKamelet, setSelectedKamelet] = useState<string>(); + const [globalErrors, registerResponseErrors, resetGlobalErrors] = useResponseErrorHandler( + responseToFormErrorFields, + setError + ); useEffect(() => { if (props.types.length > 0) { @@ -58,14 +94,44 @@ export function CreateFileModal(props: Props) { } }, [props]); - function cleanValues() { - setName(""); + function resetForm() { + resetGlobalErrors(); + reset(defaultFormValues); + setName("") setFileType(props.types.at(0) || 'INTEGRATION'); } function closeModal() { setFile("none"); - cleanValues(); + resetForm(); + } + + function handleFormSubmit() { + const code = getCode(); + const fullFileName = getFullFileName(name, fileType); + const file = new ProjectFile(fullFileName, project.projectId, code, Date.now()); + + return ProjectService.createFile(file) + .then(() => handleOnFormSubmitSuccess(code, file)) + .catch((error) => handleOnFormSubmitFailure(error)); + } + + function handleOnFormSubmitSuccess (code: string, file: ProjectFile) { + const message = "File successfully created."; + EventBus.sendAlert( "Success", message, "success"); + + ProjectService.refreshProjectData(file.projectId); + + resetForm(); + if (code) { + setFile('select', file, designerTab); + } else { + setFile("none"); + } + } + + function handleOnFormSubmitFailure(error: AxiosError) { + registerResponseErrors(error); } function getCode(): string { @@ -92,23 +158,6 @@ export function CreateFileModal(props: Props) { } } - function confirmAndCloseModal() { - const code = getCode(); - const fullFileName = getFullFileName(name, fileType); - const file = new ProjectFile(fullFileName, project.projectId, code, Date.now()); - ProjectService.createFile(file); - cleanValues(); - if (code) { - setFile('select', file, designerTab); - } else { - setFile("none"); - } - } - - function getExistingFilenames(): string[] { - return files.map(f => f.name); - } - function fileNameCheck(title: string) { return title.replace(/[^0-9a-zA-Z.]+/gi, "-").toLowerCase(); } @@ -133,8 +182,6 @@ export function CreateFileModal(props: Props) { function update(value: string, type?: string) { setName(value); - const exists = getExistingFilenames().findIndex(f => f === getFullFileName(value, type)) === -1; - setNameAvailable(exists); setFileType(type); } @@ -145,9 +192,8 @@ export function CreateFileModal(props: Props) { isOpen={["create", "copy"].includes(operation)} onClose={closeModal} actions={[ - <Button key="confirm" variant="primary" isDisabled={!nameAvailable || name === undefined || name.trim().length === 0} - onClick={event => confirmAndCloseModal()}>Save</Button>, - <Button key="cancel" variant="secondary" onClick={event => closeModal()}>Cancel</Button> + <Button key="confirm" variant="primary" onClick={handleSubmit(handleFormSubmit)}>Save</Button>, + <Button key="cancel" variant="secondary" onClick={closeModal}>Cancel</Button> ]} > <Form autoComplete="off" isHorizontal className="create-file-form"> @@ -159,6 +205,8 @@ export function CreateFileModal(props: Props) { return <ToggleGroupItem key={title} text={title} buttonId={p.name} isSelected={fileType === p.name} onChange={(_, selected) => { + resetGlobalErrors(); + clearErrors('name'); update(name, p.name); }}/> })} @@ -178,23 +226,30 @@ export function CreateFileModal(props: Props) { </ToggleGroup> </FormGroup>} <FormGroup label="Name" fieldId="name" isRequired> - <TextInput id="name" + <TextInput className="text-field" type="text" id="name" aria-label="name" value={name} - onChange={(_, value) => update(value, fileType)}/> - <FormHelperText> - <HelperText id="helper-text1"> - <HelperTextItem variant={nameAvailable ? 'default' : 'error'}> - {!nameAvailable ? 'File ': ''}{getFullFileName(name, fileType)}{!nameAvailable ? ' already exists': ''} - </HelperTextItem> - </HelperText> - </FormHelperText> + validated={!!errors.name ? 'error' : 'default'} + {...register('name')} + onChange={(e, value) => { + update(value, fileType); + register('name').onChange(e); + }} + /> + {!!errors.name && <Text style={{ color: 'red', fontStyle: 'italic'}}>{errors?.name?.message}</Text>} </FormGroup> {isKamelet && <FormGroup label="Copy from" fieldId="kamelet"> <TypeaheadSelect listOfValues={listOfValues} onSelect={value => { setSelectedKamelet(value) }}/> </FormGroup>} + <Grid> + {globalErrors && + globalErrors.map((error) => ( + <Alert title={error} key={error} variant="danger"></Alert> + ))} + <Divider role="presentation" /> + </Grid> </Form> </Modal> ) diff --git a/karavan-web/karavan-app/src/main/webui/src/project/files/UploadFileModal.tsx b/karavan-web/karavan-app/src/main/webui/src/project/files/UploadFileModal.tsx index dfe1a198..70ba3715 100644 --- a/karavan-web/karavan-app/src/main/webui/src/project/files/UploadFileModal.tsx +++ b/karavan-web/karavan-app/src/main/webui/src/project/files/UploadFileModal.tsx @@ -17,16 +17,18 @@ import React, {useEffect, useState} from 'react'; import { TextInput, - Button, Modal, FormGroup, ModalVariant, Switch, Form, FileUpload, Radio + Button, Modal, FormGroup, ModalVariant, Switch, Form, FileUpload, Radio, Alert, Divider, Grid, Text } from '@patternfly/react-core'; import '../../designer/karavan.css'; import {ProjectFile} from "../../api/ProjectModels"; -import {KaravanApi} from "../../api/KaravanApi"; import {useFileStore} from "../../api/ProjectStore"; import {Accept, DropEvent, FileRejection} from "react-dropzone"; import {EventBus} from "../../designer/utils/EventBus"; import {shallow} from "zustand/shallow"; import {ProjectService} from "../../api/ProjectService"; +import {useForm} from "react-hook-form"; +import {useResponseErrorHandler} from "../../shared/error/UseResponseErrorHandler"; +import {AxiosError} from "axios"; interface Props { projectId: string, @@ -34,6 +36,26 @@ interface Props { export function UploadFileModal(props: Props) { + const defaultFormValues = { + upload: "" + }; + + const responseToFormErrorFields = new Map<string, string>([ + ["name", "upload"] + ]); + + const { + register, + setError, + handleSubmit, + formState: { errors }, + reset, + clearErrors + } = useForm({ + mode: "onChange", + defaultValues: defaultFormValues + }); + const [operation, setFile] = useFileStore((s) => [s.operation, s.setFile], shallow); const [type, setType] = useState<'integration' | 'openapi' | 'other'>('integration'); const [filename, setFilename] = useState(''); @@ -43,6 +65,10 @@ export function UploadFileModal(props: Props) { const [isRejected, setIsRejected] = useState(false); const [generateRest, setGenerateRest] = useState(true); const [generateRoutes, setGenerateRoutes] = useState(true); + const [globalErrors, registerResponseErrors, resetGlobalErrors] = useResponseErrorHandler( + responseToFormErrorFields, + setError + ); useEffect(() => { setFilename('') @@ -50,37 +76,41 @@ export function UploadFileModal(props: Props) { setType('integration') }, []); + function resetForm() { + resetGlobalErrors(); + } + function closeModal () { - setFile("none") + setFile("none"); + resetForm(); } - function saveAndCloseModal () { + function handleFormSubmit() { const file = new ProjectFile(filename, props.projectId, data, Date.now()); - if (type === "openapi"){ - KaravanApi.postOpenApi(file, generateRest, generateRoutes, integrationName, res => { - if (res.status === 200) { - EventBus.sendAlert("File uploaded", "", "info") - closeModal(); - ProjectService.refreshProjectData(props.projectId); - } else { - closeModal(); - EventBus.sendAlert("Error", res.statusText, "warning") - } - }) + + if (type === "openapi") { + return ProjectService.createOpenApiFile(file, generateRest, generateRoutes, integrationName) + .then(() => handleOnFormSubmitSuccess()) + .catch((error) => handleOnFormSubmitFailure(error)); } else { - KaravanApi.postProjectFile(file, res => { - if (res.status === 200) { - EventBus.sendAlert("File uploaded", "", "info") - closeModal(); - ProjectService.refreshProjectData(props.projectId); - } else { - closeModal(); - EventBus.sendAlert("Error", res.statusText, "warning") - } - }) + return ProjectService.createFile(file) + .then(() => handleOnFormSubmitSuccess()) + .catch((error) => handleOnFormSubmitFailure(error)); } } + function handleOnFormSubmitSuccess () { + const message = "File successfully uploaded."; + EventBus.sendAlert( "Success", message, "success"); + + closeModal(); + ProjectService.refreshProjectData(props.projectId); + } + + function handleOnFormSubmitFailure(error: AxiosError) { + registerResponseErrors(error); + } + const handleFileInputChange = (file: File) => setFilename(file.name); const handleFileReadStarted = (fileHandle: File) => setIsLoading(true); const handleFileReadFinished = (fileHandle: File) => setIsLoading(false); @@ -90,6 +120,8 @@ export function UploadFileModal(props: Props) { setFilename(''); setData(''); setIsRejected(false); + resetGlobalErrors(); + reset(defaultFormValues); }; @@ -104,20 +136,32 @@ export function UploadFileModal(props: Props) { isOpen={operation === 'upload'} onClose={closeModal} actions={[ - <Button key="confirm" variant="primary" onClick={saveAndCloseModal} isDisabled={fileNotUploaded}>Save</Button>, + <Button key="confirm" variant="primary" onClick={handleSubmit(handleFormSubmit)} isDisabled={fileNotUploaded}>Save</Button>, <Button key="cancel" variant="secondary" onClick={closeModal}>Cancel</Button> ]} > <Form> <FormGroup fieldId="type"> <Radio value="Integration" label="Integration yaml" name="Integration" id="Integration" isChecked={type === 'integration'} - onChange={(event, _) => setType(_ ? 'integration': 'openapi' )} + onChange={(event, _) => { + resetGlobalErrors(); + clearErrors("upload"); + setType(_ ? 'integration': 'openapi' ); + }} />{' '} <Radio value="OpenAPI" label="OpenAPI json/yaml" name="OpenAPI" id="OpenAPI" isChecked={type === 'openapi'} - onChange={(event, _) => setType( _ ? 'openapi' : 'integration' )} + onChange={(event, _) => { + resetGlobalErrors(); + clearErrors("upload"); + setType( _ ? 'openapi' : 'integration' ); + }} /> <Radio value="Other" label="Other" name="Other" id="Other" isChecked={type === 'other'} - onChange={(event, _) => setType( _ ? 'other' : 'integration' )} + onChange={(event, _) => { + resetGlobalErrors(); + clearErrors("upload"); + setType( _ ? 'other' : 'integration' ); + }} /> </FormGroup> <FormGroup fieldId="upload"> @@ -129,16 +173,27 @@ export function UploadFileModal(props: Props) { hideDefaultPreview browseButtonText="Upload" isLoading={isLoading} - onFileInputChange={(_event, fileHandle: File) => handleFileInputChange(fileHandle)} - onDataChange={(_event, data) => handleTextOrDataChange(data)} - onTextChange={(_event, text) => handleTextOrDataChange(text)} + onFileInputChange={(_event, fileHandle: File) => { + handleFileInputChange(fileHandle); + register('upload').onChange(_event); + }} + onDataChange={(_event, data) => { + handleTextOrDataChange(data); + register('upload').onChange(_event); + }} + onTextChange={(_event, text) => { + handleTextOrDataChange(data); + register('upload').onChange(_event); + }} onReadStarted={(_event, fileHandle: File) => handleFileReadStarted(fileHandle)} onReadFinished={(_event, fileHandle: File) => handleFileReadFinished(fileHandle)} allowEditingUploadedText={false} onClearClick={handleClear} dropzoneProps={{accept: accept, onDropRejected: handleFileRejected}} - validated={isRejected ? 'error' : 'default'} + validated={!!errors.upload && isRejected ? 'error' : 'default'} + {...register('upload')} /> + {!!errors.upload && <Text style={{ color: 'red', fontStyle: 'italic'}}>{errors?.upload?.message}</Text>} </FormGroup> {type === 'openapi' && <FormGroup fieldId="generateRest"> <Switch @@ -167,7 +222,14 @@ export function UploadFileModal(props: Props) { onChange={(_, value) => setIntegrationName(value)} /> </FormGroup>} + <Grid> + {globalErrors && + globalErrors.map((error) => ( + <Alert title={error} key={error} variant="danger"></Alert> + ))} + <Divider role="presentation" /> + </Grid> </Form> </Modal> ) -}; \ No newline at end of file +} \ No newline at end of file