tableUtils.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  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. useCallback,
  16. useEffect,
  17. useMemo,
  18. CSSProperties,
  19. MouseEvent,
  20. ChangeEvent,
  21. SyntheticEvent,
  22. } from "react";
  23. import Autocomplete, { createFilterOptions } from "@mui/material/Autocomplete";
  24. import Box from "@mui/material/Box";
  25. import Input from "@mui/material/Input";
  26. import TableCell, { TableCellProps } from "@mui/material/TableCell";
  27. import Switch from "@mui/material/Switch";
  28. import IconButton from "@mui/material/IconButton";
  29. import CheckIcon from "@mui/icons-material/Check";
  30. import ClearIcon from "@mui/icons-material/Clear";
  31. import EditIcon from "@mui/icons-material/Edit";
  32. import DeleteIcon from "@mui/icons-material/Delete";
  33. import { DatePicker } from "@mui/x-date-pickers/DatePicker";
  34. import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
  35. import { BaseDateTimePickerSlotsComponentsProps } from "@mui/x-date-pickers/DateTimePicker/shared";
  36. import { isValid } from "date-fns";
  37. import { FormatConfig } from "../../context/taipyReducers";
  38. import { dateToString, getDateTime, getDateTimeString, getNumberString, getTimeZonedDate } from "../../utils/index";
  39. import { TaipyActiveProps, TaipyMultiSelectProps, getSuffixedClassNames } from "./utils";
  40. import { FilterOptionsState, TextField } from "@mui/material";
  41. /**
  42. * A column description as received by the backend.
  43. */
  44. export interface ColumnDesc {
  45. /** The unique column identifier. */
  46. dfid: string;
  47. /** The column type. */
  48. type: string;
  49. /** The value format. */
  50. format?: string;
  51. /** The column title. */
  52. title?: string;
  53. /** The order of the column. */
  54. index: number;
  55. /** The column width. */
  56. width?: number | string;
  57. /** If true, the column cannot be edited. */
  58. notEditable?: boolean;
  59. /** The name of the column that holds the CSS classname to
  60. * apply to the cells. */
  61. style?: string;
  62. /** The name of the column that holds the tooltip to
  63. * show on the cells. */
  64. tooltip?: string;
  65. /** The value that would replace a NaN value. */
  66. nanValue?: string;
  67. /** The TimeZone identifier used if the type is `date`. */
  68. tz?: string;
  69. /** The flag that allows filtering. */
  70. filter?: boolean;
  71. /** The name of the aggregation function. */
  72. apply?: string;
  73. /** The flag that allows the user to aggregate the column. */
  74. groupBy?: boolean;
  75. widthHint?: number;
  76. /** The list of values that can be used on edit. */
  77. lov?: string[];
  78. /** If true the user can enter any value besides the lov values. */
  79. freeLov?: boolean;
  80. }
  81. export const DEFAULT_SIZE = "small";
  82. export type Order = "asc" | "desc";
  83. /**
  84. * A cell value type.
  85. */
  86. export type RowValue = string | number | boolean | null;
  87. /**
  88. * The definition of a table row.
  89. *
  90. * A row definition associates a name (a string) to a type (a {@link RowValue}).
  91. */
  92. export type RowType = Record<string, RowValue>;
  93. export const EDIT_COL = "taipy_edit";
  94. export const LINE_STYLE = "__taipy_line_style__";
  95. export const defaultDateFormat = "yyyy/MM/dd";
  96. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  97. export type TableValueType = Record<string, Record<string, any>>;
  98. export interface TaipyTableProps extends TaipyActiveProps, TaipyMultiSelectProps {
  99. data?: TableValueType;
  100. columns?: string;
  101. defaultColumns: string;
  102. height?: string;
  103. width?: string;
  104. pageSize?: number;
  105. onEdit?: string;
  106. onDelete?: string;
  107. onAdd?: string;
  108. onAction?: string;
  109. editable?: boolean;
  110. defaultEditable?: boolean;
  111. lineStyle?: string;
  112. tooltip?: string;
  113. cellTooltip?: string;
  114. nanValue?: string;
  115. filter?: boolean;
  116. size?: "small" | "medium";
  117. defaultKey?: string; // for testing purposes only
  118. userData?: unknown;
  119. }
  120. export type PageSizeOptionsType = (
  121. | number
  122. | {
  123. value: number;
  124. label: string;
  125. }
  126. )[];
  127. export interface TaipyPaginatedTableProps extends TaipyTableProps {
  128. pageSizeOptions?: string;
  129. allowAllRows?: boolean;
  130. showAll?: boolean;
  131. }
  132. export const baseBoxSx = { width: "100%" };
  133. export const paperSx = { width: "100%", mb: 2 };
  134. export const tableSx = { minWidth: 250 };
  135. export const headBoxSx = { display: "flex", alignItems: "flex-start" };
  136. export const iconInRowSx = { fontSize: "body2.fontSize" };
  137. export const iconsWrapperSx = { gridColumnStart: 2, display: "flex", alignItems: "center" } as CSSProperties;
  138. const cellBoxSx = { display: "grid", gridTemplateColumns: "1fr auto", alignItems: "center" } as CSSProperties;
  139. const tableFontSx = { fontSize: "body2.fontSize" };
  140. export interface OnCellValidation {
  141. (value: RowValue, rowIndex: number, colName: string, userValue: string, tz?: string): void;
  142. }
  143. export interface OnRowDeletion {
  144. (rowIndex: number): void;
  145. }
  146. export interface OnRowSelection {
  147. (rowIndex: number, colName?: string): void;
  148. }
  149. export interface OnRowClick {
  150. (e: MouseEvent<HTMLTableRowElement>): void;
  151. }
  152. interface EditableCellProps {
  153. rowIndex: number;
  154. value: RowValue;
  155. colDesc: ColumnDesc;
  156. formatConfig: FormatConfig;
  157. onValidation?: OnCellValidation;
  158. onDeletion?: OnRowDeletion;
  159. onSelection?: OnRowSelection;
  160. nanValue?: string;
  161. className?: string;
  162. tooltip?: string;
  163. tableCellProps?: Partial<TableCellProps>;
  164. }
  165. export const defaultColumns = {} as Record<string, ColumnDesc>;
  166. export const getsortByIndex = (cols: Record<string, ColumnDesc>) => (key1: string, key2: string) =>
  167. cols[key1].index < cols[key2].index ? -1 : cols[key1].index > cols[key2].index ? 1 : 0;
  168. const formatValue = (val: RowValue, col: ColumnDesc, formatConf: FormatConfig, nanValue?: string): string => {
  169. if (val === undefined) {
  170. return "";
  171. }
  172. switch (col.type) {
  173. case "datetime":
  174. if (val === "NaT") {
  175. return nanValue || "";
  176. }
  177. return val ? getDateTimeString(val as string, col.format || defaultDateFormat, formatConf, col.tz) : "";
  178. case "int":
  179. case "float":
  180. if (val === null) {
  181. return nanValue || "";
  182. }
  183. return getNumberString(val as number, col.format, formatConf);
  184. default:
  185. return val ? (val as string) : "";
  186. }
  187. };
  188. const VALID_BOOLEAN_STRINGS = ["true", "1", "t", "y", "yes", "yeah", "sure"];
  189. const isBooleanTrue = (val: RowValue) =>
  190. typeof val == "string" ? VALID_BOOLEAN_STRINGS.some((s) => s == val.trim().toLowerCase()) : !!val;
  191. const defaultCursor = { cursor: "default" };
  192. const defaultCursorIcon = { ...iconInRowSx, "& .MuiSwitch-input": defaultCursor };
  193. const renderCellValue = (val: RowValue | boolean, col: ColumnDesc, formatConf: FormatConfig, nanValue?: string) => {
  194. if (val !== null && val !== undefined && col.type && col.type.startsWith("bool")) {
  195. return <Switch checked={val as boolean} size="small" title={val ? "True" : "False"} sx={defaultCursorIcon} />;
  196. }
  197. return <span style={defaultCursor}>{formatValue(val as RowValue, col, formatConf, nanValue)}</span>;
  198. };
  199. const getCellProps = (col: ColumnDesc, base: Partial<TableCellProps> = {}): Partial<TableCellProps> => {
  200. switch (col.type) {
  201. case "bool":
  202. base.align = "center";
  203. break;
  204. }
  205. if (col.width) {
  206. base.width = col.width;
  207. }
  208. return base;
  209. };
  210. export const getRowIndex = (row: Record<string, RowValue>, rowIndex: number, startIndex = 0) =>
  211. typeof row["_tp_index"] === "number" ? row["_tp_index"] : rowIndex + startIndex;
  212. export const addDeleteColumn = (nbToRender: number, columns: Record<string, ColumnDesc>) => {
  213. if (nbToRender) {
  214. Object.keys(columns).forEach((key) => columns[key].index++);
  215. columns[EDIT_COL] = {
  216. dfid: EDIT_COL,
  217. type: "",
  218. format: "",
  219. title: "",
  220. index: 0,
  221. width: nbToRender * 4 + "em",
  222. filter: false,
  223. };
  224. }
  225. return columns;
  226. };
  227. export const getClassName = (row: Record<string, unknown>, style?: string, col?: string) =>
  228. style ? (((col && row[`tps__${col}__${style}`]) || row[style]) as string) : undefined;
  229. export const getTooltip = (row: Record<string, unknown>, tooltip?: string, col?: string) =>
  230. tooltip ? (((col && row[`tpt__${col}__${tooltip}`]) || row[tooltip]) as string) : undefined;
  231. const setInputFocus = (input: HTMLInputElement) => input && input.focus();
  232. const textFieldProps = { textField: { margin: "dense" } } as BaseDateTimePickerSlotsComponentsProps<Date>;
  233. const filter = createFilterOptions<string>();
  234. const getOptionKey = (option: string) => (Array.isArray(option) ? option[0] : option);
  235. const getOptionLabel = (option: string) => (Array.isArray(option) ? option[1] : option);
  236. export const EditableCell = (props: EditableCellProps) => {
  237. const {
  238. onValidation,
  239. value,
  240. colDesc,
  241. formatConfig,
  242. rowIndex,
  243. onDeletion,
  244. onSelection,
  245. nanValue,
  246. className,
  247. tooltip,
  248. tableCellProps = {},
  249. } = props;
  250. const [val, setVal] = useState<RowValue | Date>(value);
  251. const [edit, setEdit] = useState(false);
  252. const [deletion, setDeletion] = useState(false);
  253. const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => setVal(e.target.value), []);
  254. const onCompleteChange = useCallback((e: SyntheticEvent, value: string | null) => setVal(value), []);
  255. const onBoolChange = useCallback((e: ChangeEvent<HTMLInputElement>) => setVal(e.target.checked), []);
  256. const onDateChange = useCallback((date: Date | null) => setVal(date), []);
  257. const withTime = useMemo(() => !!colDesc.format && colDesc.format.toLowerCase().includes("h"), [colDesc.format]);
  258. const onCheckClick = useCallback(() => {
  259. let castedVal = val;
  260. switch (colDesc.type) {
  261. case "bool":
  262. castedVal = isBooleanTrue(val as RowValue);
  263. break;
  264. case "int":
  265. try {
  266. castedVal = parseInt(val as string, 10);
  267. } catch (e) {
  268. // ignore
  269. }
  270. break;
  271. case "float":
  272. try {
  273. castedVal = parseFloat(val as string);
  274. } catch (e) {
  275. // ignore
  276. }
  277. break;
  278. case "datetime":
  279. if (val === null) {
  280. castedVal = val;
  281. } else if (isValid(val)) {
  282. castedVal = dateToString(getTimeZonedDate(val as Date, formatConfig.timeZone, withTime), withTime);
  283. } else {
  284. return;
  285. }
  286. break;
  287. }
  288. onValidation &&
  289. onValidation(
  290. castedVal as RowValue,
  291. rowIndex,
  292. colDesc.dfid,
  293. val as string,
  294. colDesc.type == "datetime" ? formatConfig.timeZone : undefined
  295. );
  296. setEdit((e) => !e);
  297. }, [onValidation, val, rowIndex, colDesc.dfid, colDesc.type, formatConfig.timeZone, withTime]);
  298. const onEditClick = useCallback(
  299. (evt?: MouseEvent) => {
  300. evt && evt.stopPropagation();
  301. colDesc.type?.startsWith("date")
  302. ? setVal(getDateTime(value as string, formatConfig.timeZone, withTime))
  303. : setVal(value);
  304. onValidation && setEdit((e) => !e);
  305. },
  306. [onValidation, value, formatConfig.timeZone, colDesc.type, withTime]
  307. );
  308. const onKeyDown = useCallback(
  309. (e: React.KeyboardEvent<HTMLElement>) => {
  310. switch (e.key) {
  311. case "Enter":
  312. onCheckClick();
  313. break;
  314. case "Escape":
  315. onEditClick();
  316. break;
  317. }
  318. },
  319. [onCheckClick, onEditClick]
  320. );
  321. const onDeleteCheckClick = useCallback(() => {
  322. onDeletion && onDeletion(rowIndex);
  323. setDeletion((d) => !d);
  324. }, [onDeletion, rowIndex]);
  325. const onDeleteClick = useCallback(
  326. (evt?: MouseEvent) => {
  327. evt && evt.stopPropagation();
  328. onDeletion && setDeletion((d) => !d);
  329. },
  330. [onDeletion]
  331. );
  332. const onDeleteKeyDown = useCallback(
  333. (e: React.KeyboardEvent<HTMLInputElement>) => {
  334. switch (e.key) {
  335. case "Enter":
  336. onDeleteCheckClick();
  337. break;
  338. case "Escape":
  339. onDeleteClick();
  340. break;
  341. }
  342. },
  343. [onDeleteCheckClick, onDeleteClick]
  344. );
  345. const onSelect = useCallback(
  346. (e: MouseEvent<HTMLDivElement>) => {
  347. e.stopPropagation();
  348. onSelection && onSelection(rowIndex, colDesc.dfid);
  349. },
  350. [onSelection, rowIndex, colDesc.dfid]
  351. );
  352. const filterOptions = useCallback(
  353. (options: string[], params: FilterOptionsState<string>) => {
  354. const filtered = filter(options, params);
  355. if (colDesc.freeLov) {
  356. const { inputValue } = params;
  357. if (
  358. inputValue &&
  359. !options.some((option) => inputValue == (Array.isArray(option) ? option[1] : option))
  360. ) {
  361. filtered.push(inputValue);
  362. }
  363. }
  364. return filtered;
  365. },
  366. [colDesc.freeLov]
  367. );
  368. useEffect(() => {
  369. !onValidation && setEdit(false);
  370. }, [onValidation]);
  371. return (
  372. <TableCell
  373. {...getCellProps(colDesc, tableCellProps)}
  374. className={
  375. onValidation ? getSuffixedClassNames(className || "tpc", edit ? "-editing" : "-editable") : className
  376. }
  377. title={tooltip}
  378. >
  379. {edit ? (
  380. colDesc.type?.startsWith("bool") ? (
  381. <Box sx={cellBoxSx}>
  382. <Switch
  383. checked={val as boolean}
  384. size="small"
  385. title={val ? "True" : "False"}
  386. sx={iconInRowSx}
  387. onChange={onBoolChange}
  388. inputRef={setInputFocus}
  389. />
  390. <Box sx={iconsWrapperSx}>
  391. <IconButton onClick={onCheckClick} size="small" sx={iconInRowSx}>
  392. <CheckIcon fontSize="inherit" />
  393. </IconButton>
  394. <IconButton onClick={onEditClick} size="small" sx={iconInRowSx}>
  395. <ClearIcon fontSize="inherit" />
  396. </IconButton>
  397. </Box>
  398. </Box>
  399. ) : colDesc.type?.startsWith("date") ? (
  400. <Box sx={cellBoxSx}>
  401. {withTime ? (
  402. <DateTimePicker
  403. value={val as Date}
  404. onChange={onDateChange}
  405. slotProps={textFieldProps}
  406. inputRef={setInputFocus}
  407. sx={tableFontSx}
  408. />
  409. ) : (
  410. <DatePicker
  411. value={val as Date}
  412. onChange={onDateChange}
  413. slotProps={textFieldProps}
  414. inputRef={setInputFocus}
  415. sx={tableFontSx}
  416. />
  417. )}
  418. <Box sx={iconsWrapperSx}>
  419. <IconButton onClick={onCheckClick} size="small" sx={iconInRowSx}>
  420. <CheckIcon fontSize="inherit" />
  421. </IconButton>
  422. <IconButton onClick={onEditClick} size="small" sx={iconInRowSx}>
  423. <ClearIcon fontSize="inherit" />
  424. </IconButton>
  425. </Box>
  426. </Box>
  427. ) : colDesc.lov ? (
  428. <Box sx={cellBoxSx}>
  429. <Autocomplete
  430. autoComplete={true}
  431. fullWidth
  432. selectOnFocus={!!colDesc.freeLov}
  433. clearOnBlur={!!colDesc.freeLov}
  434. handleHomeEndKeys={!!colDesc.freeLov}
  435. options={colDesc.lov}
  436. getOptionKey={getOptionKey}
  437. getOptionLabel={getOptionLabel}
  438. filterOptions={filterOptions}
  439. freeSolo={!!colDesc.freeLov}
  440. value={val as string}
  441. onChange={onCompleteChange}
  442. renderInput={(params) => (
  443. <TextField
  444. {...params}
  445. fullWidth
  446. inputRef={setInputFocus}
  447. onChange={colDesc.freeLov ? onChange : undefined}
  448. margin="dense"
  449. variant="standard"
  450. sx={tableFontSx}
  451. />
  452. )}
  453. />
  454. <Box sx={iconsWrapperSx}>
  455. <IconButton onClick={onCheckClick} size="small" sx={iconInRowSx}>
  456. <CheckIcon fontSize="inherit" />
  457. </IconButton>
  458. <IconButton onClick={onEditClick} size="small" sx={iconInRowSx}>
  459. <ClearIcon fontSize="inherit" />
  460. </IconButton>
  461. </Box>
  462. </Box>
  463. ) : (
  464. <Input
  465. value={val}
  466. onChange={onChange}
  467. onKeyDown={onKeyDown}
  468. inputRef={setInputFocus}
  469. margin="dense"
  470. sx={tableFontSx}
  471. endAdornment={
  472. <Box sx={iconsWrapperSx}>
  473. <IconButton onClick={onCheckClick} size="small" sx={iconInRowSx}>
  474. <CheckIcon fontSize="inherit" />
  475. </IconButton>
  476. <IconButton onClick={onEditClick} size="small" sx={iconInRowSx}>
  477. <ClearIcon fontSize="inherit" />
  478. </IconButton>
  479. </Box>
  480. }
  481. />
  482. )
  483. ) : EDIT_COL === colDesc.dfid ? (
  484. deletion ? (
  485. <Input
  486. value="Confirm"
  487. onKeyDown={onDeleteKeyDown}
  488. inputRef={setInputFocus}
  489. sx={tableFontSx}
  490. endAdornment={
  491. <Box sx={iconsWrapperSx}>
  492. <IconButton onClick={onDeleteCheckClick} size="small" sx={iconInRowSx}>
  493. <CheckIcon fontSize="inherit" />
  494. </IconButton>
  495. <IconButton onClick={onDeleteClick} size="small" sx={iconInRowSx}>
  496. <ClearIcon fontSize="inherit" />
  497. </IconButton>
  498. </Box>
  499. }
  500. />
  501. ) : onDeletion ? (
  502. <Box sx={iconsWrapperSx}>
  503. <IconButton onClick={onDeleteClick} size="small" sx={iconInRowSx}>
  504. <DeleteIcon fontSize="inherit" />
  505. </IconButton>
  506. </Box>
  507. ) : null
  508. ) : (
  509. <Box sx={cellBoxSx} onClick={onSelect}>
  510. {renderCellValue(value, colDesc, formatConfig, nanValue)}
  511. {onValidation ? (
  512. <Box sx={iconsWrapperSx}>
  513. <IconButton onClick={onEditClick} size="small" sx={iconInRowSx}>
  514. <EditIcon fontSize="inherit" />
  515. </IconButton>
  516. </Box>
  517. ) : null}
  518. </Box>
  519. )}
  520. </TableCell>
  521. );
  522. };