Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions workspaces/ballerina/ballerina-core/src/interfaces/bi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ export interface BaseType {
ballerinaType?: string;
selected: boolean;
typeMembers?: PropertyTypeMemberInfo[];
minItems?: number; // minimum items for EXPRESSION_SET fields
defaultItems?: number; // default number of items for EXPRESSION_SET fields
}

export interface DropdownType extends BaseType {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,8 @@ function getCustomEntryNodeIcon(type: string) {
return "bi-mcp";
case "solace":
return "bi-solace";
case "mssql":
return "bi-mssql";
default:
return "bi-globe";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,6 @@ export const ExpressionField: React.FC<ExpressionFieldProps> = (props: Expressio
return (
<DynamicArrayBuilder
value={value}
label={field.label}
onChange={(val) => onChange(val, val.length)}
expressionFieldProps={props}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,81 +16,172 @@
* under the License.
*/

import React, { useState, useEffect, useMemo, useRef } from "react";
import styled from '@emotion/styled';
import { Button, Codicon, ThemeColors } from '@wso2/ui-toolkit';
import { ExpressionField, ExpressionFieldProps, getEditorConfiguration } from "../../ExpressionField";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Codicon, ErrorBanner, ThemeColors } from '@wso2/ui-toolkit';
import { ExpressionFieldProps } from "../../ExpressionField";
import { InputMode } from "../ChipExpressionEditor/types";
import { getPrimaryInputType } from "@wso2/ballerina-core";
import { getInputModeFromBallerinaType, getInputModeFromTypes } from "../ChipExpressionEditor/utils";
import { getInputModeFromBallerinaType } from "../ChipExpressionEditor/utils";
import { ChipExpressionEditorComponent } from "../ChipExpressionEditor/components/ChipExpressionEditor";
import { useFormContext } from "../../../../context";
import { S } from "../styles";
import { ChipExpressionEditorDefaultConfiguration } from "../ChipExpressionEditor/ChipExpressionDefaultConfig";

interface DynamicArrayBuilderProps {
label: string;
value: string | any[];
onChange: (updated: string, updatedCursorPosition: number) => void;
expressionFieldProps: ExpressionFieldProps;
}

/**
* DynamicArrayBuilder component for managing array inputs with validation.
* Supports minItems and defaultItems configuration from the field's EXPRESSION_SET type.
*/
export const DynamicArrayBuilder = (props: DynamicArrayBuilderProps) => {
const { label, value, onChange, expressionFieldProps } = props;
const { value, expressionFieldProps } = props;
const { form } = useFormContext();
const { setValue, getValues } = form;
const { setValue, setError, clearErrors, formState: { errors } } = form;

// Extract configuration from EXPRESSION_SET type definition
const expressionSetType = expressionFieldProps.field.types.find(t => t.fieldType === "EXPRESSION_SET");
const minItems = expressionSetType?.minItems ?? 1;
const defaultItems = expressionSetType?.defaultItems ?? 1;

const [isInitialized, setIsInitialized] = useState(false);
const currentValuesRef = useRef<string[]>([]);

/**
* Converts the incoming value to an array, using defaultItems if empty.
*/
const getInitialValue = (): string[] => {
if (Array.isArray(value) && value.length > 0) {
return value;
}

const isEmpty = !value ||
value === '' ||
value === '[]' ||
(Array.isArray(value) && value.length === 0);

// Use a ref to track the current editing state to avoid stale closures
const currentValuesRef = useRef<string[]>(Array.isArray(value) ? value : [""]);
return isEmpty && defaultItems > 0 ? Array(defaultItems).fill("") : [];
};

// Update ref when prop changes from parent (e.g., opening in edit mode)
// Initialize the field with default items on mount
useEffect(() => {
currentValuesRef.current = Array.isArray(value) ? value : [""];
}, [value]);
const initialValue = getInitialValue();
const shouldInitialize = initialValue.length > 0 && (
!value ||
value === '' ||
value === '[]' ||
(Array.isArray(value) && value.length === 0)
);

// Use the value from props directly (controlled by parent/form context)
if (shouldInitialize) {
currentValuesRef.current = initialValue;
setValue(expressionFieldProps.field.key, initialValue, { shouldValidate: false, shouldDirty: false });
}
setIsInitialized(true);
}, []);

// Update ref when value changes from parent
useEffect(() => {
if (isInitialized) {
currentValuesRef.current = getInitialValue();
}
}, [value, isInitialized]);

// Compute current array values
const arrayValues = useMemo(() => {
return Array.isArray(value) ? value : [""];
}, [value]);
if (!isInitialized) {
return currentValuesRef.current;
}
return getInitialValue();
}, [value, isInitialized]);

// Validate minItems constraint
useEffect(() => {
if (!isInitialized) return;

const hasNonEmptyValues = arrayValues.some(v => v && v.trim() !== '');

if (minItems > 0) {
const isInvalid = arrayValues.length < minItems || !hasNonEmptyValues;

if (isInvalid) {
setError(expressionFieldProps.field.key, {
type: 'required',
message: `At least ${minItems} ${minItems > 1 ? 'items are' : 'item is'} required with valid value${minItems > 1 ? 's' : ''}`
});
} else {
clearErrors(expressionFieldProps.field.key);
}
} else {
clearErrors(expressionFieldProps.field.key);
}
}, [arrayValues, minItems, expressionFieldProps.field.key, setError, clearErrors, isInitialized]);

// Ensure minimum number of items are always visible
useEffect(() => {
if (!isInitialized) return;

const requiredCount = Math.max(minItems, defaultItems);

if (requiredCount > 0 && arrayValues.length < requiredCount) {
const paddedArray = [...arrayValues];
while (paddedArray.length < requiredCount) {
paddedArray.push('');
}
currentValuesRef.current = paddedArray;
setValue(expressionFieldProps.field.key, paddedArray, { shouldValidate: true });
}
}, [arrayValues, isInitialized, minItems, defaultItems, expressionFieldProps.field.key, setValue]);

const handleInputChange = (index: number, newValue: string) => {
// Use the current ref value to ensure we have the latest state
const currentArray = [...currentValuesRef.current];
currentArray[index] = newValue;
currentValuesRef.current = currentArray;
setValue(expressionFieldProps.field.key, currentArray, { shouldValidate: false, shouldDirty: true });
const updatedArray = [...currentValuesRef.current];
updatedArray[index] = newValue;
currentValuesRef.current = updatedArray;
setValue(expressionFieldProps.field.key, updatedArray, { shouldValidate: true, shouldDirty: true });
};

const handleDelete = (index: number) => {
const currentArray = [...currentValuesRef.current];
const newArray = currentArray.filter((_, i) => i !== index);
currentValuesRef.current = newArray;
setValue(expressionFieldProps.field.key, newArray, { shouldValidate: false, shouldDirty: true });
const updatedArray = currentValuesRef.current.filter((_, i) => i !== index);
currentValuesRef.current = updatedArray;
setValue(expressionFieldProps.field.key, updatedArray, { shouldValidate: true, shouldDirty: true });
};

const handleAdd = () => {
const currentArray = [...currentValuesRef.current];
const newArray = [...currentArray, ''];
currentValuesRef.current = newArray;
setValue(expressionFieldProps.field.key, newArray, { shouldValidate: false, shouldDirty: true });
const updatedArray = [...currentValuesRef.current, ''];
currentValuesRef.current = updatedArray;
setValue(expressionFieldProps.field.key, updatedArray, { shouldValidate: true, shouldDirty: true });
};

const primaryInputMode = useMemo(() => {
if (!expressionFieldProps.field.types || expressionFieldProps.field.types.length === 0) {
return InputMode.EXP;
}
return getInputModeFromBallerinaType(getPrimaryInputType(expressionFieldProps.field.types).ballerinaType)
return getInputModeFromBallerinaType(getPrimaryInputType(expressionFieldProps.field.types).ballerinaType);
}, [expressionFieldProps.field.types]);

const renderError = () => {
const error = errors[expressionFieldProps.field.key];
if (!error) return null;

const errorMessage = typeof error.message === 'string'
? error.message
: String(error.message || 'Validation error');

return <ErrorBanner errorMsg={errorMessage} />;
};

return (
<S.Container>
{arrayValues.map((value, index) => (
{arrayValues.map((itemValue, index) => (
<S.ItemContainer key={`${expressionFieldProps.field.key}-${index}`}>
<ChipExpressionEditorComponent
getHelperPane={props.expressionFieldProps.getHelperPane}
isExpandedVersion={false}
completions={props.expressionFieldProps.completions}
onChange={(value) => handleInputChange(index, value)}
value={value}
value={itemValue}
sanitizedExpression={props.expressionFieldProps.sanitizedExpression}
rawExpression={props.expressionFieldProps.rawExpression}
fileName={props.expressionFieldProps.fileName}
Expand All @@ -107,20 +198,20 @@ export const DynamicArrayBuilder = (props: DynamicArrayBuilderProps) => {
<S.DeleteButton
appearance="icon"
onClick={() => handleDelete(index)}
disabled={arrayValues.length <= minItems}
>
<Codicon sx={{ color: ThemeColors.ERROR }} name="trash" />
</S.DeleteButton>
</S.ItemContainer>
))}
<Button
<S.AddButton
onClick={handleAdd}
appearance="icon"
>
<div style={{ display: 'flex', gap: '4px', padding: '4px 8px', alignItems: 'center' }}>
<Codicon name="add" />
Add Item
</div>
</Button>
<Codicon name="add" />
Add Item
</S.AddButton>
{renderError()}
</S.Container>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ export function getCustomEntryNodeIcon(type: string) {
return <Icon name="bi-mcp" />;
case "solace":
return <Icon name="bi-solace" sx={{ color: "#00C895" }}/>;
case "mssql":
return <Icon name="bi-mssql" sx={{ color: "#b61d1c" }}/>;
default:
return null;
}
Expand Down
Loading