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}
+}
+

Reply via email to