diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 8353b81c4..6ac728eb9 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -8,7 +8,7 @@ on:
jobs:
backend:
- timeout-minutes: 20
+ timeout-minutes: 40
strategy:
matrix:
node-version: [16.x]
diff --git a/Pipfile b/Pipfile
index 9d499dabf..8c602fb52 100644
--- a/Pipfile
+++ b/Pipfile
@@ -22,6 +22,8 @@ pyngrok = "==5.1"
flask-talisman = "==1.0"
gevent = "==21.12.0"
gevent-websocket = "==0.10.1"
+kthread = "==0.2.3"
+werkzeug = "==2.0.3"
[dev-packages]
black = "*"
diff --git a/gui/doc/input.csv b/gui/doc/input.csv
index 34f498738..f66c56cbf 100644
--- a/gui/doc/input.csv
+++ b/gui/doc/input.csv
@@ -4,4 +4,6 @@ password,bool,False,"If True, the text is obscured: all input characters are dis
label,str,None,The label associated with the input.
>sharedInput,,,
>on_change,,,
+multiline,bool,False,"If True, the text is presented as a multi line input."
+lines_shown,int,5,"The height of the displayed element if multiline is True."
>propagate,,,
diff --git a/gui/doc/table.csv b/gui/doc/table.csv
index 8fab825a0..eef46d374 100644
--- a/gui/doc/table.csv
+++ b/gui/doc/table.csv
@@ -32,6 +32,8 @@ width,str|int|float,"""100vw""","The width, in CSS units, of this table control.
height,str|int|float,"""80vh""","The height, in CSS units, of this table control."
nan_value,str,"""""",The replacement text for NaN (not-a-number) values.
nan_value[col_name ],str,"""""",The replacement text for NaN (not-a-number) values for the indicated column.
+editable,dynamic(bool),FALSE,"Indicates, if True, that all columns can be edited."
+editable[col_name ],bool,"editable","Indicates, if False, that the indicated column cannot be edited when editable is True."
on_edit,Callback,,"The name of a function that is to be triggered when a cell edition is validated.
All parameters of that function are optional:
diff --git a/gui/package.json b/gui/package.json
index f7f7df9a7..74750fe21 100644
--- a/gui/package.json
+++ b/gui/package.json
@@ -1,6 +1,6 @@
{
"name": "taipy-gui",
- "version": "1.1.0",
+ "version": "1.1.3",
"private": true,
"dependencies": {
"@emotion/react": "^11.5.0",
diff --git a/gui/src/components/Taipy/AutoLoadingTable.tsx b/gui/src/components/Taipy/AutoLoadingTable.tsx
index 1fb339bb4..2ee6c0fb0 100644
--- a/gui/src/components/Taipy/AutoLoadingTable.tsx
+++ b/gui/src/components/Taipy/AutoLoadingTable.tsx
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useContext, useCallback, useRef, useMemo, CSSProperties, MouseEvent } from "react";
import Box from "@mui/material/Box";
import MuiTable from "@mui/material/Table";
-import TableCell from "@mui/material/TableCell";
+import TableCell, { TableCellProps } from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
@@ -26,7 +26,6 @@ import {
} from "../../context/taipyReducers";
import {
ColumnDesc,
- getCellProps,
getsortByIndex,
Order,
TaipyTableProps,
@@ -39,11 +38,11 @@ import {
RowValue,
EDIT_COL,
OnRowDeletion,
- iconInRowSx,
addDeleteColumn,
headBoxSx,
getClassName,
LINE_STYLE,
+ iconInRowSx,
} from "./tableUtils";
import { useDispatchRequestUpdateOnFirstRender, useDynamicProperty, useFormatConfig } from "../../utils/hooks";
@@ -52,7 +51,7 @@ interface RowData {
columns: Record;
rows: RowType[];
classes: Record;
- cellStyles: CSSProperties[];
+ cellProps: Partial[];
isItemLoaded: (index: number) => boolean;
selection: number[];
formatConfig: FormatConfig;
@@ -70,7 +69,7 @@ const Row = ({
columns,
rows,
classes,
- cellStyles,
+ cellProps,
isItemLoaded,
selection,
formatConfig,
@@ -96,24 +95,18 @@ const Row = ({
selected={selection.indexOf(index) > -1}
>
{colsOrder.map((col, cidx) => (
-
-
-
+ colDesc={columns[col]}
+ value={rows[index][col]}
+ formatConfig={formatConfig}
+ rowIndex={index}
+ onValidation={onValidation}
+ onDeletion={onDeletion}
+ nanValue={columns[col].nanValue || nanValue}
+ tableCellProps={cellProps[cidx]}
+ />
))}
) : (
@@ -181,13 +174,16 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
useDispatchRequestUpdateOnFirstRender(dispatch, id, updateVars);
- const handleRequestSort = useCallback(
- (event: React.MouseEvent, col: string) => {
- const isAsc = orderBy === col && order === "asc";
- setOrder(isAsc ? "desc" : "asc");
- setOrderBy(col);
- setRows([]);
- setTimeout(() => infiniteLoaderRef.current?.resetloadMoreItemsCache(true), 1); // So that the state can be changed
+ const onSort = useCallback(
+ (e: React.MouseEvent) => {
+ const col = e.currentTarget.getAttribute("data-dfid");
+ if (col) {
+ const isAsc = orderBy === col && order === "asc";
+ setOrder(isAsc ? "desc" : "asc");
+ setOrderBy(col);
+ setRows([]);
+ setTimeout(() => infiniteLoaderRef.current?.resetloadMoreItemsCache(true), 1); // So that the state can be changed
+ }
},
[orderBy, order]
);
@@ -199,13 +195,6 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
}
}, [props.data]);
- const createSortHandler = useCallback(
- (col: string) => (event: MouseEvent) => {
- handleRequestSort(event, col);
- },
- [handleRequestSort]
- );
-
const onAggregate = useCallback((e: MouseEvent) => {
const groupBy = e.currentTarget.getAttribute("data-dfid");
if (groupBy) {
@@ -224,7 +213,16 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
let hNan = !!props.nanValue;
if (props.columns) {
try {
- const columns = typeof props.columns === "string" ? JSON.parse(props.columns) : props.columns;
+ const columns = (
+ typeof props.columns === "string" ? JSON.parse(props.columns) : props.columns
+ ) as Record;
+ Object.values(columns).forEach((col) => {
+ if (typeof col.notEditable != "boolean") {
+ col.notEditable = !editable;
+ } else {
+ col.notEditable = col.notEditable || !editable;
+ }
+ });
addDeleteColumn(!!(active && editable && (tp_onAdd || tp_onDelete)), columns);
const colsOrder = Object.keys(columns).sort(getsortByIndex(columns));
const styles = colsOrder.reduce>((pv, col) => {
@@ -363,9 +361,10 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
columns: columns,
rows: rows,
classes: {},
- cellStyles: colsOrder.map((col) => ({
- width: columns[col].width || columns[col].widthHint,
- height: ROW_HEIGHT - 32,
+ cellProps: colsOrder.map((col) => ({
+ sx: { width: columns[col].width || columns[col].widthHint, height: ROW_HEIGHT - 32 },
+ component: "div",
+ variant: "body",
})),
isItemLoaded: isItemLoaded,
selection: selected,
@@ -416,16 +415,23 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
width={columns[col].width}
>
{columns[col].dfid === EDIT_COL ? (
- active && editable && tp_onAdd ? (
-
-
-
- ) : null
+ active && editable && tp_onAdd ? (
+
+
+
+
+
+ ) : null
) : (
@@ -434,14 +440,14 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
onClick={onAggregate}
size="small"
title="aggregate"
- sx={iconInRowSx}
data-dfid={columns[col].dfid}
disabled={!active}
+ sx={iconInRowSx}
>
{aggregates.includes(columns[col].dfid) ? (
-
+
) : (
-
+
)}
) : null}
diff --git a/gui/src/components/Taipy/Input.tsx b/gui/src/components/Taipy/Input.tsx
index 8b3fe778a..f4ef99d59 100644
--- a/gui/src/components/Taipy/Input.tsx
+++ b/gui/src/components/Taipy/Input.tsx
@@ -20,11 +20,22 @@ const getActionKeys = (keys?: string): string[] => {
};
const Input = (props: TaipyInputProps) => {
- const { className, type, id, updateVarName, propagate = true, defaultValue = "", tp_onAction, tp_onChange } = props;
+ const {
+ className,
+ type,
+ id,
+ updateVarName,
+ propagate = true,
+ defaultValue = "",
+ tp_onAction,
+ tp_onChange,
+ multiline = false,
+ linesShown = 5,
+ } = props;
const [value, setValue] = useState(defaultValue);
const { dispatch } = useContext(TaipyContext);
const delayCall = useRef(-1);
- const [actionKeys] = useState(() => tp_onAction ? getActionKeys(props.actionKeys): []);
+ const [actionKeys] = useState(() => (tp_onAction ? getActionKeys(props.actionKeys) : []));
const changeDelay = typeof props.changeDelay === "number" && props.changeDelay >= 0 ? props.changeDelay : 300;
const active = useDynamicProperty(props.active, props.defaultActive, true);
@@ -76,7 +87,7 @@ const Input = (props: TaipyInputProps) => {
{
onChange={handleInput}
disabled={!active}
onKeyDown={tp_onAction ? handleAction : undefined}
+ multiline={multiline}
+ minRows={linesShown}
/>
);
diff --git a/gui/src/components/Taipy/PaginatedTable.tsx b/gui/src/components/Taipy/PaginatedTable.tsx
index 16c88f3ae..ab828bac8 100644
--- a/gui/src/components/Taipy/PaginatedTable.tsx
+++ b/gui/src/components/Taipy/PaginatedTable.tsx
@@ -32,14 +32,12 @@ import { TaipyContext } from "../../context/taipyContext";
import { createRequestTableUpdateAction, createSendActionNameAction } from "../../context/taipyReducers";
import {
addDeleteColumn,
- getCellProps,
baseBoxSx,
EditableCell,
EDIT_COL,
getClassName,
getsortByIndex,
headBoxSx,
- iconInRowSx,
LINE_STYLE,
OnCellValidation,
OnRowDeletion,
@@ -50,6 +48,8 @@ import {
RowValue,
tableSx,
TaipyPaginatedTableProps,
+ ColumnDesc,
+ iconInRowSx,
} from "./tableUtils";
import { useDispatchRequestUpdateOnFirstRender, useDynamicProperty, useFormatConfig } from "../../utils/hooks";
@@ -96,12 +96,21 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
let hNan = !!props.nanValue;
if (props.columns) {
try {
- const columns = typeof props.columns === "string" ? JSON.parse(props.columns) : props.columns;
+ const columns = (
+ typeof props.columns === "string" ? JSON.parse(props.columns) : props.columns
+ ) as Record;
+ Object.values(columns).forEach((col) => {
+ if (typeof col.notEditable != "boolean") {
+ col.notEditable = !editable;
+ } else {
+ col.notEditable = col.notEditable || !editable;
+ }
+ });
addDeleteColumn(!!(active && editable && (tp_onAdd || tp_onDelete)), columns);
const colsOrder = Object.keys(columns).sort(getsortByIndex(columns));
const styles = colsOrder.reduce>((pv, col) => {
if (columns[col].style) {
- pv[columns[col].dfid] = columns[col].style;
+ pv[columns[col].dfid] = columns[col].style as string;
}
hNan = hNan || !!columns[col].nanValue;
return pv;
@@ -194,22 +203,18 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
dispatch,
]);
- const handleRequestSort = useCallback(
- (event: MouseEvent, col: string) => {
- const isAsc = orderBy === col && order === "asc";
- setOrder(isAsc ? "desc" : "asc");
- setOrderBy(col);
+ const onSort = useCallback(
+ (e: MouseEvent) => {
+ const col = e.currentTarget.getAttribute("data-dfid");
+ if (col) {
+ const isAsc = orderBy === col && order === "asc";
+ setOrder(isAsc ? "desc" : "asc");
+ setOrderBy(col);
+ }
},
[orderBy, order]
);
- const createSortHandler = useCallback(
- (col: string) => (event: MouseEvent) => {
- handleRequestSort(event, col);
- },
- [handleRequestSort]
- );
-
const handleChangePage = useCallback(
(event: unknown, newPage: number) => {
setStartIndex(newPage * rowsPerPage);
@@ -312,7 +317,7 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
@@ -325,16 +330,23 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
width={columns[col].width}
>
{columns[col].dfid === EDIT_COL ? (
- active && editable && tp_onAdd ? (
-
-
-
- ) : null
+ active && editable && tp_onAdd ? (
+
+
+
+
+
+ ) : null
) : (
@@ -343,14 +355,14 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
onClick={onAggregate}
size="small"
title="aggregate"
- sx={iconInRowSx}
data-dfid={columns[col].dfid}
disabled={!active}
+ sx={iconInRowSx}
>
{aggregates.includes(columns[col].dfid) ? (
-
+
) : (
-
+
)}
) : null}
@@ -390,29 +402,21 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
className={getClassName(rows[index], props.lineStyle)}
>
{colsOrder.map((col, cidx) => (
-
-
-
+ className={getClassName(row, columns[col].style)}
+ colDesc={columns[col]}
+ value={row[col]}
+ formatConfig={formatConfig}
+ rowIndex={index}
+ onValidation={
+ active && editable && tp_onEdit ? onCellValidation : undefined
+ }
+ onDeletion={
+ active && editable && tp_onDelete ? onRowDeletion : undefined
+ }
+ nanValue={columns[col].nanValue || props.nanValue}
+ />
))}
);
diff --git a/gui/src/components/Taipy/tableUtils.tsx b/gui/src/components/Taipy/tableUtils.tsx
index 1c16ec4db..36c5a2646 100644
--- a/gui/src/components/Taipy/tableUtils.tsx
+++ b/gui/src/components/Taipy/tableUtils.tsx
@@ -1,5 +1,5 @@
import React, { useState, useCallback, CSSProperties } from "react";
-import { TableCellProps } from "@mui/material/TableCell";
+import TableCell, { TableCellProps } from "@mui/material/TableCell";
import Box from "@mui/material/Box";
import Input from "@mui/material/Input";
import IconButton from "@mui/material/IconButton";
@@ -24,6 +24,9 @@ export interface ColumnDesc {
style?: string;
nanValue?: string;
tz?: string;
+ widthHint?: number;
+ apply?: string;
+ groupBy?: boolean;
}
export type Order = "asc" | "desc";
@@ -68,22 +71,25 @@ const formatValue = (val: RowValue, col: ColumnDesc, formatConf: FormatConfig, n
const renderCellValue = (val: RowValue | boolean, col: ColumnDesc, formatConf: FormatConfig, nanValue?: string) => {
if (val !== null && val !== undefined && col.type && col.type.startsWith("bool")) {
- return ;
+ return ;
}
return <>{formatValue(val as RowValue, col, formatConf, nanValue)}>;
};
-export const getCellProps = (col: ColumnDesc): Partial => {
- const ret: Partial = {};
+const getCellProps = (col: ColumnDesc, base: Partial = {}): Partial => {
switch (col.type) {
case "int":
case "float":
- ret.align = "right";
+ base.align = "right";
+ break;
+ case "bool":
+ base.align = "center";
+ break;
}
if (col.width) {
- ret.width = col.width;
+ base.width = col.width;
}
- return ret;
+ return base;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -140,12 +146,21 @@ interface EditableCellProps {
onValidation?: OnCellValidation;
onDeletion?: OnRowDeletion;
nanValue?: string;
+ className?: string;
+ tableCellProps?: Partial;
}
export const addDeleteColumn = (render: boolean, columns: Record) => {
if (render) {
Object.keys(columns).forEach((key) => columns[key].index++);
- columns[EDIT_COL] = { dfid: EDIT_COL, type: "", format: "", title: "", index: 0, width: "2em" };
+ columns[EDIT_COL] = {
+ dfid: EDIT_COL,
+ type: "",
+ format: "",
+ title: "",
+ index: 0,
+ width: "4em",
+ };
}
return columns;
};
@@ -156,15 +171,27 @@ export const getClassName = (row: Record, style?: string) =>
const setInputFocus = (input: HTMLInputElement) => input && input.focus();
const cellBoxSx = { display: "grid", gridTemplateColumns: "1fr auto", alignItems: "center" } as CSSProperties;
-export const iconInRowSx = { height: "1em" } as CSSProperties;
+export const iconInRowSx = { fontSize: "body2.fontSize" };
+const tableFontSx = { fontSize: "body2.fontSize" };
export const EditableCell = (props: EditableCellProps) => {
- const { onValidation, value, colDesc, formatConfig, rowIndex, onDeletion, nanValue } = props;
+ const {
+ onValidation,
+ value,
+ colDesc,
+ formatConfig,
+ rowIndex,
+ onDeletion,
+ nanValue,
+ className,
+ tableCellProps = {},
+ } = props;
const [val, setVal] = useState(value);
const [edit, setEdit] = useState(false);
const [deletion, setDeletion] = useState(false);
- const onChange = useCallback((e) => setVal(e.target.value), []);
+ const onChange = useCallback((e: React.ChangeEvent) => setVal(e.target.value), []);
+
const onCheckClick = useCallback(() => {
onValidation && onValidation(val, rowIndex, colDesc.dfid);
setEdit((e) => !e);
@@ -209,55 +236,60 @@ export const EditableCell = (props: EditableCellProps) => {
[onDeleteCheckClick, onDeleteClick]
);
- return edit ? (
-
-
-
-
-
-
+ return (
+
+ {edit ? (
+
+
+
+
+
+
+
+ >
+ }
+ />
+ ) : EDIT_COL === colDesc.dfid ? (
+ deletion ? (
+
+
+
+
+
+
+
+ >
+ }
+ />
+ ) : onDeletion ? (
+
+
- >
- }
- />
- ) : EDIT_COL === colDesc.dfid ? (
- deletion ? (
-
-
-
-
-
-
+ ) : null
+ ) : (
+
+ {renderCellValue(value, colDesc, formatConfig, nanValue)}
+ {onValidation && !colDesc.notEditable ? (
+
+
- >
- }
- />
- ) : onDeletion ? (
-
-
-
- ) : null
- ) : onValidation ? (
-
- {formatValue(value, colDesc, formatConfig)}
- {!colDesc.notEditable ? (
-
-
-
- ) : null}
-
- ) : (
- renderCellValue(value, colDesc, formatConfig, nanValue)
+ ) : null}
+
+ )}
+
);
};
diff --git a/gui/src/components/Taipy/utils.ts b/gui/src/components/Taipy/utils.ts
index 377fb0a5b..506892859 100644
--- a/gui/src/components/Taipy/utils.ts
+++ b/gui/src/components/Taipy/utils.ts
@@ -43,6 +43,8 @@ export interface TaipyInputProps extends TaipyActiveProps, TaipyChangeProps, Tai
changeDelay?: number;
tp_onAction?: string;
actionKeys?: string;
+ multiline?: boolean;
+ linesShown?: number;
}
export interface TaipyLabelProps {
diff --git a/setup.py b/setup.py
index c1c29063d..25dc05f53 100644
--- a/setup.py
+++ b/setup.py
@@ -24,7 +24,7 @@
requirements = [
- "flask>=2.1,<3.0",
+ "flask>=2.1,<2.2",
"flask-cors>=3.0.10,<4.0",
"flask-socketio>=5.1.1,<6.0",
"markdown>=3.3.4,<4.0",
@@ -35,6 +35,10 @@
"tzlocal>=3.0,<5.0",
"backports.zoneinfo>=0.2.1,<0.3;python_version<'3.9'",
"flask-talisman>=1.0,<2.0",
+ "gevent>=21.12.0,<22.0",
+ "gevent-websocket>=0.10.1,<0.11",
+ "kthread>=0.2.3,<0.3",
+ "werkzeug>=2.0,<2.1",
]
test_requirements = ["pytest>=3.8"]
@@ -45,12 +49,7 @@
"python-magic>=0.4.24,<0.5;platform_system!='Windows'",
"python-magic-bin>=0.4.14,<0.5;platform_system=='Windows'",
],
- "rdp": ["rdp>=0.8"],
"arrow": ["pyarrow>=7.0,<9.0"],
- "gevent": [
- "gevent>=21.12.0,<22.0",
- "gevent-websocket>=0.10.1,<0.11",
- ],
}
@@ -91,7 +90,7 @@ def run(self):
test_suite="tests",
tests_require=test_requirements,
url="https://github.com/avaiga/taipy-gui",
- version="1.1.2",
+ version="1.1.3",
zip_safe=False,
extras_require=extras_require,
cmdclass={"build_py": NPMInstall},
diff --git a/taipy/gui/data/pandas_data_accessor.py b/taipy/gui/data/pandas_data_accessor.py
index 7f63cf43b..a0fa51b3d 100644
--- a/taipy/gui/data/pandas_data_accessor.py
+++ b/taipy/gui/data/pandas_data_accessor.py
@@ -33,7 +33,7 @@ class _PandasDataAccessor(_DataAccessor):
@staticmethod
def get_supported_classes() -> t.List[str]:
- return [t.__name__ for t in _PandasDataAccessor.__types]
+ return [t.__name__ for t in _PandasDataAccessor.__types] # type: ignore
@staticmethod
def __style_function(
@@ -156,7 +156,7 @@ def __format_data(
return ret
def get_col_types(self, var_name: str, value: t.Any) -> t.Union[None, t.Dict[str, str]]: # type: ignore
- if isinstance(value, _PandasDataAccessor.__types):
+ if isinstance(value, _PandasDataAccessor.__types): # type: ignore
return value.dtypes.apply(lambda x: x.name).to_dict()
elif isinstance(value, list):
ret_dict: t.Dict[str, str] = {}
@@ -272,6 +272,6 @@ def get_data(
return ret_payload
else:
value = value[0]
- if isinstance(value, _PandasDataAccessor.__types):
+ if isinstance(value, _PandasDataAccessor.__types): # type: ignore
return self.__get_data(gui, var_name, value, payload, data_format)
return {}
diff --git a/taipy/gui/gui.py b/taipy/gui/gui.py
index d0823a248..02d8d5c95 100644
--- a/taipy/gui/gui.py
+++ b/taipy/gui/gui.py
@@ -373,10 +373,7 @@ def _update_var(
propagate=True,
holder: t.Optional[_TaipyBase] = None,
on_change: t.Optional[str] = None,
- from_map_dict: bool = False,
) -> None:
- if from_map_dict:
- var_name = _variable_encode(var_name, self._get_locals_context())
if holder:
var_name = holder.get_name()
hash_expr = self.__evaluator.get_hash_from_expr(var_name)
@@ -402,7 +399,11 @@ def _update_var(
self.__send_var_list_update(list(derived_modified), var_name)
def __call_on_change(self, var_name: str, value: t.Any, on_change: t.Optional[str] = None):
- # TODO: what if _update_function changes 'var_name'... infinite loop?
+ suffix_var_name = ""
+ if "." in var_name:
+ first_dot_index = var_name.index(".")
+ suffix_var_name = var_name[first_dot_index + 1 :]
+ var_name = var_name[:first_dot_index]
var_name_decode, module_name = _variable_decode(self._get_expr_from_hash(var_name))
current_context = self._get_locals_context()
if module_name == current_context:
@@ -420,6 +421,7 @@ def __call_on_change(self, var_name: str, value: t.Any, on_change: t.Optional[st
if not _found:
warnings.warn(f"Can't find matching variable for {var_name} on {current_context} context")
return
+ var_name = f"{var_name}.{suffix_var_name}" if suffix_var_name else var_name
on_change_fn = self._get_user_function(on_change) if on_change else None
if not callable(on_change_fn):
on_change_fn = self._get_user_function("on_change")
diff --git a/taipy/gui/renderers/builder.py b/taipy/gui/renderers/builder.py
index 3fe987eda..bd58ffaaa 100644
--- a/taipy/gui/renderers/builder.py
+++ b/taipy/gui/renderers/builder.py
@@ -354,6 +354,13 @@ def get_dataframe_attributes(self, date_format="MM/dd/yyyy", number_format=None)
if columns is not None:
self.__update_col_desc_from_indexed(columns, "nan_value")
self.__update_col_desc_from_indexed(columns, "width")
+ editables = self.__get_name_indexed_property("editable")
+ for k, v in editables.items():
+ if not _is_boolean_true(v):
+ if col_desc := next((x for x in columns.values() if x["dfid"] == k), None):
+ col_desc["notEditable"] = True
+ else:
+ warnings.warn(f"{self.__element_name} editable[{k}] is not in the list of displayed columns")
group_by = self.__get_name_indexed_property("group_by")
for k, v in group_by.items():
if _is_boolean_true(v):
@@ -749,7 +756,8 @@ def set_labels(self, var_name: str = "labels"):
if value := self.__attributes.get(var_name):
if _is_boolean_true(value):
return self.__set_react_attribute(_to_camel_case(var_name), True)
- return self.__set_dict_attribute(var_name)
+ elif isinstance(value, (dict, _MapDict)):
+ return self.__set_dict_attribute(var_name)
return self
def set_partial(self):
diff --git a/taipy/gui/renderers/factory.py b/taipy/gui/renderers/factory.py
index 22b95e22f..4f88ff715 100644
--- a/taipy/gui/renderers/factory.py
+++ b/taipy/gui/renderers/factory.py
@@ -247,6 +247,9 @@ class _Factory:
("on_action", _AttributeType.function),
("action_keys",),
("label",),
+ ("change_delay", _AttributeType.number, gui._get_config("change_delay", None)),
+ ("multiline", _AttributeType.boolean, False),
+ ("lines_shown", _AttributeType.number, 5),
]
),
"layout": lambda gui, control_type, attrs: _Builder(
diff --git a/taipy/gui/server.py b/taipy/gui/server.py
index c595691f9..eb570f1b5 100644
--- a/taipy/gui/server.py
+++ b/taipy/gui/server.py
@@ -15,6 +15,7 @@
import os
import re
import socket
+import time
import typing as t
import webbrowser
@@ -23,10 +24,11 @@
from flask_cors import CORS
from flask_socketio import SocketIO
from flask_talisman import Talisman
+from kthread import KThread
from werkzeug.serving import is_running_from_reloader
from .renderers.jsonencoder import _TaipyJsonEncoder
-from .utils import _is_in_notebook, _KillableThread
+from .utils import _is_in_notebook, _RuntimeManager
if t.TYPE_CHECKING:
from .gui import Gui
@@ -167,14 +169,20 @@ def _run_notebook(self):
def _get_async_mode(self) -> str:
return self._ws.async_mode
+ def _is_port_open(self, host, port) -> bool:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ result = sock.connect_ex((host, port))
+ sock.close()
+ return result == 0
+
def runWithWS(self, host, port, debug, use_reloader, flask_log, run_in_thread, ssl_context):
host_value = host if host != "0.0.0.0" else "localhost"
+ if _is_in_notebook() or run_in_thread:
+ runtime_manager = _RuntimeManager()
+ runtime_manager.add_gui(self._gui, port)
if debug and not is_running_from_reloader():
# Check that the port is not already opened
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- result = sock.connect_ex((host_value, port))
- sock.close()
- if result == 0:
+ if self._is_port_open(host_value, port):
raise ConnectionError(
f"Port {port} is already opened on {host_value}. You have another server application running on the same port."
)
@@ -191,7 +199,7 @@ def runWithWS(self, host, port, debug, use_reloader, flask_log, run_in_thread, s
if _is_in_notebook() or run_in_thread:
self._host = host
self._port = port
- self._thread = _KillableThread(target=self._run_notebook)
+ self._thread = KThread(target=self._run_notebook)
self._thread.start()
return
if self._get_async_mode() != "threading":
@@ -199,8 +207,7 @@ def runWithWS(self, host, port, debug, use_reloader, flask_log, run_in_thread, s
self._ws.run(self._flask, host=host, port=port, debug=debug, use_reloader=use_reloader)
def stop_thread(self):
- if hasattr(self, "_thread"):
- if self._get_async_mode() != "threading":
- self._ws.stop()
+ if hasattr(self, "_thread") and self._thread.is_alive():
self._thread.kill()
- self._thread.join()
+ while self._is_port_open(self._host, self._port):
+ time.sleep(0.1)
diff --git a/taipy/gui/utils/__init__.py b/taipy/gui/utils/__init__.py
index 12d15b583..69af0918a 100644
--- a/taipy/gui/utils/__init__.py
+++ b/taipy/gui/utils/__init__.py
@@ -19,6 +19,7 @@
)
from ._locals_context import _LocalsContext
from ._map_dict import _MapDict
+from ._runtime_manager import _RuntimeManager
from ._variable_directory import _variable_decode, _variable_encode, _VariableDirectory
from .boolean import _is_boolean, _is_boolean_true
from .clientvarname import _get_client_var_name
@@ -31,7 +32,6 @@
from .get_module_name import _get_module_name_from_frame, _get_module_name_from_imported_var
from .getdatecolstrname import _RE_PD_TYPE, _get_date_col_str_name
from .isnotebook import _is_in_notebook
-from .killable_thread import _KillableThread
from .types import (
_TaipyBase,
_TaipyBool,
diff --git a/taipy/gui/utils/_bindings.py b/taipy/gui/utils/_bindings.py
index 55583aa00..cdb7c51e0 100644
--- a/taipy/gui/utils/_bindings.py
+++ b/taipy/gui/utils/_bindings.py
@@ -46,7 +46,7 @@ def __setter(ud: _Bindings, value: t.Any):
def __getter(ud: _Bindings) -> t.Any:
value = getattr(ud._get_data_scope(), name)
if isinstance(value, _MapDict):
- return _MapDict(value._dict, lambda k, v: ud.__gui._update_var(f"{name}.{k}", v, from_map_dict=True))
+ return _MapDict(value._dict, lambda k, v: ud.__gui._update_var(f"{name}.{k}", v))
else:
return value
diff --git a/taipy/gui/utils/_evaluator.py b/taipy/gui/utils/_evaluator.py
index afb47fda8..f2b73a034 100644
--- a/taipy/gui/utils/_evaluator.py
+++ b/taipy/gui/utils/_evaluator.py
@@ -26,6 +26,7 @@
_getscopeattr,
_getscopeattr_drill,
_hasscopeattr,
+ _MapDict,
_setscopeattr,
_setscopeattr_drill,
_TaipyBase,
@@ -40,7 +41,7 @@ class _Evaluator:
__EXPR_RE = re.compile(r"\{(([^\}]*)([^\{]*))\}")
__EXPR_IS_EXPR = re.compile(r"[^\\][{}]")
__EXPR_IS_EDGE_CASE = re.compile(r"^\s*{([^}]*)}\s*$")
- __EXPR_VALID_VAR_EDGE_CASE = re.compile(r"^([a-zA-Z\.\_0-9]*)$")
+ __EXPR_VALID_VAR_EDGE_CASE = re.compile(r"^([a-zA-Z\.\_0-9\[\]]*)$")
__EXPR_EDGE_CASE_F_STRING = re.compile(r"[\{]*[a-zA-Z_][a-zA-Z0-9_]*:.+")
__IS_TAIPYEXPR_RE = re.compile(r"TpExPr_(.*)")
@@ -231,15 +232,31 @@ def re_evaluate_expr(self, gui: Gui, var_name: str) -> t.Set[str]:
# backup for later reference
var_name_original = var_name
expr_original = self.__hash_to_expr[var_name]
- # since this is an edge case --> only 1 item in the dict and that item is the original var
- for v in self.__expr_to_var_map[expr_original].values():
- var_name = v
- # construct correct var_path to reassign values
- var_name_full, _ = _variable_decode(expr_original)
- var_name_full = var_name_full.split(".")
- var_name_full[0] = var_name
- var_name_full = ".".join(var_name_full)
- _setscopeattr_drill(gui, var_name_full, _getscopeattr(gui, var_name_original))
+ temp_expr_var_map = self.__expr_to_var_map[expr_original]
+ if len(temp_expr_var_map) <= 1:
+ # since this is an edge case --> only 1 item in the dict and that item is the original var
+ for v in temp_expr_var_map.values():
+ var_name = v
+ # construct correct var_path to reassign values
+ var_name_full, _ = _variable_decode(expr_original)
+ var_name_full = var_name_full.split(".")
+ var_name_full[0] = var_name
+ var_name_full = ".".join(var_name_full)
+ _setscopeattr_drill(gui, var_name_full, _getscopeattr(gui, var_name_original))
+ else:
+ # multiple key-value pair in expr_var_map --> expr is special case a["b"]
+ key = ""
+ for v in temp_expr_var_map.values():
+ if isinstance(_getscopeattr(gui, v), _MapDict):
+ var_name = v
+ else:
+ key = v
+ if key == "":
+ return modified_vars
+ _setscopeattr_drill(gui, f"{var_name}.{_getscopeattr(gui, key)}", _getscopeattr(gui, var_name_original))
+ # A middle check to see if var_name is from _MapDict
+ if "." in var_name:
+ var_name = var_name[: var_name.index(".")]
# otherwise, thar var_name is correct and doesn't require any resolution
if var_name not in self.__var_to_expr_list:
# warnings.warn("{var_name} not found")
diff --git a/taipy/gui/utils/_map_dict.py b/taipy/gui/utils/_map_dict.py
index f2e908778..c3a3edc5e 100644
--- a/taipy/gui/utils/_map_dict.py
+++ b/taipy/gui/utils/_map_dict.py
@@ -37,7 +37,7 @@ def __getitem__(self, key):
value = self._dict.__getitem__(key)
if isinstance(value, dict):
if self._update_var:
- return _MapDict(value, lambda s, v: self._update_var(f"{key}.{s}", v, from_map_dict=True))
+ return _MapDict(value, lambda s, v: self._update_var(f"{key}.{s}", v))
else:
return _MapDict(value)
return value
diff --git a/taipy/gui/utils/_runtime_manager.py b/taipy/gui/utils/_runtime_manager.py
new file mode 100644
index 000000000..839d60726
--- /dev/null
+++ b/taipy/gui/utils/_runtime_manager.py
@@ -0,0 +1,27 @@
+# Copyright 2022 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.
+
+import typing as t
+
+from .singleton import _Singleton
+
+if t.TYPE_CHECKING:
+ from ..gui import Gui
+
+
+class _RuntimeManager(object, metaclass=_Singleton):
+ def __init__(self):
+ self.__port_gui: t.Dict[int, "Gui"] = {}
+
+ def add_gui(self, gui: "Gui", port: int):
+ if port in self.__port_gui:
+ self.__port_gui[port].stop()
+ self.__port_gui[port] = gui
diff --git a/taipy/gui/utils/boolean.py b/taipy/gui/utils/boolean.py
index d67d804f5..54655f713 100644
--- a/taipy/gui/utils/boolean.py
+++ b/taipy/gui/utils/boolean.py
@@ -13,7 +13,7 @@
def _is_boolean_true(s: t.Union[bool, str]) -> bool:
- return s if isinstance(s, bool) else s.lower() in ["true", "1", "t", "y", "yes", "yeah", "sure"]
+ return s if isinstance(s, bool) else s.lower() in ["true", "1", "t", "y", "yes", "yeah", "sure"] if isinstance(s, str) else False
def _is_boolean(s: t.Any) -> bool:
diff --git a/taipy/gui/utils/clientvarname.py b/taipy/gui/utils/clientvarname.py
index b80135d66..211d42f88 100644
--- a/taipy/gui/utils/clientvarname.py
+++ b/taipy/gui/utils/clientvarname.py
@@ -10,5 +10,10 @@
# specific language governing permissions and limitations under the License.
+_replace_dict = {".": "__", "[": "_SqrOp_", "]": "_SqrCl_"}
+
+
def _get_client_var_name(s: str) -> str:
- return s.replace(".", "__")
+ for k, v in _replace_dict.items():
+ s = s.replace(k, v)
+ return s
diff --git a/taipy/gui/utils/killable_thread.py b/taipy/gui/utils/killable_thread.py
deleted file mode 100644
index 6caac6bc9..000000000
--- a/taipy/gui/utils/killable_thread.py
+++ /dev/null
@@ -1,40 +0,0 @@
-# Copyright 2022 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.
-
-import sys
-import threading
-
-
-class _KillableThread(threading.Thread): # pragma: no cover
- def __init__(self, *args, **keywords):
- threading.Thread.__init__(self, *args, **keywords)
- self.killed = False
-
- def start(self):
- self.__run_backup = self.run
- self.run = self.__run
- threading.Thread.start(self)
-
- def __run(self):
- sys.settrace(self.globaltrace)
- self.__run_backup()
- self.run = self.__run_backup
-
- def globaltrace(self, frame, why, arg):
- return self.localtrace if why == "call" else None
-
- def localtrace(self, frame, why, arg):
- if self.killed and why == "line":
- raise SystemExit(0)
- return self.localtrace
-
- def kill(self):
- self.killed = True
diff --git a/taipy/gui/utils/singleton.py b/taipy/gui/utils/singleton.py
new file mode 100644
index 000000000..8b48cb6c2
--- /dev/null
+++ b/taipy/gui/utils/singleton.py
@@ -0,0 +1,21 @@
+# Copyright 2022 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.
+
+from typing import Dict
+
+
+class _Singleton(type):
+ _instances: Dict = {}
+
+ def __call__(self, *args, **kwargs):
+ if self not in self._instances:
+ self._instances[self] = super(_Singleton, self).__call__(*args, **kwargs)
+ return self._instances[self]
diff --git a/tests/taipy/gui/control/test_slider.py b/tests/taipy/gui/control/test_slider.py
index dddfefb18..78db7c0e1 100644
--- a/tests/taipy/gui/control/test_slider.py
+++ b/tests/taipy/gui/control/test_slider.py
@@ -9,6 +9,7 @@
# 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.
+import inspect
from taipy.gui import Gui
@@ -31,6 +32,22 @@ def test_slider_with_min_max(gui: Gui, test_client, helpers):
helpers.test_control_md(gui, md_string, expected_list)
+def test_slider_with_dict_labels_md(gui: Gui, helpers):
+ sel = "Item 1"
+ labels = {"Item 1": "Label Start", "Item 3": "Label End"}
+ gui._set_frame(inspect.currentframe())
+ md_string = "<|{sel}|slider|lov=Item 1;Item 2;Item 3|labels={labels}|>"
+ expected_list = ["
+<|{a_dict.key}|input|id=inp2|>
+<|test|button|on_action=on_action_1|id=btn1|>
+<|test|button|on_action=on_action_2|id=btn2|>
+"""
+ a_key = "key"
+ a_dict = {a_key: "Taipy"}
+
+ def on_action_1(state):
+ state.a_dict.key = "Hello"
+
+ def on_action_2(state):
+ state.a_dict[state.a_key] = "World"
+
+ gui._set_frame(inspect.currentframe())
+ gui.add_page(name="test", page=page_md)
+ helpers.run_e2e(gui)
+ page.goto("/test")
+ page.expect_websocket()
+ page.wait_for_selector("#inp1")
+
+ assert_text(page, "Taipy", "Taipy")
+
+ page.fill("input#inp1", "Taipy is the best")
+ function_evaluated = True
+ try:
+ page.wait_for_function("document.querySelector('#inp2').value !== 'Taipy'")
+ except Exception as e:
+ function_evaluated = False
+ logging.getLogger().debug(f"Function evaluation timeout.\n{e}")
+ if function_evaluated:
+ assert_text(page, "Taipy is the best", "Taipy is the best")
+
+ page.fill("#inp2", "Taipy-Gui")
+ function_evaluated = True
+ try:
+ page.wait_for_function("document.querySelector('#inp1').value !== 'Taipy is the best'")
+ except Exception as e:
+ function_evaluated = False
+ logging.getLogger().debug(f"Function evaluation timeout.\n{e}")
+ if function_evaluated:
+ assert_text(page, "Taipy-Gui", "Taipy-Gui")
+
+ page.click("#btn1")
+ function_evaluated = True
+ try:
+ page.wait_for_function("document.querySelector('#inp1').value !== 'Taipy-Gui'")
+ except Exception as e:
+ function_evaluated = False
+ logging.getLogger().debug(f"Function evaluation timeout.\n{e}")
+ if function_evaluated:
+ assert_text(page, "Hello", "Hello")
+
+ page.click("#btn2")
+ function_evaluated = True
+ try:
+ page.wait_for_function("document.querySelector('#inp1').value !== 'Hello'")
+ except Exception as e:
+ function_evaluated = False
+ logging.getLogger().debug(f"Function evaluation timeout.\n{e}")
+ if function_evaluated:
+ assert_text(page, "World", "World")
+
+
+def assert_text(page, inp1, inp2):
+ assert page.input_value("input#inp1") == inp1
+ assert page.input_value("input#inp2") == inp2
diff --git a/tox.ini b/tox.ini
index 60a8616be..6a606fd8b 100644
--- a/tox.ini
+++ b/tox.ini
@@ -28,6 +28,7 @@ commands =
platform = linux
commands =
pipenv install --dev --skip-lock
+ pipenv run pip install "flask==2.1"
pipenv run pip uninstall gevent gevent-websocket -y
pipenv run ipython kernel install --name "python3" --user
pipenv run playwright install chromium
@@ -38,6 +39,7 @@ commands =
platform = win32
commands =
pipenv install --dev --skip-lock
+ pipenv run pip install "flask==2.1"
pipenv run pip uninstall gevent gevent-websocket -y
pipenv run ipython kernel install --name "python3" --user
pipenv run playwright install chromium
@@ -48,6 +50,7 @@ commands =
platform = darwin
commands =
pipenv install --dev --skip-lock
+ pipenv run pip install "flask==2.1"
pipenv run pip uninstall gevent gevent-websocket -y
pipenv run ipython kernel install --name "python3" --user
pipenv run playwright install chromium