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 13c5410c86e195fa67a0bc9e8fff90dad15a3d64 Author: Marat Gubaidullin <[email protected]> AuthorDate: Mon Aug 24 18:00:33 2026 -0400 Karavan-app UI Utils --- .../src/main/webui/src/ui/utils/CodeUtils.ts | 206 ++++++++++++ .../src/main/webui/src/ui/utils/FileUtils.ts | 12 + .../src/main/webui/src/ui/utils/ModalForm.css | 97 ++++++ .../src/main/webui/src/ui/utils/StringUtils.ts | 355 +++++++++++++++++++++ .../src/main/webui/src/ui/utils/form-util.css | 44 +++ .../src/main/webui/src/ui/utils/useFormUtil.tsx | 300 +++++++++++++++++ 6 files changed, 1014 insertions(+) diff --git a/karavan-app/src/main/webui/src/ui/utils/CodeUtils.ts b/karavan-app/src/main/webui/src/ui/utils/CodeUtils.ts new file mode 100644 index 00000000..72c0da7f --- /dev/null +++ b/karavan-app/src/main/webui/src/ui/utils/CodeUtils.ts @@ -0,0 +1,206 @@ +/* + * 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 {APPLICATION_PROPERTIES, ProjectFile} from "@models/ProjectModels"; +import {BeanFactoryDefinition} from "@core/model/CamelDefinition"; +import {Integration, IntegrationFile, KameletTypes, MetadataLabels} from "@core/model/IntegrationDefinition"; +import {CamelDefinitionYaml} from "@core/api/CamelDefinitionYaml"; +import {CamelUi} from "@designer/utils/CamelUi"; +import {KameletApi} from "@core/api/KameletApi"; +import {CamelUtil} from "@core/api/CamelUtil"; +import {EventBus} from "@designer/utils/EventBus"; +import {ApplicationProperty} from "@core/model/MainConfigurationModel"; +import {MainConfigurationApi} from "@core/api/MainConfigurationApi"; + +export class CodeUtils { + + static getBeans(files: ProjectFile[]): BeanFactoryDefinition[] { + const result: BeanFactoryDefinition[] = []; + CodeUtils.getIntegrations(files).forEach(integration => { + const beans = CamelUi.getBeans(integration); + result.push(...beans); + }) + return result; + } + + static getIntegrations(files: IntegrationFile[]): Integration[] { + const integrations: Integration[] = []; + files.filter((file) => file.name.endsWith(".camel.yaml")).forEach((file) => { + try { + const i = CamelDefinitionYaml.yamlToIntegration(file.name, file.code); + integrations.push(i); + } catch (e: any){ + console.error(e); + EventBus.sendAlert(`Error parsing ${file.name}`, e?.message, 'danger'); + } + }) + return integrations; + } + + static getPropertyPlaceholders(files: ProjectFile[]): [string, string][] { + const result: [string, string][] = [] + const code = CodeUtils.getPropertyCode(files); + if (code) { + const lines = code.split('\n').map((line) => line.trim()); + lines + .filter(line => !line.startsWith("camel.") && !line.startsWith("jkube.") && !line.startsWith("jib.")) + .filter(line => line !== undefined && line !== null && line.length > 0) + .forEach(line => { + const parts = line.split("="); + if (parts.length > 0) { + result.push([parts[0], parts[1]]); + } + }) + } + return result; + } + + static getPropertyCode(files: ProjectFile[]) { + const file = files.filter(f => f.name === APPLICATION_PROPERTIES)?.at(0); + return file?.code; + } + + static getCodeForNewFile(fileName: string, type: string, copyFromKamelet?: string): string { + if (type === 'INTEGRATION') { + return CamelDefinitionYaml.integrationToYaml(Integration.createNew(fileName, 'plain')); + } else if (type === 'KAMELET') { + const filenameParts = fileName.replace('.kamelet.yaml', '').split('-'); + const name = filenameParts.join('-'); + const type: string | undefined = filenameParts.slice(-1)[0] + const kameletType: KameletTypes | undefined = (type === "sink" || type === "source" || type === "action") ? type : undefined; + const integration = Integration.createNew(name, 'kamelet'); + const meta: MetadataLabels = new MetadataLabels({"camel.apache.org/kamelet.type": kameletType}); + integration.metadata.labels = meta; + if (copyFromKamelet !== undefined && copyFromKamelet !== '') { + const kamelet= KameletApi.getAllKamelets().filter(k => k.metadata.name === copyFromKamelet).at(0); + if (kamelet) { + (integration as any).spec = kamelet.spec; + (integration as any).metadata.labels = kamelet.metadata.labels; + (integration as any).metadata.annotations = kamelet.metadata.annotations; + const i = CamelUtil.cloneIntegration(integration); + return CamelDefinitionYaml.integrationToYaml(i); + } + } + return CamelDefinitionYaml.integrationToYaml(integration); + } else { + return ''; + } + } + + static getApplicationPropertiesCurrentValues(code: string): ApplicationProperty[] { + if (!code) return []; // Add a guard for null or undefined code + return code.split(/\r?\n/) + .filter((p: string) => p.trim() !== '' && p.indexOf('#') !== 0) // Skip empty/whitespace lines and comments + .map((p: string) => { + const i = p.indexOf("="); + // Handle lines that might not contain '=' + if (i === -1) { + return {name: p.trim(), value: ''}; + } + const key = p.substring(0, i).trim(); + const value = p.substring(i + 1).trim(); + return {name: key, value: value}; + }); + } + + + /** + * Replaces all deprecated property keys in a given .properties string with their new names. + * + * @param code The input string containing properties in .ini format. + * @returns A new string with deprecated keys replaced. + */ + static getReplaceAllPropertiesNames(code: string): string { + // 1. Split the input text into an array of lines. + const lines = code.split('\n'); + + // 2. Process each line to check for and replace keys. + const updatedLines = lines.map(line => { + const trimmedLine = line.trim(); + + // 3. Ignore comments (starting with # or !) and empty lines. + if (trimmedLine.startsWith('#') || trimmedLine.startsWith('!') || trimmedLine === '') { + return line; + } + + // 4. Find the first separator (= or :) to isolate the key. + const separatorIndex = trimmedLine.search(/[=:]/); + + // If no separator is found or the line starts with it, it's not a valid key-value pair. + if (separatorIndex <= 0) { + return line; + } + + // Extract the key, trimming any whitespace around it. + const key = trimmedLine.substring(0, separatorIndex).trim(); + + // 5. Use the API to check if the key is deprecated. + const change = MainConfigurationApi.findChangeByPropertyName(key); + + // 6. If a new name is found, replace the old key in the original line. + // This replacement preserves leading whitespace and the value part of the string. + if (change && change.replaced) { + return line.replace(key, change.replaced); + } + + // 7. If no change is needed, return the original line. + return line; + }); + + // 8. Join the updated lines back into a single string. + return updatedLines.join('\n'); + } + + static sortApplicationProperties(input: string): string { + // Define priority order for prefixes + const priorities = [ + 'camel.karavan.', + 'camel.jbang.', + 'camel.context', + 'camel.', + 'jib.', + 'jkube.' + ]; + + // Function to determine the priority of each line + const getPriority = (line: string): number => { + for (let i = 0; i < priorities.length; i++) { + if (line.startsWith(priorities[i])) { + return i; // Return the index as priority + } + } + return priorities.length; // Return a default priority for lines not matching any prefix + }; + + // Split input string into lines, sort them by priority and return the sorted result + return input + .split('\n') + .sort((a, b) => { + const priorityA = getPriority(a); + const priorityB = getPriority(b); + + // If priorities are equal, use lexicographical order + if (priorityA === priorityB) { + return a.localeCompare(b); + } + + // Otherwise, sort by priority + return priorityA - priorityB; + }) + .join('\n'); + } +} \ No newline at end of file diff --git a/karavan-app/src/main/webui/src/ui/utils/FileUtils.ts b/karavan-app/src/main/webui/src/ui/utils/FileUtils.ts new file mode 100644 index 00000000..e687f52f --- /dev/null +++ b/karavan-app/src/main/webui/src/ui/utils/FileUtils.ts @@ -0,0 +1,12 @@ +import {ProjectFile} from "@models/ProjectModels"; + +export function upsertFile(files: ProjectFile[], file: ProjectFile): ProjectFile[] { + const index = files.findIndex(f => f.name === file.name); + + if (index !== -1) { + files[index] = file; + } else { + files.push(file); + } + return [...files]; +} diff --git a/karavan-app/src/main/webui/src/ui/utils/ModalForm.css b/karavan-app/src/main/webui/src/ui/utils/ModalForm.css new file mode 100644 index 00000000..8ddcff60 --- /dev/null +++ b/karavan-app/src/main/webui/src/ui/utils/ModalForm.css @@ -0,0 +1,97 @@ +.modal-90 { + min-height: 90%; +} + +.example-editor { + border-bottom: 1px solid lightgray; + border-left: 1px solid lightgray; + border-right: 1px solid lightgray; +} + +.example-editor .margin { + background-color: var(--pf-t--global--background--color--primary--default); +} + +.modal-form { + padding-bottom: 8px; + gap: 10px; +} + +.modal-form .form-expandable { + display: flex; + flex-direction: row; + justify-content: start; + align-items: start; + gap: 6px; + padding: 6px; + background-color: var(--pf-t--global--background--color--primary--default); + border-width: 1px; + border-style: solid; + border-color: var(--pf-t--global--border--color--default); + border-radius: var(--pf-t--global--border--radius--medium); +} + +.modal-form .form-expandable-hidden-border:after { + display: none; +} + + +.modal-form .form-expandable .pf-v6-c-expandable-section__toggle { + display: flex; + flex-direction: row; + text-wrap: nowrap; + /*width: 160px;*/ +} + +.modal-form .form-expandable .pf-v6-c-expandable-section__content { + flex: 2; + padding-right: 0; + padding-left: 0; +} + +.modal-form .form-expandable .enum-input input { + min-width: 70px; +} + +.modal-form .form-expandable .pf-v6-c-table.pf-m-compact tr:where(.pf-v6-c-table__tr):not(.pf-v6-c-table__expandable-row) > *:first-child { + --pf-v6-c-table--cell--PaddingLeft: 0; + + .pf-v6-c-button.pf-m-plain { + padding: 0; + } +} + +.modal-form .form-expandable .pf-v6-c-table.pf-m-compact tr:where(.pf-v6-c-table__tr):not(.pf-v6-c-table__expandable-row) > *:last-child { + --pf-v6-c-table--cell--PaddingRight: 0; +} + +.modal-form .form-expandable .combinations-toggle-group .pf-v6-c-toggle-group__button { + padding: 0; +} + +.modal-form .form-expandable .combinations-toggle-group .pf-v6-c-toggle-group__button .combination-name { + padding: 6px 16px 6px 16px; +} + +.modal-form .form-expandable .modal-table { + + .modal-table-row { + vertical-align: middle; + } + + .pf-v6-c-table__td { + padding: 8px 4px 8px 4px; + } +} + +.modal-form .form-expandable .pf-v6-c-table.pf-m-compact tr:where(.pf-v6-c-table__tr):not(.pf-v6-c-table__expandable-row) > *:first-child { + padding: 0; +} + +.modal-form .form-expandable .pf-v6-c-table tr:where(.pf-v6-c-table__tr) > :where(th, td) { + padding-block-start: var(--pf-t--global--spacer--xs); + padding-block-end: var(--pf-t--global--spacer--xs); + padding-inline-start: var(--pf-t--global--spacer--xs); + padding-inline-end: var(--pf-t--global--spacer--xs); +} + diff --git a/karavan-app/src/main/webui/src/ui/utils/StringUtils.ts b/karavan-app/src/main/webui/src/ui/utils/StringUtils.ts new file mode 100644 index 00000000..4a9c1e5f --- /dev/null +++ b/karavan-app/src/main/webui/src/ui/utils/StringUtils.ts @@ -0,0 +1,355 @@ +/* + * 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. + */ + +export function decapitalize(input: string) { + return input[0].toLowerCase() + input.substring(1); +} + +export function isEmpty(str: string) { + return !str?.trim(); +} + +export function isValidFileName(input: string): boolean { + const pattern =/^[a-zA-Z0-9._-]+$/; + return pattern.test(input); +} + +export function isValidProjectId(input: string): boolean { + const pattern = /^[a-z][a-z0-9-]*$/; + return pattern.test(input); +} + + +export function getShortCommit(commitId: string): string { + return commitId ? commitId?.substring(0, 7) : "-"; +} + +export function hasLowercase(password: string): boolean { + const pattern = /[a-z]/; + return pattern.test(password); +} + +export function hasUppercase(password: string): boolean { + const pattern = /[A-Z]/; + return pattern.test(password); +} + +export function hasDigit(password: string): boolean { + const pattern = /\d/; + return pattern.test(password); +} + +export function hasSpecialCharacter(password: string): boolean { + const pattern = /[@$!%*?&]/; + return pattern.test(password); +} + +export function hasMinimumLength(password: string, minLength: number = 8): boolean { + return password.length >= minLength; +} + +export function isValidPassword(password: string): boolean { + return hasLowercase(password) && + hasUppercase(password) && + hasDigit(password) && + hasSpecialCharacter(password) && + hasMinimumLength(password); +} + +export function getMegabytes(bytes?: number): number { + return (bytes ? (bytes / 1024 / 1024) : 0); +} + +export function nameToProjectId(str: string): string { + let kebab = str + .replace(/([a-z])([A-Z])/g, '$1-$2') // handle camelCase to kebab + .replace(/[^a-zA-Z0-9]+/g, '-') // replace non-alphanumeric with dash + .toLowerCase() + .replace(/^-+|-+$/g, ''); // trim leading/trailing dashes + + // Ensure the first character is a letter + if (!/^[a-z]/.test(kebab)) { + kebab = 't-' + kebab; + } + return kebab; +} + +export function pathAndMethodToDescription(path: string, method: string = 'get'): string { + // Define verbs for HTTP methods + const verbs: Record<string, string> = { + get: 'Get', + post: 'Create', + put: 'Update', + patch: 'Update', + delete: 'Delete' + }; + + // Remove leading/trailing slashes and split into parts + const parts = path.replace(/^\/|\/$/g, '').split('/'); + // Separate out resource parts and parameters + const resourceParts: string[] = []; + const params: string[] = []; + + for (const part of parts) { + if (part.startsWith('{') && part.endsWith('}')) { + params.push(part.slice(1, -1)); + } else { + resourceParts.push(part); + } + } + + // Capitalize each resource part and join as a phrase + const isPlural = method === 'get' && path.endsWith('/{id}'); + let resource = + resourceParts + .map(word => word.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())) + .join(' '); + + if (isPlural && resource.endsWith('s')) { + resource = resource.slice(0, -1); + } + // Prepare the params phrase + const paramStr = params.length ? ' by ' + params.join(' and ') : ''; + + // Final verb + const verb = verbs[method.toLowerCase()] || 'Access'; + + return `${verb} ${resource}${paramStr}`; +} + +export function dataTypeAndMethodToDescription( + dataType: string, + method: string, + isList: boolean = false +): string { + const pluralize = (word: string) => word.endsWith('s') ? word : word + 's'; + const typeWord = isList ? pluralize(dataType) : dataType; + const typeWordLower = typeWord.charAt(0).toLowerCase() + typeWord.slice(1); + + switch (method.toUpperCase()) { + case 'GET': + return isList + ? `List all ${typeWordLower}` + : `Get a ${typeWordLower} by ID`; + case 'POST': + return `Create a new ${typeWordLower}`; + case 'PUT': + return `Replace a ${typeWordLower} by ID`; + case 'PATCH': + return `Update part of a ${typeWordLower} by ID`; + case 'DELETE': + return `Delete a ${typeWordLower} by ID`; + default: + return `${method} ${typeWordLower}`; + } +} + +function toTitleCase(str: string): string { + return str + .replace(/([A-Z])/g, ' $1') // add space before capital letters (for camelCase) + .replace(/[_-]/g, ' ') // replace _ and - with space + .replace(/\s+/g, ' ') // collapse multiple spaces + .replace(/^./, s => s.toUpperCase()) // capitalize first letter + .replace(/ ([a-z])/g, s => s.toUpperCase()); // capitalize after space +} + +function singularize(word: string): string { + // Simple plural to singular, you can improve this with a library if needed + if (word.endsWith('ies')) return word.slice(0, -3) + 'y'; + if (word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1); + return word; +} + +export function pathToDescription(path: string): string { + const parts = path.replace(/^\/|\/$/g, '').split('/'); + + const resourceParts: string[] = []; + const paramParts: string[] = []; + + for (const part of parts) { + if (part.startsWith('{') && part.endsWith('}')) { + // parameter, e.g. {userId} + const paramName = part.slice(1, -1); + paramParts.push( + toTitleCase( + paramName + .replace(/([a-z])([A-Z])/g, '$1 $2') // split camelCase + .replace(/_/g, ' ') + ) + ); + } else { + resourceParts.push(singularize(toTitleCase(part))); + } + } + + let description = resourceParts.join(' '); + if (paramParts.length) { + description += ' by ' + paramParts.join(' and '); + } + + return description.trim(); +} +export function calculateDuration(start: string, end: string): string { + const startTime = new Date(start).getTime(); + const endTime = new Date(end).getTime(); + if (isNaN(startTime) || isNaN(endTime)) { + return(''); + } + const durationMs = endTime - startTime; + return durationToString(durationMs); +} + +export function durationToString(durationMs: number): string { + if (durationMs < 0) { + return 'Invalid duration'; + } + + const ms = durationMs % 1000; + const seconds = Math.floor((durationMs / 1000) % 60); + const minutes = Math.floor((durationMs / (1000 * 60)) % 60); + const hours = Math.floor((durationMs / (1000 * 60 * 60)) % 24); + const days = Math.floor(durationMs / (1000 * 60 * 60 * 24)); + + const parts = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (seconds > 0) parts.push(`${seconds}s`); + if (ms > 0 || parts.length === 0) parts.push(`${ms}ms`); + + return parts.join(' '); +} + +export function toFakeUTCISOString(date: Date): string { + const yyyy = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const dd = String(date.getDate()).padStart(2, '0'); + const hh = String(date.getHours()).padStart(2, '0'); + const min = String(date.getMinutes()).padStart(2, '0'); + const sec = String(date.getSeconds()).padStart(2, '0'); + + return `${yyyy}-${mm}-${dd}T${hh}:${min}:${sec}Z`; +} + +export function extractTitleFromMarkdown(markdown: string): string | null { + // Step 1: Try to find title in the rehype ignore block + const rehypeRegex = /<!--rehype:ignore:start-->([\s\S]*?)<!--rehype:ignore:end-->/; + const rehypeMatch = markdown.match(rehypeRegex); + + if (rehypeMatch) { + const titleTagRegex = /title:\s*(.+)/i; + const titleMatch = rehypeMatch[1].match(titleTagRegex); + if (titleMatch) { + return titleMatch[1].trim(); + } + } + + // Step 2: Fallback to first level-1 heading + const headingRegex = /^#\s+(.+)$/m; + const headingMatch = markdown.match(headingRegex); + + if (headingMatch) { + return headingMatch[1].trim(); + } + + // If no title found + return null; +} + +export function convertAnyToString(value: any): string { + if (value === null) return "null"; + if (value === undefined) return "undefined"; + + // If it's already a string, check if it might be JSON + if (typeof value === "string") { + const trimmed = value.trim(); + + // Try parsing directly (normal JSON string) + try { + const parsed = JSON.parse(trimmed); + return JSON.stringify(parsed, null, 2); + } catch { + // Try parsing escaped JSON string + try { + const unescaped = trimmed.replace(/\\"/g, '"'); + const parsed = JSON.parse(unescaped); + return JSON.stringify(parsed, null, 2); + } catch { + // Not JSON, return as-is + return value; + } + } + } + + // Handle objects and arrays + if (typeof value === "object") { + try { + return JSON.stringify(value, null, 2); + } catch { + // Circular reference or unserializable object + return Object.prototype.toString.call(value); + } + } + + // Fallback for primitives (number, boolean, symbol, bigint, function) + try { + return String(value); + } catch { + return "[Unstringifiable value]"; + } +} + +const placeholderRegexp = /\{\{\s*([^}:]+?)\s*\}\}/g; +export function replacePlaceholders(code: string, placeholders: Record<string, string>){ + return code.replace(placeholderRegexp, (match, rawKey) => { + const key = rawKey.trim(); + return key in placeholders ? placeholders[key] : match; + }); +} + +export function findPlaceholders(code: string): string[] { + const regex = placeholderRegexp; + const results: string[] = []; + let match: RegExpExecArray | null; + + while ((match = regex.exec(code)) !== null) { + results.push(match[1].trim()); + } + return [...new Set(results)]; +} + +export function splitCamelCase(str: string): string[] { + return str.split(/(?=[A-Z])/); +} + +export function cleanMarkdownBackticks(code: string | null | undefined): string | null | undefined { + if (!code) { + return code; + } + return code.replace(/^```[a-zA-Z0-9]*\s*$/gm, "").trim(); +} + +export function extractMarkdownLanguage(code: string | null | undefined): string | null { + if (!code) { + return null; + } + // Captures any letters/numbers immediately following ``` at the start of a line + const match = code.match(/^```([a-zA-Z0-9]+)/m); + + // Returns the matched group if found, otherwise returns null + return match ? match[1] : null; +} \ No newline at end of file diff --git a/karavan-app/src/main/webui/src/ui/utils/form-util.css b/karavan-app/src/main/webui/src/ui/utils/form-util.css new file mode 100644 index 00000000..ca411353 --- /dev/null +++ b/karavan-app/src/main/webui/src/ui/utils/form-util.css @@ -0,0 +1,44 @@ +/* + * 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. + */ + +.pf-v6-c-modal-box .text-field-with-prefix { + gap: 0; +} + +.pf-v6-c-modal-box .text-field-with-prefix .text-field-prefix { + margin-top: auto; + margin-bottom: auto; +} + +.pf-v6-c-modal-box .text-field-with-prefix .pf-v6-c-text-input-group__text-input { + padding-left: 0; +} + +.pf-v6-c-modal-box .text-field-with-suffix .text-field-suffix { + margin-top: auto; + margin-bottom: auto; + padding-left: 3px; + padding-right: 3px; +} + +.form-util-text-field { + width: 100%; +} +.form-util-text-field-suffix, +.form-util-text-field-suffix input { + width: 150px +} \ No newline at end of file diff --git a/karavan-app/src/main/webui/src/ui/utils/useFormUtil.tsx b/karavan-app/src/main/webui/src/ui/utils/useFormUtil.tsx new file mode 100644 index 00000000..fa8cdd90 --- /dev/null +++ b/karavan-app/src/main/webui/src/ui/utils/useFormUtil.tsx @@ -0,0 +1,300 @@ +/* + * 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 {Controller, FieldError, UseFormReturn,} from "react-hook-form"; +import { + Button, + Content, + ContentVariants, + Flex, + FormGroup, + FormHelperText, + FormSelect, + FormSelectOption, + HelperText, + HelperTextItem, + Switch, + TextArea, + TextInput, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities +} from "@patternfly/react-core"; +import "./form-util.css" +import {EyeIcon, EyeSlashIcon} from "@patternfly/react-icons"; +import {hasDigit, hasLowercase, hasMinimumLength, hasSpecialCharacter, hasUppercase} from "./StringUtils"; + +export function useFormUtil(formContext: UseFormReturn<any>) { + + const [showPassword, setShowPassword] = useState<boolean>(false); + + function getHelper(text?: string) { + if (text) { + return ( + <FormHelperText> + <HelperText> + <HelperTextItem variant={'default'}> + {text} + </HelperTextItem> + </HelperText> + </FormHelperText> + ) + } else return (<></>) + } + + function getError(error: FieldError | undefined) { + if (error) { + return ( + <FormHelperText> + <HelperText> + <HelperTextItem variant={'error'}> + {error.message} + </HelperTextItem> + </HelperText> + </FormHelperText> + ) + } else return (<></>) + } + + function getTextFieldReadOnly(fieldName: string, label: string) { + const {getValues} = formContext; + return ( + <FormGroup label={label} fieldId={fieldName} isRequired> + <TextInput className="text-field" + id={`${fieldName}-text`} + readOnly isDisabled + value={getValues(fieldName) || ''} + /> + </FormGroup> + ) + } + + 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) { + 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={() => ( + <TextInput className="text-field" type={type} + id={`${fieldName}-text`} + required={validate !== undefined} + value={getValues(fieldName) || ''} + validated={errors[fieldName] ? 'error' : 'default'} + onChange={(_, v) => { + setValue(fieldName, v, {shouldValidate: true}); + onChange?.(v) + }} + onBlur={_ => onBlur?.()} + /> + )} + /> + {getError((errors as any)[fieldName])} + {getHelper(hint)} + </FormGroup> + ) + } + + function getTextArea(fieldName: string, label: string, validate?: ((value: string, formValues: any) => boolean | string) | Record<string, (value: string, formValues: any) => boolean | string>) { + const {setValue, getValues, control, formState: {errors}} = formContext; + return ( + <FormGroup label={label} fieldId={fieldName} isRequired> + <Controller + rules={{required: "Required field", validate: validate}} + control={control} + name={fieldName} + render={() => ( + <TextArea type="text" + id={`${fieldName}-text`} + 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={_ => setShowPassword(!showPassword)}> + {showPassword ? <EyeIcon/> : <EyeSlashIcon/>} + </Button> + </div> + )} + /> + {getError((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={ContentVariants.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={() => ( + <TextInputGroup> + <TextInputGroupMain type={type} id={fieldName} + value={getValues(fieldName)} + // validated={!!errors[fieldName] ? 'error' : 'default'} + onChange={(_, v) => { + setValue(fieldName, v, {shouldValidate: true}); + }} + /> + <TextInputGroupUtilities style={{paddingRight: '4px'}}> + <Content id={fieldName + ':suffix'}>{suffix}</Content> + </TextInputGroupUtilities> + </TextInputGroup> + )} + /> + {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 + 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 getSwitches(fieldName: string, label: string, options: [string, string][]) { + const {watch, register, setValue} = formContext; + return ( + <FormGroup label={label} fieldId={fieldName} isRequired {...register(fieldName)}> + <Flex direction={{default: 'column'}}> + {options.map((option) => { + 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> + ) + } + + return {getFormSelect, getTextField, getSwitches, getTextFieldPrefix, getTextArea, getPasswordField, getTextFieldSuffix, getTextFieldReadOnly} +} +
