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 };