superset-frontend/src/components/Datasource/CollectionTable.tsx (447 lines of code) (raw):

/** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you 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 { ReactNode, DetailedHTMLProps, TdHTMLAttributes, PureComponent, } from 'react'; import { nanoid } from 'nanoid'; import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls'; import { t, styled } from '@superset-ui/core'; import Button from 'src/components/Button'; import { Icons } from 'src/components/Icons'; import Fieldset from './Fieldset'; import { recurseReactClone } from './utils'; interface CRUDCollectionProps { allowAddItem?: boolean; allowDeletes?: boolean; collection: Record<PropertyKey, any>[]; columnLabels?: Record<PropertyKey, any>; columnLabelTooltips?: Record<PropertyKey, any>; emptyMessage?: ReactNode; expandFieldset?: ReactNode; extraButtons?: ReactNode; itemGenerator?: () => any; itemCellProps?: (( val: unknown, label: string, record: any, ) => DetailedHTMLProps< TdHTMLAttributes<HTMLTableCellElement>, HTMLTableCellElement >)[]; itemRenderers?: (( val: unknown, onChange: () => void, label: string, record: any, ) => ReactNode)[]; onChange?: (arg0: any) => void; tableColumns: any[]; sortColumns: string[]; stickyHeader?: boolean; } type Sort = number | string | boolean | any; enum SortOrder { Asc = 1, Desc = 2, Unsorted = 0, } interface CRUDCollectionState { collection: Record<PropertyKey, any>; collectionArray: Record<PropertyKey, any>[]; expandedColumns: Record<PropertyKey, any>; sortColumn: string; sort: SortOrder; } function createCollectionArray(collection: Record<PropertyKey, any>) { return Object.keys(collection).map(k => collection[k]); } function createKeyedCollection(arr: Array<object>) { const collectionArray = arr.map((o: any) => ({ ...o, id: o.id || nanoid(), })); const collection: Record<PropertyKey, any> = {}; collectionArray.forEach((o: any) => { collection[o.id] = o; }); return { collection, collectionArray, }; } const CrudTableWrapper = styled.div<{ stickyHeader?: boolean }>` ${({ stickyHeader }) => stickyHeader && ` height: 350px; overflow-y: auto; overflow-x: auto; .table { min-width: 800px; } thead th { background: #fff; position: sticky; top: 0; z-index: 9; min } `} ${({ theme }) => ` th span { vertical-align: ${theme.gridUnit * -2}px; } .text-right { text-align: right; } .empty-collection { padding: ${theme.gridUnit * 2 + 2}px; } .tiny-cell { width: ${theme.gridUnit + 1}px; } i.fa-caret-down, i.fa-caret-up { width: ${theme.gridUnit + 1}px; } td.expanded { border-top: 0; padding: 0; } `} `; const CrudButtonWrapper = styled.div` text-align: right; ${({ theme }) => `margin-bottom: ${theme.gridUnit * 2}px`} `; const StyledButtonWrapper = styled.span` ${({ theme }) => ` margin-top: ${theme.gridUnit * 3}px; margin-left: ${theme.gridUnit * 3}px; button>span>:first-of-type { margin-right: 0; } `} `; export default class CRUDCollection extends PureComponent< CRUDCollectionProps, CRUDCollectionState > { constructor(props: CRUDCollectionProps) { super(props); const { collection, collectionArray } = createKeyedCollection( props.collection, ); this.state = { expandedColumns: {}, collection, collectionArray, sortColumn: '', sort: 0, }; this.renderItem = this.renderItem.bind(this); this.onAddItem = this.onAddItem.bind(this); this.renderExpandableSection = this.renderExpandableSection.bind(this); this.getLabel = this.getLabel.bind(this); this.onFieldsetChange = this.onFieldsetChange.bind(this); this.renderTableBody = this.renderTableBody.bind(this); this.changeCollection = this.changeCollection.bind(this); this.sortColumn = this.sortColumn.bind(this); this.renderSortIcon = this.renderSortIcon.bind(this); } UNSAFE_componentWillReceiveProps(nextProps: CRUDCollectionProps) { if (nextProps.collection !== this.props.collection) { const { collection, collectionArray } = createKeyedCollection( nextProps.collection, ); this.setState({ collection, collectionArray, }); } } onCellChange(id: number, col: string, val: boolean) { this.changeCollection({ ...this.state.collection, [id]: { ...this.state.collection[id], [col]: val, }, }); } onAddItem() { if (this.props.itemGenerator) { let newItem = this.props.itemGenerator(); if (!newItem.id) { newItem = { ...newItem, id: nanoid() }; } this.changeCollection(this.state.collection, newItem); } } onFieldsetChange(item: any) { this.changeCollection({ ...this.state.collection, [item.id]: item, }); } getLabel(col: any) { const { columnLabels } = this.props; let label = columnLabels?.[col] ? columnLabels[col] : col; if (label.startsWith('__')) { // special label-free columns (ie: caret for expand, delete cross) label = ''; } return label; } getTooltip(col: string) { const { columnLabelTooltips } = this.props; return columnLabelTooltips?.[col]; } changeCollection(collection: any, newItem?: object) { this.setState({ collection }); if (this.props.onChange) { const collectionArray = this.state.collectionArray .map((c: { id: number }) => collection[c.id]) // filter out removed items .filter(c => c !== undefined); if (newItem) { collectionArray.unshift(newItem); } this.props.onChange(collectionArray); } } deleteItem(id: number) { const newColl = { ...this.state.collection }; delete newColl[id]; this.changeCollection(newColl); } effectiveTableColumns() { const { tableColumns, allowDeletes, expandFieldset } = this.props; const cols = allowDeletes ? tableColumns.concat(['__actions']) : tableColumns; return expandFieldset ? ['__expand'].concat(cols) : cols; } toggleExpand(id: any) { this.onCellChange(id, '__expanded', false); this.setState(prevState => ({ expandedColumns: { ...prevState.expandedColumns, [id]: !prevState.expandedColumns[id], }, })); } sortColumn(col: string, sort = SortOrder.Unsorted) { const { sortColumns } = this.props; // default sort logic sorting string, boolean and number const compareSort = (m: Sort, n: Sort) => { if (typeof m === 'string') { return (m || ' ').localeCompare(n); } return m - n; }; return () => { if (sortColumns?.includes(col)) { // display in unsorted order if no sort specified if (sort === SortOrder.Unsorted) { const { collection } = createKeyedCollection(this.props.collection); const collectionArray = createCollectionArray(collection); this.setState({ collectionArray, sortColumn: '', sort, }); return; } // newly ordered collection const sorted = [...this.state.collectionArray].sort( (a: Record<PropertyKey, any>, b: Record<PropertyKey, any>) => compareSort(a[col], b[col]), ); const newCollection = sort === SortOrder.Asc ? sorted : sorted.reverse(); this.setState(prevState => ({ ...prevState, collectionArray: newCollection, sortColumn: col, sort, })); } }; } renderSortIcon(col: string) { if (this.state.sortColumn === col && this.state.sort === SortOrder.Asc) { return <Icons.SortAsc onClick={this.sortColumn(col, 2)} />; } if (this.state.sortColumn === col && this.state.sort === SortOrder.Desc) { return <Icons.SortDesc onClick={this.sortColumn(col, 0)} />; } return <Icons.Sort onClick={this.sortColumn(col, 1)} />; } renderTH(col: string, sortColumns: Array<string>) { const tooltip = this.getTooltip(col); return ( <th key={col} className="no-wrap"> {this.getLabel(col)} {tooltip && ( <> {' '} <InfoTooltipWithTrigger label={t('description')} tooltip={tooltip} /> </> )} {sortColumns?.includes(col) && this.renderSortIcon(col)} </th> ); } renderHeaderRow() { const cols = this.effectiveTableColumns(); const { allowDeletes, expandFieldset, extraButtons, sortColumns } = this.props; return ( <thead> <tr> {expandFieldset && <th aria-label="Expand" className="tiny-cell" />} {cols.map(col => this.renderTH(col, sortColumns))} {extraButtons} {allowDeletes && ( <th key="delete-item" aria-label="Delete" className="tiny-cell" /> )} </tr> </thead> ); } renderExpandableSection(item: any) { const propsGenerator = () => ({ item, onChange: this.onFieldsetChange }); return recurseReactClone( this.props.expandFieldset, Fieldset, propsGenerator, ); } getCellProps(record: any, col: any) { const cellPropsFn = this.props.itemCellProps?.[col]; const val = record[col]; return cellPropsFn ? cellPropsFn(val, this.getLabel(col), record) : {}; } renderCell(record: any, col: any) { const renderer = this.props.itemRenderers?.[col]; const val = record[col]; const onChange = this.onCellChange.bind(this, record.id, col); return renderer ? renderer(val, onChange, this.getLabel(col), record) : val; } renderItem(record: any) { const { allowAddItem, allowDeletes, expandFieldset, tableColumns } = this.props; /* eslint-disable no-underscore-dangle */ const isExpanded = !!this.state.expandedColumns[record.id] || record.__expanded; let tds = []; if (expandFieldset) { tds.push( <td key="__expand" className="expand"> <i role="button" aria-label="Toggle expand" tabIndex={0} // TODO: Remove fa-icon // eslint-disable-next-line icons/no-fa-icons-usage className={`fa fa-caret-${ isExpanded ? 'down' : 'right' } text-primary pointer`} onClick={this.toggleExpand.bind(this, record.id)} /> </td>, ); } tds = tds.concat( tableColumns.map(col => ( <td {...this.getCellProps(record, col)} key={col}> {this.renderCell(record, col)} </td> )), ); if (allowAddItem) { tds.push(<td key="add" aria-label="Add" />); } if (allowDeletes) { tds.push( <td key="__actions" data-test="crud-delete-option" className="text-primary" > <Icons.DeleteOutlined aria-label="Delete item" className="pointer" data-test="crud-delete-icon" role="button" tabIndex={0} onClick={this.deleteItem.bind(this, record.id)} iconSize="l" /> </td>, ); } const trs = [ <tr {...{ 'data-test': 'table-row' }} className="row" key={record.id}> {tds} </tr>, ]; if (isExpanded) { trs.push( <tr className="exp" key={`exp__${record.id}`}> <td colSpan={this.effectiveTableColumns().length} className="expanded" > <div>{this.renderExpandableSection(record)}</div> </td> </tr>, ); } return trs; } renderEmptyCell() { return ( <tr> <td className="empty-collection">{this.props.emptyMessage}</td> </tr> ); } renderTableBody() { const data = this.state.collectionArray; const content = data.length ? data.map(d => this.renderItem(d)) : this.renderEmptyCell(); return <tbody data-test="table-content-rows">{content}</tbody>; } render() { return ( <> <CrudButtonWrapper> {this.props.allowAddItem && ( <StyledButtonWrapper> <Button buttonSize="small" buttonStyle="tertiary" onClick={this.onAddItem} data-test="add-item-button" > <Icons.PlusOutlined iconSize="m" data-test="crud-add-table-item" /> {t('Add item')} </Button> </StyledButtonWrapper> )} </CrudButtonWrapper> <CrudTableWrapper className="CRUD" stickyHeader={this.props.stickyHeader} > <table data-test="crud-table" className="table"> {this.renderHeaderRow()} {this.renderTableBody()} </table> </CrudTableWrapper> </> ); } }