Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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/new-trees-wonder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@ensembleui/react-kitchen-sink": patch
"@ensembleui/react-runtime": patch
---

support item-template in MultiSelect widget
34 changes: 23 additions & 11 deletions apps/kitchen-sink/src/ensemble/screens/forms.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -225,11 +225,17 @@ View:
- MultiSelect:
id: multiselectoptions2
label: Choose multiple
data:
- label: Option 1
value: option1
- label: Option 2
value: option2
item-template:
data: ${getData.body.users}
name: user
value: ${user.email}
template:
Column:
children:
- Text:
text: ${user.firstName} ${user.lastName}
- Text:
text: ${user.email}
- Date:
htmlAttributes:
data-testid: date0-test
Expand Down Expand Up @@ -414,12 +420,18 @@ View:
- MultiSelect:
id: initial_multiselectoptions2
label: Choose multiple
value: option2
data:
- label: Option 1
value: option1
- label: Option 2
value: option2
value: ${[getData.body.users[0].email, getData.body.users[1].email]}
item-template:
data: ${getData.body.users}
name: user
value: ${user.email}
template:
Column:
children:
- Text:
text: ${user.firstName} ${user.lastName}
- Text:
text: ${user.email}
# - Date:
# id: initail_date
# label: Date
Expand Down
60 changes: 53 additions & 7 deletions packages/runtime/src/widgets/Form/MultiSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,21 @@ import React, {
useRef,
useState,
} from "react";
import type { Expression, EnsembleAction } from "@ensembleui/react-framework";
import type {
Expression,
EnsembleAction,
CustomScope,
} from "@ensembleui/react-framework";
import {
CustomScopeProvider,
defaultScreenContext,
evaluate,
unwrapWidget,
useRegisterBindings,
useTemplateData,
} from "@ensembleui/react-framework";
import { PlusCircleOutlined } from "@ant-design/icons";
import { Select as SelectComponent, Space, Form } from "antd";
import { Select as SelectComponent, Space, Form, Select } from "antd";
import {
get,
isArray,
Expand All @@ -29,6 +36,7 @@ import { useEnsembleAction } from "../../runtime/hooks/useEnsembleAction";
import type {
EnsembleWidgetProps,
EnsembleWidgetStyles,
HasItemTemplate,
} from "../../shared/types";
import { EnsembleRuntime } from "../../runtime";
import { getComponentStyles } from "../../shared/styles";
Expand Down Expand Up @@ -75,10 +83,12 @@ export type MultiSelectProps = {
maxTagTextLength: Expression<number>;
notFoundContent?: Expression<string> | { [key: string]: unknown };
} & EnsembleWidgetProps<MultiSelectStyles> &
FormInputProps<object[] | string[]>;
HasItemTemplate & {
"item-template"?: { value: Expression<string> };
} & FormInputProps<object[] | string[]>;

const MultiSelect: React.FC<MultiSelectProps> = (props) => {
const { data, ...rest } = props;
const { data, "item-template": itemTemplate, ...rest } = props;
const [options, setOptions] = useState<MultiSelectOption[]>([]);
const [newOption, setNewOption] = useState("");
const [selectedValues, setSelectedValues] = useState<MultiSelectOption[]>();
Expand All @@ -89,6 +99,10 @@ const MultiSelect: React.FC<MultiSelectProps> = (props) => {
const onSearchAction = useEnsembleAction(props.onSearch);

const { rawData } = useTemplateData({ data });
const { namedData } = useTemplateData({
data: itemTemplate?.data,
name: itemTemplate?.name,
});
const { id, rootRef, values } = useRegisterBindings(
{ ...rest, initialValue: props.value, selectedValues, options, widgetName },
props.id,
Expand Down Expand Up @@ -156,6 +170,34 @@ const MultiSelect: React.FC<MultiSelectProps> = (props) => {
setOptions(tempOptions);
}, [rawData, values?.labelKey, values?.valueKey, values?.items]);

const renderOptions = useMemo(() => {
if (isObject(itemTemplate) && !isEmpty(namedData)) {
const tempOptions = namedData.map((item: unknown) => {
const value = evaluate<string | number>(
defaultScreenContext,
itemTemplate.value,
{
[itemTemplate.name]: get(item, itemTemplate.name) as unknown,
},
);
return (
<Select.Option
className={`${values?.id || ""}_option`}
key={value}
value={value}
>
<CustomScopeProvider value={item as CustomScope}>
{EnsembleRuntime.render([itemTemplate.template])}
</CustomScopeProvider>
</Select.Option>
);
});
return tempOptions;
}

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

// handle form instance
const formInstance = Form.useFormInstance();
useEffect(() => {
Expand Down Expand Up @@ -288,6 +330,9 @@ const MultiSelect: React.FC<MultiSelectProps> = (props) => {
.${id}_input .ant-select-selector {
${getComponentStyles("multiSelect", values?.styles, true, true) as string}
}
.${id}_input .ant-select-selector .ant-select-selection-item {
height: auto !important;
}
.ant-select-item.ant-select-item-option.${id}_option[aria-selected="true"]
{
${
Expand Down Expand Up @@ -359,7 +404,6 @@ const MultiSelect: React.FC<MultiSelectProps> = (props) => {
dropdownStyle={values?.styles}
filterOption={props.onSearch ? false : handleFilterOption}
id={values?.id}
labelRender={labelRender}
maxCount={values?.maxCount ? toNumber(values.maxCount) : undefined}
maxTagCount={
values?.maxTagCount as number | "responsive" | undefined
Expand All @@ -374,7 +418,7 @@ const MultiSelect: React.FC<MultiSelectProps> = (props) => {
onChange={handleChange}
onSearch={handleSearch} // required for display new custom option with Dropdown element
optionFilterProp="children"
options={options}
{...(options.length > 0 ? { options, labelRender } : {})}
placeholder={
values?.hintText ? (
<span style={{ ...values.hintStyle }}>{values.hintText}</span>
Expand All @@ -383,7 +427,9 @@ const MultiSelect: React.FC<MultiSelectProps> = (props) => {
)
}
value={values?.selectedValues}
/>
>
{renderOptions}
</SelectComponent>
</EnsembleFormItem>
</div>
</>
Expand Down
173 changes: 173 additions & 0 deletions packages/runtime/src/widgets/Form/__tests__/MultiSelect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -645,5 +645,178 @@ describe("MultiSelect Widget", () => {
expect(screen.getByText("Anothe...")).toBeInTheDocument();
});
});

test("supports item-template for custom option rendering", async () => {
render(
<Form
children={[
{
name: "MultiSelect",
properties: {
id: "templateMultiSelect",
label: "Choose User",
"item-template": {
data: [
{
id: 1,
name: "John Doe",
email: "john@example.com",
role: "admin",
},
{
id: 2,
name: "Jane Smith",
email: "jane@example.com",
role: "user",
},
{
id: 3,
name: "Bob Johnson",
email: "bob@example.com",
role: "moderator",
},
],
name: "user",
value: `\${user.id}`,
template: {
name: "Column",
properties: {
children: [
{
name: "Text",
properties: {
text: `\${user.name}`,
styles: {
fontWeight: "bold",
},
},
},
{
name: "Text",
properties: {
text: `\${user.email}`,
styles: {
fontSize: 12,
color: "gray",
},
},
},
],
},
},
},
},
},
...defaultFormButton,
]}
id="form"
/>,
{ wrapper: FormTestWrapper },
);

userEvent.click(screen.getByRole("combobox"));

await waitFor(() => {
expect(screen.getByText("John Doe")).toBeInTheDocument();
expect(screen.getByText("john@example.com")).toBeInTheDocument();
expect(screen.getByText("Jane Smith")).toBeInTheDocument();
expect(screen.getByText("jane@example.com")).toBeInTheDocument();
expect(screen.getByText("Bob Johnson")).toBeInTheDocument();
expect(screen.getByText("bob@example.com")).toBeInTheDocument();
});

userEvent.click(screen.getByText("John Doe"));
userEvent.click(screen.getByText("Jane Smith"));

userEvent.click(screen.getByRole("combobox"));

const getValueButton = screen.getByText("Get Value");
fireEvent.click(getValueButton);

await waitFor(() => {
expect(logSpy).toHaveBeenCalledWith(
expect.objectContaining({ templateMultiSelect: [1, 2] }),
);
});
});

test("supports item-template with initial selected values", async () => {
render(
<Form
children={[
{
name: "MultiSelect",
properties: {
id: "templateMultiSelectWithInitial",
label: "Choose User",
value: `\${[2, 3]}`, // pre-select Jane and Bob
"item-template": {
data: [
{
id: 1,
name: "John Doe",
email: "john@example.com",
role: "admin",
},
{
id: 2,
name: "Jane Smith",
email: "jane@example.com",
role: "user",
},
{
id: 3,
name: "Bob Johnson",
email: "bob@example.com",
role: "moderator",
},
],
name: "user",
value: `\${user.id}`,
template: {
name: "Column",
properties: {
children: [
{
name: "Text",
properties: {
text: `\${user.name} (\${user.role})`,
},
},
{ name: "Text", properties: { text: `\${user.email}` } },
],
},
},
},
},
},
...defaultFormButton,
]}
id="form"
/>,
{ wrapper: FormTestWrapper },
);

const getValueButton = screen.getByText("Get Value");
fireEvent.click(getValueButton);

await waitFor(() => {
expect(logSpy).toHaveBeenCalledWith(
expect.objectContaining({ templateMultiSelectWithInitial: [2, 3] }),
);
});

userEvent.click(screen.getByRole("combobox"));

await waitFor(() => {
const johnDoeOptions = screen.getAllByText("john@example.com");
const janeSmithOptions = screen.getAllByText("Jane Smith (user)");
const bobJohnsonOptions = screen.getAllByText("Bob Johnson (moderator)");

expect(johnDoeOptions.length).toBeGreaterThan(0);
expect(janeSmithOptions.length).toBeGreaterThan(0);
expect(bobJohnsonOptions.length).toBeGreaterThan(0);
});
});
});
/* eslint-enable react/no-children-prop */