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
commit 2d790e4ec3d3f322d40652b6d097abac561b4674 Author: Marat Gubaidullin <[email protected]> AuthorDate: Fri Feb 27 18:52:44 2026 -0500 Front-end Projects for 4.18.0 --- .../src/karavan/features/projects/Complexity.css | 42 ++++++ .../karavan/features/projects/ComplexityApi.tsx | 40 ++++++ .../karavan/features/projects/ComplexityModels.ts | 72 ++++++++++ .../features/projects/CreateProjectModal.tsx | 150 +++++++++++++++++++++ .../features/projects/DeleteProjectModal.tsx | 69 ++++++++++ .../features/projects/ProjectStatusLabel.tsx | 80 +++++++++++ .../karavan/features/projects/ProjectZipApi.tsx | 39 ++++++ .../src/karavan/features/projects/ProjectsPage.tsx | 88 ++++++++++++ .../src/karavan/features/projects/ProjectsTab.tsx | 146 ++++++++++++++++++++ .../karavan/features/projects/ProjectsTableRow.tsx | 117 ++++++++++++++++ .../features/projects/ProjectsTableRowActivity.tsx | 19 +++ .../projects/ProjectsTableRowComplexity.tsx | 48 +++++++ .../features/projects/ProjectsTableRowTimeLine.css | 43 ++++++ .../features/projects/ProjectsTableRowTimeLine.tsx | 48 +++++++ .../karavan/features/projects/ProjectsToolbar.tsx | 105 +++++++++++++++ .../karavan/features/projects/SettingsToolbar.tsx | 59 ++++++++ .../features/projects/UploadProjectModal.tsx | 99 ++++++++++++++ 17 files changed, 1264 insertions(+) diff --git a/karavan-app/src/main/webui/src/karavan/features/projects/Complexity.css b/karavan-app/src/main/webui/src/karavan/features/projects/Complexity.css new file mode 100644 index 00000000..9ff97868 --- /dev/null +++ b/karavan-app/src/main/webui/src/karavan/features/projects/Complexity.css @@ -0,0 +1,42 @@ +.karavan .complexity .top-icon { + width: 1em; + height: 1em; + vertical-align: middle; +} +.karavan .complexity svg { + vertical-align: middle; +} + +.karavan .complexity .complexity-label { + .pf-v6-c-label__icon { + margin-right: 2px; + } +} + +.karavan .files-table { + .icon { + height: 16px; + width: 16px; + } + + .icon-docker { + fill: #0db7ed; + } +} + +.validation-icon { + height: 16px; + width: 16px; +} +.validation-icon-danger { + color: var(--pf-t--global--icon--color--status--danger--default); + fill: var(--pf-t--global--icon--color--status--danger--default); +} +.validation-icon-pending { + fill: var(--pf-t--global--color--brand--default); + color: var(--pf-t--global--color--brand--default); +} + +.rotated-run-forward { + animation: rotate-icon-forward 3s linear infinite +} \ No newline at end of file diff --git a/karavan-app/src/main/webui/src/karavan/features/projects/ComplexityApi.tsx b/karavan-app/src/main/webui/src/karavan/features/projects/ComplexityApi.tsx new file mode 100644 index 00000000..6172a8c5 --- /dev/null +++ b/karavan-app/src/main/webui/src/karavan/features/projects/ComplexityApi.tsx @@ -0,0 +1,40 @@ +import axios from "axios"; +import {ErrorEventBus} from "@bus/ErrorEventBus"; +import {ComplexityProject} from "./ComplexityModels"; +import {AuthApi} from "@api/auth/AuthApi"; + +axios.defaults.headers.common['Accept'] = 'application/json'; +axios.defaults.headers.common['Content-Type'] = 'application/json'; +const instance = AuthApi.getInstance(); + +export class ComplexityApi { + + static async getComplexityProject(projectId: string, after: (complexity?: ComplexityProject) => void) { + instance.get('/ui/complexity/' + projectId) + .then(res => { + if (res.status === 200) { + after(res.data); + } else { + after(undefined); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + after(undefined); + }); + } + + static async getComplexityProjects(after: (complexities: ComplexityProject[]) => void) { + instance.get('/ui/complexity') + .then(res => { + if (res.status === 200) { + const c: ComplexityProject[] = Array.isArray(res.data) ? res.data?.map(x => new ComplexityProject(x)) : []; + after(c); + } else { + after([]); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + after([]); + }); + } +} diff --git a/karavan-app/src/main/webui/src/karavan/features/projects/ComplexityModels.ts b/karavan-app/src/main/webui/src/karavan/features/projects/ComplexityModels.ts new file mode 100644 index 00000000..b8e166fd --- /dev/null +++ b/karavan-app/src/main/webui/src/karavan/features/projects/ComplexityModels.ts @@ -0,0 +1,72 @@ +export type ComplexityType = 'easy' | 'normal' | 'complex' + +export class ComplexityRoute { + routeId: string = '' + nodePrefixId: string = '' + fileName: string = '' + consumers: any = []; + producers: any[] = []; + routeTemplateRef: string; + isTemplated: boolean; +} + +export class ComplexityFile { + fileName: string = ''; + error: string = ''; + type: string = ''; + chars: number = 0; + routes: number = 0; + beans: number = 0; + rests: number = 0; + complexity: ComplexityType = 'easy'; + complexityLines: ComplexityType = 'easy'; + complexityRoutes: ComplexityType = 'easy'; + complexityRests: ComplexityType = 'easy'; + complexityBeans: ComplexityType = 'easy'; + complexityProcessors: ComplexityType = 'easy'; + complexityComponentsInt: ComplexityType = 'easy'; + complexityComponentsExt: ComplexityType = 'easy'; + complexityKamelets: ComplexityType = 'easy'; + processors: any = {}; + componentsInt: any = {}; + componentsExt: any = {}; + kamelets: any = {}; + generated: boolean = false; + + public constructor(init?: Partial<ComplexityFile>) { + Object.assign(this, init); + } +} + +export class ComplexityProject { + projectId: string = ''; + lastUpdateDate: number = 0; + complexityRoute: ComplexityType = 'easy'; + complexityRest: ComplexityType = 'easy'; + complexityJava: ComplexityType = 'easy'; + complexityFiles: ComplexityType = 'easy'; + files: ComplexityFile[] = [] + routes: ComplexityRoute[] = [] + dependencies: string[] = [] + rests: number = 0; + exposesOpenApi: boolean = false; + type: string; + + public constructor(init?: Partial<ComplexityProject>) { + Object.assign(this, init); + } +} + +export function getComplexityColor(complexity: ComplexityType) { + return complexity === 'easy' ? 'green' : (complexity === 'complex' ? 'orange' : 'blue'); +} + +export function getMaxComplexity(complexities: (ComplexityType) []): ComplexityType { + if (complexities.filter(c => c === 'complex').length > 0) { + return 'complex' + } else if (complexities.filter(c => c === 'normal').length > 0) { + return 'normal' + } else { + return 'easy' + } +} diff --git a/karavan-app/src/main/webui/src/karavan/features/projects/CreateProjectModal.tsx b/karavan-app/src/main/webui/src/karavan/features/projects/CreateProjectModal.tsx new file mode 100644 index 00000000..0d8c67d0 --- /dev/null +++ b/karavan-app/src/main/webui/src/karavan/features/projects/CreateProjectModal.tsx @@ -0,0 +1,150 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, {useEffect} from 'react'; +import {Alert, Button, Form, FormAlert, Modal, ModalBody, ModalFooter, ModalHeader, ModalVariant} from '@patternfly/react-core'; +import {useProjectsStore, useProjectStore} from "@stores/ProjectStore"; +import {Project, RESERVED_WORDS} from "@models/ProjectModels"; +import {isValidProjectId, nameToProjectId} from "@util/StringUtils"; +import {EventBus} from "@features/project/designer/utils/EventBus"; +import {SubmitHandler, useForm} from "react-hook-form"; +import {useFormUtil} from "@util/useFormUtil"; +import {KaravanApi} from "@api/KaravanApi"; +import {AxiosResponse} from "axios"; +import {shallow} from "zustand/shallow"; +import {useNavigate} from "react-router-dom"; +import {ROUTES} from "@app/navigation/Routes"; + +export function CreateProjectModal() { + + const [project, operation, setOperation] = useProjectStore((s) => [s.project, s.operation, s.setOperation], shallow); + const [projects, setProjects] = useProjectsStore((s) => [s.projects, s.setProjects], shallow); + const [isReset, setReset] = React.useState(false); + const [isProjectIdChanged, setIsProjectIdChanged] = React.useState(false); + const [backendError, setBackendError] = React.useState<string>(); + const formContext = useForm<Project>({mode: "all"}); + const {getTextField} = useFormUtil(formContext); + const { + formState: {errors}, + handleSubmit, + reset, + trigger, + setValue, + getValues + } = formContext; + const navigate = useNavigate(); + + useEffect(() => { + const p = new Project(); + if (operation === 'copy') { + p.projectId = project.projectId; + p.name = project.name; + p.type = project.type; + } + reset(p); + setBackendError(undefined); + setReset(true); + }, [reset]); + + React.useEffect(() => { + isReset && trigger(); + }, [trigger, isReset]); + + function closeModal() { + setOperation("none"); + } + + const onSubmit: SubmitHandler<Project> = (data) => { + if (operation === 'copy') { + KaravanApi.copyProject(project.projectId, data, after) + } else { + KaravanApi.postProject(data, after) + } + } + + function after (result: boolean, res: AxiosResponse<Project> | any) { + if (result) { + onSuccess(res.data.projectId); + } else { + setBackendError(res?.response?.data); + } + } + + function onSuccess (projectId: string) { + const message = operation !== "copy" ? "Project successfully created." : "Project successfully copied."; + EventBus.sendAlert( "Success", message, "success"); + KaravanApi.getProjects((projects: Project[]) => { + setProjects(projects); + setOperation("none"); + navigate(`${ROUTES.PROJECTS}/${projectId}`); + }); + } + + function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>): void { + if (event.key === 'Enter') { + handleSubmit(onSubmit)() + } + } + + function onNameChange (value: string) { + if (!isProjectIdChanged) { + setValue('projectId', nameToProjectId(value), {shouldValidate: true}) + } + } + function onIdChange (value: string) { + setIsProjectIdChanged(true) + } + + return ( + <Modal + variant={ModalVariant.small} + isOpen={["create", "copy"].includes(operation)} + onClose={closeModal} + onKeyDown={onKeyDown} + > + + <ModalHeader title={operation !== 'copy' ? "Create Project" : "Copy Project from " + project?.projectId}/> + <ModalBody> + <Form isHorizontal={true} autoComplete="off"> + {getTextField('name', 'Name', { + length: v => v.length > 5 || 'Project name should be longer that 5 characters', + }, 'text', onNameChange)} + {getTextField('projectId', 'Project ID', { + regex: v => isValidProjectId(v) || 'Only lowercase characters, numbers and dashes allowed', + length: v => v.length > 5 || 'Project ID should be longer that 5 characters', + name: v => !RESERVED_WORDS.includes(v) || "Reserved word", + uniques: v => !projects.map(p=> p.name).includes(v) || "Project already exists!", + }, 'text', onIdChange)} + {backendError && + <FormAlert> + <Alert variant="danger" title={backendError} aria-live="polite" isInline /> + </FormAlert> + } + </Form> + </ModalBody> + <ModalFooter> + <Button key="confirm" variant="primary" + onClick={handleSubmit(onSubmit)} + isDisabled={Object.getOwnPropertyNames(errors).length > 0} + > + Save + </Button> + <Button key="cancel" variant="secondary" onClick={closeModal}>Cancel</Button> + </ModalFooter> + </Modal> + ) +} \ No newline at end of file diff --git a/karavan-app/src/main/webui/src/karavan/features/projects/DeleteProjectModal.tsx b/karavan-app/src/main/webui/src/karavan/features/projects/DeleteProjectModal.tsx new file mode 100644 index 00000000..6fca6e12 --- /dev/null +++ b/karavan-app/src/main/webui/src/karavan/features/projects/DeleteProjectModal.tsx @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, {useState} from 'react'; +import {Content, ContentVariants, HelperText, HelperTextItem, Switch} from '@patternfly/react-core'; +import {useProjectStore} from "@stores/ProjectStore"; +import {ProjectService} from "@services/ProjectService"; +import {shallow} from "zustand/shallow"; +import {ModalConfirmation} from "@shared/ui/ModalConfirmation"; + +export function DeleteProjectModal() { + + const [project, operation] = useProjectStore((s) => [s.project, s.operation], shallow); + const [deleteContainers, setDeleteContainers] = useState(false); + + function closeModal() { + useProjectStore.setState({operation: "none"}) + } + + function confirmAndCloseModal() { + ProjectService.deleteProject(project, deleteContainers); + useProjectStore.setState({operation: "none"}); + } + + const isOpen = operation === "delete"; + return ( + <ModalConfirmation + isOpen={isOpen} + message={ + <> + <Content> + <Content component={ContentVariants.h3}>Delete project <b>{project?.projectId}</b> ?</Content> + <HelperText> + <HelperTextItem variant="warning"> + Project will be also deleted from <b>git</b> repository + </HelperTextItem> + </HelperText> + <Content component={ContentVariants.p}></Content> + <Content component={ContentVariants.p}></Content> + </Content> + <Switch + label={"Delete related container and/or deployments?"} + isChecked={deleteContainers} + onChange={(_, checked) => setDeleteContainers(checked)} + isReversed + /> + </> + } + btnConfirm='Delete' + btnConfirmVariant='danger' + onConfirm={() => confirmAndCloseModal()} + onCancel={() => closeModal()} + /> + ) +} \ No newline at end of file diff --git a/karavan-app/src/main/webui/src/karavan/features/projects/ProjectStatusLabel.tsx b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectStatusLabel.tsx new file mode 100644 index 00000000..2a888102 --- /dev/null +++ b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectStatusLabel.tsx @@ -0,0 +1,80 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http:www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, {ReactElement} from 'react'; +import {ContainerType} from '@models/ProjectModels'; +import {BuildIcon, CogIcon, CubesIcon, DevIcon, InProgressIcon, LockIcon, PackageIcon} from '@patternfly/react-icons'; +import {Label} from "@patternfly/react-core"; +import {useStatusesStore} from "@stores/ProjectStore"; +import {shallow} from "zustand/shallow"; +import {useContainerStatusesStore} from "@stores/ContainerStatusesStore"; + +interface Props { + projectId: string +} + +export function ProjectStatusLabel(props: Props) { + + const {projectId} = props; + const [deployments] = useStatusesStore((state) => [state.deployments], shallow) + const {containers} = useContainerStatusesStore(); + const camelContainer = containers.filter(c => c.projectId === projectId && ['devmode', 'packaged'].includes(c.type)).at(0); + const isCamelRunning = camelContainer && camelContainer?.state === 'running'; + + const buildContainer = containers.filter(c => c.projectId === projectId && ['build'].includes(c.type)).at(0); + const isBuildRunning = buildContainer && buildContainer?.state === 'running'; + const hasContainers = containers.filter(c => c.projectId === projectId).length > 0; + const isRunning = containers.filter(c => c.projectId === projectId && c.state === 'running').length > 0; + + const colorRunBack = 'var(--pf-t--color--green--30)'; + const colorRun = 'var(--pf-t--global--color--status--success--200)'; + const colorControl = 'var(--pf-v6-c-button--m-control--Color)'; + const colorBack = isRunning ? colorRunBack : colorControl; + const variant = hasContainers ? 'filled' : 'outline'; + const firstIcon = (isRunning || isBuildRunning) + ? <CogIcon color={colorRun} className={'rotated-run-forward'}/> + : <InProgressIcon/>; + + const typeIconColor = isRunning ? colorRun : colorControl; + const iconMap: Record<ContainerType, ReactElement | undefined> = { + devmode: <DevIcon color={typeIconColor}/>, + packaged: <PackageIcon color={typeIconColor}/>, + internal: <LockIcon color={typeIconColor}/>, + build: <BuildIcon color={typeIconColor}/>, + unknown: undefined, + }; + + const type: ContainerType = camelContainer?.type || buildContainer?.type || 'unknown'; + const typeIcon = iconMap[type]; + + if (hasContainers) { + return ( + <Label color={isRunning ? 'green' : 'grey'} variant={variant} style={{padding: '8px'}} > + <div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '6px', width: '100%'}}> + {firstIcon} + {typeIcon ? typeIcon : <CubesIcon color={typeIconColor}/>} + </div> + </Label> + ) + } else { + return ( + <div style={{display: 'flex', justifyContent: 'space-around', alignItems: 'center', gap: '0.2rem', padding: '8px'}}> + {/*<InProgressIcon/>*/} + </div> + ) + } +} diff --git a/karavan-app/src/main/webui/src/karavan/features/projects/ProjectZipApi.tsx b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectZipApi.tsx new file mode 100644 index 00000000..33a866f1 --- /dev/null +++ b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectZipApi.tsx @@ -0,0 +1,39 @@ +import axios from "axios"; +import {ErrorEventBus} from "@bus/ErrorEventBus"; +import {AuthApi} from "@api/auth/AuthApi"; + +axios.defaults.headers.common['Accept'] = 'application/json'; +axios.defaults.headers.common['Content-Type'] = 'application/json'; +const instance = AuthApi.getInstance(); + +export class ProjectZipApi { + + static async downloadZip(projectId: string, after: (res: any) => void) { + instance.get('/ui/zip/project/' + projectId, + { + responseType: 'blob', headers: {'Accept': 'application/octet-stream'} + }).then(response => { + after(response.data); + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async uploadZip(fileHandle: File, after: (res: any) => void) { + const formData = new FormData(); + formData.append('file', fileHandle); + formData.append('name', fileHandle.name); + + instance.post('/ui/zip/project', formData, + {headers: {'Content-Type': 'multipart/form-data'}} + ).then(res => { + if (res.status === 200) { + after(res); + } else { + after(undefined); + } + }).catch(err => { + after(err); + }); + } +} diff --git a/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsPage.tsx b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsPage.tsx new file mode 100644 index 00000000..5d1733c4 --- /dev/null +++ b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsPage.tsx @@ -0,0 +1,88 @@ +import React, {useEffect, useState} from 'react'; +import {capitalize, Content, Nav, NavItem, NavList,} from '@patternfly/react-core'; +import {RightPanel} from "@shared/ui/RightPanel"; +import {BUILD_IN_PROJECTS} from "@models/ProjectModels"; +import {useFileStore, useProjectsStore, useProjectStore} from "@stores/ProjectStore"; +import {shallow} from "zustand/shallow"; +import {DeveloperManager} from "@features/project/developer/DeveloperManager"; +import {ErrorBoundaryWrapper} from "@shared/ui/ErrorBoundaryWrapper"; +import {ProjectsTab} from "@features/projects/ProjectsTab"; +import {ProjectFunctionHook} from "@app/navigation/ProjectFunctionHook"; +import {useDataPolling} from "@shared/polling/useDataPolling"; +import {useContainerStatusesStore} from "@stores/ContainerStatusesStore"; + +export const IntegrationsMenus = ['integrations'] as const; +export type IntegrationsMenu = typeof IntegrationsMenus[number]; + +export function ProjectsPage() { + + const [fetchProjects, projects, fetchProjectsCommited] = useProjectsStore((s) => [s.fetchProjects, s.projects, s.fetchProjectsCommited], shallow) + const [setProject] = useProjectStore((s) => [s.setProject], shallow); + const {fetchContainers} = useContainerStatusesStore(); + const [file, operation, setFile] = useFileStore((s) => [s.file, s.operation, s.setFile], shallow); + const showFilePanel = file !== undefined && operation === 'select'; + const [currentMenu, setCurrentMenu] = useState<IntegrationsMenu>(IntegrationsMenus[0]); + + const {refreshSharedData} = ProjectFunctionHook(); + useDataPolling('ProjectPanel', fetchContainers, 10000); + + useEffect(() => { + fetchProjects(); + fetchProjectsCommited(); + refreshSharedData(); + }, []); + + function title() { + return (<Content component="h2">Projects</Content>) + } + + const onNavSelect = (_: any, selectedItem: { + groupId: number | string; + itemId: number | string; + to: string; + } + ) => { + const menu = selectedItem.itemId; + setCurrentMenu(menu as IntegrationsMenu); + const isBuildIn = BUILD_IN_PROJECTS.includes(menu?.toString()); + if (isBuildIn) { + const p = projects.find(p => p.projectId === menu); + if (p) { + setProject(p, "select"); + } + } + setFile('none', undefined); + }; + + function getNavigation() { + return ( + <Nav onSelect={onNavSelect} aria-label="Nav" variant="horizontal"> + <NavList> + {IntegrationsMenus.map((item, i) => { + return ( + <NavItem key={item} preventDefault itemId={item} isActive={currentMenu === item} to={"#"}> + {capitalize(item?.toString())} + </NavItem> + ) + })} + </NavList> + </Nav> + ) + } + + return ( + <RightPanel + title={title()} + toolsStart={getNavigation()} + tools={undefined} + mainPanel={ + <div className="right-panel-card"> + <ErrorBoundaryWrapper onError={error => console.error(error)}> + {!showFilePanel && currentMenu === 'integrations' && <ProjectsTab/>} + {showFilePanel && <DeveloperManager/>} + </ErrorBoundaryWrapper> + </div> + } + /> + ) +} \ No newline at end of file diff --git a/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTab.tsx b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTab.tsx new file mode 100644 index 00000000..6c568650 --- /dev/null +++ b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTab.tsx @@ -0,0 +1,146 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, {useEffect, useState} from 'react'; +import {Bullseye, EmptyState, EmptyStateVariant, ProgressStep, ProgressStepper} from '@patternfly/react-core'; +import {InnerScrollContainer, OuterScrollContainer, Table, Tbody, Td, Th, Thead, Tr} from '@patternfly/react-table'; +import {SearchIcon} from '@patternfly/react-icons'; +import {shallow} from "zustand/shallow"; +import {useProjectsStore, useProjectStore} from "@stores/ProjectStore"; +import {KaravanApi} from "@api/KaravanApi"; +import {CreateProjectModal} from "@features/projects/CreateProjectModal"; +import {DeleteProjectModal} from "@features/projects/DeleteProjectModal"; +import {useSearchStore} from "@stores/SearchStore"; +import {ComplexityProject} from "@features/projects/ComplexityModels"; +import {ComplexityApi} from "@features/projects/ComplexityApi"; +import ProjectsTableRow from "@features/projects/ProjectsTableRow"; +import {ProjectsToolbar} from "@features/projects/ProjectsToolbar"; +import {ProjectType} from "@models/ProjectModels"; +import {useDataPolling} from "@shared/polling/useDataPolling"; + +export function ProjectsTab() { + + const [projects, projectsCommited] = useProjectsStore((s) => [s.projects, s.projectsCommited], shallow) + const [operation] = useProjectStore((s) => [s.operation], shallow) + const [search, searchResults] = useSearchStore((s) => [s.search, s.searchResults], shallow) + const [complexities, setComplexities] = useState<ComplexityProject[]>([]); + const [labels, setLabels] = useState<any>(); + const [selectedLabels, setSelectedLabels] = useState<string[]>([]); + + useEffect(() => refreshActivity(), []); + useDataPolling('ProjectsTab', refreshActivity, 10000); + + function refreshActivity() { + KaravanApi.getProjectsLabels(data => { + setLabels(data); + }); + ComplexityApi.getComplexityProjects(complexities => { + setComplexities(complexities); + }) + } + + const toggleLabel = (label: string) => { + setSelectedLabels((prevSelectedLabels) => { + if (prevSelectedLabels.includes(label)) { + // Remove the label if it already exists in the array + return prevSelectedLabels.filter((item) => item !== label); + } else { + // Add the label if it doesn't exist in the array + return [...prevSelectedLabels, label]; + } + }); + }; + + function getEmptyState() { + return ( + <Tr> + <Td colSpan={8}> + <Bullseye> + <EmptyState variant={EmptyStateVariant.sm} titleText="No results found" icon={SearchIcon} headingLevel="h2"/> + </Bullseye> + </Td> + </Tr> + ) + } + + function getProjectsTable() { + let projs = projects + .filter(p => p.type === ProjectType.integration) + .filter(p => searchResults.map(s => s.projectId).includes(p.projectId) || search === ''); + if (selectedLabels.length > 0) { + projs = projs.filter(p => { + const labs: string[] = labels[p.projectId] !== undefined && Array.isArray(labels[p.projectId]) ? labels[p.projectId] : []; + return labs.some(l => selectedLabels.includes(l)); + }); + } + return ( + <div style={{display: 'flex', flexDirection: 'column', height: '100%'}}> + <ProjectsToolbar/> + <OuterScrollContainer> + <InnerScrollContainer> + <Table aria-label="Projects" variant='compact' isStickyHeader> + <Thead> + <Tr> + <Th key='status' screenReaderText='pass' modifier='fitContent'/> + <Th key='projectId'>Name</Th> + <Th key='name'>Description</Th> + <Th key='timeline' modifier={"fitContent"}> + <ProgressStepper isCenterAligned className={"projects-table-header-progress-stepper"}> + <ProgressStep id="commited" titleId="commited"> + <div style={{textWrap: 'nowrap'}}>Commited</div> + </ProgressStep> + <ProgressStep id="saved" titleId="saved"> + <div style={{textWrap: 'nowrap'}}>Saved</div> + </ProgressStep> + </ProgressStepper> + </Th> + <Th key='complexity' modifier={"fitContent"} textCenter>Complexity</Th> + <Th key='action' modifier={"fitContent"} aria-label='topology-modal'></Th> + </Tr> + </Thead> + <Tbody> + {projs.map(project => { + const complexity = complexities.filter(c => c.projectId === project.projectId).at(0) || new ComplexityProject({projectId: project.projectId}); + const projectCommited = projectsCommited.find(pc => pc.projectId === project.projectId); + return ( + <ProjectsTableRow + key={project.projectId} + project={project} + projectCommited={projectCommited} + complexity={complexity} + labels={Array.isArray(labels?.[project.projectId]) ? labels?.[project.projectId] : []} + selectedLabels={selectedLabels} + onLabelClick={toggleLabel} + /> + ) + })} + {projs.length === 0 && getEmptyState()} + </Tbody> + </Table> + </InnerScrollContainer> + </OuterScrollContainer> + </div> + ) + } + + return ( + <div className="right-panel-card"> + {getProjectsTable()} + {["create", "copy"].includes(operation) && <CreateProjectModal/>} + {["delete"].includes(operation) && <DeleteProjectModal/>} + </div> + ) +} \ No newline at end of file diff --git a/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTableRow.tsx b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTableRow.tsx new file mode 100644 index 00000000..e8d55b86 --- /dev/null +++ b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTableRow.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import {Badge, Button, Flex, FlexItem, Tooltip} from '@patternfly/react-core'; +import '@features/projects/Complexity.css'; +import {Td, Tr} from "@patternfly/react-table"; +import DeleteIcon from "@patternfly/react-icons/dist/js/icons/times-circle-icon"; +import CopyIcon from "@patternfly/react-icons/dist/esm/icons/copy-icon"; +import DownloadIcon from "@patternfly/react-icons/dist/esm/icons/download-icon"; +import {shallow} from "zustand/shallow"; +import {useNavigate} from "react-router-dom"; +import {BUILD_IN_PROJECTS, Project, ProjectCommited} from "@models/ProjectModels"; +import {useProjectStore} from "@stores/ProjectStore"; +import FileSaver from "file-saver"; +import TimeAgo from 'javascript-time-ago' +import en from 'javascript-time-ago/locale/en' +import {ROUTES} from "@app/navigation/Routes"; +import {ProjectStatusLabel} from "@features/projects/ProjectStatusLabel"; +import {ComplexityProject} from "@features/projects/ComplexityModels"; +import {ProjectZipApi} from "@features/projects/ProjectZipApi"; +import {ProjectsTableRowComplexity} from "@features/projects/ProjectsTableRowComplexity"; +import {ProjectsTableRowTimeLine} from "@features/projects/ProjectsTableRowTimeLine"; + +TimeAgo.addDefaultLocale(en) + +interface Props { + project: Project + projectCommited?: ProjectCommited + complexity: ComplexityProject + labels: string[] + selectedLabels: string[] + onLabelClick: (label: string) => void +} + +function ProjectsTableRow(props: Props) { + + const {project, complexity, labels, selectedLabels, onLabelClick, projectCommited} = props; + const [setProject] = useProjectStore((state) => [state.setProject], shallow); + const navigate = useNavigate(); + + const isBuildIn = BUILD_IN_PROJECTS.includes(project.projectId); + + function downloadProject(projectId: string) { + ProjectZipApi.downloadZip(projectId, data => { + FileSaver.saveAs(data, projectId + ".zip"); + }); + } + + return ( + <Tr key={project.projectId} className={"projects-table-row"}> + <Td modifier='fitContent' style={{paddingInlineEnd: 0, paddingInlineStart: '6px'}}> + {!isBuildIn && <ProjectStatusLabel projectId={project.projectId}/>} + </Td> + <Td> + <Button style={{padding: '6px', paddingInlineStart: 0}} variant={"link"} onClick={e => { + navigate(`${ROUTES.PROJECTS}/${project.projectId}`); + }}> + {project.projectId} + </Button> + </Td> + <Td> + <div style={{display: 'flex', flexDirection: 'column', alignItems: 'start', justifyContent: 'start', gap: '3px'}}> + <div> + {project.name} + </div> + {labels.length > 0 && + <div style={{display: 'flex', flexDirection: 'row', gap: '3px'}}> + {labels.map((label) => ( + <Badge key={label} isRead={!selectedLabels.includes(label)} style={{fontWeight: 'normal', cursor: 'pointer'}} + onClick={event => onLabelClick(label)}> + {label} + </Badge> + ))} + </div> + } + </div> + </Td> + <Td modifier={"nowrap"} textCenter> + <ProjectsTableRowTimeLine project={project} projectCommited={projectCommited} /> + </Td> + <Td noPadding textCenter> + {!isBuildIn && <ProjectsTableRowComplexity complexity={complexity}/>} + </Td> + <Td className="project-action-buttons" modifier={"fitContent"}> + <Flex direction={{default: "row"}} justifyContent={{default: "justifyContentFlexEnd"}} spaceItems={{default: 'spaceItemsNone'}} flexWrap={{default: 'nowrap'}}> + {!isBuildIn && + <FlexItem> + <Tooltip content={"Delete"} position={"bottom"}> + <Button className="dev-action-button" variant={"link"} isDanger icon={<DeleteIcon/>} onClick={e => { + setProject(project, "delete"); + }}></Button> + </Tooltip> + </FlexItem> + } + {!isBuildIn && + <FlexItem> + <Tooltip content={"Copy"} position={"bottom"}> + <Button className="dev-action-button" variant={"link"} icon={<CopyIcon/>} + onClick={e => { + setProject(project, "copy"); + }}></Button> + </Tooltip> + </FlexItem> + } + <FlexItem> + <Tooltip content={"Export"} position={"bottom-end"}> + <Button className="dev-action-button" variant={"link"} icon={<DownloadIcon/>} + onClick={e => { + downloadProject(project.projectId); + }}></Button> + </Tooltip> + </FlexItem> + </Flex> + </Td> + </Tr> + ) +} + +export default ProjectsTableRow \ No newline at end of file diff --git a/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTableRowActivity.tsx b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTableRowActivity.tsx new file mode 100644 index 00000000..06230798 --- /dev/null +++ b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTableRowActivity.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import {Label, LabelGroup} from "@patternfly/react-core"; + +interface Props { + activeUsers: string[] +} + +export function ProjectsTableRowActivity (props: Props) { + + const {activeUsers} = props; + + return ( + <LabelGroup className='active-users' numLabels={3}> + {activeUsers.length > 0 && activeUsers.slice(0, 5).map(user => + <Label key={user} color='blue' >{user}</Label> + )} + </LabelGroup> + ) +} \ No newline at end of file diff --git a/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTableRowComplexity.tsx b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTableRowComplexity.tsx new file mode 100644 index 00000000..a15c70e7 --- /dev/null +++ b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTableRowComplexity.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import {Label, Tooltip} from '@patternfly/react-core'; +import './Complexity.css'; +import {ComplexityProject, ComplexityType, getComplexityColor, getMaxComplexity} from "./ComplexityModels"; +import IconEasy from "@patternfly/react-icons/dist/esm/icons/ok-icon"; +import IconNormal from "@patternfly/react-icons/dist/esm/icons/ok-icon"; +import IconComplex from "@patternfly/react-icons/dist/esm/icons/warning-triangle-icon"; +import {BUILD_IN_PROJECTS} from "@models/ProjectModels"; + +interface Props { + complexity: ComplexityProject +} + +export function ProjectsTableRowComplexity (props: Props) { + + const {complexity} = props; + const routesComplexity = complexity.complexityRoute; + const restComplexity = complexity.complexityRest; + const javaComplexity = complexity.complexityJava; + const fileComplexity = complexity.complexityFiles; + + const complexities: ComplexityType[] = []; + complexities.push(routesComplexity); + complexities.push(restComplexity); + complexities.push(javaComplexity); + complexities.push(fileComplexity); + const maxComplexity = getMaxComplexity(complexities) + const color = getComplexityColor(maxComplexity); + const isBuildIn = BUILD_IN_PROJECTS.includes(complexity.projectId); + + const label = isBuildIn + ? <Label key='build-in' variant={"outline"} color={'blue'}><IconNormal color={'var(--pf-t--global--color--brand--default)'}/></Label> + : ( + <Tooltip content={maxComplexity}> + <> + {maxComplexity === 'easy' && <Label key='success' color={color}><IconEasy/></Label>} + {maxComplexity === 'normal' && <Label key='info' color={color}><IconNormal/></Label>} + {maxComplexity === 'complex' && <Label key='warning' color={color}><IconComplex/></Label>} + </> + </Tooltip> + ) + + return ( + <div style={{display: "flex", gap: "3px", justifyContent: 'center', marginLeft: '16px', marginRight: '16px'}} className='complexity'> + {label} + </div> + ) +} \ No newline at end of file diff --git a/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTableRowTimeLine.css b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTableRowTimeLine.css new file mode 100644 index 00000000..dc50f119 --- /dev/null +++ b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTableRowTimeLine.css @@ -0,0 +1,43 @@ +.projects-table-row { + vertical-align: middle; +} + +.projects-table-header-progress-stepper { + .pf-v6-c-progress-stepper__step-main { + margin: 0; + } + .pf-v6-c-progress-stepper__step-connector { + visibility: hidden; + height: 0; + } + .pf-v6-c-progress-stepper__step-title { + font-size: var(--pf-v6-c-table--cell--FontSize); + font-weight: var(--pf-v6-c-table--cell--FontWeight); + line-height: var(--pf-v6-c-table--cell--LineHeight); + color: var(--pf-v6-c-table--cell--Color); + text-overflow: var(--pf-v6-c-table--cell--TextOverflow); + } +} + +.projects-table-progress-stepper-wrapper { + display: flex; + flex-direction: column; + align-items: center; + .commit-label { + .pf-v6-c-label__text { + font-size: var(--pf-t--global--font--size--xs); + } + } +} + +.projects-table-progress-stepper { + min-width: 200px; + .pf-v6-c-progress-stepper__step-main { + margin: 0; + } + .pf-v6-c-progress-stepper__step-title { + font-size: var(--pf-t--global--font--size--xs); + font-weight: var(--pf-v6-c-progress-stepper__step-title--FontWeight); + color: var(--pf-v6-c-progress-stepper__step-title--Color); + } +} \ No newline at end of file diff --git a/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTableRowTimeLine.tsx b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTableRowTimeLine.tsx new file mode 100644 index 00000000..d2fd4476 --- /dev/null +++ b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTableRowTimeLine.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import {Label, ProgressStep, ProgressStepper} from '@patternfly/react-core'; +import '@features/projects/Complexity.css'; +import {Project, ProjectCommited} from "@models/ProjectModels"; +import TimeAgo from 'javascript-time-ago' +import en from 'javascript-time-ago/locale/en' +import './ProjectsTableRowTimeLine.css' +import CheckCircleIcon from "@patternfly/react-icons/dist/esm/icons/check-circle-icon"; +import {InProgress} from "@carbon/icons-react"; + +TimeAgo.addDefaultLocale(en) + +interface Props { + project: Project + projectCommited?: ProjectCommited +} + +export function ProjectsTableRowTimeLine(props: Props) { + + const {project, projectCommited} = props; + const timeAgo = new TimeAgo('en-US') + + const commitTimeStamp = projectCommited !== undefined ? projectCommited.lastCommitTimestamp : 0; + const commited = commitTimeStamp !== 0; + const lastUpdate = project.lastUpdate; + const synced = lastUpdate === commitTimeStamp; + const commitIcon = commited ? <CheckCircleIcon/> : undefined; + const commitLabel = commited ? timeAgo.format(new Date(commitTimeStamp)) : 'No commits yet'; + const savedIcon = synced ? <CheckCircleIcon/> : <InProgress/>; + const savedLabel = synced ? '' : timeAgo.format(new Date(lastUpdate)); + return ( + <div className="projects-table-progress-stepper-wrapper"> + <ProgressStepper isCenterAligned className={"projects-table-progress-stepper"}> + <ProgressStep icon={commitIcon} variant={commited ? "success" : "default"} id="commit" titleId="commit" aria-label="commit"> + {!synced && <div style={{textWrap: 'nowrap'}}>{commitLabel}</div>} + </ProgressStep> + <ProgressStep icon={savedIcon} isCurrent={!synced} variant={synced ? "success" : "default"} id="saved" titleId="saved" aria-label="saved"> + <div style={{textWrap: 'nowrap'}}>{savedLabel}</div> + </ProgressStep> + </ProgressStepper> + {synced && + <Label color={"green"} isCompact className={"commit-label"}> + {commitLabel} + </Label> + } + </div> + ) +} \ No newline at end of file diff --git a/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsToolbar.tsx b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsToolbar.tsx new file mode 100644 index 00000000..6a784332 --- /dev/null +++ b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsToolbar.tsx @@ -0,0 +1,105 @@ +import React, {useEffect, useState} from 'react'; +import {Button, TextInputGroup, TextInputGroupMain, TextInputGroupUtilities, Tooltip, TooltipPosition,} from '@patternfly/react-core'; +import {SearchIcon} from '@patternfly/react-icons'; +import {useAppConfigStore, useProjectStore} from "@stores/ProjectStore"; +import {Project} from "@models/ProjectModels"; +import {shallow} from "zustand/shallow"; +import RefreshIcon from "@patternfly/react-icons/dist/esm/icons/sync-alt-icon"; +import {ProjectService} from "@services/ProjectService"; +import {useSearchStore} from "@stores/SearchStore"; +import {useDebounceValue} from "usehooks-ts"; +import {SearchApi} from "@api/SearchApi"; +import TimesIcon from "@patternfly/react-icons/dist/esm/icons/times-icon"; +import PullIcon from "@patternfly/react-icons/dist/esm/icons/code-branch-icon"; +import {UploadProjectModal} from "@features/projects/UploadProjectModal"; +import {ModalConfirmation} from "@shared/ui/ModalConfirmation"; + +export function ProjectsToolbar() { + + const [search, setSearch, setSearchResults] = useSearchStore((s) => [s.search, s.setSearch, s.setSearchResults], shallow) + const [setProject] = useProjectStore((s) => [s.setProject], shallow) + const [showUpload, setShowUpload] = useState<boolean>(false); + const [debouncedSearch] = useDebounceValue(search, 300); + const [pullIsOpen, setPullIsOpen] = useState(false); + const [config] = useAppConfigStore((s) => [s.config], shallow); + const isDev = config.environment === 'dev'; + + useEffect(() => { + if (search !== undefined && search !== '') { + SearchApi.searchAll(search, response => { + if (response) { + setSearchResults(response); + } + }) + } else { + setSearchResults([]) + } + }, [debouncedSearch]); + + function searchInput() { + return ( + <TextInputGroup style={{ width: "300px" }}> + <TextInputGroupMain + value={search} + id="search-input" + // placeholder='Search' + type="text" + autoComplete={"off"} + autoFocus={true} + icon={<SearchIcon />} + onChange={(_event, value) => { + setSearch(value); + }} + aria-label="text input example" + /> + <TextInputGroupUtilities> + <Button variant="plain" onClick={_ => { + setSearch(''); + }}> + <TimesIcon aria-hidden={true}/> + </Button> + </TextInputGroupUtilities> + </TextInputGroup> + ) + } + + return ( + <div className="project-files-toolbar" style={{justifyContent: "flex-end"}}> + <Tooltip content='Pull new Integrations from git' position={TooltipPosition.left}> + <Button icon={<PullIcon/>} + variant={"link"} + isDanger + onClick={e => setPullIsOpen(true)} + /> + </Tooltip> + <Button icon={<RefreshIcon/>} + variant={"link"} + onClick={e => ProjectService.refreshProjects()} + /> + {searchInput()} + {isDev && + <Button className="dev-action-button" variant="secondary" + onClick={e => setShowUpload(true)}> + Import project + </Button> + } + {isDev && + <Button className="dev-action-button" variant="primary" + onClick={e => setProject(new Project(), 'create')}> + Create Project + </Button> + } + {showUpload && <UploadProjectModal open={showUpload} onClose={() => setShowUpload(false)}/>} + <ModalConfirmation isOpen={pullIsOpen} + message='Pull new Integrations from Git!' + onConfirm={() => { + ProjectService.pullAllProjects(); + setPullIsOpen(false); + }} + onCancel={() => setPullIsOpen(false)} + btnConfirmVariant='danger' + btnConfirm='Confirm Pull' + /> + </div> + ) +} \ No newline at end of file diff --git a/karavan-app/src/main/webui/src/karavan/features/projects/SettingsToolbar.tsx b/karavan-app/src/main/webui/src/karavan/features/projects/SettingsToolbar.tsx new file mode 100644 index 00000000..560c958d --- /dev/null +++ b/karavan-app/src/main/webui/src/karavan/features/projects/SettingsToolbar.tsx @@ -0,0 +1,59 @@ +import React, {useState} from 'react'; +import {Button, Flex, FlexItem, Modal, ModalBody, ModalFooter, ModalHeader,} from '@patternfly/react-core'; +import {useAppConfigStore, useFileStore, useProjectStore} from "@stores/ProjectStore"; +import {shallow} from "zustand/shallow"; +import {ProjectType} from "@models/ProjectModels"; +import {KaravanApi} from "@api/KaravanApi"; +import {CatalogIcon} from '@patternfly/react-icons'; +import {EditorToolbar} from "@features/project/developer/EditorToolbar"; + +export function SettingsToolbar() { + + const [project] = useProjectStore((state) => [state.project], shallow) + const [file, operation] = useFileStore((state) => [state.file, state.operation], shallow) + const {config} = useAppConfigStore(); + const [showConfirmation, setShowConfirmation] = useState<boolean>(false); + + const isConfiguration = project.projectId === ProjectType.configuration.toString(); + const isKamelets = project.projectId === ProjectType.kamelets.toString(); + const isKubernetes = config.infrastructure === 'kubernetes'; + const tooltip = isKubernetes ? "Save All Configmaps" : "Save all on shared volume"; + const confirmMessage = isKubernetes ? "Save all configurations as Configmaps" : "Save all configurations on shared volume"; + + function shareConfigurations () { + KaravanApi.shareConfigurations(res => {}); + setShowConfirmation(false); + } + + function getConfirmation() { + return (<Modal + className="modal-confirm" + variant={"small"} + isOpen={showConfirmation} + onClose={() => setShowConfirmation(false)} + onEscapePress={e => setShowConfirmation(false)}> + <ModalHeader title="Confirmation" /> + <ModalBody> + <div>{confirmMessage}</div> + </ModalBody> + <ModalFooter> + <Button key="confirm" variant="primary" onClick={shareConfigurations}>Confirm</Button>, + <Button key="cancel" variant="link" onClick={_ => setShowConfirmation(false)}>Cancel</Button> + </ModalFooter> + </Modal>) + } + + function getToolbar() { + if (file !== undefined && isConfiguration) { + return (<EditorToolbar/>) + } else { + return ( + <Flex className="toolbar" direction={{default: "row"}} alignItems={{default: "alignItemsCenter"}}> + {showConfirmation && getConfirmation()} + </Flex> + ) + } + } + + return getToolbar(); +} diff --git a/karavan-app/src/main/webui/src/karavan/features/projects/UploadProjectModal.tsx b/karavan-app/src/main/webui/src/karavan/features/projects/UploadProjectModal.tsx new file mode 100644 index 00000000..da58a409 --- /dev/null +++ b/karavan-app/src/main/webui/src/karavan/features/projects/UploadProjectModal.tsx @@ -0,0 +1,99 @@ +import React, {useState} from 'react'; +import {Button, Content, FileUpload, Form, FormGroup, Modal, ModalBody, ModalFooter, ModalHeader, ModalVariant,} from '@patternfly/react-core'; +import {Accept, DropEvent} from "react-dropzone"; +import {EventBus} from "@features/project/designer/utils/EventBus"; +import {ProjectService} from "@services/ProjectService"; +import {ProjectZipApi} from "./ProjectZipApi"; +import {ErrorEventBus} from "@bus/ErrorEventBus"; + +interface Props { + open: boolean, + onClose: () => void +} + +export function UploadProjectModal(props: Props) { + + const [value, setValue] = React.useState<File>(); + const [filename, setFilename] = React.useState<string>(); + const [isLoading, setIsLoading] = useState(false); + const [isRejected, setIsRejected] = useState(false); + + const handleFileInputChange = (_: any, file: File) => { + setFilename(file.name); + }; + + const onReadFinished = (event: DropEvent, fileHandle: File): void => { + setValue(fileHandle); + setIsLoading(false) + } + + const handleClear = (_event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { + setFilename(undefined); + setValue(undefined); + }; + + + function onConfirm(){ + if (filename !== undefined && value !== undefined) { + ProjectZipApi.uploadZip(value, res => { + if (res.status === 200) { + EventBus.sendAlert( "Success", "Integration uploaded", "success"); + ProjectService.refreshProjects(); + } else if (res.status === 304) { + EventBus.sendAlert( "Attention", "Integration already exists", "warning"); + } else { + ErrorEventBus.sendApiError(res); + } + }) + closeModal(); + } + } + + function closeModal() { + props.onClose?.() + } + + const accept : Accept = {'application/x-zip': ['.zip']}; + return ( + <Modal + title="Upload project" + variant={ModalVariant.small} + isOpen={props.open} + onClose={closeModal} + > + <ModalHeader> + <Content component='h2'>Import Integration</Content> + </ModalHeader> + <ModalBody> + <Form> + <FormGroup fieldId="upload"> + <FileUpload + id="file-upload" + value={value} + filename={filename} + type="dataURL" + hideDefaultPreview + browseButtonText="Upload" + isLoading={isLoading} + onFileInputChange={handleFileInputChange} + onReadStarted={(_event, fileHandle: File) => setIsLoading(true)} + onReadFinished={onReadFinished} + allowEditingUploadedText={false} + onClearClick={handleClear} + dropzoneProps={{accept: accept, onDropRejected: fileRejections => setIsRejected(true)}} + /> + </FormGroup> + </Form> + </ModalBody> + <ModalFooter> + <Button key="confirm" variant="primary" + onClick={event => onConfirm()} + isDisabled={filename === undefined || value === undefined} + > + Save + </Button> + <Button key="cancel" variant="secondary" onClick={closeModal}>Cancel</Button> + </ModalFooter> + </Modal> + ) +} \ No newline at end of file
