/** * 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: Array; columnLabels?: object; columnLabelTooltips?: object; emptyMessage?: ReactNode; expandFieldset?: ReactNode; extraButtons?: ReactNode; itemGenerator?: () => any; itemCellProps?: (( val: unknown, label: string, record: any, ) => DetailedHTMLProps< TdHTMLAttributes, HTMLTableCellElement >)[]; itemRenderers?: (( val: unknown, onChange: () => void, label: string, record: any, ) => ReactNode)[]; onChange?: (arg0: any) => void; tableColumns: Array; sortColumns: Array; stickyHeader?: boolean; } type Sort = number | string | boolean | any; enum SortOrder { Asc = 1, Desc = 2, Unsorted = 0, } interface CRUDCollectionState { collection: object; collectionArray: Array; expandedColumns: object; sortColumn: string; sort: SortOrder; } function createCollectionArray(collection: object) { return Object.keys(collection).map(k => collection[k]); } function createKeyedCollection(arr: Array) { const collectionArray = arr.map((o: any) => ({ ...o, id: o.id || nanoid(), })); const collection = {}; 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; `} `; 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: object, b: object) => 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 ; } if (this.state.sortColumn === col && this.state.sort === SortOrder.Desc) { return ; } return ; } renderTH(col: string, sortColumns: Array) { const tooltip = this.getTooltip(col); return ( {this.getLabel(col)} {tooltip && ( <> {' '} )} {sortColumns?.includes(col) && this.renderSortIcon(col)} ); } renderHeaderRow() { const cols = this.effectiveTableColumns(); const { allowDeletes, expandFieldset, extraButtons, sortColumns } = this.props; return ( {expandFieldset && } {cols.map(col => this.renderTH(col, sortColumns))} {extraButtons} {allowDeletes && ( )} ); } 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( , ); } tds = tds.concat( tableColumns.map(col => ( {this.renderCell(record, col)} )), ); if (allowAddItem) { tds.push(); } if (allowDeletes) { tds.push( , ); } const trs = [ {tds} , ]; if (isExpanded) { trs.push(
{this.renderExpandableSection(record)}
, ); } return trs; } renderEmptyCell() { return ( {this.props.emptyMessage} ); } renderTableBody() { const data = this.state.collectionArray; const content = data.length ? data.map(d => this.renderItem(d)) : this.renderEmptyCell(); return {content}; } render() { return ( <> {this.props.allowAddItem && ( )} {this.renderHeaderRow()} {this.renderTableBody()}
); } }