ToolJet/frontend/src/Editor/Components/Table/Table.jsx

717 lines
23 KiB
React
Raw Normal View History

2021-04-30 06:31:32 +00:00
import React, { useMemo, useState, useEffect } from 'react';
import {
useTable,
useFilters,
useSortBy,
useGlobalFilter,
useAsyncDebounce,
usePagination,
useBlockLayout,
useResizeColumns
2021-04-30 06:31:32 +00:00
} from 'react-table';
import { resolveReferences } from '@/_helpers/utils';
import Skeleton from 'react-loading-skeleton';
import SelectSearch, { fuzzySearch } from 'react-select-search';
2021-04-30 06:31:32 +00:00
import { useExportData } from 'react-table-plugins';
import Papa from 'papaparse';
import { Pagination } from './Pagination';
2021-05-09 07:26:54 +00:00
import { CustomSelect } from './CustomSelect';
import { Tags } from './Tags';
2021-04-25 05:14:54 +00:00
var _ = require('lodash');
2021-04-03 05:25:41 +00:00
2021-04-30 06:31:32 +00:00
export function Table({
id,
width,
height,
component,
onComponentClick,
currentState = { components: {} },
onEvent,
paramUpdated,
changeCanDrag,
onComponentOptionChanged,
onComponentOptionsChanged
2021-04-30 06:31:32 +00:00
}) {
const color = component.definition.styles.textColor.value;
const actions = component.definition.properties.actions || { value: [] };
const serverSidePaginationProperty = component.definition.properties.serverSidePagination;
const serverSidePagination = serverSidePaginationProperty ? serverSidePaginationProperty.value : false;
2021-04-30 06:31:32 +00:00
const [loadingState, setLoadingState] = useState(false);
useEffect(() => {
const loadingStateProperty = component.definition.properties.loadingState;
if (loadingStateProperty && currentState) {
const newState = resolveReferences(loadingStateProperty.value, currentState, false);
2021-04-30 06:31:32 +00:00
setLoadingState(newState);
}
2021-04-30 06:31:32 +00:00
}, [currentState]);
const [componentState, setcomponentState] = useState(currentState.components[component.component] || {});
useEffect(() => {
setcomponentState(currentState.components[component.name] || {});
}, [currentState.components[component.name]]);
const [isFiltersVisible, setFiltersVisibility] = useState(false);
const [filters, setFilters] = useState([]);
function showFilters() {
setFiltersVisibility(true);
}
function hideFilters() {
setFiltersVisibility(false);
}
function filterColumnChanged(index, value) {
const newFilters = filters;
newFilters[index].id = value;
2021-04-30 06:31:32 +00:00
setFilters(newFilters);
setAllFilters(newFilters.filter((filter) => filter.id !== ''));
2021-04-30 06:31:32 +00:00
}
function filterOperationChanged(index, value) {
const newFilters = filters;
newFilters[index].value = {
...newFilters[index].value,
operation: value
2021-04-30 06:31:32 +00:00
};
setFilters(newFilters);
setAllFilters(newFilters.filter((filter) => filter.id !== ''));
2021-04-30 06:31:32 +00:00
}
function filterValueChanged(index, value) {
const newFilters = filters;
newFilters[index].value = {
...newFilters[index].value,
value: value
2021-04-30 06:31:32 +00:00
};
setFilters(newFilters);
setAllFilters(newFilters.filter((filter) => filter.id !== ''));
2021-04-30 06:31:32 +00:00
}
function addFilter() {
setFilters([...filters, { id: '', value: { operation: 'contains', value: '' } }]);
}
function removeFilter(index) {
let newFilters = filters;
newFilters.splice(index, 1);
setFilters(newFilters);
setAllFilters(newFilters);
}
function clearFilters() {
setFilters([]);
setAllFilters([]);
}
const defaultColumn = React.useMemo(
() => ({
minWidth: 60,
width: 268
2021-04-30 06:31:32 +00:00
}),
[]
);
const columnSizes = component.definition.properties.columnSizes || {};
function handleCellValueChange(index, key, value, rowData) {
const changeSet = componentState.changeSet;
const dataUpdates = componentState.dataUpdates || [];
let obj = changeSet ? changeSet[index] || {} : {};
obj = _.set(obj, key, value);
let newChangeset = {
...changeSet,
[index]: {
...obj
}
2021-04-30 06:31:32 +00:00
};
obj = _.set(rowData, key, value);
let newDataUpdates = [...dataUpdates, { ...obj }];
onComponentOptionsChanged(component, [
['dataUpdates', newDataUpdates],
['changeSet', newChangeset]
2021-04-30 06:31:32 +00:00
]);
}
function getExportFileBlob({
columns, data
}) {
2021-04-30 06:31:32 +00:00
const headerNames = columns.map((col) => col.exportValue);
const csvString = Papa.unparse({ fields: headerNames, data });
return new Blob([csvString], { type: 'text/csv' });
}
function onPageIndexChanged(page) {
onComponentOptionChanged(component, 'pageIndex', page).then(() => {
onEvent('onPageChanged', { component, data: {} });
});
}
2021-04-30 06:31:32 +00:00
function handleChangesSaved() {
Object.keys(changeSet).forEach((key) => {
2021-04-30 06:31:32 +00:00
tableData[key] = {
..._.merge(tableData[key], changeSet[key])
2021-04-30 06:31:32 +00:00
};
});
onComponentOptionChanged(component, 'changeSet', {});
onComponentOptionChanged(component, 'dataUpdates', []);
}
function handleChangesDiscarded() {
onComponentOptionChanged(component, 'changeSet', {});
onComponentOptionChanged(component, 'dataUpdates', []);
}
function customFilter(rows, columnIds, filterValue) {
try {
if (filterValue.operation === 'equals') {
return rows.filter((row) => row.values[columnIds[0]] === filterValue.value);
}
2021-05-09 12:03:03 +00:00
if (filterValue.operation === 'matches') {
return rows.filter((row) => row.values[columnIds[0]].toString().toLowerCase().includes(filterValue.value.toLowerCase()));
}
2021-04-30 06:31:32 +00:00
if (filterValue.operation === 'gt') {
return rows.filter((row) => row.values[columnIds[0]] > filterValue.value);
}
if (filterValue.operation === 'lt') {
return rows.filter((row) => row.values[columnIds[0]] < filterValue.value);
}
if (filterValue.operation === 'gte') {
return rows.filter((row) => row.values[columnIds[0]] >= filterValue.value);
}
if (filterValue.operation === 'lte') {
return rows.filter((row) => row.values[columnIds[0]] <= filterValue.value);
}
let value = filterValue.value;
if (typeof value === 'string') {
value = value.toLowerCase();
}
return rows.filter((row) => {
let rowValue = row.values[columnIds[0]];
if (typeof rowValue === 'string') {
rowValue = rowValue.toLowerCase();
}
return rowValue.includes(value);
});
} catch {
return rows;
}
}
2021-04-04 06:26:46 +00:00
2021-04-30 06:31:32 +00:00
const changeSet = componentState ? componentState.changeSet : {};
2021-04-09 04:56:17 +00:00
2021-04-30 06:31:32 +00:00
const columnData = component.definition.properties.columns.value.map((column) => {
const columnSize = columnSizes[column.key] || columnSizes[column.name];
const columnType = column.columnType;
2021-04-09 04:56:17 +00:00
2021-04-30 06:31:32 +00:00
const columnOptions = {};
if (columnType === 'dropdown' || columnType === 'multiselect' || columnType === 'badge' || columnType === 'badges') {
const values = resolveReferences(column.values, currentState) || [];
const labels = resolveReferences(column.labels, currentState, []) || [];
2021-04-30 06:31:32 +00:00
if (typeof labels === 'object') {
columnOptions.selectOptions = labels.map((label, index) => {
2021-04-30 06:31:32 +00:00
return { name: label, value: values[index] };
});
}
2021-04-03 05:25:41 +00:00
}
const width = columnSize || defaultColumn.width;
2021-04-30 06:31:32 +00:00
return {
Header: column.name,
accessor: column.key || column.name,
filter: customFilter,
width: width,
2021-04-30 06:31:32 +00:00
Cell: function (cell) {
const rowChangeSet = changeSet ? changeSet[cell.row.index] : null;
const cellValue = rowChangeSet ? rowChangeSet[column.name] || cell.value : cell.value;
if (columnType === undefined || columnType === 'default') {
return <span>{cellValue}</span>;
} if (columnType === 'string') {
2021-04-30 06:31:32 +00:00
if (column.isEditable) {
return (
<input
type="text"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleCellValueChange(cell.row.index, column.key || column.name, e.target.value, cell.row.original);
}
}}
onBlur={(e) => {
handleCellValueChange(cell.row.index, column.key || column.name, e.target.value, cell.row.original);
}}
className="form-control-plaintext form-control-plaintext-sm"
defaultValue={cellValue}
/>
);
}
return <span>{cellValue}</span>;
} if (columnType === 'text') {
return <textarea
rows="1"
className="form-control-plaintext text-container text-muted"
readOnly={!column.isEditable}
style={{maxWidth: width, minWidth: width - 10}}
onBlur={(e) => {
handleCellValueChange(cell.row.index, column.key || column.name, e.target.value, cell.row.original);
}}
2021-05-31 02:57:22 +00:00
value={cellValue}
>
</textarea>;
} if (columnType === 'dropdown') {
2021-04-30 06:31:32 +00:00
return (
<div>
<SelectSearch
options={columnOptions.selectOptions}
2021-04-30 06:31:32 +00:00
value={cellValue}
search={true}
onChange={(value) => {
handleCellValueChange(cell.row.index, column.key || column.name, value, cell.row.original);
}}
filterOptions={fuzzySearch}
placeholder="Select.."
/>
</div>
);
} if (columnType === 'multiselect') {
2021-04-30 06:31:32 +00:00
return (
<div>
<SelectSearch
printOptions="on-focus"
multiple
search={true}
placeholder="Select.."
options={columnOptions.selectOptions}
2021-04-30 06:31:32 +00:00
value={cellValue}
onChange={(value) => {
handleCellValueChange(cell.row.index, column.key || column.name, value, cell.row.original);
}}
/>
</div>
);
2021-05-09 07:26:54 +00:00
} if (columnType === 'badge') {
return (
<div>
<CustomSelect
options={columnOptions.selectOptions}
value={cellValue}
onChange={(value) => {
handleCellValueChange(cell.row.index, column.key || column.name, value, cell.row.original);
}}
/>
</div>
);
} if (columnType === 'badges') {
return (
<div>
<CustomSelect
options={columnOptions.selectOptions}
value={cellValue}
multiple={true}
onChange={(value) => {
handleCellValueChange(cell.row.index, column.key || column.name, value, cell.row.original);
}}
/>
</div>
);
} if (columnType === 'tags') {
return (
<div>
<Tags
value={cellValue}
onChange={(value) => {
handleCellValueChange(cell.row.index, column.key || column.name, value, cell.row.original);
}}
/>
</div>
);
2021-04-30 06:31:32 +00:00
}
return cellValue || '';
}
2021-04-30 06:31:32 +00:00
};
});
let tableData = [];
if (currentState) {
tableData = resolveReferences(component.definition.properties.data.value, currentState, []);
2021-04-30 06:31:32 +00:00
if (!Array.isArray(tableData)) tableData = [];
console.log('resolved param', tableData);
}
tableData = tableData || [];
const actionsCellData = actions.value.length > 0
? [
{
id: 'actions',
Header: 'Actions',
accessor: 'edit',
width: columnSizes.actions || defaultColumn.width,
Cell: (cell) => {
return actions.value.map((action) => (
2021-04-30 06:31:32 +00:00
<button
key={action.name}
2021-04-30 06:31:32 +00:00
className="btn btn-sm m-1 btn-light"
style={{ background: action.backgroundColor, color: action.textColor }}
onClick={(e) => {
e.stopPropagation();
onEvent('onTableActionButtonClicked', { component, data: cell.row.original, action });
}}
>
{action.buttonText}
</button>
));
}
}
]
: [];
2021-04-30 06:31:32 +00:00
const columns = useMemo(
() => [...columnData, ...actionsCellData],
[JSON.stringify(columnData), actionsCellData.length, componentState.changeSet] // Hack: need to fix
);
const data = useMemo(() => tableData, [tableData.length]);
const computedStyles = {
color,
width: `${width}px`
2021-04-30 06:31:32 +00:00
};
const {
getTableProps,
getTableBodyProps,
headerGroups,
page,
canPreviousPage,
canNextPage,
pageOptions,
gotoPage,
pageCount,
nextPage,
previousPage,
setPageSize,
state,
prepareRow,
setAllFilters,
preGlobalFilteredRows,
setGlobalFilter,
state: { pageIndex, pageSize },
exportData
2021-04-30 06:31:32 +00:00
} = useTable(
{
columns,
data,
defaultColumn,
initialState: { pageIndex: 0, pageSize: serverSidePagination ? -1 : 10}, // pageSize should be unset if server-side pagination is enabled
pageCount: -1,
manualPagination: false,
getExportFileBlob
2021-04-09 04:56:17 +00:00
},
2021-04-30 06:31:32 +00:00
useFilters,
useGlobalFilter,
useSortBy,
usePagination,
useBlockLayout,
useResizeColumns,
useExportData
);
useEffect(() => {
if (!state.columnResizing.isResizingColumn) {
changeCanDrag(true);
paramUpdated(id, 'columnSizes', { ...columnSizes, ...state.columnResizing.columnWidths});
2021-04-30 06:31:32 +00:00
} else {
changeCanDrag(false);
}
}, [state.columnResizing.isResizingColumn]);
2021-04-30 06:31:32 +00:00
function GlobalFilter() {
2021-04-30 06:31:32 +00:00
const count = preGlobalFilteredRows.length;
const [value, setValue] = React.useState(state.globalFilter);
const onChange = useAsyncDebounce((filterValue) => {
setGlobalFilter(filterValue || undefined);
2021-04-30 06:31:32 +00:00
}, 200);
2021-04-09 05:39:27 +00:00
return (
2021-04-30 06:31:32 +00:00
<div className="ms-2 d-inline-block">
Search:{' '}
<input
defaultValue={value || ''}
onBlur={(e) => {
2021-04-30 06:31:32 +00:00
setValue(e.target.value);
onChange(e.target.value);
}}
onKeyDown={(e) => {
if(e.key === 'Enter') {
setValue(e.target.value);
onChange(e.target.value);
}
}
}
2021-04-30 06:31:32 +00:00
placeholder={`${count} records`}
style={{
border: '0'
2021-04-30 06:31:32 +00:00
}}
/>
</div>
);
}
return (
<div
className="card"
2021-05-26 07:32:48 +00:00
style={{ width: `${width}px`, height: `${height}px` }}
2021-04-30 06:31:32 +00:00
onClick={() => onComponentClick(id, component)}
>
<div className="card-body border-bottom py-3 jet-data-table-header">
<div className="d-flex">
{!serverSidePagination &&
<div className="text-muted">
Show
<div className="mx-2 d-inline-block">
<select
value={pageSize}
className="form-control form-control-sm"
onChange={(e) => {
setPageSize(Number(e.target.value));
}}
>
{[10, 20, 30, 40, 50].map((itemsCount) => (
<option key={itemsCount} value={itemsCount}>
{itemsCount}
</option>
))}
</select>
</div>
entries
2021-04-30 06:31:32 +00:00
</div>
}
2021-04-30 06:31:32 +00:00
<div className="ms-auto text-muted">
<GlobalFilter />
2021-04-30 06:31:32 +00:00
</div>
</div>
</div>
<div className="table-responsive jet-data-table">
<table {...getTableProps()} className="table table-vcenter table-nowrap table-bordered" style={computedStyles}>
<thead>
{headerGroups.map((headerGroup) => (
<tr {...headerGroup.getHeaderGroupProps()} tabIndex="0" className="tr">
{headerGroup.headers.map((column) => (
<th
{...column.getHeaderProps(column.getSortByToggleProps())}
className={column.isSorted ? (column.isSortedDesc ? 'sort-desc th' : 'sort-asc th') : 'th'}
>
{column.render('Header')}
<div
draggable="true"
{...column.getResizerProps()}
className={`resizer ${column.isResizing ? 'isResizing' : ''}`}
/>
</th>
))}
</tr>
))}
</thead>
2021-05-26 12:31:33 +00:00
{!loadingState && page.length === 0 &&
<center className="w-100"><div className="py-5"> no data </div></center>
}
2021-04-30 06:31:32 +00:00
{!loadingState && (
<tbody {...getTableBodyProps()}>
{console.log('page', page)}
{page.map((row) => {
2021-04-30 06:31:32 +00:00
prepareRow(row);
return (
<tr
className="table-row"
{...row.getRowProps()}
onClick={(e) => {
e.stopPropagation();
onEvent('onRowClicked', { component, data: row.original });
}}
>
{row.cells.map((cell) => {
let cellProps = cell.getCellProps();
if (componentState.changeSet) {
if (componentState.changeSet[cell.row.index]) {
if (_.get(componentState.changeSet[cell.row.index], cell.column.id, undefined)) {
console.log('componentState.changeSet', componentState.changeSet);
cellProps.style.backgroundColor = '#ffffde';
2021-04-30 06:31:32 +00:00
}
}
}
return <td {...cellProps}>{cell.render('Cell')}</td>;
})}
</tr>
);
})}
</tbody>
)}
</table>
{loadingState === true && (
<div style={{ width: '100%' }} className="p-2">
<Skeleton count={5} />
</div>
)}
</div>
<div className="card-footer d-flex align-items-center jet-table-footer">
<div className="table-footer row">
<div className="col">
<Pagination
serverSide={serverSidePagination}
autoGotoPage={gotoPage}
autoCanNextPage={canNextPage}
autoPageCount={pageCount}
autoPageOptions={pageOptions}
onPageIndexChanged={onPageIndexChanged}
/>
2021-04-30 06:31:32 +00:00
</div>
{Object.keys(componentState.changeSet || {}).length > 0 && (
<div className="col">
<button
className={`btn btn-primary btn-sm ${componentState.isSavingChanges ? 'btn-loading' : ''}`}
onClick={() => onEvent('onBulkUpdate', { component }).then(() => {
handleChangesSaved();
})
2021-04-30 06:31:32 +00:00
}
>
Save Changes
</button>
<button className="btn btn-light btn-sm mx-2" onClick={() => handleChangesDiscarded()}>
2021-04-30 06:31:32 +00:00
Cancel
</button>
</div>
)}
<div className="col-auto">
<span data-tip="Filter data" className="btn btn-light btn-sm p-1 mx-2" onClick={() => showFilters()}>
2021-05-31 10:38:18 +00:00
<img src="/assets/images/icons/filter.svg" width="13" height="13" />
{filters.length > 0 &&
<a class="badge bg-azure" style={{width: '4px', height: '4px', marginTop: '5px'}}></a>
}
2021-04-30 06:31:32 +00:00
</span>
<span
data-tip="Download as CSV"
className="btn btn-light btn-sm p-1"
onClick={() => exportData('csv', true)}
>
2021-05-31 10:38:18 +00:00
<img src="/assets/images/icons/download.svg" width="13" height="13" />
2021-04-30 06:31:32 +00:00
</span>
</div>
</div>
</div>
{isFiltersVisible && (
<div className="table-filters card">
<div className="card-header row">
2021-04-30 06:31:32 +00:00
<div className="col">
<h4 className="text-muted">Filters</h4>
2021-04-30 06:31:32 +00:00
</div>
<div className="col-auto">
<button onClick={() => hideFilters()} className="btn btn-light btn-sm">
x
</button>
</div>
</div>
<div className="card-body">
{filters.map((filter, index) => (
<div className="row mb-2" key={index}>
<div className="col p-2" style={{ maxWidth: '70px' }}>
<small>{index > 0 ? 'and' : 'where'}</small>
</div>
<div className="col">
<SelectSearch
options={columnData.map((column) => {
return { name: column.Header, value: column.accessor };
})}
value={filter.id}
search={true}
onChange={(value) => {
filterColumnChanged(index, value);
}}
filterOptions={fuzzySearch}
placeholder="Select.."
/>
</div>
<div className="col" style={{ maxWidth: '180px' }}>
<SelectSearch
options={[
{ name: 'contains', value: 'contains' },
2021-05-09 12:03:03 +00:00
{ name: 'matches', value: 'matches' },
2021-04-30 06:31:32 +00:00
{ name: 'equals', value: 'equals' },
{ name: 'greater than', value: 'gt' },
{ name: 'less than', value: 'lt' },
{ name: 'greater than or equals', value: 'gte' },
{ name: 'less than or equals', value: 'lte' }
2021-04-30 06:31:32 +00:00
]}
value={filter.value.operation}
search={true}
onChange={(value) => {
filterOperationChanged(index, value);
}}
filterOptions={fuzzySearch}
placeholder="Select.."
/>
</div>
<div className="col">
<input
type="text"
value={filter.value.value}
placeholder="value"
className="form-control"
onChange={(e) => filterValueChanged(index, e.target.value)}
/>
</div>
<div className="col-auto">
<button onClick={() => removeFilter(index)} className="btn btn-light btn-sm p-2 text-danger">
x
</button>
</div>
</div>
))}
{filters.length === 0 && (
2021-04-30 06:31:32 +00:00
<div>
<center>
<span className="text-muted">no filters yet.</span>
</center>
</div>
)}
</div>
<div className="card-footer">
2021-04-30 06:31:32 +00:00
<button onClick={addFilter} className="btn btn-light btn-sm text-muted">
+ add filter
</button>
<button onClick={() => clearFilters()} className="btn btn-light btn-sm mx-2 text-muted">
clear filters
</button>
</div>
</div>
)}
</div>
);
2021-04-12 13:35:48 +00:00
}