mirror of
https://github.com/apache/superset.git
synced 2024-09-06 13:57:40 -04:00
feat(CRUD): add new empty state (#19310)
* feat(CRUD): add new empty state * fix ci * add svg license
This commit is contained in:
parent
f21ba68a30
commit
d49fd01ff3
34
superset-frontend/src/assets/images/filter-results.svg
Normal file
34
superset-frontend/src/assets/images/filter-results.svg
Normal file
@ -0,0 +1,34 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<svg width="120" height="150" viewBox="0 0 120 150" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M100.133 19.8391L100.134 19.8402L119.5 40.6963V149.5H0.5V0.5H82.2811L100.133 19.8391Z" fill="#F7F7F7" stroke="#D9D9D9"/>
|
||||
<path d="M82.5 0V42H120" stroke="#D9D9D9"/>
|
||||
<mask id="path-3-inside-1_738_30486" fill="white">
|
||||
<rect x="24" y="65" width="71.7778" height="9.44444" rx="0.5"/>
|
||||
</mask>
|
||||
<rect x="24" y="65" width="71.7778" height="9.44444" rx="0.5" fill="white" stroke="#D9D9D9" stroke-width="2" mask="url(#path-3-inside-1_738_30486)"/>
|
||||
<mask id="path-4-inside-2_738_30486" fill="white">
|
||||
<rect x="39.1113" y="85.7778" width="41.5556" height="9.44444" rx="0.5"/>
|
||||
</mask>
|
||||
<rect x="39.1113" y="85.7778" width="41.5556" height="9.44444" rx="0.5" fill="white" stroke="#D9D9D9" stroke-width="2" mask="url(#path-4-inside-2_738_30486)"/>
|
||||
<mask id="path-5-inside-3_738_30486" fill="white">
|
||||
<rect x="50.4443" y="106.556" width="18.8889" height="9.44444" rx="0.5"/>
|
||||
</mask>
|
||||
<rect x="50.4443" y="106.556" width="18.8889" height="9.44444" rx="0.5" fill="white" stroke="#D9D9D9" stroke-width="2" mask="url(#path-5-inside-3_738_30486)"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.9 KiB |
@ -56,6 +56,7 @@ export interface ButtonProps {
|
||||
| 'rightTop'
|
||||
| 'rightBottom';
|
||||
onClick?: OnClickHandler;
|
||||
onMouseDown?: OnClickHandler;
|
||||
disabled?: boolean;
|
||||
buttonStyle?: ButtonStyle;
|
||||
buttonSize?: 'default' | 'small' | 'xsmall';
|
||||
|
@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import React, { ReactNode } from 'react';
|
||||
import React, { ReactNode, SyntheticEvent } from 'react';
|
||||
import { styled, css, SupersetTheme } from '@superset-ui/core';
|
||||
import { Empty } from 'src/components';
|
||||
import Button from 'src/components/Button';
|
||||
@ -140,6 +140,11 @@ const ImageContainer = ({ image, size }: ImageContainerProps) => (
|
||||
/>
|
||||
);
|
||||
|
||||
const handleMouseDown = (e: SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
export const EmptyStateBig = ({
|
||||
title,
|
||||
image,
|
||||
@ -159,7 +164,11 @@ export const EmptyStateBig = ({
|
||||
<BigTitle>{title}</BigTitle>
|
||||
{description && <BigDescription>{description}</BigDescription>}
|
||||
{buttonAction && buttonText && (
|
||||
<ActionButton buttonStyle="primary" onClick={buttonAction}>
|
||||
<ActionButton
|
||||
buttonStyle="primary"
|
||||
onClick={buttonAction}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{buttonText}
|
||||
</ActionButton>
|
||||
)}
|
||||
@ -186,7 +195,11 @@ export const EmptyStateMedium = ({
|
||||
<Title>{title}</Title>
|
||||
{description && <Description>{description}</Description>}
|
||||
{buttonText && buttonAction && (
|
||||
<ActionButton buttonStyle="primary" onClick={buttonAction}>
|
||||
<ActionButton
|
||||
buttonStyle="primary"
|
||||
onClick={buttonAction}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{buttonText}
|
||||
</ActionButton>
|
||||
)}
|
||||
|
@ -31,3 +31,7 @@ export const FilterContainer = styled.div`
|
||||
align-items: center;
|
||||
width: ${SELECT_WIDTH}px;
|
||||
`;
|
||||
|
||||
export type FilterHandler = {
|
||||
clearFilter: () => void;
|
||||
};
|
||||
|
@ -16,12 +16,17 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, {
|
||||
useState,
|
||||
useMemo,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
import moment, { Moment } from 'moment';
|
||||
import { styled } from '@superset-ui/core';
|
||||
import { RangePicker } from 'src/components/DatePicker';
|
||||
import { FormLabel } from 'src/components/Form';
|
||||
import { BaseFilter } from './Base';
|
||||
import { BaseFilter, FilterHandler } from './Base';
|
||||
|
||||
interface DateRangeFilterProps extends BaseFilter {
|
||||
onSubmit: (val: number[]) => void;
|
||||
@ -38,17 +43,23 @@ const RangeFilterContainer = styled.div`
|
||||
width: 360px;
|
||||
`;
|
||||
|
||||
export default function DateRangeFilter({
|
||||
Header,
|
||||
initialValue,
|
||||
onSubmit,
|
||||
}: DateRangeFilterProps) {
|
||||
function DateRangeFilter(
|
||||
{ Header, initialValue, onSubmit }: DateRangeFilterProps,
|
||||
ref: React.RefObject<FilterHandler>,
|
||||
) {
|
||||
const [value, setValue] = useState<ValueState | null>(initialValue ?? null);
|
||||
const momentValue = useMemo((): [Moment, Moment] | null => {
|
||||
if (!value || (Array.isArray(value) && !value.length)) return null;
|
||||
return [moment(value[0]), moment(value[1])];
|
||||
}, [value]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clearFilter: () => {
|
||||
setValue(null);
|
||||
onSubmit([]);
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<RangeFilterContainer>
|
||||
<FormLabel>{Header}</FormLabel>
|
||||
@ -72,3 +83,5 @@ export default function DateRangeFilter({
|
||||
</RangeFilterContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(DateRangeFilter);
|
||||
|
@ -16,13 +16,13 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import React, { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { t, styled } from '@superset-ui/core';
|
||||
import Icons from 'src/components/Icons';
|
||||
import { AntdInput } from 'src/components';
|
||||
import { SELECT_WIDTH } from 'src/components/ListView/utils';
|
||||
import { FormLabel } from 'src/components/Form';
|
||||
import { BaseFilter } from './Base';
|
||||
import { BaseFilter, FilterHandler } from './Base';
|
||||
|
||||
interface SearchHeaderProps extends BaseFilter {
|
||||
Header: string;
|
||||
@ -42,12 +42,10 @@ const StyledInput = styled(AntdInput)`
|
||||
border-radius: ${({ theme }) => theme.gridUnit}px;
|
||||
`;
|
||||
|
||||
export default function SearchFilter({
|
||||
Header,
|
||||
name,
|
||||
initialValue,
|
||||
onSubmit,
|
||||
}: SearchHeaderProps) {
|
||||
function SearchFilter(
|
||||
{ Header, name, initialValue, onSubmit }: SearchHeaderProps,
|
||||
ref: React.RefObject<FilterHandler>,
|
||||
) {
|
||||
const [value, setValue] = useState(initialValue || '');
|
||||
const handleSubmit = () => {
|
||||
if (value) {
|
||||
@ -61,6 +59,13 @@ export default function SearchFilter({
|
||||
}
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clearFilter: () => {
|
||||
setValue('');
|
||||
onSubmit('');
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<FormLabel>{Header}</FormLabel>
|
||||
@ -78,3 +83,5 @@ export default function SearchFilter({
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(SearchFilter);
|
||||
|
@ -16,12 +16,17 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, {
|
||||
useState,
|
||||
useMemo,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
import { t } from '@superset-ui/core';
|
||||
import { Select } from 'src/components';
|
||||
import { Filter, SelectOption } from 'src/components/ListView/types';
|
||||
import { FormLabel } from 'src/components/Form';
|
||||
import { FilterContainer, BaseFilter } from './Base';
|
||||
import { FilterContainer, BaseFilter, FilterHandler } from './Base';
|
||||
|
||||
interface SelectFilterProps extends BaseFilter {
|
||||
fetchSelects?: Filter['fetchSelects'];
|
||||
@ -31,14 +36,17 @@ interface SelectFilterProps extends BaseFilter {
|
||||
selects: Filter['selects'];
|
||||
}
|
||||
|
||||
function SelectFilter({
|
||||
Header,
|
||||
name,
|
||||
fetchSelects,
|
||||
initialValue,
|
||||
onSelect,
|
||||
selects = [],
|
||||
}: SelectFilterProps) {
|
||||
function SelectFilter(
|
||||
{
|
||||
Header,
|
||||
name,
|
||||
fetchSelects,
|
||||
initialValue,
|
||||
onSelect,
|
||||
selects = [],
|
||||
}: SelectFilterProps,
|
||||
ref: React.RefObject<FilterHandler>,
|
||||
) {
|
||||
const [selectedOption, setSelectedOption] = useState(initialValue);
|
||||
|
||||
const onChange = (selected: SelectOption) => {
|
||||
@ -53,6 +61,12 @@ function SelectFilter({
|
||||
setSelectedOption(undefined);
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clearFilter: () => {
|
||||
onClear();
|
||||
},
|
||||
}));
|
||||
|
||||
const fetchAndFormatSelects = useMemo(
|
||||
() => async (inputValue: string, page: number, pageSize: number) => {
|
||||
if (fetchSelects) {
|
||||
@ -88,4 +102,4 @@ function SelectFilter({
|
||||
</FilterContainer>
|
||||
);
|
||||
}
|
||||
export default SelectFilter;
|
||||
export default forwardRef(SelectFilter);
|
||||
|
@ -16,7 +16,12 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import React, {
|
||||
createRef,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { withTheme } from '@superset-ui/core';
|
||||
|
||||
import {
|
||||
@ -28,6 +33,7 @@ import {
|
||||
import SearchFilter from './Search';
|
||||
import SelectFilter from './Select';
|
||||
import DateRangeFilter from './DateRange';
|
||||
import { FilterHandler } from './Base';
|
||||
|
||||
interface UIFiltersProps {
|
||||
filters: Filters;
|
||||
@ -35,11 +41,24 @@ interface UIFiltersProps {
|
||||
updateFilterValue: (id: number, value: FilterValue['value']) => void;
|
||||
}
|
||||
|
||||
function UIFilters({
|
||||
filters,
|
||||
internalFilters = [],
|
||||
updateFilterValue,
|
||||
}: UIFiltersProps) {
|
||||
function UIFilters(
|
||||
{ filters, internalFilters = [], updateFilterValue }: UIFiltersProps,
|
||||
ref: React.RefObject<{ clearFilters: () => void }>,
|
||||
) {
|
||||
const filterRefs = useMemo(
|
||||
() =>
|
||||
Array.from({ length: filters.length }, () => createRef<FilterHandler>()),
|
||||
[filters.length],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clearFilters: () => {
|
||||
filterRefs.forEach((filter: any) => {
|
||||
filter.current?.clearFilter?.();
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
{filters.map(
|
||||
@ -49,6 +68,7 @@ function UIFilters({
|
||||
if (input === 'select') {
|
||||
return (
|
||||
<SelectFilter
|
||||
ref={filterRefs[index]}
|
||||
Header={Header}
|
||||
fetchSelects={fetchSelects}
|
||||
initialValue={initialValue}
|
||||
@ -65,6 +85,7 @@ function UIFilters({
|
||||
if (input === 'search' && typeof Header === 'string') {
|
||||
return (
|
||||
<SearchFilter
|
||||
ref={filterRefs[index]}
|
||||
Header={Header}
|
||||
initialValue={initialValue}
|
||||
key={id}
|
||||
@ -76,6 +97,7 @@ function UIFilters({
|
||||
if (input === 'datetime_range') {
|
||||
return (
|
||||
<DateRangeFilter
|
||||
ref={filterRefs[index]}
|
||||
Header={Header}
|
||||
initialValue={initialValue}
|
||||
key={id}
|
||||
@ -91,4 +113,4 @@ function UIFilters({
|
||||
);
|
||||
}
|
||||
|
||||
export default withTheme(UIFilters);
|
||||
export default withTheme(forwardRef(UIFilters));
|
||||
|
@ -17,10 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { t, styled } from '@superset-ui/core';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Empty } from 'src/components';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import Alert from 'src/components/Alert';
|
||||
import EmptyImage from 'src/assets/images/empty.svg';
|
||||
import cx from 'classnames';
|
||||
import Button from 'src/components/Button';
|
||||
import Icons from 'src/components/Icons';
|
||||
@ -38,6 +36,7 @@ import {
|
||||
ViewModeType,
|
||||
} from './types';
|
||||
import { ListViewError, useListViewState } from './utils';
|
||||
import { EmptyStateBig, EmptyStateProps } from '../EmptyState';
|
||||
|
||||
const ListViewStyles = styled.div`
|
||||
text-align: center;
|
||||
@ -223,10 +222,7 @@ export interface ListViewProps<T extends object = any> {
|
||||
defaultViewMode?: ViewModeType;
|
||||
highlightRowId?: number;
|
||||
showThumbnails?: boolean;
|
||||
emptyState?: {
|
||||
message?: string;
|
||||
slot?: React.ReactNode;
|
||||
};
|
||||
emptyState?: EmptyStateProps;
|
||||
}
|
||||
|
||||
function ListView<T extends object = any>({
|
||||
@ -248,7 +244,7 @@ function ListView<T extends object = any>({
|
||||
cardSortSelectOptions,
|
||||
defaultViewMode = 'card',
|
||||
highlightRowId,
|
||||
emptyState = {},
|
||||
emptyState,
|
||||
}: ListViewProps<T>) {
|
||||
const {
|
||||
getTableProps,
|
||||
@ -263,6 +259,7 @@ function ListView<T extends object = any>({
|
||||
toggleAllRowsSelected,
|
||||
setViewMode,
|
||||
state: { pageIndex, pageSize, internalFilters, viewMode },
|
||||
query,
|
||||
} = useListViewState({
|
||||
bulkSelectColumnConfig,
|
||||
bulkSelectMode: bulkSelectEnabled && Boolean(bulkActions.length),
|
||||
@ -291,6 +288,14 @@ function ListView<T extends object = any>({
|
||||
});
|
||||
}
|
||||
|
||||
const filterControlsRef = useRef<{ clearFilters: () => void }>(null);
|
||||
|
||||
const handleClearFilterControls = useCallback(() => {
|
||||
if (query.filters) {
|
||||
filterControlsRef.current?.clearFilters();
|
||||
}
|
||||
}, [query.filters]);
|
||||
|
||||
const cardViewEnabled = Boolean(renderCard);
|
||||
|
||||
useEffect(() => {
|
||||
@ -308,6 +313,7 @@ function ListView<T extends object = any>({
|
||||
<div className="controls">
|
||||
{filterable && (
|
||||
<FilterControls
|
||||
ref={filterControlsRef}
|
||||
filters={filters}
|
||||
internalFilters={internalFilters}
|
||||
updateFilterValue={applyFilterValue}
|
||||
@ -394,12 +400,21 @@ function ListView<T extends object = any>({
|
||||
)}
|
||||
{!loading && rows.length === 0 && (
|
||||
<EmptyWrapper className={viewMode}>
|
||||
<Empty
|
||||
image={<EmptyImage />}
|
||||
description={emptyState.message || t('No Data')}
|
||||
>
|
||||
{emptyState.slot || null}
|
||||
</Empty>
|
||||
{query.filters ? (
|
||||
<EmptyStateBig
|
||||
title={t('No results match your filter criteria')}
|
||||
description={t('Try different criteria to display results.')}
|
||||
image="filter-results.svg"
|
||||
buttonAction={() => handleClearFilterControls()}
|
||||
buttonText={t('clear all filters')}
|
||||
/>
|
||||
) : (
|
||||
<EmptyStateBig
|
||||
{...emptyState}
|
||||
title={emptyState?.title || t('No Data')}
|
||||
image={emptyState?.image || 'filter-results.svg'}
|
||||
/>
|
||||
)}
|
||||
</EmptyWrapper>
|
||||
)}
|
||||
</div>
|
||||
|
@ -378,6 +378,7 @@ export function useListViewState({
|
||||
toggleAllRowsSelected,
|
||||
applyFilterValue,
|
||||
setViewMode,
|
||||
query,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -22,7 +22,6 @@ import { useHistory } from 'react-router-dom';
|
||||
import { t, SupersetClient, makeApi, styled } from '@superset-ui/core';
|
||||
import moment from 'moment';
|
||||
import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar';
|
||||
import Button from 'src/components/Button';
|
||||
import FacePile from 'src/components/FacePile';
|
||||
import { Tooltip } from 'src/components/Tooltip';
|
||||
import ListView, {
|
||||
@ -366,15 +365,15 @@ function AlertList({
|
||||
});
|
||||
}
|
||||
|
||||
const EmptyStateButton = (
|
||||
<Button buttonStyle="primary" onClick={() => handleAlertEdit(null)}>
|
||||
<i className="fa fa-plus" /> {title}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const emptyState = {
|
||||
message: t('No %s yet', titlePlural),
|
||||
slot: canCreate ? EmptyStateButton : null,
|
||||
title: t('No %s yet', titlePlural),
|
||||
image: 'filter-results.svg',
|
||||
buttonAction: () => handleAlertEdit(null),
|
||||
buttonText: canCreate ? (
|
||||
<>
|
||||
<i className="fa fa-plus" /> {title}{' '}
|
||||
</>
|
||||
) : null,
|
||||
};
|
||||
|
||||
const filters: Filters = useMemo(
|
||||
|
@ -24,7 +24,6 @@ import moment from 'moment';
|
||||
import rison from 'rison';
|
||||
|
||||
import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar';
|
||||
import Button from 'src/components/Button';
|
||||
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
|
||||
import DeleteModal from 'src/components/DeleteModal';
|
||||
import ListView, { ListViewProps } from 'src/components/ListView';
|
||||
@ -239,22 +238,17 @@ function AnnotationList({
|
||||
hasHistory = false;
|
||||
}
|
||||
|
||||
const EmptyStateButton = (
|
||||
<Button
|
||||
buttonStyle="primary"
|
||||
onClick={() => {
|
||||
handleAnnotationEdit(null);
|
||||
}}
|
||||
>
|
||||
const emptyState = {
|
||||
title: t('No annotation yet'),
|
||||
image: 'filter-results.svg',
|
||||
buttonAction: () => {
|
||||
handleAnnotationEdit(null);
|
||||
},
|
||||
buttonText: (
|
||||
<>
|
||||
<i className="fa fa-plus" /> {t('Annotation')}
|
||||
</>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const emptyState = {
|
||||
message: t('No annotation yet'),
|
||||
slot: EmptyStateButton,
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -32,7 +32,6 @@ import ListView, {
|
||||
Filters,
|
||||
FilterOperator,
|
||||
} from 'src/components/ListView';
|
||||
import Button from 'src/components/Button';
|
||||
import DeleteModal from 'src/components/DeleteModal';
|
||||
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
|
||||
import AnnotationLayerModal from './AnnotationLayerModal';
|
||||
@ -311,22 +310,15 @@ function AnnotationLayersList({
|
||||
[],
|
||||
);
|
||||
|
||||
const EmptyStateButton = (
|
||||
<Button
|
||||
buttonStyle="primary"
|
||||
onClick={() => {
|
||||
handleAnnotationLayerEdit(null);
|
||||
}}
|
||||
>
|
||||
const emptyState = {
|
||||
title: t('No annotation layers yet'),
|
||||
image: 'filter-results.svg',
|
||||
buttonAction: () => handleAnnotationLayerEdit(null),
|
||||
buttonText: (
|
||||
<>
|
||||
<i className="fa fa-plus" /> {t('Annotation layer')}
|
||||
</>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const emptyState = {
|
||||
message: t('No annotation layers yet'),
|
||||
slot: EmptyStateButton,
|
||||
),
|
||||
};
|
||||
|
||||
const onLayerAdd = (id?: number) => {
|
||||
|
Loading…
Reference in New Issue
Block a user