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 = [
+ "