blob: 67475f0993745fc84b02ef1bf330b11654d5af8c [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 { t, styled } from '@superset-ui/core';
import { useCallback, useEffect, useRef, useState, ReactNode } from 'react';
import cx from 'classnames';
import TableCollection from '@superset-ui/core/components/TableCollection';
import BulkTagModal from 'src/features/tags/BulkTagModal';
import {
Alert,
Button,
Checkbox,
Icons,
EmptyState,
Loading,
type EmptyStateProps,
} from '@superset-ui/core/components';
import CardCollection from './CardCollection';
import FilterControls from './Filters';
import { CardSortSelect } from './CardSortSelect';
import {
ListViewFetchDataConfig as FetchDataConfig,
ListViewFilters as Filters,
SortColumn,
CardSortSelectOption,
ViewModeType,
} from './types';
import { ListViewError, useListViewState } from './utils';
const ListViewStyles = styled.div`
${({ theme }) => `
text-align: center;
background-color: ${theme.colorBgLayout};
padding-top: ${theme.paddingXS}px;
.superset-list-view {
text-align: left;
border-radius: 4px 0;
margin: 0 ${theme.sizeUnit * 4}px;
.header {
display: flex;
padding-bottom: ${theme.sizeUnit * 4}px;
& .controls {
display: flex;
flex-wrap: wrap;
column-gap: ${theme.sizeUnit * 6}px;
row-gap: ${theme.sizeUnit * 4}px;
}
}
.body.empty table {
margin-bottom: 0;
}
.body {
overflow-x: auto;
overflow-y: hidden;
}
.ant-empty {
.ant-empty-image {
height: auto;
}
}
}
.pagination-container {
display: flex;
flex-direction: column;
justify-content: center;
margin-bottom: ${theme.sizeUnit * 4}px;
}
.row-count-container {
margin-top: ${theme.sizeUnit * 2}px;
color: ${theme.colorText};
}
`}
`;
const FullPageLoadingWrapper = styled.div`
${({ theme }) => `
display: flex;
align-items: center;
justify-content: center;
min-height: 50vh;
padding: ${theme.sizeUnit * 16}px;
`}
`;
const BulkSelectWrapper = styled(Alert)`
${({ theme }) => `
border-radius: 0;
margin-bottom: 0;
color: ${theme.colorText};
background-color: ${theme.colorPrimaryBg};
.selectedCopy {
display: inline-block;
padding: ${theme.sizeUnit * 2}px 0;
}
.deselect-all, .tag-btn {
color: ${theme.colorPrimary};
margin-left: ${theme.sizeUnit * 4}px;
}
.divider {
margin: ${`${-theme.sizeUnit * 2}px 0 ${-theme.sizeUnit * 2}px ${theme.sizeUnit * 4}px`};
width: 1px;
height: ${theme.sizeUnit * 8}px;
box-shadow: inset -1px 0px 0px ${theme.colorBorder};
display: inline-flex;
vertical-align: middle;
position: relative;
}
.ant-alert-close-icon {
margin-top: ${theme.sizeUnit * 1.5}px;
}
`}
`;
const bulkSelectColumnConfig = {
Cell: ({ row }: any) => (
<Checkbox {...row.getToggleRowSelectedProps()} id={row.id} />
),
Header: ({ getToggleAllRowsSelectedProps }: any) => (
<Checkbox
{...getToggleAllRowsSelectedProps()}
id="header-toggle-all"
data-test="header-toggle-all"
/>
),
id: 'selection',
size: 'sm',
};
const ViewModeContainer = styled.div`
${({ theme }) => `
padding-right: ${theme.sizeUnit * 4}px;
margin-top: ${theme.sizeUnit * 5 + 1}px;
white-space: nowrap;
display: inline-block;
.toggle-button {
display: inline-block;
border-radius: ${theme.borderRadius}px;
padding: ${theme.sizeUnit}px;
padding-bottom: ${theme.sizeUnit * 0.5}px;
&:first-of-type {
margin-right: ${theme.sizeUnit * 2}px;
}
}
.active {
background-color: ${theme.colorText};
svg {
color: ${theme.colorBgLayout};
}
}
`}
`;
const EmptyWrapper = styled.div`
${({ theme }) => `
padding: ${theme.sizeUnit * 40}px 0;
&.table {
background: ${theme.colorBgContainer};
}
`}
`;
const ViewModeToggle = ({
mode,
setMode,
}: {
mode: 'table' | 'card';
setMode: (mode: 'table' | 'card') => void;
}) => (
<ViewModeContainer>
<div
role="button"
tabIndex={0}
onClick={e => {
e.currentTarget.blur();
setMode('card');
}}
className={cx('toggle-button', { active: mode === 'card' })}
>
<Icons.AppstoreOutlined iconSize="xl" />
</div>
<div
role="button"
tabIndex={0}
onClick={e => {
e.currentTarget.blur();
setMode('table');
}}
className={cx('toggle-button', { active: mode === 'table' })}
>
<Icons.UnorderedListOutlined iconSize="xl" />
</div>
</ViewModeContainer>
);
export interface ListViewProps<T extends object = any> {
columns: any[];
data: T[];
count: number;
pageSize: number;
fetchData: (conf: FetchDataConfig) => any;
refreshData: () => void;
addSuccessToast: (msg: string) => void;
addDangerToast: (msg: string) => void;
loading: boolean;
className?: string;
initialSort?: SortColumn[];
filters?: Filters;
bulkActions?: Array<{
key: string;
name: ReactNode;
onSelect: (rows: any[]) => any;
type?: 'primary' | 'secondary' | 'danger';
}>;
bulkSelectEnabled?: boolean;
disableBulkSelect?: () => void;
renderBulkSelectCopy?: (selects: any[]) => ReactNode;
renderCard?: (row: T & { loading: boolean }) => ReactNode;
cardSortSelectOptions?: Array<CardSortSelectOption>;
defaultViewMode?: ViewModeType;
highlightRowId?: number;
showThumbnails?: boolean;
emptyState?: EmptyStateProps;
columnsForWrapText?: string[];
enableBulkTag?: boolean;
bulkTagResourceName?: string;
}
export function ListView<T extends object = any>({
columns,
data,
count,
pageSize: initialPageSize,
fetchData,
refreshData,
loading,
initialSort = [],
className = '',
filters = [],
bulkActions = [],
bulkSelectEnabled = false,
disableBulkSelect = () => {},
renderBulkSelectCopy = selected => t('%s Selected', selected.length),
renderCard,
showThumbnails,
cardSortSelectOptions,
defaultViewMode = 'card',
highlightRowId,
emptyState,
columnsForWrapText,
enableBulkTag = false,
bulkTagResourceName,
addSuccessToast,
addDangerToast,
}: ListViewProps<T>) {
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
pageCount = 1,
gotoPage,
applyFilterValue,
setSortBy,
selectedFlatRows,
toggleAllRowsSelected,
setViewMode,
state: { pageIndex, pageSize, internalFilters, sortBy, viewMode },
query,
} = useListViewState({
bulkSelectColumnConfig,
bulkSelectMode: bulkSelectEnabled && Boolean(bulkActions.length),
columns,
count,
data,
fetchData,
initialPageSize,
initialSort,
initialFilters: filters,
renderCard: Boolean(renderCard),
defaultViewMode,
});
const allowBulkTagActions = bulkTagResourceName && enableBulkTag;
const filterable = Boolean(filters.length);
if (filterable) {
const columnAccessors = columns.reduce(
(acc, col) => ({ ...acc, [col.id || col.accessor]: true }),
{},
);
filters.forEach(f => {
if (!columnAccessors[f.id]) {
throw new ListViewError(
`Invalid filter config, ${f.id} is not present in columns`,
);
}
});
}
const filterControlsRef = useRef<{ clearFilters: () => void }>(null);
const handleClearFilterControls = useCallback(() => {
if (query.filters) {
filterControlsRef.current?.clearFilters();
}
}, [query.filters]);
const cardViewEnabled = Boolean(renderCard);
const [showBulkTagModal, setShowBulkTagModal] = useState<boolean>(false);
useEffect(() => {
// discard selections if bulk select is disabled
if (!bulkSelectEnabled) toggleAllRowsSelected(false);
}, [bulkSelectEnabled, toggleAllRowsSelected]);
useEffect(() => {
if (!loading && pageIndex > pageCount - 1 && pageCount > 0) {
gotoPage(0);
}
}, [gotoPage, loading, pageCount, pageIndex]);
return (
<ListViewStyles>
{allowBulkTagActions && (
<BulkTagModal
show={showBulkTagModal}
selected={selectedFlatRows}
refreshData={refreshData}
resourceName={bulkTagResourceName}
addSuccessToast={addSuccessToast}
addDangerToast={addDangerToast}
onHide={() => setShowBulkTagModal(false)}
/>
)}
<div data-test={className} className={`superset-list-view ${className} `}>
<div className="header">
{cardViewEnabled && (
<ViewModeToggle mode={viewMode} setMode={setViewMode} />
)}
<div className="controls" data-test="filters-select">
{filterable && (
<FilterControls
ref={filterControlsRef}
filters={filters}
internalFilters={internalFilters}
updateFilterValue={applyFilterValue}
/>
)}
{viewMode === 'card' && cardSortSelectOptions && (
<CardSortSelect
initialSort={sortBy}
onChange={(value: SortColumn[]) => setSortBy(value)}
options={cardSortSelectOptions}
/>
)}
</div>
</div>
<div className={`body ${rows.length === 0 ? 'empty' : ''} `}>
{bulkSelectEnabled && (
<BulkSelectWrapper
data-test="bulk-select-controls"
type="info"
closable
showIcon={false}
onClose={disableBulkSelect}
message={
<>
<div className="selectedCopy" data-test="bulk-select-copy">
{renderBulkSelectCopy(selectedFlatRows)}
</div>
{Boolean(selectedFlatRows.length) && (
<>
<span
data-test="bulk-select-deselect-all"
style={{ cursor: 'pointer' }}
role="button"
tabIndex={0}
className="deselect-all"
onClick={() => toggleAllRowsSelected(false)}
>
{t('Deselect all')}
</span>
<div className="divider" />
{bulkActions.map(action => (
<Button
data-test="bulk-select-action"
key={action.key}
buttonStyle={action.type}
cta
onClick={() =>
action.onSelect(
selectedFlatRows.map((r: any) => r.original),
)
}
>
{action.name}
</Button>
))}
{enableBulkTag && (
<span
data-test="bulk-select-tag-btn"
role="button"
style={{ cursor: 'pointer' }}
tabIndex={0}
className="tag-btn"
onClick={() => setShowBulkTagModal(true)}
>
{t('Add Tag')}
</span>
)}
</>
)}
</>
}
/>
)}
{viewMode === 'card' && (
<CardCollection
bulkSelectEnabled={bulkSelectEnabled}
prepareRow={prepareRow}
renderCard={renderCard}
rows={rows}
loading={loading}
showThumbnails={showThumbnails}
/>
)}
{viewMode === 'table' && (
<>
{loading && rows.length === 0 ? (
<FullPageLoadingWrapper>
<Loading />
</FullPageLoadingWrapper>
) : (
<TableCollection
getTableProps={getTableProps}
getTableBodyProps={getTableBodyProps}
prepareRow={prepareRow}
headerGroups={headerGroups}
setSortBy={setSortBy}
rows={rows}
columns={columns}
loading={loading && rows.length > 0}
highlightRowId={highlightRowId}
columnsForWrapText={columnsForWrapText}
bulkSelectEnabled={bulkSelectEnabled}
selectedFlatRows={selectedFlatRows}
toggleRowSelected={(rowId, value) => {
const row = rows.find((r: any) => r.id === rowId);
if (row) {
prepareRow(row);
(row as any).toggleRowSelected(value);
}
}}
toggleAllRowsSelected={toggleAllRowsSelected}
pageIndex={pageIndex}
pageSize={pageSize}
totalCount={count}
onPageChange={newPageIndex => {
gotoPage(newPageIndex);
}}
/>
)}
</>
)}
{!loading && rows.length === 0 && (
<EmptyWrapper className={viewMode} data-test="empty-state">
{query.filters ? (
<EmptyState
title={t('No results match your filter criteria')}
description={t('Try different criteria to display results.')}
size="large"
image="filter-results.svg"
buttonAction={() => handleClearFilterControls()}
buttonText={t('clear all filters')}
/>
) : (
<EmptyState
{...emptyState}
title={emptyState?.title || t('No Data')}
size="large"
image={emptyState?.image || 'filter-results.svg'}
/>
)}
</EmptyWrapper>
)}
</div>
</div>
</ListViewStyles>
);
}