blob: 59f3d07fb46cd506c6d736bea6ae8a1eacbd52f0 [file] [log] [blame]
/**
* 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 { PureComponent, ReactNode } from 'react';
import { nanoid } from 'nanoid';
import { t, styled, css, SupersetTheme } from '@superset-ui/core';
import { Icons, Button, InfoTooltip } from '@superset-ui/core/components';
import { FilterValue } from 'react-table';
import Table, {
type ColumnsType,
type SortOrder,
type SorterResult,
type TablePaginationConfig,
TableSize,
} from '@superset-ui/core/components/Table';
import Fieldset from './Fieldset';
import { recurseReactClone } from './utils';
import {
type CRUDCollectionProps,
type CRUDCollectionState,
type Sort,
} from './types';
const CrudButtonWrapper = styled.div`
text-align: right;
${({ theme }) => `margin-bottom: ${theme.sizeUnit * 2}px`}
`;
const StyledButtonWrapper = styled.span`
${({ theme }) => `
margin-top: ${theme.sizeUnit * 3}px;
margin-left: ${theme.sizeUnit * 3}px;
button>span>:first-of-type {
margin-right: 0;
}
`}
`;
type CollectionItem = { id: string | number; [key: string]: any };
function createCollectionArray(collection: Record<PropertyKey, any>) {
return Object.keys(collection).map(k => collection[k] as CollectionItem);
}
function createKeyedCollection(arr: Array<object>) {
const collectionArray = arr.map(
(o: any) =>
({
...o,
id: o.id || nanoid(),
}) as CollectionItem,
);
const collection: Record<PropertyKey, any> = {};
collectionArray.forEach((o: CollectionItem) => {
collection[o.id] = o;
});
return {
collection,
collectionArray,
};
}
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.onAddItem = this.onAddItem.bind(this);
this.renderExpandableSection = this.renderExpandableSection.bind(this);
this.getLabel = this.getLabel.bind(this);
this.onFieldsetChange = this.onFieldsetChange.bind(this);
this.changeCollection = this.changeCollection.bind(this);
this.handleTableChange = this.handleTableChange.bind(this);
this.buildTableColumns = this.buildTableColumns.bind(this);
this.toggleExpand = this.toggleExpand.bind(this);
}
UNSAFE_componentWillReceiveProps(nextProps: CRUDCollectionProps) {
if (nextProps.collection !== this.props.collection) {
const { collection, collectionArray } = createKeyedCollection(
nextProps.collection,
);
this.setState(prevState => ({
collection,
collectionArray,
expandedColumns: prevState.expandedColumns,
}));
}
}
onCellChange(id: number, col: string, val: boolean) {
this.setState(prevState => {
const updatedCollection = {
...prevState.collection,
[id]: {
...prevState.collection[id],
[col]: val,
},
};
const updatedCollectionArray = prevState.collectionArray.map(item =>
item.id === id ? updatedCollection[id] : item,
);
if (this.props.onChange) {
this.props.onChange(updatedCollectionArray);
}
return {
collection: updatedCollection,
collectionArray: updatedCollectionArray,
};
});
}
onAddItem() {
if (this.props.itemGenerator) {
let newItem = this.props.itemGenerator();
const shouldStartExpanded = newItem.expanded === true;
if (!newItem.id) {
newItem = { ...newItem, id: nanoid() };
}
delete newItem.expanded;
this.setState(
prevState => {
const newCollection = {
...prevState.collection,
[newItem.id]: newItem,
};
const newExpandedColumns = shouldStartExpanded
? { ...prevState.expandedColumns, [newItem.id]: true }
: prevState.expandedColumns;
const newCollectionArray = [newItem, ...prevState.collectionArray];
return {
collection: newCollection,
collectionArray: newCollectionArray,
expandedColumns: newExpandedColumns,
};
},
() => {
if (this.props.onChange) {
this.props.onChange(this.state.collectionArray);
}
},
);
}
}
onFieldsetChange(item: any) {
this.changeCollection({
...this.state.collection,
[item.id]: item,
});
}
getLabel(col: any): string {
const { columnLabels } = this.props;
let label = columnLabels?.[col] ? columnLabels[col] : col;
if (label.startsWith('__')) {
label = '';
}
return label;
}
getTooltip(col: string): string | undefined {
const { columnLabelTooltips } = this.props;
return columnLabelTooltips?.[col];
}
changeCollection(collection: any) {
const newCollectionArray = createCollectionArray(collection);
this.setState({ collection, collectionArray: newCollectionArray });
if (this.props.onChange) {
this.props.onChange(newCollectionArray);
}
}
deleteItem(id: string | number) {
const newColl = { ...this.state.collection };
delete newColl[id];
this.changeCollection(newColl);
}
toggleExpand(id: any) {
this.setState(prevState => ({
expandedColumns: {
...prevState.expandedColumns,
[id]: !prevState.expandedColumns[id],
},
}));
}
handleTableChange(
_pagination: TablePaginationConfig,
_filters: Record<string, FilterValue | null>,
sorter: SorterResult<CollectionItem> | SorterResult<CollectionItem>[],
) {
const columnSorter = Array.isArray(sorter) ? sorter[0] : sorter;
let newSortColumn = '';
let newSortOrder = 0;
if (columnSorter?.columnKey && columnSorter?.order) {
newSortColumn = columnSorter.columnKey as string;
newSortOrder = columnSorter.order === 'ascend' ? 1 : 2;
}
const { sortColumns } = this.props;
const col = newSortColumn;
if (sortColumns?.includes(col) || newSortOrder === 0) {
let sortedArray = [...this.props.collection];
if (newSortOrder !== 0) {
const compareSort = (m: Sort, n: Sort) => {
if (typeof m === 'string' && typeof n === 'string') {
return (m || '').localeCompare(n || '');
}
if (typeof m === 'number' && typeof n === 'number') {
return m - n;
}
if (typeof m === 'boolean' && typeof n === 'boolean') {
return m === n ? 0 : m ? 1 : -1;
}
const mStr = String(m ?? '');
const nStr = String(n ?? '');
return mStr.localeCompare(nStr);
};
sortedArray.sort((a: any, b: any) => compareSort(a[col], b[col]));
if (newSortOrder === 2) {
sortedArray.reverse();
}
} else {
const { collectionArray } = createKeyedCollection(
this.props.collection,
);
sortedArray = collectionArray;
}
this.setState({
collectionArray: sortedArray,
sortColumn: newSortColumn,
sort: newSortOrder,
});
}
}
renderExpandableSection(item: any): ReactNode {
const propsGenerator = () => ({ item, onChange: this.onFieldsetChange });
return recurseReactClone(
this.props.expandFieldset,
Fieldset,
propsGenerator,
);
}
renderCell(record: any, col: any): ReactNode {
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;
}
buildTableColumns() {
const { tableColumns, allowDeletes, sortColumns = [] } = this.props;
const antdColumns: ColumnsType = tableColumns.map(col => {
const label = this.getLabel(col);
const tooltip = this.getTooltip(col);
const isSortable = sortColumns.includes(col);
const currentSortOrder: SortOrder | null | undefined =
this.state.sortColumn === col
? this.state.sort === 1
? 'ascend'
: this.state.sort === 2
? 'descend'
: null
: null;
return {
key: col,
dataIndex: col,
minWidth: 100,
title: (
<>
{label}
{tooltip && (
<>
{' '}
<InfoTooltip
label={t('description')}
tooltip={tooltip}
placement="top"
/>
</>
)}
</>
),
render: (text: any, record: CollectionItem) =>
this.renderCell(record, col),
onCell: (record: CollectionItem) => {
const cellPropsFn = this.props.itemCellProps?.[col];
const val = record[col];
return cellPropsFn ? cellPropsFn(val, label, record) : {};
},
sorter: isSortable,
sortOrder: currentSortOrder,
};
});
if (allowDeletes) {
antdColumns.push({
key: '__actions',
dataIndex: '__actions',
sorter: false,
title: <></>,
onCell: () => ({}),
sortOrder: null,
minWidth: 50,
render: (_, record: CollectionItem) => (
<span
data-test="crud-delete-option"
className="text-primary"
css={(theme: SupersetTheme) => css`
display: flex;
justify-content: center;
color: ${theme.colorTextTertiary};
`}
>
<Icons.DeleteOutlined
aria-label="Delete item"
className="pointer"
data-test="crud-delete-icon"
role="button"
tabIndex={0}
onClick={() => this.deleteItem(record.id)}
iconSize="l"
iconColor="inherit"
/>
</span>
),
});
}
return antdColumns as ColumnsType<CollectionItem>;
}
render() {
const {
stickyHeader,
emptyMessage = t('No items'),
expandFieldset,
} = this.props;
const tableColumns = this.buildTableColumns();
const expandedRowKeys = Object.keys(this.state.expandedColumns).filter(
id => this.state.expandedColumns[id],
);
const expandableConfig = expandFieldset
? {
expandedRowRender: (record: CollectionItem) =>
this.renderExpandableSection(record),
rowExpandable: () => true,
expandedRowKeys,
onExpand: (expanded: boolean, record: CollectionItem) => {
this.toggleExpand(record.id);
},
}
: undefined;
return (
<>
<CrudButtonWrapper>
{this.props.allowAddItem && (
<StyledButtonWrapper>
<Button
buttonSize="small"
buttonStyle="secondary"
onClick={this.onAddItem}
data-test="add-item-button"
>
<Icons.PlusOutlined
iconSize="m"
data-test="crud-add-table-item"
/>
{t('Add item')}
</Button>
</StyledButtonWrapper>
)}
</CrudButtonWrapper>
<Table<CollectionItem>
data-test="crud-table"
columns={tableColumns}
data={this.state.collectionArray as CollectionItem[]}
rowKey={(record: CollectionItem) => String(record.id)}
sticky={stickyHeader}
pagination={false}
onChange={this.handleTableChange}
locale={{ emptyText: emptyMessage }}
css={
stickyHeader &&
css`
height: 350px;
overflow: auto;
`
}
expandable={expandableConfig}
size={TableSize.Middle}
tableLayout="auto"
/>
</>
);
}
}