| /** |
| * 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 React, { ReactNode } from 'react'; |
| import shortid from 'shortid'; |
| 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'; |
| import './crud.less'; |
| |
| interface CRUDCollectionProps { |
| allowAddItem?: boolean; |
| allowDeletes?: boolean; |
| collection: Array<object>; |
| columnLabels?: object; |
| emptyMessage?: ReactNode; |
| expandFieldset?: ReactNode; |
| extraButtons?: ReactNode; |
| itemGenerator?: () => any; |
| itemRenderers?: (( |
| val: unknown, |
| onChange: () => void, |
| label: string, |
| record: any, |
| ) => ReactNode)[]; |
| onChange?: (arg0: any) => void; |
| tableColumns: Array<any>; |
| sortColumns: Array<string>; |
| stickyHeader?: boolean; |
| } |
| |
| type Sort = number | string | boolean | any; |
| |
| enum SortOrder { |
| asc = 1, |
| desc = 2, |
| unsort = 0, |
| } |
| |
| interface CRUDCollectionState { |
| collection: object; |
| collectionArray: Array<object>; |
| expandedColumns: object; |
| sortColumn: string; |
| sort: SortOrder; |
| } |
| |
| function createCollectionArray(collection: object) { |
| return Object.keys(collection).map(k => collection[k]); |
| } |
| |
| function createKeyedCollection(arr: Array<object>) { |
| const newArr = arr.map((o: any) => ({ |
| ...o, |
| id: o.id || shortid.generate(), |
| })); |
| const map = {}; |
| newArr.forEach((o: any) => { |
| map[o.id] = o; |
| }); |
| return map; |
| } |
| |
| 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 |
| } |
| `} |
| th span { |
| vertical-align: ${({ theme }) => theme.gridUnit * -2}px; |
| } |
| `; |
| |
| const CrudButtonWrapper = styled.div` |
| text-align: right; |
| ${({ theme }) => `margin-bottom: ${theme.gridUnit * 2}px`} |
| `; |
| |
| export default class CRUDCollection extends React.PureComponent< |
| CRUDCollectionProps, |
| CRUDCollectionState |
| > { |
| constructor(props: CRUDCollectionProps) { |
| super(props); |
| |
| const collection = createKeyedCollection(props.collection); |
| this.state = { |
| expandedColumns: {}, |
| collection, |
| collectionArray: createCollectionArray(collection), |
| 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 = createKeyedCollection(nextProps.collection); |
| this.setState({ |
| collection, |
| collectionArray: createCollectionArray(collection), |
| }); |
| } |
| } |
| |
| 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: shortid.generate() }; |
| } |
| this.changeCollection({ |
| ...this.state.collection, |
| [newItem.id]: newItem, |
| }); |
| } |
| } |
| |
| onFieldsetChange(item: any) { |
| this.changeCollection({ |
| ...this.state.collection, |
| [item.id]: item, |
| }); |
| } |
| |
| getLabel(col: any) { |
| const { columnLabels } = this.props; |
| let label = columnLabels && columnLabels[col] ? columnLabels[col] : col; |
| if (label.startsWith('__')) { |
| // special label-free columns (ie: caret for expand, delete cross) |
| label = ''; |
| } |
| return label; |
| } |
| |
| changeCollection(collection: any) { |
| this.setState({ collection }); |
| if (this.props.onChange) { |
| this.props.onChange(Object.keys(collection).map(k => collection[k])); |
| } |
| } |
| |
| 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.unsort) { |
| 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.unsort) { |
| const collection = createKeyedCollection(this.props.collection); |
| this.setState({ |
| collectionArray: createCollectionArray(collection), |
| sortColumn: '', |
| sort, |
| }); |
| return; |
| } |
| |
| this.setState(prevState => { |
| // newly ordered collection |
| const sorted = [ |
| ...prevState.collectionArray, |
| ].sort((a: object, b: object) => compareSort(a[col], b[col])); |
| const newCollection = |
| sort === SortOrder.asc ? sorted : sorted.reverse(); |
| return { |
| ...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)} />; |
| } |
| |
| 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 => ( |
| <th key={col}> |
| {this.getLabel(col)} |
| {sortColumns?.includes(col) && this.renderSortIcon(col)} |
| </th> |
| ))} |
| {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, |
| ); |
| } |
| |
| renderCell(record: any, col: any) { |
| const renderer = this.props.itemRenderers && 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} |
| 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 key={col}>{this.renderCell(record, col)}</td> |
| )), |
| ); |
| if (allowAddItem) { |
| tds.push(<td key="add" />); |
| } |
| if (allowDeletes) { |
| tds.push( |
| <td |
| key="__actions" |
| data-test="crud-delete-option" |
| className="text-primary" |
| > |
| <Icons.Trash |
| aria-label="Delete item" |
| className="pointer" |
| data-test="crud-delete-icon" |
| role="button" |
| tabIndex={0} |
| onClick={this.deleteItem.bind(this, record.id)} |
| /> |
| </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 && ( |
| <span className="m-t-10 m-r-10"> |
| <Button |
| buttonSize="small" |
| buttonStyle="primary" |
| onClick={this.onAddItem} |
| data-test="add-item-button" |
| > |
| <i data-test="crud-add-table-item" className="fa fa-plus" />{' '} |
| {t('Add item')} |
| </Button> |
| </span> |
| )} |
| </CrudButtonWrapper> |
| <CrudTableWrapper |
| className="CRUD" |
| stickyHeader={this.props.stickyHeader} |
| > |
| <table data-test="crud-table" className="table"> |
| {this.renderHeaderRow()} |
| {this.renderTableBody()} |
| </table> |
| </CrudTableWrapper> |
| </> |
| ); |
| } |
| } |