Ver código fonte

Merge pull request #1321 from Avaiga/1297-value-format

Metric format converter
Nam Nguyen 11 meses atrás
pai
commit
9d68463147

+ 49 - 46
frontend/taipy-gui/src/components/Taipy/Metric.tsx

@@ -11,25 +11,14 @@
  * specific language governing permissions and limitations under the License.
  */
 
-import React, {
-    CSSProperties,
-    lazy,
-    Suspense,
-    useMemo
-} from 'react';
+import React, {CSSProperties, lazy, Suspense, useMemo} from 'react';
 import {Data} from "plotly.js";
-import {
-    useClassNames,
-    useDynamicJsonProperty,
-    useDynamicProperty
-} from "../../utils/hooks";
-import {
-    TaipyBaseProps,
-    TaipyHoverProps
-} from "./utils";
 import Box from "@mui/material/Box";
 import Skeleton from "@mui/material/Skeleton";
 import Tooltip from "@mui/material/Tooltip";
+import {useClassNames, useDynamicJsonProperty, useDynamicProperty} from "../../utils/hooks";
+import {extractPrefix, extractSuffix, sprintfToD3Converter} from "../../utils/formatConversion";
+import {TaipyBaseProps, TaipyHoverProps} from "./utils";
 
 const Plot = lazy(() => import("react-plotly.js"));
 
@@ -51,6 +40,8 @@ interface MetricProps extends TaipyBaseProps, TaipyHoverProps {
     width?: string | number;
     height?: string | number;
     showValue?: boolean;
+    format?: string;
+    deltaFormat?: string;
 }
 
 const emptyLayout = {} as Record<string, Record<string, unknown>>;
@@ -67,49 +58,60 @@ const Metric = (props: MetricProps) => {
     const delta = useDynamicProperty(props.delta, props.defaultDelta, undefined)
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
     const baseLayout = useDynamicJsonProperty(props.layout, props.defaultLayout || "", emptyLayout);
-    const baseStyle = useDynamicJsonProperty(props.style, props.defaultStyle || "", defaultStyle);
     const hover = useDynamicProperty(props.hoverText, props.defaultHoverText, undefined);
 
-    const data = useMemo(() => ([
-        {
-            domain: {x: [0, 1], y: [0, 1]},
-            value: value,
-            type: "indicator",
-            mode: "gauge" + (showValue ? "+number" : "") + (delta !== undefined ? "+delta" : ""),
-            delta: {
-                reference: typeof value === 'number' && typeof delta === 'number' ? value - delta : undefined,
-            },
-            gauge: {
-                axis: {
-                    range: [
-                        typeof props.min === 'number' ? props.min : 0,
-                        typeof props.max === 'number' ? props.max : 100
-                    ]
+    const data = useMemo(() => {
+        return [
+            {
+                domain: {x: [0, 1], y: [0, 1]},
+                value: value,
+                type: "indicator",
+                mode: "gauge" + (showValue ? "+number" : "") + (delta !== undefined ? "+delta" : ""),
+                number: {
+                    prefix: extractPrefix(props.format),
+                    suffix: extractSuffix(props.format),
+                    valueformat: sprintfToD3Converter(props.format),
                 },
-                shape: props.type === "linear" ? "bullet" : "angular",
-                threshold: {
-                    line: {color: "red", width: 4},
-                    thickness: 0.75,
-                    value: threshold
-                }
-            },
-        }
-    ]), [
-        value,
-        showValue,
-        delta,
+                delta: {
+                    reference: typeof value === 'number' && typeof delta === 'number' ? value - delta : undefined,
+                    prefix: extractPrefix(props.deltaFormat),
+                    suffix: extractSuffix(props.deltaFormat),
+                    valueformat: sprintfToD3Converter(props.deltaFormat)
+                },
+                gauge: {
+                    axis: {
+                        range: [
+                            props.min || 0,
+                            props.max || 100
+                        ]
+                    },
+                    shape: props.type === "linear" ? "bullet" : "angular",
+                    threshold: {
+                        line: {color: "red", width: 4},
+                        thickness: 0.75,
+                        value: threshold
+                    }
+                },
+            }
+        ];
+    }, [
+        props.format,
+        props.deltaFormat,
         props.min,
         props.max,
         props.type,
+        value,
+        showValue,
+        delta,
         threshold
     ]);
 
     const style = useMemo(
         () =>
             height === undefined
-                ? ({...baseStyle, width: width} as CSSProperties)
-                : ({...baseStyle, width: width, height: height} as CSSProperties),
-        [baseStyle, height, width]
+                ? ({...defaultStyle, width: width} as CSSProperties)
+                : ({...defaultStyle, width: width, height: height} as CSSProperties),
+        [height, width]
     );
 
     const skelStyle = useMemo(() => ({...style, minHeight: "7em"}), [style]);
@@ -122,6 +124,7 @@ const Metric = (props: MetricProps) => {
                         data={data as Data[]}
                         layout={baseLayout}
                         style={style}
+                        useResizeHandler
                     />
                 </Suspense>
             </Tooltip>

+ 125 - 0
frontend/taipy-gui/src/utils/formatConversion.ts

@@ -0,0 +1,125 @@
+/*
+ * Copyright 2021-2024 Avaiga Private Limited
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ */
+
+/*
+ * Regular expressions used for parsing sprintf format strings.
+ */
+const re = {
+    text: /^[^\x25]+/,                         // Matches non-placeholder text
+    modulo: /^\x25{2}/,                        // Matches the '%%' escape sequence
+    placeholder: /^\x25?(?:\.(\d+))?([b-giostuvxX])/, // Matches placeholders
+};
+
+/*
+ * This function formats a precision specifier for a number. It takes an optional precision and specifier string.
+ * If no precision is provided, it defaults to 2. The function returns a string that represents the formatted precision.
+ */
+const precisionFormat = (precision?: string, specifier?: string): string => {
+    // Default to precision of 2 if not specified
+    return "." + (precision?.slice(1) ?? "2") + specifier;
+}
+
+/*
+ * This function parses a sprintf format string and returns an array of strings and objects. Each object has a single
+ * key, 'placeholder', that contains the placeholder string.
+ */
+const sprintfParse = (fmt?: string): (string | { placeholder: string; })[] => {
+    let _fmt = fmt;
+    let match;
+    const parseTree = [];
+
+    while (_fmt) {
+        if ((match = re.text.exec(_fmt)) !== null) {
+            // Non-placeholder text
+            parseTree.push(match[0]);
+        } else if ((match = re.modulo.exec(_fmt)) !== null) {
+            // '%%' escape sequence
+            parseTree.push('%');
+        } else if ((match = re.placeholder.exec(_fmt)) !== null) {
+            // Placeholder
+            if (match && match[0]) {
+                parseTree.push({
+                    placeholder: match[0],
+                });
+            }
+        }
+
+        if (match) {
+            _fmt = _fmt.substring(match[0].length);
+        }
+    }
+
+    return parseTree;
+}
+
+/*
+ * This function converts a sprintf format string to a D3 format string. It takes an optional sprintf format string and
+ * returns a D3 format string. If no format string is provided, it returns an empty string.
+ */
+export const sprintfToD3Converter = (fmt?: string): string => {
+    const sprintfFmtArr = sprintfParse(fmt);
+    const objectIndex = sprintfFmtArr.findIndex((element) => typeof element === 'object');
+    let placeholderValue;
+
+    if (typeof sprintfFmtArr[objectIndex] === 'object' && sprintfFmtArr[objectIndex] !== null) {
+        placeholderValue = (sprintfFmtArr[objectIndex] as { placeholder: string }).placeholder;
+    }
+
+    if (!placeholderValue) {
+        return "";
+    }
+
+    return placeholderValue.replace(/%([0-9]*)([.][0-9]+)?([bdieufgoxX])/g, (match, width, precision, type) => {
+        switch (type) {
+            case "b":
+            case "d":
+            case "e":
+            case "o":
+            case "x":
+            case "X":
+                return type;
+            case "i":
+                return "d";
+            case "f":
+            case "g":
+                return precisionFormat(precision, type);
+            case "u":
+                return "("
+            default:
+                return "";
+        }
+    });
+}
+
+/*
+ * This function extracts the prefix from a sprintf format string. It takes an optional sprintf format string and returns
+ * a string that represents the prefix of the format string. If no format string is provided, it returns an empty string.
+ */
+export const extractPrefix = (fmt?: string): string => {
+    if (!fmt) return "";
+    const sprintfFmtArr = sprintfParse(fmt);
+    const objectIndex = sprintfFmtArr.findIndex((element) => typeof element === 'object');
+    return sprintfFmtArr.slice(0, objectIndex).join('');
+}
+
+/*
+ * This function extracts the suffix from a sprintf format string. It takes an optional sprintf format string and returns
+ * a string that represents the suffix of the format string. If no format string is provided, it returns an empty string.
+ */
+export const extractSuffix = (fmt?: string): string => {
+    if (!fmt) return "";
+    const sprintfFmtArr = sprintfParse(fmt);
+    const objectIndex = sprintfFmtArr.findIndex((element) => typeof element === 'object');
+    return sprintfFmtArr.slice(objectIndex + 1).join('');
+}
+

+ 2 - 1
taipy/gui/_renderers/factory.py

@@ -362,7 +362,6 @@ class _Factory:
                 ("id",),
                 ("active", PropertyType.dynamic_boolean, True),
                 ("layout", PropertyType.dynamic_dict),
-                ("style", PropertyType.dynamic_dict),
                 ("type", PropertyType.string, "circular"),
                 ("min", PropertyType.number, 0),
                 ("max", PropertyType.number, 100),
@@ -371,6 +370,8 @@ class _Factory:
                 ("width", PropertyType.string_or_number),
                 ("height", PropertyType.string_or_number),
                 ("show_value", PropertyType.boolean, True),
+                ("format", PropertyType.string),
+                ("delta_format", PropertyType.string),
             ]
         ),
         "navbar": lambda gui, control_type, attrs: _Builder(

+ 16 - 6
taipy/gui/viselements.json

@@ -868,12 +868,6 @@
                         "default_value": "100",
                         "doc": "The maximum value of the metric indicator"
                     },
-                    {
-                        "name": "show_value",
-                        "type": "bool",
-                        "default_value": "True",
-                        "doc": "If set to False, the value is not displayed."
-                    },
                     {
                         "name": "delta",
                         "type": "dynamic(int|float)",
@@ -884,6 +878,22 @@
                         "type": "dynamic(int|float)",
                         "doc": "The threshold value to display."
                     },
+                    {
+                        "name": "show_value",
+                        "type": "bool",
+                        "default_value": "True",
+                        "doc": "If set to False, the value is not displayed."
+                    },
+                    {
+                        "name": "format",
+                        "type": "str",
+                        "doc": "The format to use when displaying the value.<br/>This uses the <code>printf</code> syntax."
+                    },
+                    {
+                        "name": "delta_format",
+                        "type": "str",
+                        "doc": "The format to use when displaying the delta value.<br/>This uses the <code>printf</code> syntax."
+                    },
                     {
                         "name": "width",
                         "type": "str|number",

+ 212 - 46
tests/gui/e2e/test_metric_indicator.py

@@ -14,81 +14,247 @@ from importlib import util
 import pytest
 
 if util.find_spec("playwright"):
-  from playwright._impl._page import Page
+    from playwright._impl._page import Page
 
 from taipy.gui import Gui
 
 
 @pytest.mark.extension
 def test_has_default_value(page: Page, gui: Gui, helpers):
-  page_md = """
+    page_md = """
 <|100|metric|>
 """
-  gui._set_frame(inspect.currentframe())
-  gui.add_page(name="test", page=page_md)
-  helpers.run_e2e(gui)
-  page.goto("./test")
-  page.wait_for_selector(".plot-container")
-  events_list = page.locator("//*[@class='js-plotly-plot']//*[name()='svg'][2]//*[local-name()='text']")
-  gauge_value = events_list.nth(0).text_content()
-  assert gauge_value == "100"
+    gui._set_frame(inspect.currentframe())
+    gui.add_page(name="test", page=page_md)
+    helpers.run_e2e(gui)
+    page.goto("./test")
+    page.wait_for_selector(".plot-container")
+    events_list = page.locator("//*[@class='js-plotly-plot']//*[name()='svg'][2]//*[local-name()='text']")
+    gauge_value = events_list.nth(0).text_content()
+    assert gauge_value == "100"
 
 
 @pytest.mark.extension
 def test_show_increase_delta_value(page: Page, gui: Gui, helpers):
-  page_md = """
+    page_md = """
 <|100|metric|delta=20|type=linear|>
 """
-  gui._set_frame(inspect.currentframe())
-  gui.add_page(name="test", page=page_md)
-  helpers.run_e2e(gui)
-  page.goto("./test")
-  page.wait_for_selector(".plot-container")
-  events_list = page.locator("//*[@class='js-plotly-plot']//*[name()='svg'][2]//*[local-name()='text']")
-  delta_value = events_list.nth(1).text_content()
-  assert delta_value == "▲20"
+    gui._set_frame(inspect.currentframe())
+    gui.add_page(name="test", page=page_md)
+    helpers.run_e2e(gui)
+    page.goto("./test")
+    page.wait_for_selector(".plot-container")
+    events_list = page.locator("//*[@class='js-plotly-plot']//*[name()='svg'][2]//*[local-name()='text']")
+    delta_value = events_list.nth(1).text_content()
+    assert delta_value == "▲20"
 
 
 @pytest.mark.extension
 def test_show_decrease_delta_value(page: Page, gui: Gui, helpers):
-  page_md = """
+    page_md = """
 <|100|metric|delta=-20|type=linear|>
 """
-  gui._set_frame(inspect.currentframe())
-  gui.add_page(name="test", page=page_md)
-  helpers.run_e2e(gui)
-  page.goto("./test")
-  page.wait_for_selector(".plot-container")
-  events_list = page.locator("//*[@class='js-plotly-plot']//*[name()='svg'][2]//*[local-name()='text']")
-  delta_value = events_list.nth(1).text_content()
-  assert delta_value == "▼−20"
+    gui._set_frame(inspect.currentframe())
+    gui.add_page(name="test", page=page_md)
+    helpers.run_e2e(gui)
+    page.goto("./test")
+    page.wait_for_selector(".plot-container")
+    events_list = page.locator("//*[@class='js-plotly-plot']//*[name()='svg'][2]//*[local-name()='text']")
+    delta_value = events_list.nth(1).text_content()
+    assert delta_value == "▼−20"
 
 
-@pytest.mark.extension
+@pytest.mark.teste2e
 def test_show_linear_chart(page: Page, gui: Gui, helpers):
-  page_md = """
+    page_md = """
 <|100|metric|delta=-20|type=linear|>
 """
-  gui._set_frame(inspect.currentframe())
-  gui.add_page(name="test", page=page_md)
-  helpers.run_e2e(gui)
-  page.goto("./test")
-  page.wait_for_selector(".plot-container")
-  chart = page.locator("//*[@class='js-plotly-plot']//*[name()='svg'][2]//*[@class='bullet']")
-  assert chart.is_visible()
+    gui._set_frame(inspect.currentframe())
+    gui.add_page(name="test", page=page_md)
+    helpers.run_e2e(gui)
+    page.goto("./test")
+    page.wait_for_selector(".plot-container")
+    chart = page.locator("//*[@class='js-plotly-plot']//*[name()='svg'][2]//*[@class='bullet']")
+    assert chart.is_visible()
 
 
-@pytest.mark.extension
+@pytest.mark.teste2e
 def test_show_circular_chart_as_default_type(page: Page, gui: Gui, helpers):
-  page_md = """
+    page_md = """
 <|100|metric|>
 """
-  gui._set_frame(inspect.currentframe())
-  gui.add_page(name="test", page=page_md)
-  helpers.run_e2e(gui)
-  page.goto("./test")
-  page.wait_for_selector(".plot-container")
-  chart = page.locator("//*[@class='js-plotly-plot']//*[name()='svg'][2]//*[@class='angular']")
-  assert chart.is_visible()
+    gui._set_frame(inspect.currentframe())
+    gui.add_page(name="test", page=page_md)
+    helpers.run_e2e(gui)
+    page.goto("./test")
+    page.wait_for_selector(".plot-container")
+    chart = page.locator("//*[@class='js-plotly-plot']//*[name()='svg'][2]//*[@class='angular']")
+    assert chart.is_visible()
+
+
+@pytest.mark.teste2e
+def test_format_converter_integer_to_binary(page: Page, gui: Gui, helpers):
+    page_md = """
+<|50|metric|show_value=True|delta=20|format=%b|>
+"""
+    gui._set_frame(inspect.currentframe())
+    gui.add_page(name="test", page=page_md)
+    helpers.run_e2e(gui)
+    page.goto("./test")
+    page.wait_for_selector(".plot-container")
+    number = page.locator(
+        "//*[@class='js-plotly-plot']//*[name()='svg'][2]//*[@class='number']")
+    assert number.text_content() == "110010"
+
+
+@pytest.mark.teste2e
+def test_format_converter_integer_to_signed_decimal_d_type(page: Page, gui: Gui, helpers):
+    page_md = """
+<|50|metric|show_value=True|delta=20|format=%d|>
+"""
+    gui._set_frame(inspect.currentframe())
+    gui.add_page(name="test", page=page_md)
+    helpers.run_e2e(gui)
+    page.goto("./test")
+    page.wait_for_selector(".plot-container")
+    number = page.locator(
+        "//*[@class='js-plotly-plot']//*[name()='svg'][2]//*[@class='number']")
+    assert number.text_content() == "50"
+
+
+@pytest.mark.teste2e
+def test_format_converter_integer_to_signed_decimal_i_type(page: Page, gui: Gui, helpers):
+    page_md = """
+<|50|metric|show_value=True|delta=20|format=%i|>
+"""
+    gui._set_frame(inspect.currentframe())
+    gui.add_page(name="test", page=page_md)
+    helpers.run_e2e(gui)
+    page.goto("./test")
+    page.wait_for_selector(".plot-container")
+    number = page.locator(
+        "//*[@class='js-plotly-plot']//*[name()='svg'][2]//*[@class='number']")
+    assert number.text_content() == "50"
+
+
+@pytest.mark.teste2e
+def test_format_converter_yields_float_using_science_notation(page: Page, gui: Gui, helpers):
+    page_md = """
+<|50|metric|show_value=True|delta=20|format=%e|>
+"""
+    gui._set_frame(inspect.currentframe())
+    gui.add_page(name="test", page=page_md)
+    helpers.run_e2e(gui)
+    page.goto("./test")
+    page.wait_for_selector(".plot-container")
+    number = page.locator(
+        "//*[@class='js-plotly-plot']//*[name()='svg'][2]//*[@class='number']")
+    assert number.text_content() == "5.000000e+1"
+
+
+@pytest.mark.teste2e
+def test_format_converter_yields_float_using_fixed_point_notation_f_type(page: Page, gui: Gui, helpers):
+    page_md = """
+<|99.99|metric|show_value=True|delta=20|format=%.2f|>
+"""
+    gui._set_frame(inspect.currentframe())
+    gui.add_page(name="test", page=page_md)
+    helpers.run_e2e(gui)
+    page.goto("./test")
+    page.wait_for_selector(".plot-container")
+    number = page.locator(
+        "//*[@class='js-plotly-plot']//*[name()='svg'][2]//*[@class='number']")
+    assert number.text_content() == "99.99"
+
+
+@pytest.mark.teste2e
+def test_format_converter_yields_float_using_fixed_point_notation_g_type(page: Page, gui: Gui, helpers):
+    page_md = """
+<|50.555|metric|show_value=True|delta=20|format=%.2g|>
+"""
+    gui._set_frame(inspect.currentframe())
+    gui.add_page(name="test", page=page_md)
+    helpers.run_e2e(gui)
+    page.goto("./test")
+    page.wait_for_selector(".plot-container")
+    number = page.locator(
+        "//*[@class='js-plotly-plot']//*[name()='svg'][2]//*[@class='number']")
+    assert number.text_content() == "51"
+
+
+@pytest.mark.teste2e
+def test_format_converter_yields_integer_as_octal(page: Page, gui: Gui, helpers):
+    page_md = """
+<|50|metric|show_value=True|delta=20|format=%o|>
+"""
+    gui._set_frame(inspect.currentframe())
+    gui.add_page(name="test", page=page_md)
+    helpers.run_e2e(gui)
+    page.goto("./test")
+    page.wait_for_selector(".plot-container")
+    number = page.locator(
+        "//*[@class='js-plotly-plot']//*[name()='svg'][2]//*[@class='number']")
+    assert number.text_content() == "62"
+
+
+@pytest.mark.teste2e
+def test_format_converter_yields_integer_as_hexadecimal(page: Page, gui: Gui, helpers):
+    page_md = """
+<|50|metric|show_value=True|delta=20|format=%x|>
+"""
+    gui._set_frame(inspect.currentframe())
+    gui.add_page(name="test", page=page_md)
+    helpers.run_e2e(gui)
+    page.goto("./test")
+    page.wait_for_selector(".plot-container")
+    number = page.locator(
+        "//*[@class='js-plotly-plot']//*[name()='svg'][2]//*[@class='number']")
+    assert number.text_content() == "32"
+
+
+@pytest.mark.teste2e
+def test_format_converter_yields_integer_as_uppercase_hexadecimal(page: Page, gui: Gui, helpers):
+    page_md = """
+<|50|metric|show_value=True|delta=20|format=%X|>
+"""
+    gui._set_frame(inspect.currentframe())
+    gui.add_page(name="test", page=page_md)
+    helpers.run_e2e(gui)
+    page.goto("./test")
+    page.wait_for_selector(".plot-container")
+    number = page.locator(
+        "//*[@class='js-plotly-plot']//*[name()='svg'][2]//*[@class='number']")
+    assert number.text_content() == "32"
+
+
+@pytest.mark.teste2e
+def test_format_converter_yields_integer_as_unsigned_decimal(page: Page, gui: Gui, helpers):
+    page_md = """
+<|-50|metric|show_value=True|delta=20|format=%u|>
+"""
+    gui._set_frame(inspect.currentframe())
+    gui.add_page(name="test", page=page_md)
+    helpers.run_e2e(gui)
+    page.goto("./test")
+    page.wait_for_selector(".plot-container")
+    number = page.locator(
+        "//*[@class='js-plotly-plot']//*[name()='svg'][2]//*[@class='number']")
+    assert number.text_content() == "(50)"
+
+
+@pytest.mark.teste2e
+def test_format_converter_yields_edge_cases(page: Page, gui: Gui, helpers):
+    page_md = """
+<|50|metric|show_value=True|format=a%%b%dc%%d|>
+"""
+    gui._set_frame(inspect.currentframe())
+    gui.add_page(name="test", page=page_md)
+    helpers.run_e2e(gui)
+    page.goto("./test")
+    page.wait_for_selector(".plot-container")
+    number = page.locator(
+        "//*[@class='js-plotly-plot']//*[name()='svg'][2]//*[@class='number']")
+    assert number.text_content() == "a%b50c%d"
+