PaginatedTable.tsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647
  1. /*
  2. * Copyright 2021-2024 Avaiga Private Limited
  3. *
  4. * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
  5. * the License. You may obtain a copy of the License at
  6. *
  7. * http://www.apache.org/licenses/LICENSE-2.0
  8. *
  9. * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
  10. * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
  11. * specific language governing permissions and limitations under the License.
  12. */
  13. import React, {
  14. useState,
  15. useEffect,
  16. useCallback,
  17. useRef,
  18. useMemo,
  19. CSSProperties,
  20. ChangeEvent,
  21. MouseEvent,
  22. } from "react";
  23. import Box from "@mui/material/Box";
  24. import Table from "@mui/material/Table";
  25. import TableBody from "@mui/material/TableBody";
  26. import TableCell from "@mui/material/TableCell";
  27. import TableContainer from "@mui/material/TableContainer";
  28. import TableHead from "@mui/material/TableHead";
  29. import TablePagination from "@mui/material/TablePagination";
  30. import TableRow from "@mui/material/TableRow";
  31. import TableSortLabel from "@mui/material/TableSortLabel";
  32. import Paper from "@mui/material/Paper";
  33. import Skeleton from "@mui/material/Skeleton";
  34. import Typography from "@mui/material/Typography";
  35. import Tooltip from "@mui/material/Tooltip";
  36. import { visuallyHidden } from "@mui/utils";
  37. import IconButton from "@mui/material/IconButton";
  38. import AddIcon from "@mui/icons-material/Add";
  39. import DataSaverOn from "@mui/icons-material/DataSaverOn";
  40. import DataSaverOff from "@mui/icons-material/DataSaverOff";
  41. import Download from "@mui/icons-material/Download";
  42. import { createRequestTableUpdateAction, createSendActionNameAction } from "../../context/taipyReducers";
  43. import {
  44. addDeleteColumn,
  45. baseBoxSx,
  46. defaultColumns,
  47. EditableCell,
  48. EDIT_COL,
  49. getClassName,
  50. getsortByIndex,
  51. headBoxSx,
  52. LINE_STYLE,
  53. OnCellValidation,
  54. OnRowDeletion,
  55. Order,
  56. PageSizeOptionsType,
  57. paperSx,
  58. RowType,
  59. RowValue,
  60. tableSx,
  61. TaipyPaginatedTableProps,
  62. ColumnDesc,
  63. iconInRowSx,
  64. DEFAULT_SIZE,
  65. OnRowSelection,
  66. getRowIndex,
  67. getTooltip,
  68. OnRowClick,
  69. DownloadAction,
  70. } from "./tableUtils";
  71. import {
  72. useClassNames,
  73. useDispatch,
  74. useDispatchRequestUpdateOnFirstRender,
  75. useDynamicJsonProperty,
  76. useDynamicProperty,
  77. useFormatConfig,
  78. useModule,
  79. } from "../../utils/hooks";
  80. import TableFilter, { FilterDesc } from "./TableFilter";
  81. import { getSuffixedClassNames, getUpdateVar } from "./utils";
  82. import { emptyArray } from "../../utils";
  83. const loadingStyle: CSSProperties = { width: "100%", height: "3em", textAlign: "right", verticalAlign: "center" };
  84. const skelSx = { width: "100%", height: "3em" };
  85. const rowsPerPageOptions: PageSizeOptionsType = [10, 50, 100, 500];
  86. const PaginatedTable = (props: TaipyPaginatedTableProps) => {
  87. const {
  88. id,
  89. updateVarName,
  90. pageSizeOptions,
  91. allowAllRows = false,
  92. showAll = false,
  93. height,
  94. selected = emptyArray,
  95. updateVars,
  96. onEdit = "",
  97. onDelete = "",
  98. onAdd = "",
  99. onAction = "",
  100. width = "100%",
  101. size = DEFAULT_SIZE,
  102. userData,
  103. downloadable = false,
  104. compare = false,
  105. onCompare = "",
  106. } = props;
  107. const pageSize = props.pageSize === undefined || props.pageSize < 1 ? 100 : Math.round(props.pageSize);
  108. const [value, setValue] = useState<Record<string, unknown>>({});
  109. const [startIndex, setStartIndex] = useState(0);
  110. const [rowsPerPage, setRowsPerPage] = useState(pageSize);
  111. const [order, setOrder] = useState<Order>("asc");
  112. const [orderBy, setOrderBy] = useState("");
  113. const [loading, setLoading] = useState(true);
  114. const [aggregates, setAggregates] = useState<string[]>([]);
  115. const [appliedFilters, setAppliedFilters] = useState<FilterDesc[]>([]);
  116. const dispatch = useDispatch();
  117. const pageKey = useRef("no-page");
  118. const selectedRowRef = useRef<HTMLTableRowElement | null>(null);
  119. const formatConfig = useFormatConfig();
  120. const module = useModule();
  121. const refresh = typeof props.data === "number";
  122. const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
  123. const active = useDynamicProperty(props.active, props.defaultActive, true);
  124. const editable = useDynamicProperty(props.editable, props.defaultEditable, false);
  125. const hover = useDynamicProperty(props.hoverText, props.defaultHoverText, undefined);
  126. const baseColumns = useDynamicJsonProperty(props.columns, props.defaultColumns, defaultColumns);
  127. const [colsOrder, columns, styles, tooltips, handleNan, filter] = useMemo(() => {
  128. let hNan = !!props.nanValue;
  129. if (baseColumns) {
  130. try {
  131. let filter = false;
  132. const newCols: Record<string, ColumnDesc> = {};
  133. Object.entries(baseColumns).forEach(([cId, cDesc]) => {
  134. const nDesc = (newCols[cId] = { ...cDesc });
  135. if (typeof nDesc.filter != "boolean") {
  136. nDesc.filter = !!props.filter;
  137. }
  138. filter = filter || nDesc.filter;
  139. if (typeof nDesc.notEditable != "boolean") {
  140. nDesc.notEditable = !editable;
  141. }
  142. if (nDesc.tooltip === undefined) {
  143. nDesc.tooltip = props.tooltip;
  144. }
  145. });
  146. addDeleteColumn(
  147. (active && editable && (onAdd || onDelete) ? 1 : 0) +
  148. (active && filter ? 1 : 0) +
  149. (active && downloadable ? 1 : 0),
  150. newCols
  151. );
  152. const colsOrder = Object.keys(newCols).sort(getsortByIndex(newCols));
  153. const styTt = colsOrder.reduce<Record<string, Record<string, string>>>((pv, col) => {
  154. if (newCols[col].style) {
  155. pv.styles = pv.styles || {};
  156. pv.styles[newCols[col].dfid] = newCols[col].style as string;
  157. }
  158. hNan = hNan || !!newCols[col].nanValue;
  159. if (newCols[col].tooltip) {
  160. pv.tooltips = pv.tooltips || {};
  161. pv.tooltips[newCols[col].dfid] = newCols[col].tooltip as string;
  162. }
  163. return pv;
  164. }, {});
  165. if (props.lineStyle) {
  166. styTt.styles = styTt.styles || {};
  167. styTt.styles[LINE_STYLE] = props.lineStyle;
  168. }
  169. return [colsOrder, newCols, styTt.styles, styTt.tooltips, hNan, filter];
  170. } catch (e) {
  171. console.info("PaginatedTable.columns: ", (e as Error).message || e);
  172. }
  173. }
  174. return [
  175. [] as string[],
  176. {} as Record<string, ColumnDesc>,
  177. {} as Record<string, string>,
  178. {} as Record<string, string>,
  179. hNan,
  180. false,
  181. ];
  182. }, [
  183. active,
  184. editable,
  185. onAdd,
  186. onDelete,
  187. baseColumns,
  188. props.lineStyle,
  189. props.tooltip,
  190. props.nanValue,
  191. props.filter,
  192. downloadable,
  193. ]);
  194. useDispatchRequestUpdateOnFirstRender(dispatch, id, module, updateVars);
  195. /*
  196. TODO: If the 'selected' value is a negative number, it will lead to unexpected pagination behavior.
  197. For instance, if 'selected' is -1, the pagination will display from -99 to 0 and no data will be selected.
  198. Need to fix this issue.
  199. */
  200. useEffect(() => {
  201. if (selected.length) {
  202. if (selected[0] < startIndex || selected[0] > startIndex + rowsPerPage) {
  203. setLoading(true);
  204. setStartIndex(rowsPerPage * Math.floor(selected[0] / rowsPerPage));
  205. }
  206. }
  207. }, [selected, startIndex, rowsPerPage]);
  208. useEffect(() => {
  209. if (!refresh && props.data && props.data[pageKey.current] !== undefined) {
  210. setValue(props.data[pageKey.current]);
  211. setLoading(false);
  212. }
  213. }, [refresh, props.data]);
  214. useEffect(() => {
  215. const endIndex = showAll ? -1 : startIndex + rowsPerPage - 1;
  216. const agg = aggregates.length
  217. ? colsOrder.reduce((pv, col, idx) => {
  218. if (aggregates.includes(columns[col].dfid)) {
  219. return pv + "-" + idx;
  220. }
  221. return pv;
  222. }, "-agg")
  223. : "";
  224. const cols = colsOrder.map((col) => columns[col].dfid).filter((c) => c != EDIT_COL);
  225. const afs = appliedFilters.filter((fd) => Object.values(columns).some((cd) => cd.dfid === fd.col));
  226. pageKey.current = `${startIndex}-${endIndex}-${cols.join()}-${orderBy}-${order}${agg}${afs.map(
  227. (af) => `${af.col}${af.action}${af.value}`
  228. )}`;
  229. if (refresh || !props.data || props.data[pageKey.current] === undefined) {
  230. setLoading(true);
  231. const applies = aggregates.length
  232. ? colsOrder.reduce<Record<string, unknown>>((pv, col) => {
  233. if (columns[col].apply) {
  234. pv[columns[col].dfid] = columns[col].apply;
  235. }
  236. return pv;
  237. }, {})
  238. : undefined;
  239. dispatch(
  240. createRequestTableUpdateAction(
  241. updateVarName,
  242. id,
  243. module,
  244. cols,
  245. pageKey.current,
  246. startIndex,
  247. endIndex,
  248. orderBy,
  249. order,
  250. aggregates,
  251. applies,
  252. styles,
  253. tooltips,
  254. handleNan,
  255. afs,
  256. compare ? onCompare : undefined,
  257. updateVars && getUpdateVar(updateVars, "comparedatas"),
  258. typeof userData == "object"
  259. ? (userData as Record<string, Record<string, unknown>>).context
  260. : undefined
  261. )
  262. );
  263. } else {
  264. setValue(props.data[pageKey.current]);
  265. setLoading(false);
  266. }
  267. // eslint-disable-next-line react-hooks/exhaustive-deps
  268. }, [
  269. refresh,
  270. startIndex,
  271. aggregates,
  272. colsOrder,
  273. columns,
  274. showAll,
  275. rowsPerPage,
  276. order,
  277. orderBy,
  278. updateVarName,
  279. updateVars,
  280. id,
  281. handleNan,
  282. appliedFilters,
  283. dispatch,
  284. module,
  285. compare,
  286. onCompare,
  287. userData,
  288. ]);
  289. const onSort = useCallback(
  290. (e: MouseEvent<HTMLElement>) => {
  291. const col = e.currentTarget.getAttribute("data-dfid");
  292. if (col) {
  293. const isAsc = orderBy === col && order === "asc";
  294. setOrder(isAsc ? "desc" : "asc");
  295. setOrderBy(col);
  296. }
  297. },
  298. [orderBy, order]
  299. );
  300. const handleChangePage = useCallback(
  301. (event: unknown, newPage: number) => {
  302. setStartIndex(newPage * rowsPerPage);
  303. },
  304. [rowsPerPage]
  305. );
  306. const handleChangeRowsPerPage = useCallback((event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
  307. setLoading(true);
  308. setRowsPerPage(parseInt(event.target.value, 10));
  309. setStartIndex(0);
  310. }, []);
  311. const onAggregate = useCallback((e: MouseEvent<HTMLElement>) => {
  312. const groupBy = e.currentTarget.getAttribute("data-dfid");
  313. if (groupBy) {
  314. setAggregates((ags) => {
  315. const nags = ags.filter((ag) => ag !== groupBy);
  316. if (ags.length == nags.length) {
  317. nags.push(groupBy);
  318. }
  319. return nags;
  320. });
  321. }
  322. e.stopPropagation();
  323. }, []);
  324. const onAddRowClick = useCallback(
  325. () =>
  326. dispatch(
  327. createSendActionNameAction(updateVarName, module, {
  328. action: onAdd,
  329. index: startIndex,
  330. user_data: userData,
  331. })
  332. ),
  333. [startIndex, dispatch, updateVarName, onAdd, module, userData]
  334. );
  335. const onDownload = useCallback(
  336. () =>
  337. dispatch(
  338. createSendActionNameAction(updateVarName, module, {
  339. action: DownloadAction,
  340. user_data: userData,
  341. })
  342. ),
  343. [dispatch, updateVarName, module, userData]
  344. );
  345. const tableContainerSx = useMemo(() => ({ maxHeight: height }), [height]);
  346. const pso = useMemo(() => {
  347. let psOptions = rowsPerPageOptions;
  348. if (pageSizeOptions) {
  349. try {
  350. psOptions = JSON.parse(pageSizeOptions);
  351. } catch (e) {
  352. console.log("PaginatedTable pageSizeOptions is wrong ", pageSizeOptions, e);
  353. }
  354. }
  355. if (
  356. pageSize > 0 &&
  357. !psOptions.some((ps) =>
  358. typeof ps === "number" ? ps === pageSize : typeof ps.value === "number" ? ps.value === pageSize : false
  359. )
  360. ) {
  361. psOptions.push({ value: pageSize, label: "" + pageSize });
  362. }
  363. if (allowAllRows) {
  364. psOptions.push({ value: -1, label: "All" });
  365. }
  366. psOptions.sort((a, b) => (typeof a === "number" ? a : a.value) - (typeof b === "number" ? b : b.value));
  367. return psOptions;
  368. }, [pageSizeOptions, allowAllRows, pageSize]);
  369. const { rows, rowCount, filteredCount, compRows } = useMemo(() => {
  370. const ret = { rows: [], rowCount: 0, filteredCount: 0, compRows: [] } as {
  371. rows: RowType[];
  372. rowCount: number;
  373. filteredCount: number;
  374. compRows: RowType[];
  375. };
  376. if (value) {
  377. if (value.data) {
  378. ret.rows = value.data as RowType[];
  379. }
  380. if (value.rowcount) {
  381. ret.rowCount = value.rowcount as unknown as number;
  382. if (value.fullrowcount && value.rowcount != value.fullrowcount) {
  383. ret.filteredCount = (value.fullrowcount as unknown as number) - ret.rowCount;
  384. }
  385. }
  386. if (value.comp) {
  387. ret.compRows = value.comp as RowType[];
  388. }
  389. }
  390. return ret;
  391. }, [value]);
  392. const onCellValidation: OnCellValidation = useCallback(
  393. (value: RowValue, rowIndex: number, colName: string, userValue: string, tz?: string) =>
  394. dispatch(
  395. createSendActionNameAction(updateVarName, module, {
  396. action: onEdit,
  397. value: value,
  398. index: getRowIndex(rows[rowIndex], rowIndex, startIndex),
  399. col: colName,
  400. user_value: userValue,
  401. tz: tz,
  402. user_data: userData,
  403. })
  404. ),
  405. [dispatch, updateVarName, onEdit, rows, startIndex, module, userData]
  406. );
  407. const onRowDeletion: OnRowDeletion = useCallback(
  408. (rowIndex: number) =>
  409. dispatch(
  410. createSendActionNameAction(updateVarName, module, {
  411. action: onDelete,
  412. index: getRowIndex(rows[rowIndex], rowIndex, startIndex),
  413. user_data: userData,
  414. })
  415. ),
  416. [dispatch, updateVarName, onDelete, rows, startIndex, module, userData]
  417. );
  418. const onRowSelection: OnRowSelection = useCallback(
  419. (rowIndex: number, colName?: string, value?: string) =>
  420. dispatch(
  421. createSendActionNameAction(updateVarName, module, {
  422. action: onAction,
  423. index: getRowIndex(rows[rowIndex], rowIndex, startIndex),
  424. col: colName === undefined ? null : colName,
  425. value,
  426. reason: value === undefined ? "click" : "button",
  427. user_data: userData,
  428. })
  429. ),
  430. [dispatch, updateVarName, onAction, rows, startIndex, module, userData]
  431. );
  432. const onRowClick: OnRowClick = useCallback(
  433. (e: MouseEvent<HTMLTableRowElement>) => {
  434. const { index } = e.currentTarget.dataset || {};
  435. const rowIndex = index === undefined ? NaN : Number(index);
  436. if (!isNaN(rowIndex)) {
  437. onRowSelection(rowIndex);
  438. }
  439. },
  440. [onRowSelection]
  441. );
  442. const boxSx = useMemo(() => ({ ...baseBoxSx, width: width }), [width]);
  443. return (
  444. <Box id={id} sx={boxSx} className={`${className} ${getSuffixedClassNames(className, "-paginated")}`}>
  445. <Paper sx={paperSx}>
  446. <Tooltip title={hover || ""}>
  447. <TableContainer sx={tableContainerSx}>
  448. <Table sx={tableSx} aria-labelledby="tableTitle" size={size} stickyHeader={true}>
  449. <TableHead>
  450. <TableRow>
  451. {colsOrder.map((col, idx) => (
  452. <TableCell
  453. key={col + idx}
  454. sortDirection={orderBy === columns[col].dfid && order}
  455. sx={columns[col].width ? { width: columns[col].width } : {}}
  456. >
  457. {columns[col].dfid === EDIT_COL ? (
  458. [
  459. active && editable && onAdd ? (
  460. <Tooltip title="Add a row" key="addARow">
  461. <IconButton
  462. onClick={onAddRowClick}
  463. size="small"
  464. sx={iconInRowSx}
  465. >
  466. <AddIcon fontSize="inherit" />
  467. </IconButton>
  468. </Tooltip>
  469. ) : null,
  470. active && filter ? (
  471. <TableFilter
  472. key="filter"
  473. columns={columns}
  474. colsOrder={colsOrder}
  475. onValidate={setAppliedFilters}
  476. appliedFilters={appliedFilters}
  477. className={className}
  478. filteredCount={filteredCount}
  479. />
  480. ) : null,
  481. active && downloadable ? (
  482. <Tooltip title="Download as CSV" key="downloadCsv">
  483. <IconButton
  484. onClick={onDownload}
  485. size="small"
  486. sx={iconInRowSx}
  487. >
  488. <Download fontSize="inherit" />
  489. </IconButton>
  490. </Tooltip>
  491. ) : null,
  492. ]
  493. ) : (
  494. <TableSortLabel
  495. active={orderBy === columns[col].dfid}
  496. direction={orderBy === columns[col].dfid ? order : "asc"}
  497. data-dfid={columns[col].dfid}
  498. onClick={onSort}
  499. disabled={!active}
  500. hideSortIcon={!active}
  501. >
  502. <Box sx={headBoxSx}>
  503. {columns[col].groupBy ? (
  504. <IconButton
  505. onClick={onAggregate}
  506. size="small"
  507. title="aggregate"
  508. data-dfid={columns[col].dfid}
  509. disabled={!active}
  510. sx={iconInRowSx}
  511. >
  512. {aggregates.includes(columns[col].dfid) ? (
  513. <DataSaverOff fontSize="inherit" />
  514. ) : (
  515. <DataSaverOn fontSize="inherit" />
  516. )}
  517. </IconButton>
  518. ) : null}
  519. {columns[col].title === undefined
  520. ? columns[col].dfid
  521. : columns[col].title}
  522. </Box>
  523. {orderBy === columns[col].dfid ? (
  524. <Box component="span" sx={visuallyHidden}>
  525. {order === "desc"
  526. ? "sorted descending"
  527. : "sorted ascending"}
  528. </Box>
  529. ) : null}
  530. </TableSortLabel>
  531. )}
  532. </TableCell>
  533. ))}
  534. </TableRow>
  535. </TableHead>
  536. <TableBody>
  537. {rows.map((row, index) => {
  538. const sel = selected.indexOf(index + startIndex);
  539. if (sel == 0) {
  540. setTimeout(
  541. () =>
  542. selectedRowRef.current?.scrollIntoView &&
  543. selectedRowRef.current.scrollIntoView({ block: "center" }),
  544. 1
  545. );
  546. }
  547. return (
  548. <TableRow
  549. hover
  550. tabIndex={-1}
  551. key={"row" + index}
  552. selected={sel > -1}
  553. ref={sel == 0 ? selectedRowRef : undefined}
  554. className={getClassName(row, props.lineStyle)}
  555. data-index={index}
  556. onClick={active && onAction ? onRowClick : undefined}
  557. >
  558. {colsOrder.map((col, cidx) => (
  559. <EditableCell
  560. key={"val" + index + "-" + cidx}
  561. className={getClassName(row, columns[col].style, col)}
  562. colDesc={columns[col]}
  563. value={row[col]}
  564. formatConfig={formatConfig}
  565. rowIndex={index}
  566. onValidation={
  567. active && !columns[col].notEditable && onEdit
  568. ? onCellValidation
  569. : undefined
  570. }
  571. onDeletion={
  572. active && editable && onDelete ? onRowDeletion : undefined
  573. }
  574. onSelection={active && onAction ? onRowSelection : undefined}
  575. nanValue={columns[col].nanValue || props.nanValue}
  576. tooltip={getTooltip(row, columns[col].tooltip, col)}
  577. comp={compRows && compRows[index] && compRows[index][col]}
  578. />
  579. ))}
  580. </TableRow>
  581. );
  582. })}
  583. {rows.length == 0 &&
  584. loading &&
  585. Array.from(Array(30).keys(), (v, idx) => (
  586. <TableRow hover key={"rowskel" + idx}>
  587. {colsOrder.map((col, cidx) => (
  588. <TableCell key={"skel" + cidx}>
  589. <Skeleton sx={skelSx} />
  590. </TableCell>
  591. ))}
  592. </TableRow>
  593. ))}
  594. </TableBody>
  595. </Table>
  596. </TableContainer>
  597. </Tooltip>
  598. {!showAll &&
  599. (loading ? (
  600. <Skeleton sx={loadingStyle}>
  601. <Typography>Loading...</Typography>
  602. </Skeleton>
  603. ) : (
  604. <TablePagination
  605. component="div"
  606. count={rowCount}
  607. page={startIndex / rowsPerPage}
  608. rowsPerPage={rowsPerPage}
  609. showFirstButton={true}
  610. showLastButton={true}
  611. rowsPerPageOptions={pso}
  612. onPageChange={handleChangePage}
  613. onRowsPerPageChange={handleChangeRowsPerPage}
  614. />
  615. ))}
  616. </Paper>
  617. </Box>
  618. );
  619. };
  620. export default PaginatedTable;