github.com/turbot/steampipe@v1.7.0-rc.0.0.20240517123944-7cef272d4458/ui/dashboard/src/components/dashboards/Table/index.tsx (about)

     1  import ControlDimension from "../check/Benchmark/ControlDimension";
     2  import isEmpty from "lodash/isEmpty";
     3  import isObject from "lodash/isObject";
     4  import useDeepCompareEffect from "use-deep-compare-effect";
     5  import useTemplateRender from "../../../hooks/useTemplateRender";
     6  import {
     7    AlarmIcon,
     8    InfoIcon,
     9    OKIcon,
    10    SkipIcon,
    11    UnknownIcon,
    12  } from "../../../constants/icons";
    13  import {
    14    BasePrimitiveProps,
    15    ExecutablePrimitiveProps,
    16    isNumericCol,
    17    LeafNodeDataColumn,
    18    LeafNodeDataRow,
    19  } from "../common";
    20  import { classNames } from "../../../utils/styles";
    21  import {
    22    ErrorIcon,
    23    SortAscendingIcon,
    24    SortDescendingIcon,
    25  } from "../../../constants/icons";
    26  import { memo, useEffect, useMemo, useState } from "react";
    27  import { getComponent, registerComponent } from "../index";
    28  import { PanelDefinition } from "../../../types";
    29  import { RowRenderResult } from "../common/types";
    30  import { useSortBy, useTable } from "react-table";
    31  
    32  export type TableColumnDisplay = "all" | "none";
    33  export type TableColumnWrap = "all" | "none";
    34  
    35  type TableColumnInfo = {
    36    Header: string;
    37    accessor: string;
    38    name: string;
    39    data_type: string;
    40    display?: "all" | "none";
    41    wrap: TableColumnWrap;
    42    href_template?: string;
    43    sortType?: any;
    44  };
    45  
    46  const getColumns = (
    47    cols: LeafNodeDataColumn[],
    48    properties?: TableProperties
    49  ): { columns: TableColumnInfo[]; hiddenColumns: string[] } => {
    50    if (!cols || cols.length === 0) {
    51      return { columns: [], hiddenColumns: [] };
    52    }
    53  
    54    const hiddenColumns: string[] = [];
    55    const columns: TableColumnInfo[] = cols.map((col) => {
    56      let colHref: string | null = null;
    57      let colWrap: TableColumnWrap = "none";
    58      if (properties && properties.columns && properties.columns[col.name]) {
    59        const c = properties.columns[col.name];
    60        if (c.display === "none") {
    61          hiddenColumns.push(col.name);
    62        }
    63        if (c.wrap) {
    64          colWrap = c.wrap as TableColumnWrap;
    65        }
    66        if (c.href) {
    67          colHref = c.href;
    68        }
    69      }
    70  
    71      const colInfo: TableColumnInfo = {
    72        Header: col.name,
    73        accessor: col.name,
    74        name: col.name,
    75        data_type: col.data_type,
    76        wrap: colWrap,
    77        // Boolean data types do not sort under the default alphanumeric sorting logic of react-table
    78        // On the next column type that needs specialising we'll move this out into a function / hook
    79        sortType: col.data_type === "BOOL" ? "basic" : "alphanumeric",
    80      };
    81      if (colHref) {
    82        colInfo.href_template = colHref;
    83      }
    84      return colInfo;
    85    });
    86    return { columns, hiddenColumns };
    87  };
    88  
    89  const getData = (columns: TableColumnInfo[], rows: LeafNodeDataRow[]) => {
    90    if (!columns || columns.length === 0) {
    91      return [];
    92    }
    93  
    94    if (!rows || rows.length === 0) {
    95      return [];
    96    }
    97    return rows;
    98  };
    99  
   100  type CellValueProps = {
   101    column: TableColumnInfo;
   102    rowIndex: number;
   103    rowTemplateData: RowRenderResult[];
   104    value: any;
   105    showTitle?: boolean;
   106  };
   107  
   108  const CellValue = ({
   109    column,
   110    rowIndex,
   111    rowTemplateData,
   112    value,
   113    showTitle = false,
   114  }: CellValueProps) => {
   115    const ExternalLink = getComponent("external_link");
   116    const [href, setHref] = useState<string | null>(null);
   117    const [error, setError] = useState<string | null>(null);
   118  
   119    // Calculate a link for this cell
   120    useEffect(() => {
   121      const renderedTemplateObj = rowTemplateData[rowIndex];
   122  
   123      if (!renderedTemplateObj) {
   124        setHref(null);
   125        setError(null);
   126        return;
   127      }
   128      const renderedTemplateForColumn = renderedTemplateObj[column.name];
   129      if (!renderedTemplateForColumn) {
   130        setHref(null);
   131        setError(null);
   132        return;
   133      }
   134      if (renderedTemplateForColumn.result) {
   135        setHref(renderedTemplateForColumn.result);
   136        setError(null);
   137      } else if (renderedTemplateForColumn.error) {
   138        setHref(null);
   139        setError(renderedTemplateForColumn.error);
   140      }
   141    }, [column, rowIndex, rowTemplateData]);
   142  
   143    let cellContent;
   144    const dataType = column.data_type.toLowerCase();
   145    if (value === null || value === undefined) {
   146      cellContent = href ? (
   147        <ExternalLink
   148          to={href}
   149          className="link-highlight"
   150          title={showTitle ? `${column.name}=null` : undefined}
   151        >
   152          <>null</>
   153        </ExternalLink>
   154      ) : (
   155        <span
   156          className="text-foreground-lightest"
   157          title={showTitle ? `${column.name}=null` : undefined}
   158        >
   159          <>null</>
   160        </span>
   161      );
   162    } else if (dataType === "control_status") {
   163      switch (value) {
   164        case "alarm":
   165          cellContent = (
   166            <span title="Status = Alarm">
   167              <AlarmIcon className="text-alert w-5 h-5" />
   168            </span>
   169          );
   170          break;
   171        case "error":
   172          cellContent = (
   173            <span title="Status = Error">
   174              <AlarmIcon className="text-alert w-5 h-5" />
   175            </span>
   176          );
   177          break;
   178        case "ok":
   179          cellContent = (
   180            <span title="Status = OK">
   181              <OKIcon className="text-ok w-5 h-5" />
   182            </span>
   183          );
   184          break;
   185        case "info":
   186          cellContent = (
   187            <span title="Status = Info">
   188              <InfoIcon className="text-info w-5 h-5" />
   189            </span>
   190          );
   191          break;
   192        case "skip":
   193          cellContent = (
   194            <span title="Status = Skipped">
   195              <SkipIcon className="text-skip w-5 h-5" />
   196            </span>
   197          );
   198          break;
   199        default:
   200          cellContent = (
   201            <span title="Status = Unknown">
   202              <UnknownIcon className="text-foreground-light w-5 h-5" />
   203            </span>
   204          );
   205      }
   206    } else if (dataType === "control_dimensions") {
   207      cellContent = (
   208        <div className="space-x-2">
   209          {(value || []).map((dimension) => (
   210            <ControlDimension
   211              key={dimension.key}
   212              dimensionKey={dimension.key}
   213              dimensionValue={dimension.value}
   214            />
   215          ))}
   216        </div>
   217      );
   218    } else if (dataType === "bool") {
   219      // True should be
   220      cellContent = href ? (
   221        <ExternalLink
   222          to={href}
   223          className="link-highlight"
   224          title={showTitle ? `${column.name}=${value.toString()}` : undefined}
   225        >
   226          <>{value.toString()}</>
   227        </ExternalLink>
   228      ) : (
   229        <span
   230          className={classNames(value ? null : "text-foreground-light")}
   231          title={showTitle ? `${column.name}=${value.toString()}` : undefined}
   232        >
   233          <>{value.toString()}</>
   234        </span>
   235      );
   236    } else if (dataType === "jsonb" || isObject(value)) {
   237      const asJsonString = JSON.stringify(value, null, 2);
   238      cellContent = href ? (
   239        <ExternalLink
   240          to={href}
   241          className="link-highlight"
   242          title={showTitle ? `${column.name}=${asJsonString}` : undefined}
   243        >
   244          <>{asJsonString}</>
   245        </ExternalLink>
   246      ) : (
   247        <span title={showTitle ? `${column.name}=${asJsonString}` : undefined}>
   248          {asJsonString}
   249        </span>
   250      );
   251    } else if (dataType === "text") {
   252      if (!!value.match && value.match("^https?://")) {
   253        cellContent = (
   254          <ExternalLink
   255            className="link-highlight tabular-nums"
   256            to={value}
   257            title={showTitle ? `${column.name}=${value}` : undefined}
   258          >
   259            {value}
   260          </ExternalLink>
   261        );
   262      }
   263      const mdMatch =
   264        !!value.match && value.match("^\\[(.*)\\]\\((https?://.*)\\)$");
   265      if (mdMatch) {
   266        cellContent = (
   267          <ExternalLink
   268            className="tabular-nums"
   269            to={mdMatch[2]}
   270            title={showTitle ? `${column.name}=${value}` : undefined}
   271          >
   272            {mdMatch[1]}
   273          </ExternalLink>
   274        );
   275      }
   276    } else if (dataType === "timestamp" || dataType === "timestamptz") {
   277      cellContent = href ? (
   278        <ExternalLink
   279          to={href}
   280          className="link-highlight tabular-nums"
   281          title={showTitle ? `${column.name}=${value}` : undefined}
   282        >
   283          {value}
   284        </ExternalLink>
   285      ) : (
   286        <span
   287          className="tabular-nums"
   288          title={showTitle ? `${column.name}=${value}` : undefined}
   289        >
   290          {value}
   291        </span>
   292      );
   293    } else if (isNumericCol(dataType)) {
   294      cellContent = href ? (
   295        <ExternalLink
   296          to={href}
   297          className="link-highlight tabular-nums"
   298          title={showTitle ? `${column.name}=${value}` : undefined}
   299        >
   300          {value}
   301        </ExternalLink>
   302      ) : (
   303        <span
   304          className="tabular-nums"
   305          title={showTitle ? `${column.name}=${value}` : undefined}
   306        >
   307          {value}
   308        </span>
   309      );
   310    }
   311    // Fallback is just show it as a string
   312    if (!cellContent) {
   313      cellContent = href ? (
   314        <ExternalLink
   315          to={href}
   316          className="link-highlight tabular-nums"
   317          title={showTitle ? `${column.name}=${value}` : undefined}
   318        >
   319          {value}
   320        </ExternalLink>
   321      ) : (
   322        <span
   323          className="tabular-nums"
   324          title={showTitle ? `${column.name}=${value}` : undefined}
   325        >
   326          {value}
   327        </span>
   328      );
   329    }
   330    return error ? (
   331      <span className="flex items-center space-x-2" title={error}>
   332        {cellContent} <ErrorIcon className="inline h-4 w-4 text-alert" />
   333      </span>
   334    ) : (
   335      cellContent
   336    );
   337  };
   338  
   339  const MemoCellValue = memo(CellValue);
   340  
   341  type TableColumnOptions = {
   342    display?: TableColumnDisplay;
   343    href?: string;
   344    wrap?: TableColumnWrap;
   345  };
   346  
   347  type TableColumns = {
   348    [column: string]: TableColumnOptions;
   349  };
   350  
   351  type TableType = "table" | "line" | null;
   352  
   353  export type TableProperties = {
   354    columns?: TableColumns;
   355  };
   356  
   357  export type TableProps = PanelDefinition &
   358    BasePrimitiveProps &
   359    ExecutablePrimitiveProps & {
   360      display_type?: TableType;
   361      properties?: TableProperties;
   362    };
   363  
   364  const TableView = ({
   365    rowData,
   366    columns,
   367    hiddenColumns,
   368    hasTopBorder = false,
   369  }) => {
   370    const { ready: templateRenderReady, renderTemplates } = useTemplateRender();
   371    const [rowTemplateData, setRowTemplateData] = useState<RowRenderResult[]>([]);
   372  
   373    const { getTableProps, getTableBodyProps, headerGroups, prepareRow, rows } =
   374      useTable(
   375        { columns, data: rowData, initialState: { hiddenColumns } },
   376        useSortBy
   377      );
   378  
   379    useDeepCompareEffect(() => {
   380      if (!templateRenderReady || columns.length === 0 || rows.length === 0) {
   381        setRowTemplateData([]);
   382        return;
   383      }
   384  
   385      const doRender = async () => {
   386        const templates = Object.fromEntries(
   387          columns
   388            .filter((col) => col.display !== "none" && !!col.href_template)
   389            .map((col) => [col.name, col.href_template as string])
   390        );
   391        if (isEmpty(templates)) {
   392          setRowTemplateData([]);
   393          return;
   394        }
   395        const data = rows.map((row) => row.values);
   396        const renderedResults = await renderTemplates(templates, data);
   397        setRowTemplateData(renderedResults || []);
   398      };
   399  
   400      doRender();
   401    }, [columns, renderTemplates, rows, templateRenderReady]);
   402  
   403    return (
   404      <>
   405        <table
   406          {...getTableProps()}
   407          className={classNames(
   408            "min-w-full divide-y divide-table-divide overflow-hidden",
   409            hasTopBorder ? "border-t border-divide" : null
   410          )}
   411        >
   412          <thead className="text-table-head border-b border-divide">
   413            {headerGroups.map((headerGroup) => (
   414              <tr {...headerGroup.getHeaderGroupProps()}>
   415                {headerGroup.headers.map((column) => (
   416                  <th
   417                    {...column.getHeaderProps(column.getSortByToggleProps())}
   418                    scope="col"
   419                    className={classNames(
   420                      "py-3 text-left text-sm font-normal tracking-wider whitespace-nowrap pl-4",
   421                      isNumericCol(column.data_type) ? "text-right" : null
   422                    )}
   423                  >
   424                    {column.render("Header")}
   425                    {column.isSortedDesc ? (
   426                      <SortDescendingIcon className="inline-block h-4 w-4" />
   427                    ) : (
   428                      <SortAscendingIcon
   429                        className={classNames(
   430                          "inline-block h-4 w-4",
   431                          !column.isSorted ? "invisible" : null
   432                        )}
   433                      />
   434                    )}
   435                  </th>
   436                ))}
   437              </tr>
   438            ))}
   439          </thead>
   440          <tbody
   441            {...getTableBodyProps()}
   442            className="divide-y divide-table-divide"
   443          >
   444            {rows.length === 0 && (
   445              <tr>
   446                <td
   447                  className="px-4 py-4 align-top content-center text-sm italic whitespace-nowrap"
   448                  colSpan={columns.length}
   449                >
   450                  No results
   451                </td>
   452              </tr>
   453            )}
   454            {rows.map((row, index) => {
   455              prepareRow(row);
   456              return (
   457                <tr {...row.getRowProps()}>
   458                  {row.cells.map((cell) => (
   459                    <td
   460                      {...cell.getCellProps()}
   461                      className={classNames(
   462                        "px-4 py-4 align-top content-center text-sm",
   463                        isNumericCol(cell.column.data_type) ? "text-right" : "",
   464                        cell.column.wrap === "all"
   465                          ? "break-keep"
   466                          : "whitespace-nowrap"
   467                      )}
   468                    >
   469                      <MemoCellValue
   470                        column={cell.column}
   471                        rowIndex={index}
   472                        rowTemplateData={rowTemplateData}
   473                        value={cell.value}
   474                      />
   475                    </td>
   476                  ))}
   477                </tr>
   478              );
   479            })}
   480          </tbody>
   481        </table>
   482      </>
   483    );
   484  };
   485  
   486  // TODO retain full width on mobile, no padding
   487  const TableViewWrapper = (props: TableProps) => {
   488    const { columns, hiddenColumns } = useMemo(
   489      () => getColumns(props.data ? props.data.columns : [], props.properties),
   490      [props.data, props.properties]
   491    );
   492    const rowData = useMemo(
   493      () => getData(columns, props.data ? props.data.rows : []),
   494      [columns, props.data]
   495    );
   496  
   497    return props.data ? (
   498      <TableView
   499        rowData={rowData}
   500        columns={columns}
   501        hiddenColumns={hiddenColumns}
   502        hasTopBorder={!!props.title}
   503      />
   504    ) : null;
   505  };
   506  
   507  const LineView = (props: TableProps) => {
   508    const { ready: templateRenderReady, renderTemplates } = useTemplateRender();
   509    const [columns, setColumns] = useState<TableColumnInfo[]>([]);
   510    const [rows, setRows] = useState<LeafNodeDataRow[]>([]);
   511    const [rowTemplateData, setRowTemplateData] = useState<RowRenderResult[]>([]);
   512  
   513    useEffect(() => {
   514      if (!props.data || !props.data.columns || !props.data.rows) {
   515        setColumns([]);
   516        setRows([]);
   517        return;
   518      }
   519      const newColumns: TableColumnInfo[] = [];
   520      props.data.columns.forEach((col) => {
   521        const columnOverrides =
   522          props.properties?.columns && props.properties.columns[col.name];
   523        const newColDef: TableColumnInfo = {
   524          ...col,
   525          Header: col.name,
   526          accessor: col.name,
   527          display: columnOverrides?.display ? columnOverrides.display : "all",
   528          wrap: columnOverrides?.wrap ? columnOverrides.wrap : "none",
   529          href_template: columnOverrides?.href,
   530        };
   531        newColumns.push(newColDef);
   532      });
   533  
   534      setColumns(newColumns);
   535      setRows(props.data.rows);
   536    }, [props.data, props.properties]);
   537  
   538    useDeepCompareEffect(() => {
   539      if (!templateRenderReady || columns.length === 0 || rows.length === 0) {
   540        setRowTemplateData([]);
   541        return;
   542      }
   543  
   544      const doRender = async () => {
   545        const templates = Object.fromEntries(
   546          columns
   547            .filter((col) => col.display !== "none" && !!col.href_template)
   548            .map((col) => [col.name, col.href_template as string])
   549        );
   550        if (isEmpty(templates)) {
   551          setRowTemplateData([]);
   552          return;
   553        }
   554        const renderedResults = await renderTemplates(templates, rows);
   555        setRowTemplateData(renderedResults);
   556      };
   557  
   558      doRender();
   559    }, [columns, renderTemplates, rows, templateRenderReady]);
   560  
   561    if (columns.length === 0 || rows.length === 0) {
   562      return null;
   563    }
   564  
   565    return (
   566      <div className="px-4 py-3 space-y-4">
   567        {rows.map((row, rowIndex) => {
   568          return (
   569            <div key={rowIndex} className="space-y-2">
   570              {columns.map((col) => {
   571                if (col.display === "none") {
   572                  return null;
   573                }
   574                return (
   575                  <div key={`${col.name}-${rowIndex}`}>
   576                    <span className="block text-sm text-table-head truncate">
   577                      {col.name}
   578                    </span>
   579                    <span
   580                      className={classNames(
   581                        "block",
   582                        col.wrap === "all" ? "break-keep" : "truncate"
   583                      )}
   584                    >
   585                      <MemoCellValue
   586                        column={col}
   587                        rowIndex={rowIndex}
   588                        rowTemplateData={rowTemplateData}
   589                        value={row[col.name]}
   590                        showTitle
   591                      />
   592                    </span>
   593                  </div>
   594                );
   595              })}
   596            </div>
   597          );
   598        })}
   599      </div>
   600    );
   601  };
   602  
   603  const Table = (props: TableProps) => {
   604    if (props.display_type === "line") {
   605      return <LineView {...props} />;
   606    }
   607    return <TableViewWrapper {...props} />;
   608  };
   609  
   610  registerComponent("table", Table);
   611  
   612  export default Table;
   613  
   614  export { TableView };