This is an automated email from the ASF dual-hosted git repository. mgubaidullin pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/camel-karavan.git
commit 1904c9a149a79e5ab7cb21a696da47c4d1f11005 Author: Marat Gubaidullin <[email protected]> AuthorDate: Mon Aug 24 17:54:18 2026 -0400 Fix --- .../src/main/webui/src/models/CatalogModels.ts | 86 +++ .../src/main/webui/src/stores/DashboardStore.ts | 60 ++ .../main/webui/src/stores/useProjectInfoStore.ts | 68 +++ .../ui/page-projects/CreateProjectDrawerPanel.tsx | 31 + .../src/ui/page-projects/CreateProjectPanel.tsx | 149 +++++ .../ui/page-projects/useCreateProjectFormUtil.tsx | 631 +++++++++++++++++++++ .../src/main/webui/src/ui/shared/icons/logo.svg | 178 ++++++ 7 files changed, 1203 insertions(+) diff --git a/karavan-app/src/main/webui/src/models/CatalogModels.ts b/karavan-app/src/main/webui/src/models/CatalogModels.ts new file mode 100644 index 00000000..ecd2d087 --- /dev/null +++ b/karavan-app/src/main/webui/src/models/CatalogModels.ts @@ -0,0 +1,86 @@ +import {ComplexityRouteType} from "./ComplexityModels"; + +export interface JsonSchemaProperty { + type?: string; + const?: string; + description?: string; + format?: string; + enum?: string[]; + minLength?: number; + additionalProperties?: boolean; +} + +export interface JsonSchema { + $schema: string; + $id: string; + title: string; + description?: string; + type: "object"; + required?: string[]; + properties: Record<string, JsonSchemaProperty>; + additionalProperties?: boolean; +} + +export interface ProjectInfo { + projectId: string; + isDevModeRunning: boolean; + isPackagedRunning: boolean; + isBuildRunning: boolean; + routes: RouteComponentsInfo[]; + exposesOpenApi: boolean; + implementsAsyncApi: boolean; +} + +export interface RouteComponentsInfo { + routeId: string; + nodePrefixId: string; + routeTemplateRef: string; + type: ComplexityRouteType; + fileName: string; + consumers: ComponentInfo[]; + producers: ComponentInfo[]; +} + +export interface ComponentInfo { + id: string; + name: string; + remote: boolean; + parameters: Record<string, string>; +} + +export interface OperationStatistic { + action: string; + protocol: string; + address: string; + total: number; + inflight: number; + failed: number; + projectId?: string; +} + +export const CHANNEL_PREFIX = "channel"; +export const MESSAGE_PREFIX = "message"; // fixed duplicate value +export const TAG_PREFIX = "message"; // fixed duplicate value +export const OPERATION_PREFIX = "operation"; +export const APPLICATION_PREFIX = "application"; +export const SERVER_PREFIX = "server"; +export const X_APPLICATIONS = "x-applications"; +export const X_APPLICATION_ID = "x-application-id"; +export const X_CAMEL_CONFIGURATION_REF = "x-camel-configuration-ref"; +export const X_CAMEL_SEND_REF = "x-camel-send-ref"; +export const X_CAMEL_RECEIVE_REF = "x-camel-receive-ref"; +export const X_CAMEL_ROUTE_REF = "x-camel-route-ref"; +export const EXTENSIONS_FIELD_NAME = "extensions"; +export const GENERATED_FILENAME_PREFIX = "_gen_"; + + +export const HTTP_METHODS_LOWERCASE: string[] = [ + 'get', + 'post', + 'put', + 'patch', + 'delete', + 'head' +] + +export const HTTP_METHODS: string[] = HTTP_METHODS_LOWERCASE.map(m => m.toUpperCase()); \ No newline at end of file diff --git a/karavan-app/src/main/webui/src/stores/DashboardStore.ts b/karavan-app/src/main/webui/src/stores/DashboardStore.ts new file mode 100644 index 00000000..f88a9682 --- /dev/null +++ b/karavan-app/src/main/webui/src/stores/DashboardStore.ts @@ -0,0 +1,60 @@ +import {createWithEqualityFn} from "zustand/traditional"; +import {shallow} from "zustand/shallow"; +import {Health, Metric} from "@models/DashboardModels"; + +interface MetricState { + metrics: Metric[]; + setMetrics: (metrics: Metric[]) => void; + updated: number; +} + +export const useMetricStore = createWithEqualityFn<MetricState>((set) => ({ + metrics: [], + updated: 0, + setMetrics: (metrics: Metric[]) => { + set((state: MetricState) => { + state.metrics.length = 0; + state.metrics.push(...metrics); + return {metrics: state.metrics, updated: Math.random()}; + }); + }, +}), shallow) + +interface HealthState { + healths: Health[]; + setHealths: (healths: Health[]) => void; +} + +export const useHealthStore = createWithEqualityFn<HealthState>((set) => ({ + healths: [], + setHealths: (healths: Health[]) => { + set((state: HealthState) => { + state.healths.length = 0; + state.healths.push(...healths); + return {healths: state.healths}; + }); + }, +}), shallow) + + +export type DashboardSideBarType = 'integration' | 'openAPI' | 'library' | 'mcp' + +interface DashboardState { + showSideBar: DashboardSideBarType; + setShowSideBar: (showSideBar: DashboardSideBarType, title?: string) => void; + title: string; + setTitle: (title: string) => void; +} + +export const useDashboardStore = createWithEqualityFn<DashboardState>((set) => ({ + showSideBar: null, + setShowSideBar: (showSideBar: DashboardSideBarType, title?: string) => { + set({ showSideBar: showSideBar, title: title }); + }, + title: null, + setTitle: (title: string) => { + set({ title: title }); + }, +}), shallow) + + diff --git a/karavan-app/src/main/webui/src/stores/useProjectInfoStore.ts b/karavan-app/src/main/webui/src/stores/useProjectInfoStore.ts new file mode 100644 index 00000000..e62684c5 --- /dev/null +++ b/karavan-app/src/main/webui/src/stores/useProjectInfoStore.ts @@ -0,0 +1,68 @@ +import {BUILD_IN_PROJECTS, ContainerStatus} from "@models/ProjectModels"; +import {KaravanApi} from "@api/KaravanApi"; +import isEqual from "lodash/isEqual"; +import {ProjectInfo, RouteComponentsInfo} from "@models/CatalogModels"; +import {ComplexityApi} from "@api/ComplexityApi"; +import {ComplexityProject} from "@models/ComplexityModels"; +import {createWithEqualityFn} from "zustand/traditional"; +import {shallow} from "zustand/shallow"; + + +type OpenApiState = { + projectInfos: ProjectInfo[]; + fetchProjectInfos: () => Promise<void>; +} + +export const useOpenApiStore = createWithEqualityFn<OpenApiState>((set, get) => ({ + projectInfos: [], + fetchProjectInfos: async (): Promise<void> => { + const containerStatusesPromise = new Promise<ContainerStatus[]>((resolve) => { + KaravanApi.getAllContainerStatuses((statuses: ContainerStatus[]) => { + resolve(statuses); + }); + }); + + const complexitiesPromise = new Promise<ComplexityProject[]>((resolve) => { + ComplexityApi.getComplexityProjects((complexities) => { + resolve(complexities); + }); + }); + + // Wait for BOTH API calls + const [containers, complexities] = await Promise.all([ + containerStatusesPromise, + complexitiesPromise + ]); + const currenProjectInfos = get().projectInfos; + const projectInfos: ProjectInfo[] = []; + complexities.filter(c => !BUILD_IN_PROJECTS.includes(c.type)).forEach(c => { + const routes: RouteComponentsInfo[] = [] + c.routes.forEach(r => { + routes.push({ + routeId: r.routeId, + nodePrefixId: r.nodePrefixId, + fileName: r.fileName, + consumers: r.consumers, + producers: r.producers, + routeTemplateRef: r.routeTemplateRef, + type: r.type + }) + }) + const isDevModeRunning = containers.filter(cs => cs.projectId === c.projectId && cs.type === 'devmode')?.at(0)?.state === "running" + const isPackagedRunning = containers.filter(cs => cs.projectId === c.projectId && cs.type === 'packaged')?.at(0)?.state === "running" + const isBuildRunning = containers.filter(cs => cs.projectId === c.projectId && cs.type === 'build')?.at(0)?.state === "running" + projectInfos.push({ + projectId: c.projectId, + isDevModeRunning: isDevModeRunning, + isPackagedRunning: isPackagedRunning, + isBuildRunning: isBuildRunning, + routes: routes, + exposesOpenApi: c.exposesOpenApi, + }) + }); + + if (!isEqual(currenProjectInfos, projectInfos)) { + set({ projectInfos: projectInfos }); + } + }, +}), shallow) diff --git a/karavan-app/src/main/webui/src/ui/page-projects/CreateProjectDrawerPanel.tsx b/karavan-app/src/main/webui/src/ui/page-projects/CreateProjectDrawerPanel.tsx new file mode 100644 index 00000000..d13d5299 --- /dev/null +++ b/karavan-app/src/main/webui/src/ui/page-projects/CreateProjectDrawerPanel.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import {Button, Content, Divider, DrawerHead, DrawerPanelContent,} from '@patternfly/react-core'; +import {useDashboardStore} from "@stores/DashboardStore"; +import {TimesIcon} from "@patternfly/react-icons"; +import {DashboardDevelopmentProjectPanel} from "./DashboardDevelopmentProjectPanel"; + +function DashboardDevelopmentDrawerPanel() { + + const {setShowSideBar, showSideBar, title} = useDashboardStore(); + + return ( + <DrawerPanelContent maxSize={'1000px'} defaultSize={'50%'} minSize={'500px'} isResizable> + <div style={{display: 'flex', flexDirection: 'column', height: '100%'}}> + {/* --- TOP: Fixed Header --- */} + <DrawerHead> + <div style={{display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', padding: '0'}} + onClick={e => e.stopPropagation()}> + <Content style={{flex: 1}} component={'h6'}>{title}</Content> + <Button variant="link" icon={<TimesIcon/>} onClick={() => { + setShowSideBar(null); + }}></Button> + </div> + </DrawerHead> + <Divider style={{marginTop: 0}}/> + {showSideBar === 'integration' && <DashboardDevelopmentProjectPanel/>} + </div> + </DrawerPanelContent> + ) +} + +export default DashboardDevelopmentDrawerPanel \ No newline at end of file diff --git a/karavan-app/src/main/webui/src/ui/page-projects/CreateProjectPanel.tsx b/karavan-app/src/main/webui/src/ui/page-projects/CreateProjectPanel.tsx new file mode 100644 index 00000000..11725316 --- /dev/null +++ b/karavan-app/src/main/webui/src/ui/page-projects/CreateProjectPanel.tsx @@ -0,0 +1,149 @@ +import React, {lazy, Suspense, useEffect} from 'react'; +import {Alert, Button, Divider, DrawerPanelBody, FormAlert} from '@patternfly/react-core'; +import {useProjectsStore} from "@stores/ProjectStore"; +import {Project, RESERVED_WORDS} from "@models/ProjectModels"; +import {isValidProjectId, nameToProjectId} from "@utils/StringUtils"; +import {EventBus} from "@designer/utils/EventBus"; +import {useForm} from "react-hook-form"; +import {AxiosResponse} from "axios"; +import {useDashboardStore} from "@stores/DashboardStore"; +import {ProjectService} from "@services/ProjectService"; +import {SideBarFormWrapper} from "@shared/ui/SideBarFormWrapper"; +import {useProjectInfoStore} from "@stores/useProjectInfoStore"; +import {useNavigate} from "react-router-dom"; +import {ROUTES} from "@compass/navigation/Routes"; +import {ProjectFunctionHook} from "@page-project/ProjectFunctionHook"; +import {KaravanApi} from "@api/KaravanApi"; +import {useCommandPaletteStore} from "@command-palette/useCommandPaletteStore"; +import {CommandPaletteFooter} from "@command-palette/CommandPaletteFooter"; +import {DslMetaModel} from "@designer/utils/DslMetaModel"; +import {useFormUtil} from "./useFormUtil"; + +const CommandPalettePanel = lazy(() => import("@command-palette/CommandPalettePanel").then(m => ({default: m.CommandPalettePanel}))); + +export function DashboardDevelopmentProjectPanel() { + + const projects = useProjectsStore((s) => s.projects); + const fetchProjectInfos = useProjectInfoStore((s) => s.fetchProjectInfos); + const showSideBar = useDashboardStore(state => state.showSideBar); + const setShowSideBar = useDashboardStore(state => state.setShowSideBar); + const filter = useCommandPaletteStore(state => state.filter); + + const [isProjectIdChanged, setIsProjectIdChanged] = React.useState(false); + const [backendError, setBackendError] = React.useState<string>(); + const {createOpenApiForProject, createRoutesForEmptyProject, createDlqForEmptyProject} = ProjectFunctionHook(); + const navigate = useNavigate(); + + // 1. Setup Form + const formContext = useForm<Project>({mode: "all"}); + const {getTextField, getCheckbox} = useFormUtil(formContext); + const {reset, setValue, setFocus, handleSubmit} = formContext; + + // 2. Prepare Data + useEffect(() => { + if (['integration'].includes(showSideBar)) { + const p = new Project(); + reset(p); + setBackendError(undefined); + setIsProjectIdChanged(false); + setTimeout(() => setFocus('name'), 300); + ProjectService.loadBlockedComponentAndKamelets(); + } + }, [showSideBar, reset, setFocus]); + + // 3. Save Handler + async function onSave(data: Project): Promise<string> { + await KaravanApi.postProject(data, (result, res) => after(result, res, data)); + createDlqForEmptyProject(data.projectId); + return data.projectId; + } + + + function after(result: boolean, res: AxiosResponse<Project> | any, data: Project) { + if (result) { + EventBus.sendAlert("Success", "Project successfully created!", "success"); + ProjectService.refreshProjects(); + fetchProjectInfos(); + setShowSideBar(null) + navigate(`${ROUTES.PROJECTS}/${data.projectId}`) + } else { + setBackendError(res?.response?.data); + } + + } + + // 4. Field Change Handlers + function onNameChange(value: string) { + if (!isProjectIdChanged) { + setValue('projectId', nameToProjectId(value), {shouldValidate: true}); + } + } + + function onIdChange(value: string) { + setIsProjectIdChanged(true); + } + + const footer = + <div style={{display: 'flex', justifyContent: 'space-between', gap: 6}}> + <Button variant={"tertiary"} + onClick={() => { + handleSubmit(async (data: Project) => { + await onSave(data); + })(); + }} + >Create Empty</Button> + <CommandPaletteFooter + onBeforeSave={(dsl: DslMetaModel) => { + handleSubmit(async (data: Project) => { + const projectId = await onSave(data); + createRoutesForEmptyProject(dsl, projectId); + })(); + }} + onClose={() => setShowSideBar(null)} + /> + </div> + + + return ( + <> + {/* --- TOP: Fixed panel --- */} + <DrawerPanelBody style={{flexShrink: 0, flexGrow: 0, padding: '16px 16px 16px 16px'}}> + <SideBarFormWrapper + className={"command-palette"} + formContext={formContext} + footer={<></>} + > + + {getTextField('name', 'Name', { + length: v => v.length > 5 || 'Project name should be longer than 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 than 5 characters', + name: v => !RESERVED_WORDS.includes(v) || "Reserved word", + uniques: v => !projects.map(p => p.projectId).includes(v) || "Project already exists!", + }, 'text', onIdChange)} + {backendError && ( + <FormAlert> + <Alert variant="danger" title={backendError} aria-live="polite" isInline/> + </FormAlert> + )} + </SideBarFormWrapper> + </DrawerPanelBody> + + {/*<Divider style={{marginTop: 0}}/>*/} + {/* --- MIDDLE: Scrollable panel --- */} + <DrawerPanelBody style={{flexGrow: 1, overflowY: 'auto'}}> + {showSideBar === 'integration' && <Suspense fallback={null}><CommandPalettePanel/></Suspense>} + </DrawerPanelBody> + + <Divider style={{marginTop: 0}}/> + {/* --- BOTTOM: Fixed Panel --- */} + {/* flexShrink: 0 prevents it from squishing. flexGrow: 0 prevents it from expanding. */} + <DrawerPanelBody style={{flexShrink: 0, flexGrow: 0, padding: '16px 16px 16px 16px'}}> + {showSideBar === 'integration' && footer} + </DrawerPanelBody> + </> + ); +} \ No newline at end of file diff --git a/karavan-app/src/main/webui/src/ui/page-projects/useCreateProjectFormUtil.tsx b/karavan-app/src/main/webui/src/ui/page-projects/useCreateProjectFormUtil.tsx new file mode 100644 index 00000000..3acc5a5b --- /dev/null +++ b/karavan-app/src/main/webui/src/ui/page-projects/useCreateProjectFormUtil.tsx @@ -0,0 +1,631 @@ +import React, {useState} from 'react'; +import {Controller, FieldError, UseFormReturn,} from "react-hook-form"; +import { + Alert, + Button, + capitalize, + Checkbox, + Content, + Flex, + FormGroup, + FormHelperText, + FormSelect, + FormSelectOption, + HelperText, + HelperTextItem, + Switch, + TextArea, + TextInput, + TextInputGroup, + TextInputGroupMain, + ToggleGroup, + ToggleGroupItem, +} from "@patternfly/react-core"; +import {EyeIcon, EyeSlashIcon} from "@patternfly/react-icons"; +import {hasDigit, hasLowercase, hasMinimumLength, hasSpecialCharacter, hasUppercase} from "@utils/StringUtils"; +import {SimpleSelect, TypeaheadSelect, TypeaheadSelectOption} from "@patternfly/react-templates"; +import {SimpleSelectOption} from "@patternfly/react-templates/src/components/Select/SimpleSelect"; +import {MonacoEditor} from "@shared/MonacoEditor"; +import {useAppConfig} from "@compass/useConfig"; + +export function useFormUtil(formContext: UseFormReturn<any>) { + + const [showPassword, setShowPassword] = useState<boolean>(false); + const {isDev} = useAppConfig(); + + function getError(error: FieldError | undefined) { + if (error) { + return ( + <FormHelperText> + <HelperText> + <HelperTextItem variant={'error'}> + {error.message} + </HelperTextItem> + </HelperText> + </FormHelperText> + ) + } else return (<></>) + } + + function getAlert(error: FieldError | undefined, variant?: 'success' | 'danger' | 'warning' | 'info' | 'custom') { + if (error) { + return <Alert variant={variant} isInline isPlain title={error.message}/> + } else return (<></>) + } + + function getHelper(text?: string) { + if (text) { + return ( + <FormHelperText> + <HelperText> + <HelperTextItem variant={'default'}> + {text} + </HelperTextItem> + </HelperText> + </FormHelperText> + ) + } else return (<></>) + } + + function getTextField(fieldName: string, label: string, + validate?: ((value: string, formValues: any) => boolean | string) | Record<string, (value: string, formValues: any) => boolean | string>, + type: | 'text' | 'date' | 'datetime-local' | 'email' | 'month' | 'number' | 'password' | 'search' | 'tel' | 'time' | 'url' = 'text', + onChange?: ((value: any) => void), hint?: string, onBlur?: () => void, readonly?: boolean, placeholder?: string) { + const {control, setValue, getValues, formState: {errors}} = formContext; + const rules: any = {}; + if (validate !== undefined) { + rules.required = "Required field"; + } + if (validate) { + rules.validate = validate; + } + return ( + <FormGroup label={label} fieldId={fieldName} isRequired={validate !== undefined}> + <Controller + rules={rules} + control={control} + name={fieldName} + render={({ field }) => ( + <TextInput className="text-field" + type={type} + id={fieldName} + ref={field?.ref} + required={validate !== undefined} + readOnly={readonly} + value={getValues(fieldName) || ''} + placeholder={placeholder} + validated={errors[fieldName] ? 'error' : 'default'} + onChange={(_, rawValue) => { + field.onChange(rawValue); + onChange?.(rawValue); + }} + onBlur={event => onBlur?.()} + /> + )} + /> + {getError((errors as any)[fieldName])} + {getHelper(hint)} + </FormGroup> + ) + } + + function getTextFieldForId(fieldName: string, label: string, validate?: ((value: string, formValues: any) => boolean | string) | Record<string, (value: string, formValues: any) => boolean | string>) { + const {control, getValues, formState: {errors}} = formContext; + const rules: any = {}; + rules.required = "Required field"; + if (validate) { + rules.validate = validate; + } + return ( + <FormGroup label={label} fieldId={fieldName} isRequired={validate !== undefined}> + <Controller + rules={rules} + control={control} + name={fieldName} + render={({ field }) => ( + <TextInput className="text-field" + type={"text"} + id={fieldName} + ref={field?.ref} + required={validate !== undefined} + value={getValues(fieldName) || ''} + validated={errors[fieldName] ? 'error' : 'default'} + onChange={(_, rawValue) => { + const sanitizedValue = rawValue.replace(/[^a-zA-Z0-9]/g, ''); + field.onChange(sanitizedValue); + }} + /> + )} + /> + {getError((errors as any)[fieldName])} + </FormGroup> + ) + } + + function getTextFieldForApp(fieldName: string, label: string, validate?: ((value: string, formValues: any) => boolean | string) | Record<string, (value: string, formValues: any) => boolean | string>) { + const {control, getValues, formState: {errors}} = formContext; + const rules: any = {}; + rules.required = "Required field"; + if (validate) { + rules.validate = validate; + } + return ( + <FormGroup label={label} fieldId={fieldName} isRequired={validate !== undefined}> + <Controller + rules={rules} + control={control} + name={fieldName} + render={({ field }) => ( + <TextInput className="text-field" + type={"text"} + id={fieldName} + ref={field?.ref} + required={validate !== undefined} + value={getValues(fieldName) || ''} + validated={errors[fieldName] ? 'error' : 'default'} + onChange={(_, rawValue) => { + const sanitizedValue = rawValue + .toLowerCase() // Force lower case + .replace(/[^a-z0-9-]/g, '') // Remove everything except lowercase, numbers, and dashes + .replace(/^[-0-9]+/, ''); // Remove any dashes or numbers that appear at the very start + field.onChange(sanitizedValue); + }} + /> + )} + /> + {getError((errors as any)[fieldName])} + </FormGroup> + ) + } + + function getTextArea(fieldName: string, label: string, rows: number = 1, validate?: ((value: string, formValues: any) => boolean | string) | Record<string, (value: string, formValues: any) => boolean | string>) { + const {setValue, getValues, control, formState: {errors}} = formContext; + const rules: any = {}; + if (validate !== undefined) { + rules.required = "Required field"; + } + if (validate) { + rules.validate = validate; + } + return ( + <FormGroup label={label} fieldId={fieldName} isRequired={validate !== undefined}> + <Controller + rules={rules} + control={control} + name={fieldName} + render={() => ( + <TextArea type="text" + id={fieldName} + rows={rows} + value={getValues(fieldName) || ''} + validated={errors[fieldName] ? 'error' : 'default'} + // ref={ref} + onChange={(e, v) => { + setValue(fieldName, v, {shouldValidate: true}); + }} + autoResize + /> + )} + /> + {getError((errors as any)[fieldName])} + </FormGroup> + ) + } + + function getPasswordField(fieldName: string, label: string, validate?: ((value: string, formValues: any) => boolean | string) | Record<string, (value: string, formValues: any) => boolean | string>) { + validate = { + length: v => hasMinimumLength(v) || 'Password should be at least 8 characters', + lower: v => hasLowercase(v) || 'Password should have at least one lowercase letter', + upper: v => hasUppercase(v) || 'Password should have at least one uppercase letter', + digit: v => hasDigit(v) || 'Password should have at least one digit', + special: v => hasSpecialCharacter(v) || 'Password should have at least one special character', + } + const {control, setValue, getValues, formState: {errors}} = formContext; + return ( + <FormGroup label={label} fieldId={fieldName} isRequired> + <Controller + rules={{required: "Required field", validate: validate}} + control={control} + name={fieldName} + render={() => ( + <div style={{display: 'flex'}}> + <TextInput className="text-field" type={showPassword ? "text" : "password"} id={fieldName} + value={getValues(fieldName) || ''} + validated={errors[fieldName] ? 'error' : 'default'} + onChange={(_, v) => { + setValue(fieldName, v, {shouldValidate: true}); + }} + /> + <Button variant="control" onClick={e => setShowPassword(!showPassword)}> + {showPassword ? <EyeIcon/> : <EyeSlashIcon/>} + </Button> + </div> + )} + /> + {getHelper((errors as any)[fieldName])} + </FormGroup> + ) + } + + function getTextFieldPrefix(fieldName: string, label: string, prefix: string, + required: boolean, + validate?: ((value: string, formValues: any) => boolean | string) | Record<string, (value: string, formValues: any) => boolean | string>) { + const {setValue, getValues, register, formState: {errors}} = formContext; + return ( + <FormGroup label={label} fieldId={fieldName} isRequired> + <TextInputGroup> + <TextInputGroupMain className="text-field-with-prefix" type="text" id={fieldName} + value={getValues(fieldName)} + {...register(fieldName, { + required: (required ? "Required field" : false), + validate: validate + })} + onChange={(e, v) => { + setValue(fieldName, v, {shouldValidate: true}); + }} + > + <Content className='text-field-prefix' component='p'>{prefix}</Content> + </TextInputGroupMain> + </TextInputGroup> + {getHelper((errors as any)[fieldName])} + </FormGroup> + ) + } + + function getTextFieldSuffix(fieldName: string, label: string, suffix: string, + validate?: ((value: string, formValues: any) => boolean | string) | Record<string, (value: string, formValues: any) => boolean | string>, + type: | 'text' | 'date' | 'datetime-local' | 'email' | 'month' | 'number' | 'password' | 'search' | 'tel' | 'time' | 'url' = 'text') { + const {control, setValue, getValues, formState: {errors}} = formContext; + return ( + <FormGroup label={label} fieldId={fieldName} isRequired> + <Controller + rules={{required: "Required field", validate: validate}} + control={control} + name={fieldName} + render={() => ( + <div style={{display: 'flex'}}> + <TextInput className="form-util-text-field" type={type} id={fieldName} + value={getValues(fieldName)} + validated={errors[fieldName] ? 'error' : 'default'} + onChange={(_, v) => { + setValue(fieldName, v, {shouldValidate: true}); + }} + /> + <TextInput className="form-util-text-field-suffix" id={fieldName + ':suffix'} value={suffix} isDisabled/> + </div> + )} + /> + {getHelper((errors as any)[fieldName])} + </FormGroup> + ) + } + + function getFormSelect(fieldName: string, label: string, options: [string, string][]) { + const {register, watch, setValue, formState: {errors}} = formContext; + return ( + <FormGroup label={label} fieldId={fieldName} isRequired> + <FormSelect + id={fieldName} + ouiaId={fieldName} + validated={errors[fieldName] ? 'error' : 'default'} + value={watch(fieldName)} + {...register(fieldName, {required: "Required field"})} + onChange={(e, v) => { + setValue(fieldName, v, {shouldValidate: true}); + }} + name={fieldName} + + > + <FormSelectOption key='placeholder' value={undefined} label='Select one' isDisabled/> + {options.map((option, index) => ( + <FormSelectOption key={index} value={option[0]} label={option[1]}/> + ))} + </FormSelect> + {getHelper((errors as any)[fieldName])} + </FormGroup> + ) + } + + function getTypeaheadSelect(fieldName: string, label: string, options: TypeaheadSelectOption[], readonly?: boolean, isCreatable?: boolean) { + // 1. Destructure what you need. Note: register is needed to "register" the field logic, + // but NOT to spread props onto the FormGroup. + const { register, watch, setValue, formState: { errors } } = formContext; + + // 2. Register the field silently so RHF knows it exists (for validation/submit), + // but don't attach the props to the UI component. + React.useEffect(() => { + register(fieldName); + }, [register, fieldName]); + + const value = watch(fieldName); + const initialOptions: TypeaheadSelectOption[] = options.map(o => ({ + ...o, + value: o.value ?? '', // Ensure value is robust + selected: o.value === value + })); + + const isNewValue = (value !== null && value !== undefined && !initialOptions.find((option) => option.value === value)); + + return ( + // [FIX]: Removed {...register(fieldName)} from here + <FormGroup label={label} fieldId={fieldName}> + <TypeaheadSelect + isDisabled={readonly} + // Key forces re-render if switching between known/new values (optional optimization) + key={fieldName} + id={fieldName} + ouiaId={fieldName} + initialOptions={initialOptions} + createOptionMessage={`New ${label} "${value}"`} + isCreatable={isCreatable} + isCreateOptionOnTop={true} + placeholder={`Select ${label}`} + + // Handle text input changes (typing) + onInputChange={(newVal: string) => { + if (isCreatable) { + setValue(fieldName, newVal, { shouldValidate: false, shouldDirty: true }); + } + }} + + // Handle selection from the list + onSelect={(_ev, selection) => { + setValue(fieldName, selection, { shouldValidate: true, shouldDirty: true }); + }} + + // Handle pressing Enter to create a new tag + onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => { + if (isCreatable && event.key === 'Enter') { + event.preventDefault(); // Stop form submission + event.stopPropagation(); + const inputVal = (event.target as HTMLInputElement).value; + setValue(fieldName, inputVal, { shouldValidate: true, shouldDirty: true }); + } + }} + /> + {/* Helper/Error display */} + {getHelper((errors as any)[fieldName])} + </FormGroup> + ); + } + + function getTypeaheadSelectNotCreatable(fieldName: string, label: string, options: TypeaheadSelectOption[]) { + const { register, watch, setValue, formState: { errors } } = formContext; + React.useEffect(() => { + register(fieldName); + }, [register, fieldName]); + + const value = watch(fieldName); + const initialOptions: TypeaheadSelectOption[] = options.map(o => ({ + ...o, + value: o.value ?? '', // Ensure value is robust + selected: o.value === value + })); + + return ( + <FormGroup label={label} fieldId={fieldName}> + <TypeaheadSelect + key={`${fieldName}-${value ?? 'empty'}`} + id={fieldName} + ouiaId={fieldName} + initialOptions={initialOptions} + placeholder={`Select ${label}`} + onSelect={(_ev, selection) => { + setValue(fieldName, selection, { shouldValidate: true, shouldDirty: true }); + }} + /> + {getHelper((errors as any)[fieldName])} + </FormGroup> + ); + } + + const ControlledSimpleSelect = ({ + fieldName, + label, + options, + formContext + }: { + fieldName: string; + label: string; + options: SimpleSelectOption[]; + formContext: UseFormReturn<any>; + }) => { + const { register, watch, setValue, formState: { errors } } = formContext; + + React.useEffect(() => { + register(fieldName); + }, [register, fieldName]); + + const value = watch(fieldName); + const initialOptions: SimpleSelectOption[] = options.map(o => ({ + ...o, + value: o.value ?? '', + selected: o.value === value + })); + + return ( + <FormGroup label={label} fieldId={fieldName}> + <SimpleSelect + toggleWidth={'100%'} + key={`${fieldName}-${value ?? 'empty'}`} + id={fieldName} + ouiaId={fieldName} + initialOptions={initialOptions} + placeholder={`Select ${label}`} + onSelect={(_ev, selection) => { + setValue(fieldName, selection, { shouldValidate: true, shouldDirty: true }); + }} + isScrollable={true} + popperProps={{ position: 'end' }} + /> + {/* Make sure getHelper is available here, or pass the error string directly */} + {getHelper((errors as any)[fieldName])} + </FormGroup> + ); + }; + + function getSimpleSelect(fieldName: string, label: string, options: SimpleSelectOption[]) { + return ( + <ControlledSimpleSelect + key={fieldName} // Important: gives React a stable identity + fieldName={fieldName} + label={label} + options={options} + formContext={formContext} + /> + ); + } + + + function getSwitches(fieldName: string, label: string, options: [string, string][]) { + const {watch, register, getValues, setValue, formState: {errors}} = formContext; + return ( + <FormGroup label={label} fieldId={fieldName} isRequired {...register(fieldName)}> + <Flex direction={{default: 'column'}}> + {options.map((option, index) => { + const key = option[0]; + const label = option[0]; + return (<Switch + id={key} + label={label} + isChecked={watch(fieldName) !== undefined && watch(fieldName).includes(key)} + onChange={(e, v) => { + const vals: string[] = watch(fieldName); + const idx = vals.findIndex(x => x === key); + if (idx > -1 && !v) { + vals.splice(idx, 1); + setValue(fieldName, [...vals]); + } else if (idx === -1 && v) { + vals.push(key); + setValue(fieldName, [...vals]); + } + }} + ouiaId={option[0]} + />) + })} + </Flex> + </FormGroup> + ) + } + + function getCheckbox(fieldName: string, label: string) { + const {watch, control, setValue, formState: {errors}} = formContext; + const value = watch(fieldName) + return ( + <FormGroup label={label} fieldId={fieldName} key={fieldName}> + <Controller + control={control} + name={fieldName} + render={({ field }) => { + return ( + <Checkbox id='exchangePattern' + isChecked={value} + onChange={(_, checked) => setValue(fieldName, checked, {shouldValidate: false})} + /> + ) + }}/> + </FormGroup> + ) + } + + function getToggleGroup(fieldName: string, label: string, options: string[], onChange?: (option: string, isSelected: boolean) => void) { + const {control, formState: {errors}} = formContext; + return ( + <FormGroup label={label} fieldId={fieldName}> + <Controller + control={control} + name={fieldName} + render={({field}) => ( + <ToggleGroup aria-label="ToggleGroup" className='combinations-toggle-group'> + {options.map((option) => { + return ( + <ToggleGroupItem + key={option} + text={capitalize(option)} + isSelected={option === field.value} + onChange={(_, isSelected) => { + field.onChange(isSelected ? option : undefined); + onChange?.(option, isSelected) + }} + /> + ) + })} + </ToggleGroup> + )} + /> + {getError((errors as any)[fieldName])} + </FormGroup> + ) + } + + function getMonacoEditor( + fieldName: string, + language: string = 'markdown', + height: string = '370px', + onChange?: ((value: string | undefined) => void), + validate?: ((value: string, formValues: any) => boolean | string) | Record<string, (value: string, formValues: any) => boolean | string>, + onBlur?: () => void + ) { + const { control, setValue, getValues, formState: { errors } } = formContext; + const rules: any = {}; + + if (validate !== undefined) { + rules.required = "Required field"; + } + if (validate) { + rules.validate = validate; + } + + const hasError = !!errors[fieldName]; + + return ( + <Controller + rules={rules} + control={control} + name={fieldName} + render={() => ( + <div + className={`monaco-wrapper ${hasError ? 'has-error' : ''}`} + style={{ + border: hasError + ? '1px solid var(--pf-v5-global--danger-color--100, #c9190b)' + : '1px solid var(--pf-v5-global--BorderColor--100, #d2d2d2)', + borderRadius: '3px', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + minHeight: 0 + }} + > + <MonacoEditor + height={height} // Tells Monaco to fill the div + language={language} + value={getValues(fieldName) || ''} + onChange={(v) => { + setValue(fieldName, v, { shouldValidate: true }); + onChange?.(v); + }} + onMount={(editor) => { + editor.onDidBlurEditorText(() => { + onBlur?.(); + }); + }} + options={{ + minimap: { enabled: false }, + scrollBeyondLastLine: false, + lineNumbersMinChars: 2, + automaticLayout: true, // Crucial for responsive resizing in a Drawer + readOnly: !isDev + }} + /> + </div> + )} + /> + ); + } + + return { + getFormSelect, getTextField, getSwitches, getTextFieldPrefix, getTextArea, getPasswordField, getTextFieldSuffix, getCheckbox, getSimpleSelect + } +} diff --git a/karavan-app/src/main/webui/src/ui/shared/icons/logo.svg b/karavan-app/src/main/webui/src/ui/shared/icons/logo.svg new file mode 100644 index 00000000..61f89cfe --- /dev/null +++ b/karavan-app/src/main/webui/src/ui/shared/icons/logo.svg @@ -0,0 +1,178 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + width="360.13885" + height="360.13885" + viewBox="0 0 360.13885 360.13885" + version="1.1" + preserveAspectRatio="xMidYMid" + id="svg50" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns="http://www.w3.org/2000/svg"> + <defs + id="defs31"> + <linearGradient + id="linearGradient1351"> + <stop + style="stop-color:#dcffff;stop-opacity:1" + offset="0" + id="stop1347" /> + <stop + style="stop-color:#96d2e6;stop-opacity:1" + offset="1" + id="stop1349" /> + </linearGradient> + <circle + id="path-1" + cx="128" + cy="128.00015" + r="128" /> + <linearGradient + x1="-26.051073" + y1="271.33054" + x2="254.31573" + y2="0.047514945" + id="linearGradient-3" + gradientUnits="userSpaceOnUse"> + <stop + stop-color="#F69923" + offset="0%" + id="stop10" + style="stop-color:#4790bb;stop-opacity:1" /> + <stop + stop-color="#F79A23" + offset="10.996%" + id="stop12" + style="stop-color:#64b7db;stop-opacity:1" /> + <stop + stop-color="#E97826" + offset="94.502%" + id="stop14" + style="stop-color:#326ea0;stop-opacity:1" /> + </linearGradient> + <linearGradient + x1="-32.163429" + y1="277.02905" + x2="259.33835" + y2="-5.0281582" + id="linearGradient-4" + gradientUnits="userSpaceOnUse"> + <stop + stop-color="#F69923" + offset="0%" + id="stop17" /> + <stop + stop-color="#F79A23" + offset="8.0478%" + id="stop19" /> + <stop + stop-color="#E97826" + offset="41.874%" + id="stop21" /> + </linearGradient> + <linearGradient + x1="217.94496" + y1="67.504837" + x2="99.458817" + y2="247.00549" + id="linearGradient-5" + gradientTransform="scale(0.96441978,1.0368929)" + gradientUnits="userSpaceOnUse" + xlink:href="#linearGradient-4"> + <stop + stop-color="#F6E423" + offset="0%" + id="stop24" + style="stop-color:#92d6d5;stop-opacity:1" /> + <stop + stop-color="#F79A23" + offset="41.191%" + id="stop26" + style="stop-color:#79b7cc;stop-opacity:1" /> + <stop + stop-color="#E97826" + offset="73.271%" + id="stop28" + style="stop-color:#5891c5;stop-opacity:1" /> + </linearGradient> + <mask + id="mask-2" + fill="#ffffff"> + <use + xlink:href="#path-1" + id="use33" + x="0" + y="0" + width="100%" + height="100%" /> + </mask> + <mask + id="mask-2-7" + fill="#ffffff"> + <use + xlink:href="#path-1" + id="use137" + x="0" + y="0" + width="100%" + height="100%" /> + </mask> + <linearGradient + xlink:href="#linearGradient1351" + id="linearGradient1345" + x1="233.12198" + y1="56.01545" + x2="2.2396212" + y2="242.78015" + gradientUnits="userSpaceOnUse" /> + </defs> + <g + id="g36" + transform="translate(45.104634,56.041665)" /> + <circle + fill="url(#linearGradient-3)" + fill-rule="nonzero" + mask="url(#mask-2)" + cx="127.99429" + cy="127.99429" + r="123.11053" + id="circle38" + style="fill:url(#linearGradient-3)" + transform="translate(45.104634,56.041665)" /> + <g + id="g2266" + transform="translate(45.104634,56.041665)"> + <path + d="m 98.043695,75.516752 c -1.750682,-0.002 -3.524167,0.0098 -5.292059,0.06144 -2.05519,0.06065 -4.816316,0.713182 -7.999625,1.784532 53.775199,40.834016 73.108199,114.497516 39.875049,178.514206 1.12865,0.0293 2.24876,0.12307 3.38456,0.12307 60.7361,0 111.49261,-42.32269 124.60904,-99.07129 C 214.07872,111.75077 161.80794,75.61511 98.043301,75.516654 Z" + fill="url(#linearGradient-5)" + fill-rule="nonzero" + opacity="0.75" + mask="url(#mask-2)" + id="path42" + style="fill:url(#linearGradient-5);fill-opacity:1" /> + </g> + <path + d="M 84.752011,77.367742 C 66.89547,83.377158 32.82977,104.54579 0.07906091,132.8108 2.5662796,200.14549 57.107277,254.12351 124.62706,255.88195 157.86021,191.86526 138.528,118.20176 84.752011,77.367742 Z" + fill="#28170b" + fill-rule="nonzero" + opacity="0.75" + mask="url(#mask-2)" + id="path44" + style="fill:#1e4b7b;fill-opacity:1" + transform="translate(45.104634,56.041665)" /> + <path + d="m 128.74719,54.004528 c -10.98485,5.495372 0,27.466068 0,27.466068 -32.973011,27.483724 -25.9672,74.429124 -64.435392,74.429124 -20.970343,0 -42.242226,-24.0768 -64.23273709,-38.82804 -0.28309669,3.47897 -0.78535974,6.97247 -0.78535974,10.52442 0,48.09504 26.26287383,89.92436 65.41967783,111.89721 10.952683,-1.3796 22.838636,-4.11444 31.050991,-9.59255 43.14527,-28.76482 53.85703,-83.49096 71.48633,-109.92509 10.97897,-16.492 62.43429,-15.06102 65.90679,-22.01013 5.50126,- [...] + fill="#ffffff" + fill-rule="nonzero" + mask="url(#mask-2-7)" + id="path150" + transform="translate(44.335601,55.908568)" + style="fill:url(#linearGradient1345);fill-opacity:1" /> + <path + d="M 128,256 C 57.307552,256 0,198.69245 0,128 0,57.307552 57.307552,0 128,0 c 70.69245,0 128,57.307552 128,128 0,70.69245 -57.30755,128 -128,128 z m 0,-9.76795 C 193.29776,246.23205 246.23205,193.29776 246.23205,128 246.23205,62.702243 193.29776,9.7679529 128,9.7679529 62.702243,9.7679529 9.7679529,62.702243 9.7679529,128 9.7679529,193.29776 62.702243,246.23205 128,246.23205 Z" + fill="url(#linearGradient-4)" + fill-rule="nonzero" + mask="url(#mask-2)" + id="path40" + style="fill:#2d4150;fill-opacity:1" + transform="matrix(1.020345,0,0,1.020345,41.979634,53.958328)" /> +</svg> \ No newline at end of file
