소스 검색

table comparison (#1031)

* table comparison

* doc

* union

* Update taipy/gui/viselements.json

Co-authored-by: Dinh Long Nguyen <dinhlongviolin1@gmail.com>

---------

Co-authored-by: Fred Lefévère-Laoide <Fred.Lefevere-Laoide@Taipy.io>
Co-authored-by: Dinh Long Nguyen <dinhlongviolin1@gmail.com>
Fred Lefévère-Laoide 1 년 전
부모
커밋
6f19975d8b

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 244 - 301
frontend/taipy-gui/package-lock.json


+ 2 - 2
frontend/taipy-gui/src/components/Taipy/AutoLoadingTable.spec.tsx

@@ -238,8 +238,8 @@ describe("AutoLoadingTable Component", () => {
         const elts = getAllByText("Austria");
         elts.forEach((elt: HTMLElement, idx: number) =>
             selected.indexOf(idx) == -1
-                ? expect(elt.parentElement?.parentElement?.parentElement).not.toHaveClass("Mui-selected")
-                : expect(elt.parentElement?.parentElement?.parentElement).toHaveClass("Mui-selected")
+                ? expect(elt.parentElement?.parentElement?.parentElement?.parentElement).not.toHaveClass("Mui-selected")
+                : expect(elt.parentElement?.parentElement?.parentElement?.parentElement).toHaveClass("Mui-selected")
         );
         expect(document.querySelectorAll(".Mui-selected")).toHaveLength(selected.length);
     });

+ 45 - 8
frontend/taipy-gui/src/components/Taipy/AutoLoadingTable.tsx

@@ -74,7 +74,7 @@ import {
     useModule,
 } from "../../utils/hooks";
 import TableFilter, { FilterDesc } from "./TableFilter";
-import { getSuffixedClassNames } from "./utils";
+import { getSuffixedClassNames, getUpdateVar } from "./utils";
 
 interface RowData {
     colsOrder: string[];
@@ -91,6 +91,7 @@ interface RowData {
     onRowClick?: OnRowClick;
     lineStyle?: string;
     nanValue?: string;
+    compRows?: RowType[];
 }
 
 const Row = ({
@@ -110,7 +111,8 @@ const Row = ({
         onRowSelection,
         onRowClick,
         lineStyle,
-        nanValue
+        nanValue,
+        compRows,
     },
 }: {
     index: number;
@@ -143,6 +145,7 @@ const Row = ({
                     nanValue={columns[col].nanValue || nanValue}
                     tableCellProps={cellProps[cidx]}
                     tooltip={getTooltip(rows[index], columns[col].tooltip, col)}
+                    comp={compRows && compRows[index] && compRows[index][col]}
                 />
             ))}
         </TableRow>
@@ -184,9 +187,13 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
         size = DEFAULT_SIZE,
         userData,
         downloadable = false,
+        compare = false,
+        onCompare = "",
     } = props;
     const [rows, setRows] = useState<RowType[]>([]);
+    const [compRows, setCompRows] = useState<RowType[]>([]);
     const [rowCount, setRowCount] = useState(1000); // need something > 0 to bootstrap the infinite loader
+    const [filteredCount, setFilteredCount] = useState(0);
     const dispatch = useDispatch();
     const page = useRef<key2Rows>({ key: defaultKey, promises: {} });
     const [orderBy, setOrderBy] = useState("");
@@ -212,9 +219,15 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
             const newValue = props.data[page.current.key];
             const promise = page.current.promises[newValue.start];
             setRowCount(newValue.rowcount);
+            setFilteredCount(
+                newValue.fullrowcount && newValue.rowcount != newValue.fullrowcount
+                    ? newValue.fullrowcount - newValue.rowcount
+                    : 0
+            );
             const nr = newValue.data as RowType[];
             if (Array.isArray(nr) && nr.length > newValue.start) {
                 setRows(nr);
+                newValue.comp && setCompRows(newValue.comp as RowType[])
                 promise && promise.resolve();
             } else {
                 promise && promise.reject();
@@ -277,7 +290,12 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
                         col.tooltip = props.tooltip;
                     }
                 });
-                addDeleteColumn((active && (onAdd || onDelete) ? 1 : 0) + (active && filter ? 1 : 0) + (active && downloadable ? 1 : 0), baseColumns);
+                addDeleteColumn(
+                    (active && (onAdd || onDelete) ? 1 : 0) +
+                        (active && filter ? 1 : 0) +
+                        (active && downloadable ? 1 : 0),
+                    baseColumns
+                );
                 const colsOrder = Object.keys(baseColumns).sort(getsortByIndex(baseColumns));
                 const styTt = colsOrder.reduce<Record<string, Record<string, string>>>((pv, col) => {
                     if (baseColumns[col].style) {
@@ -308,7 +326,18 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
             hNan,
             false,
         ];
-    }, [active, editable, onAdd, onDelete, baseColumns, props.lineStyle, props.tooltip, props.nanValue, props.filter, downloadable]);
+    }, [
+        active,
+        editable,
+        onAdd,
+        onDelete,
+        baseColumns,
+        props.lineStyle,
+        props.tooltip,
+        props.nanValue,
+        props.filter,
+        downloadable,
+    ]);
 
     const boxBodySx = useMemo(() => ({ height: height }), [height]);
 
@@ -374,7 +403,9 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
                         styles,
                         tooltips,
                         handleNan,
-                        afs
+                        afs,
+                        compare ? onCompare : undefined,
+                        updateVars && getUpdateVar(updateVars, "comparedatas")
                     )
                 );
             });
@@ -384,6 +415,7 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
             styles,
             tooltips,
             updateVarName,
+            updateVars,
             orderBy,
             order,
             id,
@@ -391,6 +423,8 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
             columns,
             handleNan,
             appliedFilters,
+            compare,
+            onCompare,
             dispatch,
             module,
         ]
@@ -457,7 +491,7 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
                     index: getRowIndex(rows[rowIndex], rowIndex),
                     col: colName === undefined ? null : colName,
                     value,
-                    reason: value === undefined ? "click": "button",
+                    reason: value === undefined ? "click" : "button",
                     user_data: userData,
                 })
             ),
@@ -504,10 +538,12 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
             onRowSelection: active && onAction ? onRowSelection : undefined,
             onRowClick: active && onAction ? onRowClick : undefined,
             lineStyle: props.lineStyle,
-            nanValue: props.nanValue
+            nanValue: props.nanValue,
+            compRows: compRows,
         }),
         [
             rows,
+            compRows,
             isItemLoaded,
             active,
             colsOrder,
@@ -523,7 +559,7 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
             onRowClick,
             props.lineStyle,
             props.nanValue,
-            size
+            size,
         ]
     );
 
@@ -564,6 +600,7 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
                                                             onValidate={setAppliedFilters}
                                                             appliedFilters={appliedFilters}
                                                             className={className}
+                                                            filteredCount={filteredCount}
                                                         />
                                                     ) : null,
                                                     active && downloadable ? (

+ 2 - 2
frontend/taipy-gui/src/components/Taipy/PaginatedTable.spec.tsx

@@ -326,8 +326,8 @@ describe("PaginatedTable Component", () => {
         const elts = await waitFor(() => findAllByText("Austria"));
         elts.forEach((elt: HTMLElement, idx: number) =>
             selected.indexOf(idx) == -1
-                ? expect(elt.parentElement?.parentElement?.parentElement).not.toHaveClass("Mui-selected")
-                : expect(elt.parentElement?.parentElement?.parentElement).toHaveClass("Mui-selected")
+                ? expect(elt.parentElement?.parentElement?.parentElement?.parentElement).not.toHaveClass("Mui-selected")
+                : expect(elt.parentElement?.parentElement?.parentElement?.parentElement).toHaveClass("Mui-selected")
         );
         expect(document.querySelectorAll(".Mui-selected")).toHaveLength(selected.length);
     });

+ 19 - 4
frontend/taipy-gui/src/components/Taipy/PaginatedTable.tsx

@@ -80,7 +80,7 @@ import {
     useModule,
 } from "../../utils/hooks";
 import TableFilter, { FilterDesc } from "./TableFilter";
-import { getSuffixedClassNames } from "./utils";
+import { getSuffixedClassNames, getUpdateVar } from "./utils";
 
 const loadingStyle: CSSProperties = { width: "100%", height: "3em", textAlign: "right", verticalAlign: "center" };
 const skelSx = { width: "100%", height: "3em" };
@@ -105,6 +105,8 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
         size = DEFAULT_SIZE,
         userData,
         downloadable = false,
+        compare = false,
+        onCompare = "",
     } = props;
     const pageSize = props.pageSize === undefined || props.pageSize < 1 ? 100 : Math.round(props.pageSize);
     const [value, setValue] = useState<Record<string, unknown>>({});
@@ -237,7 +239,9 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
                     styles,
                     tooltips,
                     handleNan,
-                    afs
+                    afs,
+                    compare ? onCompare : undefined,
+                    updateVars && getUpdateVar(updateVars, "comparedatas")
                 )
             );
         } else {
@@ -256,11 +260,14 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
         order,
         orderBy,
         updateVarName,
+        updateVars,
         id,
         handleNan,
         appliedFilters,
         dispatch,
         module,
+        compare,
+        onCompare
     ]);
 
     const onSort = useCallback(
@@ -351,14 +358,20 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
         return psOptions;
     }, [pageSizeOptions, allowAllRows, pageSize]);
 
-    const { rows, rowCount } = useMemo(() => {
-        const ret = { rows: [], rowCount: 0 } as { rows: RowType[]; rowCount: number };
+    const { rows, rowCount, filteredCount, compRows } = useMemo(() => {
+        const ret = { rows: [], rowCount: 0, filteredCount: 0, compRows: [] } as { rows: RowType[]; rowCount: number; filteredCount: number; compRows: RowType[] };
         if (value) {
             if (value.data) {
                 ret.rows = value.data as RowType[];
             }
             if (value.rowcount) {
                 ret.rowCount = value.rowcount as unknown as number;
+                if (value.fullrowcount && value.rowcount != value.fullrowcount) {
+                    ret.filteredCount = (value.fullrowcount as unknown as number - ret.rowCount);
+                }
+            }
+            if (value.comp) {
+                ret.compRows = value.comp as RowType[];
             }
         }
         return ret;
@@ -455,6 +468,7 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
                                                             onValidate={setAppliedFilters}
                                                             appliedFilters={appliedFilters}
                                                             className={className}
+                                                            filteredCount={filteredCount}
                                                         />
                                                     ) : null,
                                                     active && downloadable ? (
@@ -551,6 +565,7 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
                                                     onSelection={active && onAction ? onRowSelection : undefined}
                                                     nanValue={columns[col].nanValue || props.nanValue}
                                                     tooltip={getTooltip(row, columns[col].tooltip, col)}
+                                                    comp={compRows && compRows[index] && compRows[index][col]}
                                                 />
                                             ))}
                                         </TableRow>

+ 10 - 10
frontend/taipy-gui/src/components/Taipy/TableFilter.spec.tsx

@@ -55,14 +55,14 @@ afterEach(() => {
 describe("Table Filter Component", () => {
     it("renders an icon", async () => {
         const { getByTestId } = render(
-            <TableFilter columns={tableColumns} colsOrder={colsOrder} onValidate={jest.fn()} />
+            <TableFilter columns={tableColumns} colsOrder={colsOrder} onValidate={jest.fn()} filteredCount={0} />
         );
         const elt = getByTestId("FilterListIcon");
         expect(elt.parentElement?.parentElement?.tagName).toBe("BUTTON");
     });
     it("renders popover when clicked", async () => {
         const { getByTestId, getAllByText, getAllByTestId } = render(
-            <TableFilter columns={tableColumns} colsOrder={colsOrder} onValidate={jest.fn()} />
+            <TableFilter columns={tableColumns} colsOrder={colsOrder} onValidate={jest.fn()} filteredCount={0} />
         );
         const elt = getByTestId("FilterListIcon");
         await userEvent.click(elt);
@@ -76,7 +76,7 @@ describe("Table Filter Component", () => {
     });
     it("behaves on string column", async () => {
         const { getByTestId, getAllByTestId, findByRole, getByText } = render(
-            <TableFilter columns={tableColumns} colsOrder={colsOrder} onValidate={jest.fn()} />
+            <TableFilter columns={tableColumns} colsOrder={colsOrder} onValidate={jest.fn()} filteredCount={0} />
         );
         const elt = getByTestId("FilterListIcon");
         await userEvent.click(elt);
@@ -93,7 +93,7 @@ describe("Table Filter Component", () => {
     });
     it("behaves on number column", async () => {
         const { getByTestId, getAllByTestId, findByRole, getByText, getAllByText } = render(
-            <TableFilter columns={tableColumns} colsOrder={colsOrder} onValidate={jest.fn()} />
+            <TableFilter columns={tableColumns} colsOrder={colsOrder} onValidate={jest.fn()} filteredCount={0} />
         );
         const elt = getByTestId("FilterListIcon");
         await userEvent.click(elt);
@@ -116,7 +116,7 @@ describe("Table Filter Component", () => {
     });
     it("behaves on boolean column", async () => {
         const { getByTestId, getAllByTestId, findByRole, getByText, getAllByText } = render(
-            <TableFilter columns={tableColumns} colsOrder={colsOrder} onValidate={jest.fn()} />
+            <TableFilter columns={tableColumns} colsOrder={colsOrder} onValidate={jest.fn()} filteredCount={0} />
         );
         const elt = getByTestId("FilterListIcon");
         await userEvent.click(elt);
@@ -140,7 +140,7 @@ describe("Table Filter Component", () => {
     });
     it("behaves on date column", async () => {
         const { getByTestId, getAllByTestId, findByRole, getByText, getByPlaceholderText } = render(
-                <TableFilter columns={tableColumns} colsOrder={colsOrder} onValidate={jest.fn()} />
+                <TableFilter columns={tableColumns} colsOrder={colsOrder} onValidate={jest.fn()} filteredCount={0} />
         );
         const elt = getByTestId("FilterListIcon");
         await userEvent.click(elt);
@@ -162,7 +162,7 @@ describe("Table Filter Component", () => {
     it("adds a row on validation", async () => {
         const onValidate = jest.fn();
         const { getByTestId, getAllByTestId, findByRole, getByText } = render(
-            <TableFilter columns={tableColumns} colsOrder={colsOrder} onValidate={onValidate} />
+            <TableFilter columns={tableColumns} colsOrder={colsOrder} onValidate={onValidate} filteredCount={0} />
         );
         const elt = getByTestId("FilterListIcon");
         await userEvent.click(elt);
@@ -185,7 +185,7 @@ describe("Table Filter Component", () => {
     it("delete a row", async () => {
         const onValidate = jest.fn();
         const { getByTestId, getAllByTestId, findByRole, getByText } = render(
-            <TableFilter columns={tableColumns} colsOrder={colsOrder} onValidate={onValidate} />
+            <TableFilter columns={tableColumns} colsOrder={colsOrder} onValidate={onValidate} filteredCount={0} />
         );
         const elt = getByTestId("FilterListIcon");
         await userEvent.click(elt);
@@ -213,7 +213,7 @@ describe("Table Filter Component", () => {
     it("reset filters", async () => {
         const onValidate = jest.fn();
         const { getAllByTestId, getByTestId } = render(
-            <TableFilter columns={tableColumns} colsOrder={colsOrder} onValidate={onValidate} appliedFilters={[{col: "StringCol", action: "==", value: ""}]} />
+            <TableFilter columns={tableColumns} colsOrder={colsOrder} onValidate={onValidate} appliedFilters={[{col: "StringCol", action: "==", value: ""}]} filteredCount={0} />
         );
         const elt = getByTestId("FilterListIcon");
         await userEvent.click(elt);
@@ -226,7 +226,7 @@ describe("Table Filter Component", () => {
     });
     it("ignores unapplicable filters", async () => {
         const { getAllByTestId, getByTestId } = render(
-            <TableFilter columns={tableColumns} colsOrder={colsOrder} onValidate={jest.fn()} appliedFilters={[{col: "unknown col", action: "==", value: ""}]} />
+            <TableFilter columns={tableColumns} colsOrder={colsOrder} onValidate={jest.fn()} appliedFilters={[{col: "unknown col", action: "==", value: ""}]} filteredCount={0} />
         );
         const elt = getByTestId("FilterListIcon");
         await userEvent.click(elt);

+ 20 - 3
frontend/taipy-gui/src/components/Taipy/TableFilter.tsx

@@ -45,6 +45,7 @@ interface TableFilterProps {
     onValidate: (data: Array<FilterDesc>) => void;
     appliedFilters?: Array<FilterDesc>;
     className?: string;
+    filteredCount: number;
 }
 
 interface FilterRowProps {
@@ -278,7 +279,7 @@ const FilterRow = (props: FilterRowProps) => {
 };
 
 const TableFilter = (props: TableFilterProps) => {
-    const { onValidate, appliedFilters, columns, colsOrder, className = "" } = props;
+    const { onValidate, appliedFilters, columns, colsOrder, className = "", filteredCount } = props;
 
     const [showFilter, setShowFilter] = useState(false);
     const filterRef = useRef<HTMLButtonElement | null>(null);
@@ -317,7 +318,12 @@ const TableFilter = (props: TableFilterProps) => {
 
     return (
         <>
-            <Tooltip title={`${filters.length} filter${filters.length > 1 ? "s" : ""} applied`}>
+            <Tooltip
+                title={
+                    `${filters.length} filter${filters.length > 1 ? "s" : ""} applied` +
+                    (filteredCount ? ` (${filteredCount} non visible rows)` : "")
+                }
+            >
                 <IconButton
                     onClick={onShowFilterClick}
                     size="small"
@@ -325,7 +331,18 @@ const TableFilter = (props: TableFilterProps) => {
                     sx={iconInRowSx}
                     className={getSuffixedClassNames(className, "-filter-icon")}
                 >
-                    <Badge badgeContent={filters.length} color="primary" sx={{"& .MuiBadge-badge":{height: "10px", minWidth: "10px", width: "10px", borderRadius: "5px"}}}>
+                    <Badge
+                        badgeContent={filters.length}
+                        color="primary"
+                        sx={{
+                            "& .MuiBadge-badge": {
+                                height: "10px",
+                                minWidth: "10px",
+                                width: "10px",
+                                borderRadius: "5px",
+                            },
+                        }}
+                    >
                         <FilterListIcon fontSize="inherit" />
                     </Badge>
                 </IconButton>

+ 16 - 5
frontend/taipy-gui/src/components/Taipy/tableUtils.tsx

@@ -21,12 +21,16 @@ import React, {
     ChangeEvent,
     SyntheticEvent,
 } from "react";
+import { FilterOptionsState } from "@mui/material";
 import Autocomplete, { createFilterOptions } from "@mui/material/Autocomplete";
+import Badge from "@mui/material/Badge";
 import Box from "@mui/material/Box";
+import Button from "@mui/material/Button";
+import IconButton from "@mui/material/IconButton";
 import Input from "@mui/material/Input";
-import TableCell, { TableCellProps } from "@mui/material/TableCell";
 import Switch from "@mui/material/Switch";
-import IconButton from "@mui/material/IconButton";
+import TableCell, { TableCellProps } from "@mui/material/TableCell";
+import TextField from "@mui/material/TextField";
 import CheckIcon from "@mui/icons-material/Check";
 import ClearIcon from "@mui/icons-material/Clear";
 import EditIcon from "@mui/icons-material/Edit";
@@ -39,7 +43,6 @@ import { isValid } from "date-fns";
 import { FormatConfig } from "../../context/taipyReducers";
 import { dateToString, getDateTime, getDateTimeString, getNumberString, getTimeZonedDate } from "../../utils/index";
 import { TaipyActiveProps, TaipyMultiSelectProps, getSuffixedClassNames } from "./utils";
-import { Button, FilterOptionsState, TextField } from "@mui/material";
 
 /**
  * A column description as received by the backend.
@@ -129,6 +132,8 @@ export interface TaipyTableProps extends TaipyActiveProps, TaipyMultiSelectProps
     defaultKey?: string; // for testing purposes only
     userData?: unknown;
     downloadable?: boolean;
+    onCompare?: string;
+    compare?: boolean;
 }
 
 export const DownloadAction = "__Taipy__download_csv";
@@ -184,6 +189,7 @@ interface EditableCellProps {
     className?: string;
     tooltip?: string;
     tableCellProps?: Partial<TableCellProps>;
+    comp?: RowValue;
 }
 
 export const defaultColumns = {} as Record<string, ColumnDesc>;
@@ -267,6 +273,8 @@ const getOptionLabel = (option: string) => (Array.isArray(option) ? option[1] :
 
 const onCompleteClose = (evt: SyntheticEvent) => evt.stopPropagation();
 
+const emptyObject = {};
+
 export const EditableCell = (props: EditableCellProps) => {
     const {
         onValidation,
@@ -279,7 +287,8 @@ export const EditableCell = (props: EditableCellProps) => {
         nanValue,
         className,
         tooltip,
-        tableCellProps = {},
+        tableCellProps = emptyObject,
+        comp,
     } = props;
     const [val, setVal] = useState<RowValue | Date>(value);
     const [edit, setEdit] = useState(false);
@@ -444,8 +453,9 @@ export const EditableCell = (props: EditableCellProps) => {
             className={
                 onValidation ? getSuffixedClassNames(className || "tpc", edit ? "-editing" : "-editable") : className
             }
-            title={tooltip}
+            title={tooltip || comp ? `${tooltip ? tooltip : ""}${comp ? " " + formatValue(comp as RowValue, colDesc, formatConfig, nanValue) : ""}` : undefined}
         >
+            <Badge color="primary" variant="dot" invisible={comp === undefined || comp === null}>
             {edit ? (
                 colDesc.type?.startsWith("bool") ? (
                     <Box sx={cellBoxSx}>
@@ -604,6 +614,7 @@ export const EditableCell = (props: EditableCellProps) => {
                     ) : null}
                 </Box>
             )}
+            </Badge>
         </TableCell>
     );
 };

+ 10 - 2
frontend/taipy-gui/src/context/taipyReducers.ts

@@ -591,7 +591,9 @@ export const createRequestTableUpdateAction = (
     styles?: Record<string, string>,
     tooltips?: Record<string, string>,
     handleNan?: boolean,
-    filters?: Array<FilterDesc>
+    filters?: Array<FilterDesc>,
+    compare?: string,
+    compareDatas?: string
 ): TaipyAction =>
     createRequestDataUpdateAction(name, id, context, columns, pageKey, {
         start: start,
@@ -604,6 +606,8 @@ export const createRequestTableUpdateAction = (
         tooltips: tooltips,
         handlenan: handleNan,
         filters: filters,
+        compare: compare,
+        compare_datas: compareDatas,
     });
 
 export const createRequestInfiniteTableUpdateAction = (
@@ -621,7 +625,9 @@ export const createRequestInfiniteTableUpdateAction = (
     styles?: Record<string, string>,
     tooltips?: Record<string, string>,
     handleNan?: boolean,
-    filters?: Array<FilterDesc>
+    filters?: Array<FilterDesc>,
+    compare?: string,
+    compareDatas?: string
 ): TaipyAction =>
     createRequestDataUpdateAction(name, id, context, columns, pageKey, {
         infinite: true,
@@ -635,6 +641,8 @@ export const createRequestInfiniteTableUpdateAction = (
         tooltips: tooltips,
         handlenan: handleNan,
         filters: filters,
+        compare: compare,
+        compare_datas: compareDatas,
     });
 
 /**

+ 1 - 1
frontend/taipy-gui/src/utils/hooks.ts

@@ -92,7 +92,7 @@ export const useDispatchRequestUpdateOnFirstRender = (
     forceRefresh?: boolean
 ) => {
     useEffect(() => {
-        const updateArray = getUpdateVars(updateVars);
+        const updateArray = getUpdateVars(updateVars).filter(uv => !uv.includes(","));
         varName && updateArray.push(varName);
         updateArray.length && dispatch(createRequestUpdateAction(id, context, updateArray, forceRefresh));
     }, [updateVars, dispatch, id, context, varName, forceRefresh]);

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 280 - 284
frontend/taipy/package-lock.json


+ 31 - 4
taipy/gui/_renderers/builder.py

@@ -78,7 +78,7 @@ class _Builder:
         control_type: str,
         element_name: str,
         attributes: t.Optional[t.Dict[str, t.Any]],
-        hash_names: t.Dict[str, str] = None,
+        hash_names: t.Optional[t.Dict[str, str]] = None,
         default_value="<Empty>",
         lib_name: str = "taipy",
     ):
@@ -146,7 +146,7 @@ class _Builder:
 
     @staticmethod
     def _get_variable_hash_names(
-        gui: "Gui", attributes: t.Dict[str, t.Any], hash_names: t.Dict[str, str] = None
+        gui: "Gui", attributes: t.Dict[str, t.Any], hash_names: t.Optional[t.Dict[str, str]] = None
     ) -> t.Dict[str, str]:
         if hash_names is None:
             hash_names = {}
@@ -446,7 +446,7 @@ class _Builder:
     def __filter_attribute_names(self, names: t.Iterable[str]):
         return [k for k in self.__attributes if k in names or any(k.startswith(n + "[") for n in names)]
 
-    def __get_holded_name(self, key: str):
+    def __get_held_name(self, key: str):
         name = self.__hashes.get(key)
         if name:
             v = self.__attributes.get(key)
@@ -459,7 +459,7 @@ class _Builder:
         attr_names = [k for k in keys if k not in hash_names]
         return (
             {k: v for k, v in self.__attributes.items() if k in attr_names},
-            {k: self.__get_holded_name(k) for k in self.__hashes if k in hash_names},
+            {k: self.__get_held_name(k) for k in self.__hashes if k in hash_names},
         )
 
     def __build_rebuild_fn(self, fn_name: str, attribute_names: t.Iterable[str]):
@@ -483,6 +483,23 @@ class _Builder:
         date_format = _add_to_dict_and_get(self.__attributes, "date_format", "MM/dd/yyyy")
         data = self.__attributes.get("data")
         data_hash = self.__hashes.get("data", "")
+        cmp_hash = ""
+        if data_hash:
+            cmp_idx = 1
+            cmp_datas = []
+            cmp_datas_hash = []
+            while cmp_data := self.__hashes.get(f"data[{cmp_idx}]"):
+                cmp_idx += 1
+                cmp_datas.append(self.__gui._get_real_var_name(cmp_data)[0])
+                cmp_datas_hash.append(cmp_data)
+            if cmp_datas:
+                cmp_hash = self.__gui._evaluate_expr(
+                    "{"
+                    + f'{self.__gui._get_rebuild_fn_name("_compare_data")}'
+                    + f'({self.__gui._get_real_var_name(data_hash)[0]},{",".join(cmp_datas)})'
+                    + "}"
+                )
+                self.__update_vars.append(f"comparedatas={','.join(cmp_datas_hash)}")
         col_types = self.__gui._accessors._get_col_types(data_hash, _TaipyData(data, data_hash))
         col_dict = _get_columns_dict(
             data, self.__attributes.get("columns", {}), col_types, date_format, self.__attributes.get("number_format")
@@ -496,6 +513,16 @@ class _Builder:
         if col_dict is not None:
             _enhance_columns(self.__attributes, self.__hashes, col_dict, self.__element_name)
             self.__set_json_attribute("defaultColumns", col_dict)
+        if cmp_hash:
+            hash_name = self.__get_typed_hash_name(cmp_hash, PropertyType.data)
+            self.__set_react_attribute(
+                _to_camel_case("data"),
+                _get_client_var_name(hash_name),
+            )
+            self.__set_update_var_name(hash_name)
+            self.set_boolean_attribute("compare", True)
+            self.__set_string_attribute("on_compare")
+
         if line_style := self.__attributes.get("style"):
             if callable(line_style):
                 value = self.__hashes.get("style")

+ 3 - 3
taipy/gui/data/array_dict_data_accessor.py

@@ -26,7 +26,7 @@ class _ArrayDictDataAccessor(_PandasDataAccessor):
     def get_supported_classes() -> t.List[str]:
         return [t.__name__ for t in _ArrayDictDataAccessor.__types]  # type: ignore
 
-    def __get_dataframe(self, value: t.Any) -> t.Union[t.List[pd.DataFrame], pd.DataFrame]:
+    def _get_dataframe(self, value: t.Any) -> t.Union[t.List[pd.DataFrame], pd.DataFrame]:
         if isinstance(value, list):
             if not value or isinstance(value[0], (str, int, float, bool)):
                 return pd.DataFrame({"0": value})
@@ -55,12 +55,12 @@ class _ArrayDictDataAccessor(_PandasDataAccessor):
 
     def get_col_types(self, var_name: str, value: t.Any) -> t.Union[None, t.Dict[str, str]]:  # type: ignore
         if isinstance(value, _ArrayDictDataAccessor.__types):  # type: ignore
-            return super().get_col_types(var_name, self.__get_dataframe(value))
+            return super().get_col_types(var_name, self._get_dataframe(value))
         return None
 
     def get_data(  # noqa: C901
         self, guiApp: Gui, var_name: str, value: t.Any, payload: t.Dict[str, t.Any], data_format: _DataFormat
     ) -> t.Dict[str, t.Any]:
         if isinstance(value, _ArrayDictDataAccessor.__types):  # type: ignore
-            return super().get_data(guiApp, var_name, self.__get_dataframe(value), payload, data_format)
+            return super().get_data(guiApp, var_name, self._get_dataframe(value), payload, data_format)
         return {}

+ 40 - 0
taipy/gui/data/comparison.py

@@ -0,0 +1,40 @@
+# 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.
+
+import typing as t
+
+import pandas as pd
+
+from .._warnings import _warn
+from ..gui import Gui
+from ..utils import _getscopeattr
+
+
+def _compare_function(
+    gui: Gui, compare_name: str, name: str, value: pd.DataFrame, datanames: str
+) -> t.Optional[pd.DataFrame]:
+    try:
+        names = datanames.split(",")
+        if not names:
+            return None
+        compare_fn = gui._get_user_function(compare_name) if compare_name else None
+        if callable(compare_fn):
+            return gui._accessors._get_dataframe(
+                gui._call_function_with_state(compare_fn, [name, [gui._get_real_var_name(n) for n in names]])
+            )
+        elif compare_fn is not None:
+            _warn(f"{compare_name}(): compare function name is not valid.")
+        dfs = [gui._accessors._get_dataframe(_getscopeattr(gui, n)) for n in names]
+        return value.compare(dfs[0], keep_shape=True)
+    except Exception as e:
+        if not gui._call_on_exception(compare_name or "Gui._compare_function", e):
+            _warn(f"{compare_name or 'Gui._compare_function'}(): compare function raised an exception", e)
+        return None

+ 11 - 1
taipy/gui/data/data_accessor.py

@@ -36,6 +36,10 @@ class _DataAccessor(ABC):
     def get_col_types(self, var_name: str, value: t.Any) -> t.Dict[str, str]:
         pass
 
+    @abstractmethod
+    def _get_dataframe(self, value: t.Any) -> t.Union[t.List[t.Any], t.Any]:
+        pass
+
 
 class _InvalidDataAccessor(_DataAccessor):
     @staticmethod
@@ -50,6 +54,9 @@ class _InvalidDataAccessor(_DataAccessor):
     def get_col_types(self, var_name: str, value: t.Any) -> t.Dict[str, str]:
         return {}
 
+    def _get_dataframe(self, value: t.Any) -> t.Union[t.List[t.Any], t.Any]:
+        return None
+
 
 class _DataAccessors(object):
     def __init__(self) -> None:
@@ -91,7 +98,7 @@ class _DataAccessors(object):
                     self.__access_4_type[name] = inst  # type: ignore
 
     def __get_instance(self, value: _TaipyData) -> _DataAccessor:  # type: ignore
-        value = value.get()
+        value = value.get() if isinstance(value, _TaipyData) else value
         access = self.__access_4_type.get(type(value).__name__)
         if access is None:
             if value is not None:
@@ -109,3 +116,6 @@ class _DataAccessors(object):
 
     def _set_data_format(self, data_format: _DataFormat):
         self.__data_format = data_format
+
+    def _get_dataframe(self, value: t.Any):
+        return self.__get_instance(value)._get_dataframe(value)

+ 3 - 3
taipy/gui/data/numpy_data_accessor.py

@@ -26,17 +26,17 @@ class _NumpyDataAccessor(_PandasDataAccessor):
     def get_supported_classes() -> t.List[str]:
         return [t.__name__ for t in _NumpyDataAccessor.__types]  # type: ignore
 
-    def __get_dataframe(self, value: t.Any) -> pd.DataFrame:
+    def _get_dataframe(self, value: t.Any) -> pd.DataFrame:
         return pd.DataFrame(value)
 
     def get_col_types(self, var_name: str, value: t.Any) -> t.Union[None, t.Dict[str, str]]:  # type: ignore
         if isinstance(value, _NumpyDataAccessor.__types):  # type: ignore
-            return super().get_col_types(var_name, self.__get_dataframe(value))
+            return super().get_col_types(var_name, self._get_dataframe(value))
         return None
 
     def get_data(  # noqa: C901
         self, guiApp: Gui, var_name: str, value: t.Any, payload: t.Dict[str, t.Any], data_format: _DataFormat
     ) -> t.Dict[str, t.Any]:
         if isinstance(value, _NumpyDataAccessor.__types):  # type: ignore
-            return super().get_data(guiApp, var_name, self.__get_dataframe(value), payload, data_format)
+            return super().get_data(guiApp, var_name, self._get_dataframe(value), payload, data_format)
         return {}

+ 36 - 3
taipy/gui/data/pandas_data_accessor.py

@@ -20,6 +20,7 @@ from .._warnings import _warn
 from ..gui import Gui
 from ..types import PropertyType
 from ..utils import _RE_PD_TYPE, _get_date_col_str_name
+from .comparison import _compare_function
 from .data_accessor import _DataAccessor
 from .data_format import _DataFormat
 from .utils import _df_data_filter, _df_relayout
@@ -37,6 +38,9 @@ class _PandasDataAccessor(_DataAccessor):
 
     __AGGREGATE_FUNCTIONS: t.List[str] = ["count", "sum", "mean", "median", "min", "max", "std", "first", "last"]
 
+    def _get_dataframe(self, value: t.Any) -> t.Any:
+        return value
+
     @staticmethod
     def get_supported_classes() -> t.List[str]:
         return [t.__name__ for t in _PandasDataAccessor.__types]  # type: ignore
@@ -167,12 +171,15 @@ class _PandasDataAccessor(_DataAccessor):
         rowcount: t.Optional[int] = None,
         data_extraction: t.Optional[bool] = None,
         handle_nan: t.Optional[bool] = False,
+        fullrowcount: t.Optional[int] = None,
     ) -> t.Dict[str, t.Any]:
         ret: t.Dict[str, t.Any] = {
             "format": str(data_format.value),
         }
         if rowcount is not None:
             ret["rowcount"] = rowcount
+        if fullrowcount is not None and fullrowcount != rowcount:
+            ret["fullrowcount"] = fullrowcount
         if start is not None:
             ret["start"] = start
         if data_extraction is not None:
@@ -229,6 +236,7 @@ class _PandasDataAccessor(_DataAccessor):
 
         if isinstance(value, pd.Series):
             value = value.to_frame()
+        orig_df = value
         # add index if not chart
         if paged:
             if _PandasDataAccessor.__INDEX_COL not in value.columns:
@@ -238,6 +246,7 @@ class _PandasDataAccessor(_DataAccessor):
             if columns and _PandasDataAccessor.__INDEX_COL not in columns:
                 columns.append(_PandasDataAccessor.__INDEX_COL)
 
+        fullrowcount = len(value)
         # filtering
         filters = payload.get("filters")
         if isinstance(filters, list) and len(filters) > 0:
@@ -331,8 +340,31 @@ class _PandasDataAccessor(_DataAccessor):
                 handle_nan=payload.get("handlenan", False),
             )
             dictret = self.__format_data(
-                value, data_format, "records", start, rowcount, handle_nan=payload.get("handlenan", False)
+                value,
+                data_format,
+                "records",
+                start,
+                rowcount,
+                handle_nan=payload.get("handlenan", False),
+                fullrowcount=fullrowcount,
             )
+            compare = payload.get("compare")
+            if isinstance(compare, str):
+                comp_df = _compare_function(
+                    gui, compare, var_name, t.cast(pd.DataFrame, orig_df), payload.get("compare_datas", "")
+                )
+                if isinstance(comp_df, pd.DataFrame) and not comp_df.empty:
+                    try:
+                        if isinstance(comp_df.columns[0], tuple):
+                            cols: t.List[t.Hashable] = [c for c in comp_df.columns if c[1] == "other"]
+                            comp_df = t.cast(pd.DataFrame, comp_df.get(cols))
+                            comp_df.columns = t.cast(pd.Index, [t.cast(tuple, c)[0] for c in cols])
+                        comp_df.dropna(axis=1, how="all", inplace=True)
+                        comp_df = self.__build_transferred_cols(gui, columns, comp_df, new_indexes=new_indexes)
+                        dictret["comp"] = self.__format_data(comp_df, data_format, "records").get("data")
+                    except Exception as e:
+                        _warn("Pandas accessor compare raised an exception", e)
+
         else:
             ret_payload["alldata"] = True
             decimator_payload: t.Dict[str, t.Any] = payload.get("decimatorPayload", {})
@@ -358,13 +390,13 @@ class _PandasDataAccessor(_DataAccessor):
                         y1 = relayoutData.get("yaxis.range[1]")
 
                         value, is_copied = _df_relayout(
-                            value, x_column, y_column, chart_mode, x0, x1, y0, y1, is_copied
+                            t.cast(pd.DataFrame, value), x_column, y_column, chart_mode, x0, x1, y0, y1, is_copied
                         )
 
                     if nb_rows_max and decimator_instance._is_applicable(value, nb_rows_max, chart_mode):
                         try:
                             value, is_copied = _df_data_filter(
-                                value,
+                                t.cast(pd.DataFrame, value),
                                 x_column,
                                 y_column,
                                 z_column,
@@ -381,6 +413,7 @@ class _PandasDataAccessor(_DataAccessor):
                 dictret = None
             else:
                 dictret = self.__format_data(value, data_format, "list", data_extraction=True)
+
         ret_payload["value"] = dictret
         return ret_payload
 

+ 3 - 0
taipy/gui/gui.py

@@ -1502,6 +1502,9 @@ class Gui:
         attributes.update({k: args_dict.get(v) for k, v in hashes.items()})
         return attributes, hashes
 
+    def _compare_data(self, *data):
+        return data[0]
+
     def _tbl_cols(
         self, rebuild: bool, rebuild_val: t.Optional[bool], attr_json: str, hash_json: str, **kwargs
     ) -> t.Union[str, _DoNotUpdate]:

+ 0 - 6
taipy/gui/gui_actions.py

@@ -370,12 +370,6 @@ def invoke_long_callback(
     if not state or not isinstance(state._gui, Gui):
         _warn("'invoke_long_callback()' must be called in the context of a callback.")
 
-    if user_status_function_args is None:
-        user_status_function_args = []
-    if user_function_args is None:
-        user_function_args = []
-        return
-
     if user_status_function_args is None:
         user_status_function_args = []
     if user_function_args is None:

+ 7 - 1
taipy/gui/viselements.json

@@ -951,7 +951,7 @@
             "default_property": true,
             "required": true,
             "type": "dynamic(any)",
-            "doc": "The data to be represented in this table."
+            "doc": "The data to be represented in this table. This property can be indexed to define other data for comparison."
           },
           {
             "name": "page_size",
@@ -1135,6 +1135,12 @@
             "name": "downloadable",
             "type": "boolean",
             "doc": "If True, a clickable icon is shown so the user can download the data as CSV."
+          },
+          {
+            "name": "on_compare",
+            "type": "str",
+            "doc": "A data comparison function that would return a structure that identifies the differences between the different data passed as name. The default implementation compares the default data with the data[1] value.",
+            "signature": [["state", "State"], ["main_data_name", "str"], ["compare_data_names", "list[str]"]]
           }
         ]
       }

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.