Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/quiet-bulldogs-tan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@ensembleui/react-kitchen-sink": patch
"@ensembleui/react-runtime": patch
---

add support in Dropdown for creating new options using allowCreateOptions prop
14 changes: 14 additions & 0 deletions apps/kitchen-sink/src/ensemble/screens/forms.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,20 @@ View:
onChange:
executeCode: |
console.log('dropdown changed', value);
- Dropdown:
id: dropdownWithCreateOptions
label: Dropdown with create options
hintText: Try typing "New Option"
allowCreateOptions: true
value: option2
items:
- label: Option 1
value: option1
- label: Option 2
value: option2
onChange:
executeCode: |
console.log('dropdownWithCreateOptions changed', value);
# If you omit id, the form value key will be the label
- TextInput:
id: formTextInput
Expand Down
136 changes: 126 additions & 10 deletions packages/runtime/src/widgets/Form/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { Select, Form } from "antd";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Select, Form, Space } from "antd";
import { PlusCircleOutlined } from "@ant-design/icons";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
CustomScopeProvider,
evaluate,
Expand All @@ -26,6 +33,7 @@ import { getComponentStyles } from "../../shared/styles";
import type { HasBorder } from "../../shared/hasSchema";
import type { FormInputProps } from "./types";
import { EnsembleFormItem } from "./FormItem";
import { updateFieldValue } from "./Form";

const widgetName = "Dropdown";

Expand Down Expand Up @@ -56,6 +64,7 @@ export type DropdownProps = {
autoComplete: Expression<boolean>;
hintStyle?: EnsembleWidgetStyles;
panel?: { [key: string]: unknown };
allowCreateOptions?: boolean;
} & EnsembleWidgetProps<DropdownStyles> &
HasItemTemplate & {
"item-template"?: { value: Expression<string> };
Expand All @@ -64,11 +73,49 @@ export type DropdownProps = {
const DropdownRenderer = (
menu: React.ReactElement,
panel?: { [key: string]: unknown },
newOption?: string,
onAddNewOption?: (value: string) => void,
): React.ReactElement => {
const panelOption = useMemo(() => {
return panel ? EnsembleRuntime.render([unwrapWidget(panel)]) : null;
}, []);

// if we have a new option to add, show custom content along with the menu
if (newOption) {
return (
<>
<div style={{ padding: "10px 15px" }}>
<span>There are no matches</span>
</div>
<Space
style={{
padding: "10px 15px",
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
width: "100%",
cursor: "pointer",
}}
onClick={() => onAddNewOption?.(newOption)}
>
<Space>
<PlusCircleOutlined />
<span>{`Add "${newOption}"`}</span>
</Space>
</Space>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{panelOption}
</div>
</>
);
}

return (
<>
{menu}
Expand All @@ -89,8 +136,10 @@ const Dropdown: React.FC<DropdownProps> = (props) => {
const [selectedValue, setSelectedValue] = useState<
string | number | undefined
>();

const [newOption, setNewOption] = useState("");
const [isOpen, setIsOpen] = useState<boolean>(false);
const [items, setItems] = useState<SelectOption[]>([]);
const initializedRef = useRef(false);
const {
"item-template": itemTemplate,
onChange,
Expand Down Expand Up @@ -124,8 +173,21 @@ const Dropdown: React.FC<DropdownProps> = (props) => {
(value?: number | string) => {
setSelectedValue(value);
onChangeAction?.callback({ value });

// if the selected value is a new option that doesn't exist in items, add it
if (value && values?.allowCreateOptions) {
const optionExists = items.find((item) => item.value === value);
if (!optionExists) {
const newItem: SelectOption = {
label: value.toString(),
value: value,
};
setItems((prevItems) => [...prevItems, newItem]);
}
}
setNewOption("");
},
[onChangeAction?.callback],
[onChangeAction?.callback, values?.allowCreateOptions, items],
);

const onItemSelectAction = useEnsembleAction(onItemSelect);
Expand All @@ -137,16 +199,60 @@ const Dropdown: React.FC<DropdownProps> = (props) => {
[onItemSelectAction?.callback],
);

const handleSearch = (value: string): void => {
if (!values?.allowCreateOptions) {
return;
}

const isOptionExist = items.find((option) =>
option.label.toString().toLowerCase().includes(value.toLowerCase()),
);

if (!isOptionExist && value.trim()) {
setNewOption(value);
} else {
setNewOption("");
}
};

const handleAddNewOption = useCallback(
(value: string) => {
const newItem: SelectOption = {
label: value,
value: value,
};
setItems((prevItems) => [...prevItems, newItem]);

setSelectedValue(value);
onChangeAction?.callback({ value });

// trigger form's onChange action
updateFieldValue(formInstance, values?.id ?? values?.label ?? "", value);

setNewOption("");
setIsOpen(false);
},
[onChangeAction?.callback],
);

const { namedData } = useTemplateData({
data: itemTemplate?.data,
name: itemTemplate?.name,
});

// initialize items from props only once
useEffect(() => {
if (values?.items && !initializedRef.current) {
setItems(values.items);
initializedRef.current = true;
}
}, [values?.items]);

const options = useMemo(() => {
let dropdownOptions: React.ReactNode[] | null = null;

if (values?.items) {
const tempOptions = values.items.map((item) => {
if (items.length > 0) {
const tempOptions = items.map((item) => {
if (item.type === "group") {
// Render a group item with sub-items
return (
Expand All @@ -166,7 +272,7 @@ const Dropdown: React.FC<DropdownProps> = (props) => {
}
return (
<Select.Option
className={`${values.id || ""}_option`}
className={`${values?.id || ""}_option`}
key={item.value}
value={item.value}
>
Expand Down Expand Up @@ -206,7 +312,7 @@ const Dropdown: React.FC<DropdownProps> = (props) => {
}

return dropdownOptions;
}, [values?.items, namedData, itemTemplate]);
}, [items, namedData, itemTemplate]);

const { backgroundColor: _, ...formItemStyles } = values?.styles ?? {};

Expand Down Expand Up @@ -303,17 +409,24 @@ const Dropdown: React.FC<DropdownProps> = (props) => {
<div ref={rootRef} style={{ flex: 1, ...formItemStyles }}>
<EnsembleFormItem values={values}>
<Select
key={`${id}-${items.length}`}
allowClear={values?.allowClear ?? true}
className={`${values?.styles?.names || ""} ${id}_input`}
defaultValue={values?.value}
disabled={values?.enabled === false}
dropdownRender={(menu): React.ReactElement =>
DropdownRenderer(menu, values?.panel)
DropdownRenderer(
menu,
values?.panel,
newOption,
handleAddNewOption,
)
}
dropdownStyle={values?.styles}
id={values?.id}
onChange={handleChange}
onDropdownVisibleChange={(state): void => setIsOpen(state)}
onSearch={values?.allowCreateOptions ? handleSearch : undefined}
onSelect={onItemSelectCallback}
open={isOpen}
placeholder={
Expand All @@ -323,7 +436,10 @@ const Dropdown: React.FC<DropdownProps> = (props) => {
""
)
}
showSearch={Boolean(values?.autoComplete)}
showSearch={
Boolean(values?.autoComplete) ||
Boolean(values?.allowCreateOptions)
}
value={values?.selectedValue}
>
{options}
Expand Down
43 changes: 42 additions & 1 deletion packages/runtime/src/widgets/Form/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
import { Form as AntForm } from "antd";
import type { FormProps as AntFormProps } from "antd";
import { useCallback, useState } from "react";
import type { FormLayout } from "antd/es/form/Form";
import type { FormInstance, FormLayout } from "antd/es/form/Form";
import { set } from "lodash-es";
import { WidgetRegistry } from "../../registry";
import { EnsembleRuntime } from "../../runtime";
Expand Down Expand Up @@ -129,3 +129,44 @@ const getLayout = (labelPosition?: string): FormLayout => {
};

WidgetRegistry.register(widgetName, Form);

/**
* Programmatically update the value of a field and trigger Ant Design's `onFieldsChange`
* (or Ensemble Form's `onChange` action).
*
* Normally, calling `form.setFieldValue` or `form.setFieldsValue` does not fire `onFieldsChange`.
* This helper uses Ant Design's internal `dispatch` mechanism to register the update
* as if it were user-driven, so that `onFieldsChange` fires consistently.
*
* This is especially useful for widgets that update values programmatically
* (e.g. Dropdown with `allowCreateOptions`).
*
* Alternatively, one could use Ant Design's `Form.useWatch`, but this approach
* is less performant for large forms.
*
* ⚠️ Relies on Ant Design's private API (`RC_FORM_INTERNAL_HOOKS`), which may change
* in future versions.
*
* Reference: https://github.com/ant-design/ant-design/issues/23782#issuecomment-2114700558
*/
export function updateFieldValue<Values>(
form: FormInstance<Values>,
name: string,
value: unknown,
): void {
type InternalForm = FormInstance<Values> & {
getInternalHooks: (hook: string) => {
dispatch: (action: {
type: string;
namePath: string[];
value: unknown;
}) => void;
};
};

(form as InternalForm).getInternalHooks("RC_FORM_INTERNAL_HOOKS").dispatch({
type: "updateValue",
namePath: [name],
value,
});
}
Loading