From d9f1bcfce5e22febbb92b36d7544101344785b05 Mon Sep 17 00:00:00 2001 From: Fabien Lelaquais <86590727+FabienLelaquais@users.noreply.github.com> Date: Thu, 7 Sep 2023 20:09:27 +0200 Subject: [PATCH] Add the range selection capability to the slider element (#905) * Add support for array of numbers in slider * Support for range of lov values * Add minimal tests on range * Picked from PR#906 to fix tests * Make linters happy --- Pipfile | 12 +- gui/dom/package-lock.json | 6 +- gui/packaging/taipy-gui.d.ts | 2 +- gui/src/components/Taipy/Slider.spec.tsx | 14 ++ gui/src/components/Taipy/Slider.tsx | 184 +++++++++++++++-------- gui/src/components/Taipy/tableUtils.tsx | 2 +- pytest.ini | 1 + setup.py | 12 +- src/taipy/gui/renderers/builder.py | 14 +- src/taipy/gui/renderers/factory.py | 2 +- src/taipy/gui/types.py | 7 +- src/taipy/gui/utils/__init__.py | 1 + src/taipy/gui/utils/types.py | 15 ++ src/taipy/gui/viselements.json | 4 +- tests/taipy/gui/control/test_slider.py | 11 ++ tox.ini | 3 - 16 files changed, 201 insertions(+), 89 deletions(-) diff --git a/Pipfile b/Pipfile index 073e1e28d..19e029d0e 100644 --- a/Pipfile +++ b/Pipfile @@ -6,12 +6,12 @@ name = "pypi" [packages] "backports.zoneinfo" = {version="==0.2.1", markers="python_version < '3.9'", extras=["tzdata"]} flask = "==2.2.5" -flask-cors = "==3.0.10" -flask-socketio = "==5.3.0" -gevent = "==22.10.2" +flask-cors = "==4.0.0" +flask-socketio = "==5.3.6" +gevent = "==23.7.0" gevent-websocket = "==0.10.1" kthread = "==0.2.3" -markdown = "==3.4.1" +markdown = "==3.4.4" pandas = "==2.0.0" pyarrow = "==10.0.1" pyngrok = "==5.1" @@ -19,11 +19,11 @@ python-dotenv = "==0.19" python-magic = {version = "==0.4.24", markers="sys_platform != 'win32'"} python-magic-bin = {version = "==0.4.14", markers="sys_platform == 'win32'"} pytz = "==2021.3" -simple-websocket = "==0.9" +simple-websocket = "==0.10.1" taipy-config = {ref = "develop", git = "https://github.com/avaiga/taipy-config.git"} tzlocal = "==3.0" gitignore-parser = "==0.1.1" -twisted = "==22.10.0" +twisted = "==23.8.0" [dev-packages] black = "*" diff --git a/gui/dom/package-lock.json b/gui/dom/package-lock.json index 2c9efb083..940fe168e 100644 --- a/gui/dom/package-lock.json +++ b/gui/dom/package-lock.json @@ -1,12 +1,12 @@ { "name": "taipy-gui-dom", - "version": "2.3.0", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "taipy-gui-dom", - "version": "2.3.0", + "version": "3.0.0", "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0", @@ -15,7 +15,7 @@ }, "../packaging": { "name": "taipy-gui", - "version": "2.3.0" + "version": "3.0.0" }, "node_modules/js-tokens": { "version": "4.0.0", diff --git a/gui/packaging/taipy-gui.d.ts b/gui/packaging/taipy-gui.d.ts index b4943fd2d..57921919f 100644 --- a/gui/packaging/taipy-gui.d.ts +++ b/gui/packaging/taipy-gui.d.ts @@ -105,7 +105,7 @@ export interface TaipyTableProps extends TaipyActiveProps, TaipyMultiSelectProps nanValue?: string; filter?: boolean; size?: "small" | "medium"; - userData?: string; + userData?: unknown; } export interface TaipyPaginatedTableProps extends TaipyTableProps { pageSizeOptions?: string; diff --git a/gui/src/components/Taipy/Slider.spec.tsx b/gui/src/components/Taipy/Slider.spec.tsx index 86df21fc9..5db1c661d 100644 --- a/gui/src/components/Taipy/Slider.spec.tsx +++ b/gui/src/components/Taipy/Slider.spec.tsx @@ -82,6 +82,13 @@ describe("Slider Component", () => { type: "SEND_UPDATE_ACTION", }); }); + it("holds a numeric range", async () => { + const { getByDisplayValue } = render(); + const elt1 = getByDisplayValue("10"); + expect(elt1.tagName).toBe("INPUT"); + const elt2 = getByDisplayValue("90"); + expect(elt2.tagName).toBe("INPUT"); + }); it("shows discrete value when lov", async () => { const { getAllByText } = render( { expect(elts).toHaveLength(1); expect(elts[0].tagName).toBe("P"); }); + it("holds a lov range", async () => { + const { getByDisplayValue } = render(); + const elt1 = getByDisplayValue("1"); + expect(elt1.tagName).toBe("INPUT"); + const elt2 = getByDisplayValue("2"); + expect(elt2.tagName).toBe("INPUT"); + }); it("shows marks", async () => { const { getAllByText } = render( { +interface SliderProps extends LovProps { width?: string; height?: string; min?: number; @@ -37,6 +37,8 @@ interface SliderProps extends LovProps { changeDelay?: number; } +const emptyString = () => "" + const Slider = (props: SliderProps) => { const { id, @@ -50,10 +52,10 @@ const Slider = (props: SliderProps) => { updateVars = "", valueById, } = props; - const [value, setValue] = useState(0); + const [value, setValue] = useState(0); const dispatch = useDispatch(); const delayCall = useRef(-1); - const lastVal = useRef(0); + const lastVal = useRef(0); const module = useModule(); const className = useClassNames(props.libClassName, props.dynamicClassName, props.className); @@ -68,11 +70,24 @@ const Slider = (props: SliderProps) => { const max = lovList.length ? lovList.length - 1 : props.max; const horizontalOrientation = props.orientation ? props.orientation.charAt(0).toLowerCase() !== "v" : true; - const handleRange = useCallback( + // Converts the slider value (number or array of numbers) to a proper backend value + // depending on whether we're dealing with a lov or not + const convertValue = useCallback( + (v: number | number[]): number | string | number[] | string[] => + Array.isArray(v) + ? (lovList.length + ? v.map((i) => lovList.length > (i as number) ? lovList[i as number].id : lovList[0].id) + : v) + : (lovList.length && lovList.length > (v as number) ? lovList[v as number].id : v), + [lovList]) + + const handleChange = useCallback( (e: Event, val: number | number[]) => { - setValue(val as number); + setValue(val); if (update) { - lastVal.current = lovList.length && lovList.length > (val as number) ? lovList[val as number].id : val as number; + lastVal.current = convertValue(val) + // Similar invocations of createSendUpdateAction(), but they happen at different + // points in time. if (changeDelay) { if (delayCall.current < 0) { delayCall.current = window.setTimeout(() => { @@ -86,31 +101,23 @@ const Slider = (props: SliderProps) => { delayCall.current = 0; } }, - [lovList, update, updateVarName, dispatch, propagate, updateVars, valueById, props.onChange, changeDelay, module] + [update, updateVarName, dispatch, propagate, updateVars, valueById, props.onChange, changeDelay, module, convertValue] ); - const handleRangeCommitted = useCallback( + const handleChangeCommitted = useCallback( (e: Event | SyntheticEvent, val: number | number[]) => { - setValue(val as number); + setValue(val); if (!update) { - const value = lovList.length && lovList.length > (val as number) ? lovList[val as number].id : val; - dispatch( - createSendUpdateAction( - updateVarName, - value, - module, - props.onChange, - propagate, - valueById ? undefined : getUpdateVar(updateVars, "lov") - ) - ); + const converted_value = convertValue(val) + dispatch(createSendUpdateAction(updateVarName, converted_value, module, props.onChange, propagate, valueById ? undefined : getUpdateVar(updateVars, "lov"))) } }, - [lovList, update, updateVarName, dispatch, propagate, updateVars, valueById, props.onChange, module] + [update, updateVarName, dispatch, propagate, updateVars, valueById, props.onChange, module, convertValue] ); const getLabel = useCallback( - (value: number) => + (value: number | number[] | null) => + value === null || Array.isArray(value) ? null : lovList.length && lovList.length > value ? ( typeof lovList[value].item === "string" ? ( {lovList[value].item as string} @@ -124,7 +131,7 @@ const Slider = (props: SliderProps) => { ); const getText = useCallback( - (value: number, before: boolean) => { + (value: number|number[], before: boolean) => { if (lovList.length) { if (before && (textAnchor === "top" || textAnchor === "left")) { return getLabel(value); @@ -138,6 +145,7 @@ const Slider = (props: SliderProps) => { [lovList, textAnchor, getLabel] ); + const marks = useMemo(() => { if (props.labels) { if (typeof props.labels === "boolean") { @@ -193,59 +201,111 @@ const Slider = (props: SliderProps) => { return { ...sx, display: "inline-block" }; }, [lovList, horizontalOrientation, textAnchor, width, props.height]); - useEffect(() => { - if (props.value === undefined) { - let val = 0; - if (defaultValue !== undefined) { - if (typeof defaultValue === "string") { - if (lovList.length) { - try { - const arrVal = JSON.parse(defaultValue) as string[]; - val = lovList.findIndex((item) => item.id === arrVal[0]); - val = val === -1 ? 0 : val; - } catch (e) { - // Too bad also - } - } else { - try { - val = parseInt(defaultValue, 10); - } catch (e) { - // too bad + // Parse the default value once and for all + const parsedDefaultValue = useMemo(() => { + if (defaultValue === undefined) { + return 0 + } + if (typeof defaultValue === "string") { + if (lovList.length) { + try { + const arr = JSON.parse(defaultValue) as string[]; + if (arr.length > 1) { + return arr.map((i) => lovList.findIndex((j) => j.id === i)) + // Force unknown values to index 0 + .map((v) => v === -1 ? 0 : v) + } + else { + const val = lovList.findIndex((item) => item.id === arr[0]) + return val === -1 ? 0 : val + } + } + catch (e) { + throw new Error("Slider lov value couldn't be parsed"); + } + } + else { + const val = Number(defaultValue) + if (isNaN(val)) { + try { + const arr = JSON.parse(defaultValue) as number[] + if (arr.some(isNaN)) { + throw new Error("Slider values should all be numbers") } + return arr + } catch (e) { + // Invalid values + return 0 } - } else { - val = defaultValue as number; + } + else { + return val } } - setValue(val); + } + return defaultValue as number + }, [defaultValue, lovList]) + + useEffect(() => { + if (props.value === undefined) { + if (parsedDefaultValue !== undefined) { + setValue(parsedDefaultValue) + } } else { if (lovList.length) { - const val = lovList.findIndex((item) => item.id === props.value); - setValue(val === -1 ? 0 : val); + if (Array.isArray(props.value)) { + setValue(props.value.map((i) => lovList.findIndex((j) => j.id === i)) + // Force unknown values to index 0 + .map((v) => v === -1 ? 0 : v)) + } + else { + const val = lovList.findIndex((item) => item.id === props.value); + setValue(val === -1 ? 0 : val); + } } else { - setValue(props.value as number); + setValue(Array.isArray(props.value) ? props.value as number[] : props.value as number); } } - }, [props.value, lovList, defaultValue]); + }, [props.value, lovList, parsedDefaultValue, convertValue]); return ( {getText(value, true)} - + {Array.isArray(parsedDefaultValue) + ? + + : + + } {getText(value, false)} diff --git a/gui/src/components/Taipy/tableUtils.tsx b/gui/src/components/Taipy/tableUtils.tsx index 1c8a3081f..0ca11556b 100644 --- a/gui/src/components/Taipy/tableUtils.tsx +++ b/gui/src/components/Taipy/tableUtils.tsx @@ -112,7 +112,7 @@ export interface TaipyTableProps extends TaipyActiveProps, TaipyMultiSelectProps filter?: boolean; size?: "small" | "medium"; defaultKey?: string; // for testing purposes only - userData?: string; + userData?: unknown; } export type PageSizeOptionsType = ( diff --git a/pytest.ini b/pytest.ini index 3b218b0c6..18d925765 100644 --- a/pytest.ini +++ b/pytest.ini @@ -6,5 +6,6 @@ filterwarnings = ignore::DeprecationWarning:twisted ignore::DeprecationWarning:pandas ignore::DeprecationWarning:numpy + ignore::FutureWarning:pyarrow markers = teste2e:End-to-end tests diff --git a/setup.py b/setup.py index d03cd811f..1a8641d20 100644 --- a/setup.py +++ b/setup.py @@ -30,21 +30,21 @@ requirements = [ "flask>=2.2,<2.3", - "flask-cors>=3.0.10,<4.0", - "flask-socketio>=5.3.0,<6.0", - "markdown>=3.4.1,<4.0", + "flask-cors>=4.0.0,<5.0", + "flask-socketio>=5.3.6,<6.0", + "markdown>=3.4.4,<4.0", "pandas>=2.0.0,<3.0", "python-dotenv>=0.19,<0.21", "pytz>=2021.3,<2022.2", "tzlocal>=3.0,<5.0", "backports.zoneinfo>=0.2.1,<0.3;python_version<'3.9'", - "gevent>=22.10.2,<23.0", + "gevent>=23.7.0,<24.0", "gevent-websocket>=0.10.1,<0.11", "kthread>=0.2.3,<0.3", "taipy-config@git+https://git@github.com/Avaiga/taipy-config.git@develop", "gitignore-parser>=0.1,<0.2", - "simple-websocket>=0.9,<1.0", - "twisted>=22.10", + "simple-websocket>=0.10.1,<1.0", + "twisted>=23.8.0,<24.0", ] test_requirements = ["pytest>=3.8"] diff --git a/src/taipy/gui/renderers/builder.py b/src/taipy/gui/renderers/builder.py index 5a2baf26d..25046825c 100644 --- a/src/taipy/gui/renderers/builder.py +++ b/src/taipy/gui/renderers/builder.py @@ -717,9 +717,17 @@ def set_value_and_default( default_val (optional(Any)): the default value. """ var_name = self.__default_property_name if var_name is None else var_name - if var_type == PropertyType.number_or_lov_value: - var_type = PropertyType.lov_value if self.__attributes.get("lov") else PropertyType.dynamic_number - native_type = native_type if var_type == PropertyType.dynamic_number else False + if var_type == PropertyType.slider_value: + if self.__attributes.get("lov"): + var_type = PropertyType.lov_value + native_type = False + else: + var_type = ( + PropertyType.dynamic_lo_numbers + if isinstance(self.__attributes.get("value"), list) + else PropertyType.dynamic_number + ) + native_type = True if var_type == PropertyType.dynamic_boolean: return self.set_attributes([(var_name, var_type, bool(default_val), with_update)]) if hash_name := self.__hashes.get(var_name): diff --git a/src/taipy/gui/renderers/factory.py b/src/taipy/gui/renderers/factory.py index 7430828fb..63e029be8 100644 --- a/src/taipy/gui/renderers/factory.py +++ b/src/taipy/gui/renderers/factory.py @@ -391,7 +391,7 @@ class _Factory: attributes=attrs, default_value=0, ) - .set_value_and_default(native_type=True, var_type=PropertyType.number_or_lov_value) + .set_value_and_default(native_type=True, var_type=PropertyType.slider_value) .set_attributes( [ ("active", PropertyType.dynamic_boolean, True), diff --git a/src/taipy/gui/types.py b/src/taipy/gui/types.py index 8b79a9778..445cc67a9 100644 --- a/src/taipy/gui/types.py +++ b/src/taipy/gui/types.py @@ -22,6 +22,7 @@ _TaipyData, _TaipyDate, _TaipyDict, + _TaipyLoNumbers, _TaipyLov, _TaipyLovValue, _TaipyNumber, @@ -79,6 +80,10 @@ class PropertyType(Enum): """ The property holds a dynamic number. """ + dynamic_lo_numbers = _TaipyLoNumbers + """ + The property holds a dynamic list of numbers. + """ dynamic_boolean = _TaipyBool """ The property holds a dynamic Boolean value. @@ -119,7 +124,7 @@ class PropertyType(Enum): This is typically used to handle CSS dimension values, where a unit can be used. """ boolean_or_list = "boolean|list" - number_or_lov_value = "number|lovValue" + slider_value = "number|number[]|lovValue" string_list = "stringlist" decimator = Decimator """ diff --git a/src/taipy/gui/utils/__init__.py b/src/taipy/gui/utils/__init__.py index 4ec773227..335b21025 100644 --- a/src/taipy/gui/utils/__init__.py +++ b/src/taipy/gui/utils/__init__.py @@ -44,6 +44,7 @@ _TaipyData, _TaipyDate, _TaipyDict, + _TaipyLoNumbers, _TaipyLov, _TaipyLovValue, _TaipyNumber, diff --git a/src/taipy/gui/utils/types.py b/src/taipy/gui/utils/types.py index 6e336b0c4..16d73f2f7 100644 --- a/src/taipy/gui/utils/types.py +++ b/src/taipy/gui/utils/types.py @@ -97,6 +97,21 @@ def get_hash(): return _TaipyBase._HOLDER_PREFIX + "N" +class _TaipyLoNumbers(_TaipyBase): + def cast_value(self, value: t.Any): + if isinstance(value, str): + try: + return list(map(lambda f: float(f), value[1:-1].split(","))) + except Exception as e: + _warn(f"{self._get_readable_name()}: Parsing {value} as an array of numbers:\n{e}") + return [] + return super().cast_value(value) + + @staticmethod + def get_hash(): + return _TaipyBase._HOLDER_PREFIX + "Ln" + + class _TaipyDate(_TaipyBase): def get(self): val = super().get() diff --git a/src/taipy/gui/viselements.json b/src/taipy/gui/viselements.json index e9e71dcf7..4fa0383c7 100644 --- a/src/taipy/gui/viselements.json +++ b/src/taipy/gui/viselements.json @@ -130,8 +130,8 @@ { "name": "value", "default_property": true, - "type": "dynamic(int|float|str)", - "doc": "The value that is set for this slider.
It would be a lov label if it is used." + "type": "dynamic(int|float|int[]|float[]|str|str[])", + "doc": "The value that is set for this slider.
If this slider is based on a lov then this property can be set to the lov element.
This value can also hold an array of numbers to indicate that the slider reflects a range (within the [min,max] domain) defined by several knobs that the user can set independently.
If this slider is based on a lov then this property can be set to an array of lov elements. The slider is then represented with several knobs, one for each lov value." }, { "name": "min", diff --git a/tests/taipy/gui/control/test_slider.py b/tests/taipy/gui/control/test_slider.py index 923cd57b6..00d5e0f0b 100644 --- a/tests/taipy/gui/control/test_slider.py +++ b/tests/taipy/gui/control/test_slider.py @@ -91,6 +91,17 @@ def test_slider_text_anchor_default_md(gui: Gui, test_client, helpers): helpers.test_control_md(gui, md_string, expected_list) +def test_slider_array_md(gui: Gui, test_client, helpers): + gui._bind_var_val("x", [10, 20]) + md_string = "<|{x}|slider|>" + expected_list = [ + "