Skip to content

Commit 9264504

Browse files
committed
support item-template in MultiSelect widget
1 parent 5cc2fba commit 9264504

4 files changed

Lines changed: 256 additions & 18 deletions

File tree

.changeset/new-trees-wonder.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@ensembleui/react-kitchen-sink": patch
3+
"@ensembleui/react-runtime": patch
4+
---
5+
6+
support item-template in MultiSelect widget

apps/kitchen-sink/src/ensemble/screens/forms.yaml

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -225,11 +225,17 @@ View:
225225
- MultiSelect:
226226
id: multiselectoptions2
227227
label: Choose multiple
228-
data:
229-
- label: Option 1
230-
value: option1
231-
- label: Option 2
232-
value: option2
228+
item-template:
229+
data: ${getData.body.users}
230+
name: user
231+
value: ${user.email}
232+
template:
233+
Column:
234+
children:
235+
- Text:
236+
text: ${user.firstName} ${user.lastName}
237+
- Text:
238+
text: ${user.email}
233239
- Date:
234240
htmlAttributes:
235241
data-testid: date0-test
@@ -414,12 +420,18 @@ View:
414420
- MultiSelect:
415421
id: initial_multiselectoptions2
416422
label: Choose multiple
417-
value: option2
418-
data:
419-
- label: Option 1
420-
value: option1
421-
- label: Option 2
422-
value: option2
423+
value: ${[getData.body.users[0].email, getData.body.users[1].email]}
424+
item-template:
425+
data: ${getData.body.users}
426+
name: user
427+
value: ${user.email}
428+
template:
429+
Column:
430+
children:
431+
- Text:
432+
text: ${user.firstName} ${user.lastName}
433+
- Text:
434+
text: ${user.email}
423435
# - Date:
424436
# id: initail_date
425437
# label: Date

packages/runtime/src/widgets/Form/MultiSelect.tsx

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,21 @@ import React, {
66
useRef,
77
useState,
88
} from "react";
9-
import type { Expression, EnsembleAction } from "@ensembleui/react-framework";
9+
import type {
10+
Expression,
11+
EnsembleAction,
12+
CustomScope,
13+
} from "@ensembleui/react-framework";
1014
import {
15+
CustomScopeProvider,
16+
defaultScreenContext,
17+
evaluate,
1118
unwrapWidget,
1219
useRegisterBindings,
1320
useTemplateData,
1421
} from "@ensembleui/react-framework";
1522
import { PlusCircleOutlined } from "@ant-design/icons";
16-
import { Select as SelectComponent, Space, Form } from "antd";
23+
import { Select as SelectComponent, Space, Form, Select } from "antd";
1724
import {
1825
get,
1926
isArray,
@@ -29,6 +36,7 @@ import { useEnsembleAction } from "../../runtime/hooks/useEnsembleAction";
2936
import type {
3037
EnsembleWidgetProps,
3138
EnsembleWidgetStyles,
39+
HasItemTemplate,
3240
} from "../../shared/types";
3341
import { EnsembleRuntime } from "../../runtime";
3442
import { getComponentStyles } from "../../shared/styles";
@@ -75,10 +83,12 @@ export type MultiSelectProps = {
7583
maxTagTextLength: Expression<number>;
7684
notFoundContent?: Expression<string> | { [key: string]: unknown };
7785
} & EnsembleWidgetProps<MultiSelectStyles> &
78-
FormInputProps<object[] | string[]>;
86+
HasItemTemplate & {
87+
"item-template"?: { value: Expression<string> };
88+
} & FormInputProps<object[] | string[]>;
7989

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

91101
const { rawData } = useTemplateData({ data });
102+
const { namedData } = useTemplateData({
103+
data: itemTemplate?.data,
104+
name: itemTemplate?.name,
105+
});
92106
const { id, rootRef, values } = useRegisterBindings(
93107
{ ...rest, initialValue: props.value, selectedValues, options, widgetName },
94108
props.id,
@@ -156,6 +170,35 @@ const MultiSelect: React.FC<MultiSelectProps> = (props) => {
156170
setOptions(tempOptions);
157171
}, [rawData, values?.labelKey, values?.valueKey, values?.items]);
158172

173+
const renderOptions = useMemo(() => {
174+
if (isObject(itemTemplate) && !isEmpty(namedData)) {
175+
const tempOptions = namedData.map((item: unknown) => {
176+
const value = evaluate<string | number>(
177+
defaultScreenContext,
178+
itemTemplate.value,
179+
{
180+
[itemTemplate.name]: get(item, itemTemplate.name) as unknown,
181+
},
182+
);
183+
console.log("value", value);
184+
return (
185+
<Select.Option
186+
className={`${values?.id || ""}_option`}
187+
key={value}
188+
value={value}
189+
>
190+
<CustomScopeProvider value={item as CustomScope}>
191+
{EnsembleRuntime.render([itemTemplate.template])}
192+
</CustomScopeProvider>
193+
</Select.Option>
194+
);
195+
});
196+
return tempOptions;
197+
}
198+
199+
return [];
200+
}, [itemTemplate, namedData, values?.id]);
201+
159202
// handle form instance
160203
const formInstance = Form.useFormInstance();
161204
useEffect(() => {
@@ -288,6 +331,9 @@ const MultiSelect: React.FC<MultiSelectProps> = (props) => {
288331
.${id}_input .ant-select-selector {
289332
${getComponentStyles("multiSelect", values?.styles, true, true) as string}
290333
}
334+
.${id}_input .ant-select-selector .ant-select-selection-item {
335+
height: auto !important;
336+
}
291337
.ant-select-item.ant-select-item-option.${id}_option[aria-selected="true"]
292338
{
293339
${
@@ -359,7 +405,6 @@ const MultiSelect: React.FC<MultiSelectProps> = (props) => {
359405
dropdownStyle={values?.styles}
360406
filterOption={props.onSearch ? false : handleFilterOption}
361407
id={values?.id}
362-
labelRender={labelRender}
363408
maxCount={values?.maxCount ? toNumber(values.maxCount) : undefined}
364409
maxTagCount={
365410
values?.maxTagCount as number | "responsive" | undefined
@@ -374,7 +419,7 @@ const MultiSelect: React.FC<MultiSelectProps> = (props) => {
374419
onChange={handleChange}
375420
onSearch={handleSearch} // required for display new custom option with Dropdown element
376421
optionFilterProp="children"
377-
options={options}
422+
{...(options.length > 0 ? { options, labelRender } : {})}
378423
placeholder={
379424
values?.hintText ? (
380425
<span style={{ ...values.hintStyle }}>{values.hintText}</span>
@@ -383,7 +428,9 @@ const MultiSelect: React.FC<MultiSelectProps> = (props) => {
383428
)
384429
}
385430
value={values?.selectedValues}
386-
/>
431+
>
432+
{renderOptions}
433+
</SelectComponent>
387434
</EnsembleFormItem>
388435
</div>
389436
</>

packages/runtime/src/widgets/Form/__tests__/MultiSelect.test.tsx

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,5 +645,178 @@ describe("MultiSelect Widget", () => {
645645
expect(screen.getByText("Anothe...")).toBeInTheDocument();
646646
});
647647
});
648+
649+
test("supports item-template for custom option rendering", async () => {
650+
render(
651+
<Form
652+
children={[
653+
{
654+
name: "MultiSelect",
655+
properties: {
656+
id: "templateMultiSelect",
657+
label: "Choose User",
658+
"item-template": {
659+
data: [
660+
{
661+
id: 1,
662+
name: "John Doe",
663+
email: "john@example.com",
664+
role: "admin",
665+
},
666+
{
667+
id: 2,
668+
name: "Jane Smith",
669+
email: "jane@example.com",
670+
role: "user",
671+
},
672+
{
673+
id: 3,
674+
name: "Bob Johnson",
675+
email: "bob@example.com",
676+
role: "moderator",
677+
},
678+
],
679+
name: "user",
680+
value: `\${user.id}`,
681+
template: {
682+
name: "Column",
683+
properties: {
684+
children: [
685+
{
686+
name: "Text",
687+
properties: {
688+
text: `\${user.name}`,
689+
styles: {
690+
fontWeight: "bold",
691+
},
692+
},
693+
},
694+
{
695+
name: "Text",
696+
properties: {
697+
text: `\${user.email}`,
698+
styles: {
699+
fontSize: 12,
700+
color: "gray",
701+
},
702+
},
703+
},
704+
],
705+
},
706+
},
707+
},
708+
},
709+
},
710+
...defaultFormButton,
711+
]}
712+
id="form"
713+
/>,
714+
{ wrapper: FormTestWrapper },
715+
);
716+
717+
userEvent.click(screen.getByRole("combobox"));
718+
719+
await waitFor(() => {
720+
expect(screen.getByText("John Doe")).toBeInTheDocument();
721+
expect(screen.getByText("john@example.com")).toBeInTheDocument();
722+
expect(screen.getByText("Jane Smith")).toBeInTheDocument();
723+
expect(screen.getByText("jane@example.com")).toBeInTheDocument();
724+
expect(screen.getByText("Bob Johnson")).toBeInTheDocument();
725+
expect(screen.getByText("bob@example.com")).toBeInTheDocument();
726+
});
727+
728+
userEvent.click(screen.getByText("John Doe"));
729+
userEvent.click(screen.getByText("Jane Smith"));
730+
731+
userEvent.click(screen.getByRole("combobox"));
732+
733+
const getValueButton = screen.getByText("Get Value");
734+
fireEvent.click(getValueButton);
735+
736+
await waitFor(() => {
737+
expect(logSpy).toHaveBeenCalledWith(
738+
expect.objectContaining({ templateMultiSelect: [1, 2] }),
739+
);
740+
});
741+
});
742+
743+
test("supports item-template with initial selected values", async () => {
744+
render(
745+
<Form
746+
children={[
747+
{
748+
name: "MultiSelect",
749+
properties: {
750+
id: "templateMultiSelectWithInitial",
751+
label: "Choose User",
752+
value: `\${[2, 3]}`, // pre-select Jane and Bob
753+
"item-template": {
754+
data: [
755+
{
756+
id: 1,
757+
name: "John Doe",
758+
email: "john@example.com",
759+
role: "admin",
760+
},
761+
{
762+
id: 2,
763+
name: "Jane Smith",
764+
email: "jane@example.com",
765+
role: "user",
766+
},
767+
{
768+
id: 3,
769+
name: "Bob Johnson",
770+
email: "bob@example.com",
771+
role: "moderator",
772+
},
773+
],
774+
name: "user",
775+
value: `\${user.id}`,
776+
template: {
777+
name: "Column",
778+
properties: {
779+
children: [
780+
{
781+
name: "Text",
782+
properties: {
783+
text: `\${user.name} (\${user.role})`,
784+
},
785+
},
786+
{ name: "Text", properties: { text: `\${user.email}` } },
787+
],
788+
},
789+
},
790+
},
791+
},
792+
},
793+
...defaultFormButton,
794+
]}
795+
id="form"
796+
/>,
797+
{ wrapper: FormTestWrapper },
798+
);
799+
800+
const getValueButton = screen.getByText("Get Value");
801+
fireEvent.click(getValueButton);
802+
803+
await waitFor(() => {
804+
expect(logSpy).toHaveBeenCalledWith(
805+
expect.objectContaining({ templateMultiSelectWithInitial: [2, 3] }),
806+
);
807+
});
808+
809+
userEvent.click(screen.getByRole("combobox"));
810+
811+
await waitFor(() => {
812+
const johnDoeOptions = screen.getAllByText("john@example.com");
813+
const janeSmithOptions = screen.getAllByText("Jane Smith (user)");
814+
const bobJohnsonOptions = screen.getAllByText("Bob Johnson (moderator)");
815+
816+
expect(johnDoeOptions.length).toBeGreaterThan(0);
817+
expect(janeSmithOptions.length).toBeGreaterThan(0);
818+
expect(bobJohnsonOptions.length).toBeGreaterThan(0);
819+
});
820+
});
648821
});
649822
/* eslint-enable react/no-children-prop */

0 commit comments

Comments
 (0)