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/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 }) => ( <> { + 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..cd380e62f156 --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/widget/derived.js @@ -0,0 +1,71 @@ +/* eslint-disable @typescript-eslint/no-unused-vars*/ +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, moment, _) => { + 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..40aeb881e4f4 --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/widget/index.tsx @@ -0,0 +1,163 @@ +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 BaseWidget from "widgets/BaseWidget"; +import isArray from "lodash/isArray"; +import type { Selection } from "@react-types/shared"; +import type { WidgetState } from "widgets/BaseWidget"; + +import { + anvilConfig, + autocompleteConfig, + defaultsConfig, + metaConfig, + methodsConfig, + propertyPaneContentConfig, + settersConfig, +} from "../config"; +import { validateInput } from "./helpers"; +import type { WDSMultiSelectWidgetProps } from "./types"; +import derivedProperties from "./parseDerivedProperties"; + +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() { + return { + options: `{{(()=>{${derivedProperties.getOptions}})()}}`, + isValid: `{{(()=>{${derivedProperties.getIsValid}})()}}`, + selectedOptionValues: `{{(()=>{${derivedProperties.getSelectedOptionValues}})()}}`, + selectedOptionLabels: `{{(()=>{${derivedProperties.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/parseDerivedProperties.ts b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/widget/parseDerivedProperties.ts new file mode 100644 index 000000000000..485a392d6da7 --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSMultiSelectWidget/widget/parseDerivedProperties.ts @@ -0,0 +1,41 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +//@ts-ignore +import widgetPropertyFns from "!!raw-loader!./derived.js"; + +// TODO(abhinav): +// Add unit test cases +// Handle edge cases +// Error out on wrong values +// TODO: Fix this the next time the file is edited +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const derivedProperties: any = {}; +// const regex = /(\w+):\s?\(props\)\s?=>\s?{([\w\W]*?)},/gim; +const regex = + /(\w+):\s?\(props, moment, _\)\s?=>\s?{([\w\W\n]*?)},\n?\s+?\/\//gim; + +let m; + +while ((m = regex.exec(widgetPropertyFns as unknown as string)) !== null) { + // This is necessary to avoid infinite loops with zero-width matches + if (m.index === regex.lastIndex) { + regex.lastIndex++; + } + + let key = ""; + + // The result can be accessed through the `m`-variable. + m.forEach((match, groupIndex) => { + if (groupIndex === 1) { + key = match; + } + + if (groupIndex === 2) { + derivedProperties[key] = match + .trim() + .replace(/\n/g, "") + .replace(/props\./g, "this."); + } + }); +} + +export default derivedProperties; 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; +} 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, diff --git a/app/client/src/widgets/index.ts b/app/client/src/widgets/index.ts index 40c11b294100..4a68c14c910d 100644 --- a/app/client/src/widgets/index.ts +++ b/app/client/src/widgets/index.ts @@ -90,6 +90,7 @@ import { WDSSelectWidget } from "modules/ui-builder/ui/wds/WDSSelectWidget"; import { WDSCustomWidget } from "modules/ui-builder/ui/wds/WDSCustomWidget"; import { EEWDSWidgets } from "ee/modules/ui-builder/ui/wds"; import { WDSDatePickerWidget } from "modules/ui-builder/ui/wds/WDSDatePickerWidget"; +import { WDSMultiSelectWidget } from "modules/ui-builder/ui/wds/WDSMultiSelectWidget"; const LegacyWidgets = [ CanvasWidget, @@ -189,6 +190,7 @@ const WDSWidgets = [ WDSSelectWidget, WDSDatePickerWidget, WDSCustomWidget, + WDSMultiSelectWidget, ]; const Widgets = [