From f06ad8560edf2eb213fe3a56d43fd84955535f58 Mon Sep 17 00:00:00 2001 From: Pawan Kumar Date: Wed, 19 Feb 2025 12:20:51 +0530 Subject: [PATCH 1/3] add multiselect widget --- .../components/Input/src/styles.module.css | 11 +- .../config/anvilConfig.ts | 11 + .../config/autocompleteConfig.ts | 22 ++ .../config/defaultsConfig.ts | 21 ++ .../wds/WDSMultiSelectWidget/config/index.ts | 7 + .../WDSMultiSelectWidget/config/metaConfig.ts | 8 + .../config/methodsConfig.ts | 65 +++++ .../propertyPaneConfig/contentConfig.tsx | 263 ++++++++++++++++++ .../config/propertyPaneConfig/index.ts | 1 + .../defaultOptionValueValidation.ts | 111 ++++++++ .../propertyPaneConfig/validations/index.ts | 1 + .../validations/labelKeyValidation.ts | 68 +++++ .../validations/valueKeyValidation.ts | 125 +++++++++ .../config/settersConfig.ts | 25 ++ .../ui/wds/WDSMultiSelectWidget/index.ts | 3 + .../WDSMultiSelectWidget/widget/constants.ts | 5 + .../WDSMultiSelectWidget/widget/derived.js | 66 +++++ .../WDSMultiSelectWidget/widget/helpers.ts | 78 ++++++ .../wds/WDSMultiSelectWidget/widget/index.tsx | 161 +++++++++++ .../wds/WDSMultiSelectWidget/widget/types.ts | 13 + 20 files changed, 1061 insertions(+), 4 deletions(-) create mode 100644 app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/anvilConfig.ts create mode 100644 app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/autocompleteConfig.ts create mode 100644 app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/defaultsConfig.ts create mode 100644 app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/index.ts create mode 100644 app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/metaConfig.ts create mode 100644 app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/methodsConfig.ts create mode 100644 app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/propertyPaneConfig/contentConfig.tsx create mode 100644 app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/propertyPaneConfig/index.ts create mode 100644 app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/propertyPaneConfig/validations/defaultOptionValueValidation.ts create mode 100644 app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/propertyPaneConfig/validations/index.ts create mode 100644 app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/propertyPaneConfig/validations/labelKeyValidation.ts create mode 100644 app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/propertyPaneConfig/validations/valueKeyValidation.ts create mode 100644 app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/settersConfig.ts create mode 100644 app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/index.ts create mode 100644 app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/widget/constants.ts create mode 100644 app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/widget/derived.js create mode 100644 app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/widget/helpers.ts create mode 100644 app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/widget/index.tsx create mode 100644 app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/widget/types.ts diff --git a/app/client/packages/design-system/widgets/src/components/Input/src/styles.module.css b/app/client/packages/design-system/widgets/src/components/Input/src/styles.module.css index 821e337af7bf..15233295dfa0 100644 --- a/app/client/packages/design-system/widgets/src/components/Input/src/styles.module.css +++ b/app/client/packages/design-system/widgets/src/components/Input/src/styles.module.css @@ -30,6 +30,13 @@ cursor: inherit; } +.inputGroup .input:not(textarea) { + block-size: calc( + var(--body-line-height) + var(--body-margin-start) + var(--body-margin-end) + ); + padding-block: var(--inner-spacing-3); +} + .inputGroup:has([data-input-prefix]) .input { padding-inline-start: 0; } @@ -196,10 +203,6 @@ * SIZE * ---------------------------------------------------------------------------- */ -.inputGroup .input { - block-size: var(--body-line-height); -} - .inputGroup .input[data-size="small"] { block-size: calc( var(--body-line-height) + var(--body-margin-start) + var(--body-margin-end) diff --git a/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/anvilConfig.ts b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/anvilConfig.ts new file mode 100644 index 000000000000..dc7fe21e103c --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/anvilConfig.ts @@ -0,0 +1,11 @@ +import type { AnvilConfig } from "WidgetProvider/constants"; + +export const anvilConfig: AnvilConfig = { + isLargeWidget: false, + widgetSize: { + minWidth: { + base: "100%", + "180px": "sizing-30", + }, + }, +}; diff --git a/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/autocompleteConfig.ts b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/autocompleteConfig.ts new file mode 100644 index 000000000000..eff823e33fb8 --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/autocompleteConfig.ts @@ -0,0 +1,22 @@ +import { DefaultAutocompleteDefinitions } from "widgets/WidgetUtils"; + +export const autocompleteConfig = { + "!doc": + "MultiSelect is used to capture user input/s from a specified list of permitted inputs. A MultiSelect can capture multiple choices", + "!url": "https://docs.appsmith.com/widget-reference/dropdown", + isVisible: DefaultAutocompleteDefinitions.isVisible, + selectedOptionValues: { + "!type": "string", + "!doc": "The values selected in a multi select dropdown", + "!url": "https://docs.appsmith.com/widget-reference/dropdown", + }, + selectedOptionLabels: { + "!type": "string", + "!doc": "The selected options's labels in a multi select dropdown", + "!url": "https://docs.appsmith.com/widget-reference/dropdown", + }, + isDisabled: "bool", + isValid: "bool", + isDirty: "bool", + options: "[$__dropdownOption__$]", +}; diff --git a/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/defaultsConfig.ts b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/defaultsConfig.ts new file mode 100644 index 000000000000..560dcc2fc39b --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/defaultsConfig.ts @@ -0,0 +1,21 @@ +import { ResponsiveBehavior } from "layoutSystems/common/utils/constants"; +import type { WidgetDefaultProps } from "WidgetProvider/constants"; +import { SAMPLE_DATA } from "../widget/constants"; + +export const defaultsConfig = { + animateLoading: true, + label: "Label", + sourceData: JSON.stringify(SAMPLE_DATA, null, 2), + optionLabel: "name", + optionValue: "code", + defaultOptionValues: "", + isRequired: false, + isDisabled: false, + isVisible: true, + isInline: false, + widgetName: "MultiSelect", + version: 1, + responsiveBehavior: ResponsiveBehavior.Fill, + dynamicPropertyPathList: [{ key: "sourceData" }], + placeholderText: "Select an item", +} as unknown as WidgetDefaultProps; diff --git a/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/index.ts b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/index.ts new file mode 100644 index 000000000000..995925903b3f --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/index.ts @@ -0,0 +1,7 @@ +export * from "./propertyPaneConfig"; +export { metaConfig } from "./metaConfig"; +export { anvilConfig } from "./anvilConfig"; +export { defaultsConfig } from "./defaultsConfig"; +export { settersConfig } from "./settersConfig"; +export { methodsConfig } from "./methodsConfig"; +export { autocompleteConfig } from "./autocompleteConfig"; diff --git a/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/metaConfig.ts b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/metaConfig.ts new file mode 100644 index 000000000000..3a601d068002 --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/metaConfig.ts @@ -0,0 +1,8 @@ +import { WIDGET_TAGS } from "constants/WidgetConstants"; + +export const metaConfig = { + name: "MultiSelect", + tags: [WIDGET_TAGS.SELECT], + needsMeta: true, + searchTags: ["choice", "option", "choose", "pick", "select", "dropdown"], +}; diff --git a/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/methodsConfig.ts b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/methodsConfig.ts new file mode 100644 index 000000000000..2f216f49a37a --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/methodsConfig.ts @@ -0,0 +1,65 @@ +import type { + PropertyUpdates, + SnipingModeProperty, +} from "WidgetProvider/constants"; +import type { + WidgetQueryConfig, + WidgetQueryGenerationFormConfig, +} from "WidgetQueryGenerators/types"; +import { RadioGroupIcon, SelectThumbnail } from "appsmith-icons"; +import type { DynamicPath } from "utils/DynamicBindingUtils"; +import type { WidgetProps } from "widgets/BaseWidget"; + +export const methodsConfig = { + getSnipingModeUpdates: ( + propValueMap: SnipingModeProperty, + ): PropertyUpdates[] => { + return [ + { + propertyPath: "sourceData", + propertyValue: propValueMap.data, + isDynamicPropertyPath: true, + }, + ]; + }, + getQueryGenerationConfig(widget: WidgetProps) { + return { + select: { + where: `${widget.widgetName}.filterText`, + }, + }; + }, + getPropertyUpdatesForQueryBinding( + queryConfig: WidgetQueryConfig, + widget: WidgetProps, + formConfig: WidgetQueryGenerationFormConfig, + ) { + let modify; + + const dynamicPropertyPathList: DynamicPath[] = [ + ...(widget.dynamicPropertyPathList || []), + ]; + + if (queryConfig.select) { + modify = { + sourceData: queryConfig.select.data, + optionLabel: formConfig.aliases.find((d) => d.name === "label")?.alias, + optionValue: formConfig.aliases.find((d) => d.name === "value")?.alias, + defaultOptionValues: "", + serverSideFiltering: false, + onFilterUpdate: queryConfig.select.run, + }; + + dynamicPropertyPathList.push({ key: "sourceData" }); + } + + return { + modify, + dynamicUpdates: { + dynamicPropertyPathList, + }, + }; + }, + IconCmp: RadioGroupIcon, + ThumbnailCmp: SelectThumbnail, +}; diff --git a/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/propertyPaneConfig/contentConfig.tsx b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/propertyPaneConfig/contentConfig.tsx new file mode 100644 index 000000000000..43fdf69c8aff --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/propertyPaneConfig/contentConfig.tsx @@ -0,0 +1,263 @@ +import React from "react"; +import { ValidationTypes } from "constants/WidgetValidation"; +import { AutocompleteDataType } from "utils/autocomplete/AutocompleteDataType"; +import type { WDSMultiSelectWidgetProps } from "../../widget/types"; +import { defaultOptionValueValidation } from "./validations"; +import { valueKeyValidation } from "./validations/valueKeyValidation"; +import { + defaultValueExpressionPrefix, + getDefaultValueExpressionSuffix, + getLabelValueAdditionalAutocompleteData, + getLabelValueKeyOptions, + getOptionLabelValueExpressionPrefix, + optionLabelValueExpressionSuffix, +} from "../../widget/helpers"; +import { labelKeyValidation } from "./validations/labelKeyValidation"; +import { Flex } from "@appsmith/ads"; +import { SAMPLE_DATA } from "../../widget/constants"; +import { EvaluationSubstitutionType } from "ee/entities/DataTree/types"; + +export const propertyPaneContentConfig = [ + { + sectionName: "Data", + children: [ + { + helpText: + "Takes in an array of objects to display options. Bind data from an API using {{}}", + propertyName: "sourceData", + label: "Source Data", + controlType: "ONE_CLICK_BINDING_CONTROL", + controlConfig: { + aliases: [ + { + name: "label", + isSearcheable: true, + isRequired: true, + }, + { + name: "value", + isRequired: true, + }, + ], + sampleData: JSON.stringify(SAMPLE_DATA, null, 2), + }, + isJSConvertible: true, + placeholderText: '[{ "label": "label1", "value": "value1" }]', + isBindProperty: true, + isTriggerProperty: false, + validation: { + type: ValidationTypes.ARRAY, + params: { + children: { + type: ValidationTypes.OBJECT, + params: { + required: true, + }, + }, + }, + }, + evaluationSubstitutionType: EvaluationSubstitutionType.SMART_SUBSTITUTE, + }, + { + helpText: "Choose or set a field from source data as the display label", + propertyName: "optionLabel", + label: "Label key", + controlType: "DROP_DOWN", + customJSControl: "WRAPPED_CODE_EDITOR", + controlConfig: { + wrapperCode: { + prefix: getOptionLabelValueExpressionPrefix, + suffix: optionLabelValueExpressionSuffix, + }, + }, + placeholderText: "", + isBindProperty: true, + isTriggerProperty: false, + isJSConvertible: true, + evaluatedDependencies: ["sourceData"], + options: getLabelValueKeyOptions, + alwaysShowSelected: true, + validation: { + type: ValidationTypes.FUNCTION, + params: { + fn: labelKeyValidation, + expected: { + type: "String or Array", + example: `color | ["blue", "green"]`, + autocompleteDataType: AutocompleteDataType.STRING, + }, + }, + }, + additionalAutoComplete: getLabelValueAdditionalAutocompleteData, + }, + { + helpText: "Choose or set a field from source data as the value", + propertyName: "optionValue", + label: "Value key", + controlType: "DROP_DOWN", + customJSControl: "WRAPPED_CODE_EDITOR", + controlConfig: { + wrapperCode: { + prefix: getOptionLabelValueExpressionPrefix, + suffix: optionLabelValueExpressionSuffix, + }, + }, + placeholderText: "", + isBindProperty: true, + isTriggerProperty: false, + isJSConvertible: true, + evaluatedDependencies: ["sourceData"], + options: getLabelValueKeyOptions, + alwaysShowSelected: true, + validation: { + type: ValidationTypes.FUNCTION, + params: { + fn: valueKeyValidation, + expected: { + type: "String or Array", + example: `color | [1, "orange"]`, + autocompleteDataType: AutocompleteDataType.STRING, + }, + }, + }, + additionalAutoComplete: getLabelValueAdditionalAutocompleteData, + }, + { + helpText: "Selects the options with value by default", + propertyName: "defaultOptionValues", + label: "Default selected values", + controlType: "WRAPPED_CODE_EDITOR", + controlConfig: { + wrapperCode: { + prefix: defaultValueExpressionPrefix, + suffix: getDefaultValueExpressionSuffix, + }, + }, + placeholderText: "Default selected values", + isBindProperty: true, + isTriggerProperty: false, + validation: { + type: ValidationTypes.FUNCTION, + params: { + fn: defaultOptionValueValidation, + expected: { + type: "Array of values", + example: ` "option1, option2" | ['option1', 'option2'] | [{ "label": "label1", "value": "value1" }]`, + autocompleteDataType: AutocompleteDataType.ARRAY, + }, + }, + }, + dependencies: ["options"], + helperText: ( + + Make sure the default values are present in the source data to have + them selected by default in the UI. + + ), + }, + ], + }, + { + sectionName: "Label", + children: [ + { + helpText: "Sets the label text of the options widget", + propertyName: "label", + label: "Text", + controlType: "INPUT_TEXT", + placeholderText: "Label", + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.TEXT }, + }, + ], + }, + { + sectionName: "Validations", + children: [ + { + propertyName: "isRequired", + label: "Required", + helpText: "Makes input to the widget mandatory", + controlType: "SWITCH", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.BOOLEAN }, + }, + ], + }, + { + sectionName: "General", + children: [ + { + helpText: "Show help text or details about current input", + propertyName: "labelTooltip", + label: "Tooltip", + controlType: "INPUT_TEXT", + placeholderText: "", + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.TEXT }, + }, + { + helpText: "Sets a placeholder text for the select", + propertyName: "placeholderText", + label: "Placeholder", + controlType: "INPUT_TEXT", + placeholderText: "", + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.TEXT }, + hidden: (props: WDSMultiSelectWidgetProps) => { + return Boolean(props.isReadOnly); + }, + }, + { + helpText: "Controls the visibility of the widget", + propertyName: "isVisible", + label: "Visible", + controlType: "SWITCH", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.BOOLEAN }, + }, + { + propertyName: "isDisabled", + label: "Disabled", + helpText: "Disables input to this widget", + controlType: "SWITCH", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.BOOLEAN }, + }, + { + propertyName: "animateLoading", + label: "Animate loading", + controlType: "SWITCH", + helpText: "Controls the loading of the widget", + defaultValue: true, + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.BOOLEAN }, + }, + ], + }, + { + sectionName: "Events", + children: [ + { + helpText: "when a user changes the selected option", + propertyName: "onSelectionChange", + label: "onSelectionChange", + controlType: "ACTION_SELECTOR", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: true, + }, + ], + }, +]; diff --git a/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/propertyPaneConfig/index.ts b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/propertyPaneConfig/index.ts new file mode 100644 index 000000000000..7f43d3bde57a --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/propertyPaneConfig/index.ts @@ -0,0 +1 @@ +export { propertyPaneContentConfig } from "./contentConfig"; diff --git a/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/propertyPaneConfig/validations/defaultOptionValueValidation.ts b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/propertyPaneConfig/validations/defaultOptionValueValidation.ts new file mode 100644 index 000000000000..e8b8ac30a732 --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/propertyPaneConfig/validations/defaultOptionValueValidation.ts @@ -0,0 +1,111 @@ +import type { LoDashStatic } from "lodash"; +import type { ValidationResponse } from "constants/WidgetValidation"; +import type { WDSMultiSelectWidgetProps } from "../../../widget/types"; + +interface LabelValue { + label: string; + value: string; +} + +export function defaultOptionValueValidation( + value: unknown, + props: WDSMultiSelectWidgetProps, + _: LoDashStatic, +): ValidationResponse { + let isValid = false; + let parsed: LabelValue[] | (string | number)[] = []; + let message = { name: "", message: "" }; + + const DEFAULT_ERROR_MESSAGE = { + name: "TypeError", + message: + "value should match: Array | Array<{label: string, value: string | number}>", + }; + + const hasLabelValue = (obj: LabelValue): obj is LabelValue => { + return ( + _.isPlainObject(obj) && + obj.hasOwnProperty("label") && + obj.hasOwnProperty("value") && + _.isString(obj.label) && + (_.isString(obj.value) || _.isFinite(obj.value)) + ); + }; + + const hasUniqueValues = (arr: Array) => { + const uniqueValues = new Set(arr); + + return uniqueValues.size === arr.length; + }; + + // When value is "['green', 'red']", "[{label: 'green', value: 'green'}]" and "green, red" + if (_.isString(value) && value.trim() !== "") { + try { + // when value is "['green', 'red']", "[{label: 'green', value: 'green'}]" + const parsedValue = JSON.parse(value) as + | (string | number)[] + | LabelValue[]; + + // Only parse value if resulting value is an array or string + if (Array.isArray(parsedValue) || _.isString(parsedValue)) { + value = parsedValue; + } + } catch (e) { + // when value is "green, red", JSON.parse throws error + const splitByComma = (value as string).split(",") || []; + + value = splitByComma.map((s) => s.trim()); + } + } + + // When value is "['green', 'red']", "[{label: 'green', value: 'green'}]" and "green, red" + if (Array.isArray(value)) { + if (value.every((val) => _.isString(val) || _.isFinite(val))) { + // When value is ["green", "red"] + if (hasUniqueValues(value)) { + isValid = true; + parsed = value; + } else { + parsed = []; + message = { + name: "ValidationError", + message: "values must be unique. Duplicate values found", + }; + } + } else if (value.every(hasLabelValue)) { + // When value is [{label: "green", value: "red"}] + if (hasUniqueValues(value.map((val) => val.value))) { + isValid = true; + parsed = value; + } else { + parsed = []; + message = { + name: "ValidationError", + message: "path:value must be unique. Duplicate values found", + }; + } + } else { + // When value is [true, false], [undefined, undefined] etc. + parsed = []; + message = DEFAULT_ERROR_MESSAGE; + } + } else if (_.isString(value) && value.trim() === "") { + // When value is an empty string + isValid = true; + parsed = []; + } else if (_.isNumber(value) || _.isString(value)) { + // When value is a number or just a single string e.g "Blue" + isValid = true; + parsed = [value]; + } else { + // When value is undefined, null, {} etc. + parsed = []; + message = DEFAULT_ERROR_MESSAGE; + } + + return { + isValid, + parsed, + messages: [message], + }; +} diff --git a/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/propertyPaneConfig/validations/index.ts b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/propertyPaneConfig/validations/index.ts new file mode 100644 index 000000000000..b7dcdeafc390 --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/propertyPaneConfig/validations/index.ts @@ -0,0 +1 @@ +export { defaultOptionValueValidation } from "./defaultOptionValueValidation"; diff --git a/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/propertyPaneConfig/validations/labelKeyValidation.ts b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/propertyPaneConfig/validations/labelKeyValidation.ts new file mode 100644 index 000000000000..355376a602a8 --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/propertyPaneConfig/validations/labelKeyValidation.ts @@ -0,0 +1,68 @@ +import type { LoDashStatic } from "lodash"; +import type { ValidationResponse } from "constants/WidgetValidation"; + +import type { WDSMultiSelectWidgetProps } from "../../../widget/types"; + +/** + * Validation rules: + * 1. Can be a string + * 2. Can be an Array of strings + */ +export function labelKeyValidation( + value: unknown, + props: WDSMultiSelectWidgetProps, + _: LoDashStatic, +): ValidationResponse { + if (value === "" || _.isNil(value)) { + return { + parsed: "", + isValid: false, + messages: [ + { + name: "ValidationError", + message: "Value cannot be empty or null", + }, + ], + }; + } + + // Handle string values + if (_.isString(value)) { + return { + parsed: value, + isValid: true, + messages: [{ name: "", message: "" }], + }; + } + + // Handle array values + if (_.isArray(value)) { + const errorIndex = value.findIndex((item) => !_.isString(item)); + const isValid = errorIndex === -1; + + return { + parsed: isValid ? value : [], + isValid, + messages: [ + { + name: isValid ? "" : "ValidationError", + message: isValid + ? "" + : `Invalid entry at index: ${errorIndex}. Value must be a string`, + }, + ], + }; + } + + // Handle invalid types + return { + parsed: "", + isValid: false, + messages: [ + { + name: "ValidationError", + message: "Value must be a string or an array of strings", + }, + ], + }; +} diff --git a/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/propertyPaneConfig/validations/valueKeyValidation.ts b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/propertyPaneConfig/validations/valueKeyValidation.ts new file mode 100644 index 000000000000..f7d5e04b03d9 --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/propertyPaneConfig/validations/valueKeyValidation.ts @@ -0,0 +1,125 @@ +import type { LoDashStatic } from "lodash"; +import type { WDSMultiSelectWidgetProps } from "../../../widget/types"; + +/** + * Validation rules: + * 1. Can be a string (representing a key in sourceData) + * 2. Can be an Array of string, number, boolean (for direct option values) + * 3. Values must be unique + */ +export function valueKeyValidation( + value: unknown, + props: WDSMultiSelectWidgetProps, + _: LoDashStatic, +) { + if (value === "" || _.isNil(value)) { + return { + parsed: "", + isValid: false, + messages: [ + { + name: "ValidationError", + message: `value does not evaluate to type: string | Array`, + }, + ], + }; + } + + let options: unknown[] = []; + + if (_.isString(value)) { + const sourceData = _.isArray(props.sourceData) ? props.sourceData : []; + + const keys = sourceData.reduce( + (keys: Set, curr: Record) => { + Object.keys(curr).forEach((d) => keys.add(d)); + + return keys; + }, + new Set(), + ); + + if (!keys.has(value)) { + return { + parsed: value, + isValid: false, + messages: [ + { + name: "ValidationError", + message: `value key should be present in the source data`, + }, + ], + }; + } + + options = sourceData.map((d: Record) => d[value]); + } else if (_.isArray(value)) { + // Here assumption is that if evaluated array is all equal, then it is a key, + // and we can return the parsed value(from source data) as the options. + const areAllValuesEqual = value.every((item, _, arr) => item === arr[0]); + + if ( + areAllValuesEqual && + props.sourceData[0].hasOwnProperty(String(value[0])) + ) { + const parsedValue = props.sourceData.map( + (d: Record) => d[String(value[0])], + ); + + return { + parsed: parsedValue, + isValid: true, + messages: [], + }; + } + + const errorIndex = value.findIndex( + (d) => + !(_.isString(d) || (_.isNumber(d) && !_.isNaN(d)) || _.isBoolean(d)), + ); + + if (errorIndex !== -1) { + return { + parsed: [], + isValid: false, + messages: [ + { + name: "ValidationError", + message: `Invalid entry at index: ${errorIndex}. This value does not evaluate to type: string | number | boolean`, + }, + ], + }; + } else { + options = value; + } + } else { + return { + parsed: "", + isValid: false, + messages: [ + { + name: "ValidationError", + message: + "value does not evaluate to type: string | Array", + }, + ], + }; + } + + const isValid = options.every( + (d: unknown, i: number, arr: unknown[]) => arr.indexOf(d) === i, + ); + + return { + parsed: value, + isValid: isValid, + messages: isValid + ? [] + : [ + { + name: "ValidationError", + message: "Duplicate values found, value must be unique", + }, + ], + }; +} diff --git a/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/settersConfig.ts b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/settersConfig.ts new file mode 100644 index 000000000000..c04c93608989 --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/config/settersConfig.ts @@ -0,0 +1,25 @@ +export const settersConfig = { + __setters: { + setVisibility: { + path: "isVisible", + type: "boolean", + }, + setDisabled: { + path: "isDisabled", + type: "boolean", + }, + setRequired: { + path: "isRequired", + type: "boolean", + }, + setOptions: { + path: "options", + type: "array", + }, + setSelectedOption: { + path: "defaultOptionValues", + type: "array", + accessor: "selectedOptionValues", + }, + }, +}; diff --git a/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/index.ts b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/index.ts new file mode 100644 index 000000000000..d0edfddf90d8 --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/index.ts @@ -0,0 +1,3 @@ +import { WDSMultiSelectWidget } from "./widget"; + +export { WDSMultiSelectWidget }; diff --git a/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/widget/constants.ts b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/widget/constants.ts new file mode 100644 index 000000000000..ecea66e71d7f --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/widget/constants.ts @@ -0,0 +1,5 @@ +export const SAMPLE_DATA = [ + { name: "Blue", code: "BLUE" }, + { name: "Green", code: "GREEN" }, + { name: "Red", code: "RED" }, +]; diff --git a/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/widget/derived.js b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/widget/derived.js new file mode 100644 index 000000000000..0352bda15efc --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/widget/derived.js @@ -0,0 +1,66 @@ +export default { + getOptions: (props, moment, _) => { + let labels = [], + values = [], + sourceData = props.sourceData || []; + + if (typeof props.optionLabel === "string") { + labels = sourceData.map((d) => d[props.optionLabel]); + } else if (_.isArray(props.optionLabel)) { + labels = props.optionLabel; + } + + if (typeof props.optionValue === "string") { + values = sourceData.map((d) => d[props.optionValue]); + } else if (_.isArray(props.optionValue)) { + values = props.optionValue; + } + + return sourceData.map((d, i) => ({ + label: labels[i], + value: values[i], + })); + }, + getIsValid: (props, moment, _) => { + return props.isRequired + ? !_.isNil(props.selectedOptionValues) && + props.selectedOptionValues.length !== 0 + : true; + }, + getSelectedOptionValues: (props) => { + const options = props.options ?? []; + const selectedOptions = props.selectedOptions ?? []; + + const values = selectedOptions.map((o) => o.value ?? o); + const valuesInOptions = options.map((o) => o.value); + const filteredValues = values.filter((value) => + valuesInOptions.includes(value), + ); + + if (!props.isDirty && filteredValues.length !== values.length) { + return filteredValues; + } + + return values; + }, + getSelectedOptionLabels: (props, moment, _) => { + const values = props.selectedOptionValues; + const selectedOptions = props.selectedOptions ?? []; + + const options = props.options ?? []; + + return values + .map((value) => { + const label = options.find((option) => value === option.value)?.label; + + if (label) { + return label; + } else { + return selectedOptions.find( + (option) => value === (option.value ?? option), + )?.label; + } + }) + .filter((val) => !_.isNil(val)); + }, +}; diff --git a/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/widget/helpers.ts b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/widget/helpers.ts new file mode 100644 index 000000000000..3a0e1b698f5d --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/widget/helpers.ts @@ -0,0 +1,78 @@ +import get from "lodash/get"; +import uniq from "lodash/uniq"; +import isArray from "lodash/isArray"; +import isString from "lodash/isString"; +import isPlainObject from "lodash/isPlainObject"; +import type { WidgetProps } from "widgets/BaseWidget"; + +import type { Validation } from "modules/ui-builder/ui/wds/WDSInputWidget/widget/types"; +import type { WDSMultiSelectWidgetProps } from "./types"; + +import { EVAL_VALUE_PATH } from "utils/DynamicBindingUtils"; + +export function validateInput(props: WDSMultiSelectWidgetProps): Validation { + if (!props.isValid) { + return { + validationStatus: "invalid", + errorMessage: "Please select an option", + }; + } + + return { + validationStatus: "valid", + errorMessage: "", + }; +} + +export function getLabelValueKeyOptions(widget: WidgetProps) { + const sourceData = get(widget, `${EVAL_VALUE_PATH}.sourceData`); + + let parsedValue: Record | undefined = sourceData; + + if (isString(sourceData)) { + try { + parsedValue = JSON.parse(sourceData); + } catch (e) {} + } + + if (isArray(parsedValue)) { + return uniq( + parsedValue.reduce((keys, obj) => { + if (isPlainObject(obj)) { + Object.keys(obj).forEach((d) => keys.push(d)); + } + + return keys; + }, []), + ).map((d: unknown) => ({ + label: d, + value: d, + })); + } else { + return []; + } +} + +export function getLabelValueAdditionalAutocompleteData(props: WidgetProps) { + const keys = getLabelValueKeyOptions(props); + + return { + item: keys + .map((d) => d.label) + .reduce((prev: Record, curr: unknown) => { + prev[curr as string] = ""; + + return prev; + }, {}), + }; +} + +export const defaultValueExpressionPrefix = `{{ ((options, serverSideFiltering) => ( `; + +export const getDefaultValueExpressionSuffix = (widget: WidgetProps) => + `))(${widget.widgetName}.options, ${widget.widgetName}.serverSideFiltering) }}`; + +export const getOptionLabelValueExpressionPrefix = (widget: WidgetProps) => + `{{${widget.widgetName}.sourceData.map((item) => (`; + +export const optionLabelValueExpressionSuffix = `))}}`; diff --git a/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/widget/index.tsx b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/widget/index.tsx new file mode 100644 index 000000000000..b9b2073e0d40 --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/widget/index.tsx @@ -0,0 +1,161 @@ +import { MultiSelect } from "@appsmith/wds"; +import { EventType } from "constants/AppsmithActionConstants/ActionConstants"; +import type { SetterConfig, Stylesheet } from "entities/AppTheming"; +import React from "react"; +import type { + AnvilConfig, + AutocompletionDefinitions, +} from "WidgetProvider/constants"; +import type { WidgetState } from "widgets/BaseWidget"; +import BaseWidget from "widgets/BaseWidget"; +import { + anvilConfig, + autocompleteConfig, + defaultsConfig, + metaConfig, + methodsConfig, + propertyPaneContentConfig, + settersConfig, +} from "../config"; +import { validateInput } from "./helpers"; +import type { WDSMultiSelectWidgetProps } from "./types"; +import derivedPropertyFns from "./derived"; +import { parseDerivedProperties } from "widgets/WidgetUtils"; +import isArray from "lodash/isArray"; +import type { Selection } from "@react-types/shared"; + +class WDSMultiSelectWidget extends BaseWidget< + WDSMultiSelectWidgetProps, + WidgetState +> { + static type = "WDS_MULTI_SELECT_WIDGET"; + + static getConfig() { + return metaConfig; + } + + static getDefaults() { + return defaultsConfig; + } + + static getMethods() { + return methodsConfig; + } + + static getAnvilConfig(): AnvilConfig | null { + return anvilConfig; + } + + static getDependencyMap(): Record { + return { + optionLabel: ["sourceData"], + optionValue: ["sourceData"], + }; + } + + static getAutocompleteDefinitions(): AutocompletionDefinitions { + return autocompleteConfig; + } + + static getPropertyPaneContentConfig() { + return propertyPaneContentConfig; + } + + static getPropertyPaneStyleConfig() { + return []; + } + + static getDerivedPropertiesMap() { + const parsedDerivedProperties = parseDerivedProperties(derivedPropertyFns); + + return { + options: `{{(()=>{${parsedDerivedProperties.getOptions}})()}}`, + isValid: `{{(()=>{${parsedDerivedProperties.getIsValid}})()}}`, + selectedOptionValues: `{{(()=>{${parsedDerivedProperties.getSelectedOptionValues}})()}}`, + selectedOptionLabels: `{{(()=>{${parsedDerivedProperties.getSelectedOptionLabels}})()}}`, + value: `{{this.selectedOptionValues}}`, + }; + } + + static getDefaultPropertiesMap(): Record { + return { + selectedOptionValues: "defaultOptionValues", + }; + } + + static getMetaPropertiesMap() { + return { + selectedOptionValues: undefined, + isDirty: false, + }; + } + + static getStylesheetConfig(): Stylesheet { + return {}; + } + + // in case default value changes, we need to reset isDirty to false + componentDidUpdate(prevProps: WDSMultiSelectWidgetProps): void { + if ( + this.props.defaultOptionValues !== prevProps.defaultOptionValues && + this.props.isDirty + ) { + this.props.updateWidgetMetaProperty("isDirty", false); + } + } + + static getSetterConfig(): SetterConfig { + return settersConfig; + } + + onSelectionChange = (updatedValues: Selection) => { + const { commitBatchMetaUpdates, pushBatchMetaUpdates } = this.props; + + if (!this.props.isDirty) { + this.props.updateWidgetMetaProperty("isDirty", true); + } + + pushBatchMetaUpdates("selectedOptionValues", [...updatedValues], { + triggerPropertyName: "onSelectionChange", + dynamicString: this.props.onSelectionChange, + event: { + type: EventType.ON_OPTION_CHANGE, + }, + }); + + commitBatchMetaUpdates(); + }; + + getWidgetView() { + const { labelTooltip, placeholderText, selectedOptionValues, ...rest } = + this.props; + const validation = validateInput(this.props); + const options = (isArray(this.props.options) ? this.props.options : []) as { + value: string; + label: string; + }[]; + // This is key is used to force re-render of the widget when the options change. + // Why force re-render on options change? + // When the user is changing options from propety pane, the select throws an error ( related to react-aria code ) saying "cannot change id of item" due + // change in options's id. + const key = options.map((option) => option.value).join(","); + + return ( + + ); + } +} + +export { WDSMultiSelectWidget }; diff --git a/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/widget/types.ts b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/widget/types.ts new file mode 100644 index 000000000000..0a46944e3010 --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/widget/types.ts @@ -0,0 +1,13 @@ +import type { WidgetProps } from "widgets/BaseWidget"; + +export interface WDSMultiSelectWidgetProps extends WidgetProps { + options: Record[] | string; + selectedOptionValues: Set; + onSelectionChange: string; + defaultOptionValues: string; + isRequired?: boolean; + isDisabled?: boolean; + label: string; + labelTooltip?: string; + isDirty: boolean; +} From 7187282b0dcaeb72191b63a65e1121e48628255f Mon Sep 17 00:00:00 2001 From: Pawan Kumar Date: Wed, 19 Feb 2025 12:22:26 +0530 Subject: [PATCH 2/3] add widget in index file --- .../widgets/src/components/MultiSelect/src/MultiSelect.tsx | 6 +++++- app/client/src/widgets/index.ts | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/client/packages/design-system/widgets/src/components/MultiSelect/src/MultiSelect.tsx b/app/client/packages/design-system/widgets/src/components/MultiSelect/src/MultiSelect.tsx index 4cf77ff138b0..9626f3b33539 100644 --- a/app/client/packages/design-system/widgets/src/components/MultiSelect/src/MultiSelect.tsx +++ b/app/client/packages/design-system/widgets/src/components/MultiSelect/src/MultiSelect.tsx @@ -154,7 +154,11 @@ export const MultiSelect = ( shouldFocusWrap > {(item: T) => ( - + {({ isSelected }) => ( <> Date: Wed, 19 Feb 2025 12:25:03 +0530 Subject: [PATCH 3/3] add widget in constants --- app/client/src/modules/ui-builder/ui/wds/constants.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/client/src/modules/ui-builder/ui/wds/constants.ts b/app/client/src/modules/ui-builder/ui/wds/constants.ts index 6d5d01726e30..7370f7655ee4 100644 --- a/app/client/src/modules/ui-builder/ui/wds/constants.ts +++ b/app/client/src/modules/ui-builder/ui/wds/constants.ts @@ -63,6 +63,7 @@ export const WDS_V2_WIDGET_MAP = { WDS_COMBOBOX_WIDGET: "WDS_COMBOBOX_WIDGET", WDS_DATEPICKER_WIDGET: "WDS_DATEPICKER_WIDGET", WDS_CUSTOM_WIDGET: "WDS_CUSTOM_WIDGET", + WDS_MULTI_SELECT_WIDGET: "WDS_MULTI_SELECT_WIDGET", // Anvil layout widgets ZONE_WIDGET: anvilWidgets.ZONE_WIDGET,