feat(dashboard-groupby): group by - add ability to exclude columns (#15454)

* feat: group by - add ability to exclude columns

* fix: create column select in a more generic way

* fix: MR comments

* fix: remove description

* fix: multiple value bug in column select

* fix: initial value bug

* fix: lint

* fix: unit tests

* fix: MR comments

* fix: MR comment

Co-authored-by: einatnielsen <einat.bertenthal@nielsen.com>
This commit is contained in:
Einat Bertenthal 2021-07-01 13:28:07 +03:00 committed by GitHub
parent e606477ec1
commit e5d4765986
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 177 additions and 64 deletions

View File

@ -34,8 +34,9 @@ interface ColumnSelectProps {
formField?: string; formField?: string;
filterId: string; filterId: string;
datasetId?: number; datasetId?: number;
value?: string; value?: string | string[];
onChange?: (value: string) => void; onChange?: (value: string) => void;
mode?: 'multiple' | 'tags';
} }
const localCache = new Map<string, any>(); const localCache = new Map<string, any>();
@ -57,6 +58,7 @@ export function ColumnSelect({
datasetId, datasetId,
value, value,
onChange, onChange,
mode,
}: ColumnSelectProps) { }: ColumnSelectProps) {
const [columns, setColumns] = useState<Column[]>(); const [columns, setColumns] = useState<Column[]>();
const { addDangerToast } = useToasts(); const { addDangerToast } = useToasts();
@ -101,11 +103,11 @@ export function ColumnSelect({
endpoint: `/api/v1/dataset/${datasetId}`, endpoint: `/api/v1/dataset/${datasetId}`,
}).then( }).then(
({ json: { result } }) => { ({ json: { result } }) => {
if ( const lookupValue = Array.isArray(value) ? value : [value];
!result.columns.some( const valueExists = result.columns.some((column: Column) =>
(column: Column) => column.column_name === value, lookupValue?.includes(column.column_name),
) );
) { if (!valueExists) {
resetColumnField(); resetColumnField();
} }
setColumns(result.columns); setColumns(result.columns);
@ -124,7 +126,8 @@ export function ColumnSelect({
return ( return (
<Select <Select
value={value} mode={mode}
value={mode === 'multiple' ? value || [] : value}
onChange={onChange} onChange={onChange}
options={options} options={options}
placeholder={t('Select a column')} placeholder={t('Select a column')}

View File

@ -72,7 +72,6 @@ import { ColumnSelect } from './ColumnSelect';
import { NativeFiltersForm } from '../types'; import { NativeFiltersForm } from '../types';
import { import {
datasetToSelectOption, datasetToSelectOption,
doesColumnMatchFilterType,
FILTER_SUPPORTED_TYPES, FILTER_SUPPORTED_TYPES,
hasTemporalColumns, hasTemporalColumns,
setNativeFilterFieldValues, setNativeFilterFieldValues,
@ -269,13 +268,6 @@ export interface FiltersConfigFormProps {
parentFilters: { id: string; title: string }[]; parentFilters: { id: string; title: string }[];
} }
// TODO: Need to do with it something
const FILTERS_WITHOUT_COLUMN = [
'filter_timegrain',
'filter_timecolumn',
'filter_groupby',
];
const FILTERS_WITH_ADHOC_FILTERS = ['filter_select', 'filter_range']; const FILTERS_WITH_ADHOC_FILTERS = ['filter_select', 'filter_range'];
const BASIC_CONTROL_ITEMS = ['enableEmptyFilter', 'multiSelect']; const BASIC_CONTROL_ITEMS = ['enableEmptyFilter', 'multiSelect'];
@ -352,15 +344,14 @@ const FiltersConfigForm = (
// @ts-ignore // @ts-ignore
const hasDataset = !!nativeFilterItems[formFilter?.filterType]?.value const hasDataset = !!nativeFilterItems[formFilter?.filterType]?.value
?.datasourceCount; ?.datasourceCount;
const hasColumn =
hasDataset && !FILTERS_WITHOUT_COLUMN.includes(formFilter?.filterType);
const nativeFilterItem = nativeFilterItems[formFilter?.filterType] ?? {}; const nativeFilterItem = nativeFilterItems[formFilter?.filterType] ?? {};
// @ts-ignore // @ts-ignore
const enableNoResults = !!nativeFilterItem.value?.enableNoResults; const enableNoResults = !!nativeFilterItem.value?.enableNoResults;
const datasetId = formFilter?.dataset?.value; const datasetId = formFilter?.dataset?.value;
useEffect(() => { useEffect(() => {
if (datasetId && hasColumn) { if (datasetId && hasDataset) {
cachedSupersetGet({ cachedSupersetGet({
endpoint: `/api/v1/dataset/${datasetId}`, endpoint: `/api/v1/dataset/${datasetId}`,
}) })
@ -376,7 +367,7 @@ const FiltersConfigForm = (
addDangerToast(response.message); addDangerToast(response.message);
}); });
} }
}, [datasetId, hasColumn]); }, [datasetId, hasDataset]);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
changeTab(tab: 'configuration' | 'scoping') { changeTab(tab: 'configuration' | 'scoping') {
@ -384,10 +375,10 @@ const FiltersConfigForm = (
}, },
})); }));
const hasMetrics = hasColumn && !!metrics.length; const hasMetrics = hasDataset && !!metrics.length;
const hasFilledDataset = const hasFilledDataset =
!hasDataset || (datasetId && (formFilter?.column || !hasColumn)); !hasDataset || (datasetId && (formFilter?.column || !hasDataset));
const hasAdditionalFilters = FILTERS_WITH_ADHOC_FILTERS.includes( const hasAdditionalFilters = FILTERS_WITH_ADHOC_FILTERS.includes(
formFilter?.filterType, formFilter?.filterType,
@ -484,10 +475,9 @@ const FiltersConfigForm = (
(defaultDatasetSelectOptions.length === 1 (defaultDatasetSelectOptions.length === 1
? defaultDatasetSelectOptions[0].value ? defaultDatasetSelectOptions[0].value
: undefined); : undefined);
const initColumn = filterToEdit?.targets[0]?.column?.name;
const newFormData = getFormData({ const newFormData = getFormData({
datasetId, datasetId,
groupby: hasColumn ? formFilter?.column : undefined, groupby: hasDataset ? formFilter?.column : undefined,
...formFilter, ...formFilter,
}); });
@ -546,7 +536,7 @@ const FiltersConfigForm = (
const showDefaultValue = !hasDataset || (!isDataDirty && hasFilledDataset); const showDefaultValue = !hasDataset || (!isDataDirty && hasFilledDataset);
const controlItems = formFilter const { controlItems = {}, mainControlItems = {} } = formFilter
? getControlItemsMap({ ? getControlItemsMap({
disabled: false, disabled: false,
forceUpdate, forceUpdate,
@ -555,6 +545,7 @@ const FiltersConfigForm = (
filterType: formFilter.filterType, filterType: formFilter.filterType,
filterToEdit, filterToEdit,
formFilter, formFilter,
removed,
}) })
: {}; : {};
@ -731,35 +722,10 @@ const FiltersConfigForm = (
}} }}
/> />
</StyledFormItem> </StyledFormItem>
{hasColumn && ( {hasDataset &&
<StyledFormItem Object.keys(mainControlItems).map(
// don't show the column select unless we have a dataset key => mainControlItems[key].element,
// style={{ display: datasetId == null ? undefined : 'none' }} )}
name={['filters', filterId, 'column']}
initialValue={initColumn}
label={<StyledLabel>{t('Column')}</StyledLabel>}
rules={[
{ required: !removed, message: t('Column is required') },
]}
data-test="field-input"
>
<ColumnSelect
form={form}
filterId={filterId}
datasetId={datasetId}
filterValues={column =>
doesColumnMatchFilterType(formFilter?.filterType, column)
}
onChange={() => {
// We need reset default value when when column changed
setNativeFilterFieldValues(form, filterId, {
defaultDataMask: null,
});
forceUpdate();
}}
/>
</StyledFormItem>
)}
</StyledRowContainer> </StyledRowContainer>
)} )}
<StyledCollapse <StyledCollapse

View File

@ -77,6 +77,7 @@ const createControlItems = () => [
false, false,
{}, {},
{ name: 'name_1', config: { renderTrigger: true, resetConfig: true } }, { name: 'name_1', config: { renderTrigger: true, resetConfig: true } },
{ name: 'groupby', config: { multiple: true, required: false } },
]; ];
beforeEach(() => { beforeEach(() => {
@ -87,7 +88,10 @@ function renderControlItems(
controlItemsMap: ReturnType<typeof getControlItemsMap>, controlItemsMap: ReturnType<typeof getControlItemsMap>,
) { ) {
return render( return render(
<>{Object.values(controlItemsMap).map(value => value.element)}</>, // @ts-ignore
<>
{Object.values(controlItemsMap.controlItems).map(value => value.element)}
</>,
); );
} }

View File

@ -26,10 +26,19 @@ import { FormInstance } from 'antd/lib/form';
import { getChartControlPanelRegistry, styled, t } from '@superset-ui/core'; import { getChartControlPanelRegistry, styled, t } from '@superset-ui/core';
import { Tooltip } from 'src/components/Tooltip'; import { Tooltip } from 'src/components/Tooltip';
import { FormItem } from 'src/components/Form'; import { FormItem } from 'src/components/Form';
import { getControlItems, setNativeFilterFieldValues } from './utils'; import {
doesColumnMatchFilterType,
getControlItems,
setNativeFilterFieldValues,
} from './utils';
import { NativeFiltersForm, NativeFiltersFormItem } from '../types'; import { NativeFiltersForm, NativeFiltersFormItem } from '../types';
import { StyledRowFormItem } from './FiltersConfigForm'; import {
StyledFormItem,
StyledLabel,
StyledRowFormItem,
} from './FiltersConfigForm';
import { Filter } from '../../types'; import { Filter } from '../../types';
import { ColumnSelect } from './ColumnSelect';
export interface ControlItemsProps { export interface ControlItemsProps {
disabled: boolean; disabled: boolean;
@ -39,6 +48,7 @@ export interface ControlItemsProps {
filterType: string; filterType: string;
filterToEdit?: Filter; filterToEdit?: Filter;
formFilter?: NativeFiltersFormItem; formFilter?: NativeFiltersFormItem;
removed?: boolean;
} }
const CleanFormItem = styled(FormItem)` const CleanFormItem = styled(FormItem)`
@ -53,15 +63,84 @@ export default function getControlItemsMap({
filterType, filterType,
filterToEdit, filterToEdit,
formFilter, formFilter,
removed,
}: ControlItemsProps) { }: ControlItemsProps) {
const controlPanelRegistry = getChartControlPanelRegistry(); const controlPanelRegistry = getChartControlPanelRegistry();
const controlItems = const controlItems =
getControlItems(controlPanelRegistry.get(filterType)) ?? []; getControlItems(controlPanelRegistry.get(filterType)) ?? [];
const map: Record< const mapControlItems: Record<
string,
{ element: React.ReactNode; checked: boolean }
> = {};
const mapMainControlItems: Record<
string, string,
{ element: React.ReactNode; checked: boolean } { element: React.ReactNode; checked: boolean }
> = {}; > = {};
controlItems
.filter(
(mainControlItem: CustomControlItem) =>
mainControlItem?.name === 'groupby',
)
.forEach(mainControlItem => {
const initialValue =
filterToEdit?.controlValues?.[mainControlItem.name] ??
mainControlItem?.config?.default;
const initColumn = filterToEdit?.targets[0]?.column?.name;
const datasetId = formFilter?.dataset?.value;
const element = (
<>
<CleanFormItem
name={['filters', filterId, 'requiredFirst', mainControlItem.name]}
hidden
initialValue={
mainControlItem?.config?.requiredFirst &&
filterToEdit?.requiredFirst
}
/>
<StyledFormItem
// don't show the column select unless we have a dataset
// style={{ display: datasetId == null ? undefined : 'none' }}
name={['filters', filterId, 'column']}
initialValue={initColumn}
label={
<StyledLabel>
{t(`${mainControlItem.config?.label}`) || t('Column')}
</StyledLabel>
}
rules={[
{
required: mainControlItem.config?.required && !removed, // TODO: need to move ColumnSelect settings to controlPanel for all filters
message: t('Column is required'),
},
]}
data-test="field-input"
>
<ColumnSelect
mode={mainControlItem.config?.multiple && 'multiple'}
form={form}
filterId={filterId}
datasetId={datasetId}
filterValues={column =>
doesColumnMatchFilterType(formFilter?.filterType || '', column)
}
onChange={() => {
// We need reset default value when when column changed
setNativeFilterFieldValues(form, filterId, {
defaultDataMask: null,
});
forceUpdate();
}}
/>
</StyledFormItem>
</>
);
mapMainControlItems[mainControlItem.name] = {
element,
checked: initialValue,
};
});
controlItems controlItems
.filter( .filter(
(controlItem: CustomControlItem) => (controlItem: CustomControlItem) =>
@ -129,7 +208,10 @@ export default function getControlItemsMap({
</Tooltip> </Tooltip>
</> </>
); );
map[controlItem.name] = { element, checked: initialValue }; mapControlItems[controlItem.name] = { element, checked: initialValue };
}); });
return map; return {
controlItems: mapControlItems,
mainControlItems: mapMainControlItems,
};
} }

View File

@ -98,4 +98,4 @@ export const hasTemporalColumns = (
export const doesColumnMatchFilterType = (filterType: string, column: Column) => export const doesColumnMatchFilterType = (filterType: string, column: Column) =>
!column.type_generic || !column.type_generic ||
!(filterType in FILTER_SUPPORTED_TYPES) || !(filterType in FILTER_SUPPORTED_TYPES) ||
FILTER_SUPPORTED_TYPES[filterType].includes(column.type_generic); FILTER_SUPPORTED_TYPES[filterType]?.includes(column.type_generic);

View File

@ -68,7 +68,19 @@ export default function PluginFilterGroupBy(props: PluginFilterGroupByProps) {
// so we can process it like this `JSON.stringify` or start to use `Immer` // so we can process it like this `JSON.stringify` or start to use `Immer`
}, [JSON.stringify(defaultValue), multiSelect]); }, [JSON.stringify(defaultValue), multiSelect]);
const columns = data || []; const groupby = formData?.groupby?.[0]?.length
? formData?.groupby?.[0]
: null;
const withData = groupby
? data.filter(dataItem =>
// @ts-ignore
groupby.includes(dataItem.column_name),
)
: data;
const columns = data ? withData : [];
const placeholderText = const placeholderText =
columns.length === 0 columns.length === 0
? t('No columns') ? t('No columns')

View File

@ -18,6 +18,7 @@
*/ */
import { ControlPanelConfig, sections } from '@superset-ui/chart-controls'; import { ControlPanelConfig, sections } from '@superset-ui/chart-controls';
import { t } from '@superset-ui/core'; import { t } from '@superset-ui/core';
import { sharedControls } from '@superset-ui/chart-controls/lib';
import { DEFAULT_FORM_DATA } from './types'; import { DEFAULT_FORM_DATA } from './types';
const { multiSelect } = DEFAULT_FORM_DATA; const { multiSelect } = DEFAULT_FORM_DATA;
@ -26,6 +27,23 @@ const config: ControlPanelConfig = {
controlPanelSections: [ controlPanelSections: [
// @ts-ignore // @ts-ignore
sections.legacyRegularTime, sections.legacyRegularTime,
{
label: t('Query'),
expanded: true,
controlSetRows: [
[
{
name: 'groupby',
config: {
...sharedControls.groupby,
label: 'Columns to show',
multiple: true,
required: false,
},
},
],
],
},
{ {
label: t('UI Configuration'), label: t('UI Configuration'),
expanded: true, expanded: true,

View File

@ -36,8 +36,7 @@ const config: ControlPanelConfig = {
config: { config: {
...sharedControls.groupby, ...sharedControls.groupby,
label: 'Column', label: 'Column',
description: required: true,
'The numeric column based on which to calculate the range',
}, },
}, },
], ],

View File

@ -18,6 +18,7 @@
*/ */
import { t, validateNonEmpty } from '@superset-ui/core'; import { t, validateNonEmpty } from '@superset-ui/core';
import { ControlPanelConfig, sections } from '@superset-ui/chart-controls'; import { ControlPanelConfig, sections } from '@superset-ui/chart-controls';
import { sharedControls } from '@superset-ui/chart-controls/lib';
import { DEFAULT_FORM_DATA } from './types'; import { DEFAULT_FORM_DATA } from './types';
const { const {
@ -36,7 +37,18 @@ const config: ControlPanelConfig = {
{ {
label: t('Query'), label: t('Query'),
expanded: true, expanded: true,
controlSetRows: [['groupby']], controlSetRows: [
[
{
name: 'groupby',
config: {
...sharedControls.groupby,
label: 'Column',
required: true,
},
},
],
],
}, },
{ {
label: t('UI Configuration'), label: t('UI Configuration'),

View File

@ -18,10 +18,27 @@
*/ */
import { ControlPanelConfig } from '@superset-ui/chart-controls'; import { ControlPanelConfig } from '@superset-ui/chart-controls';
import { t } from '@superset-ui/core'; import { t } from '@superset-ui/core';
import { sharedControls } from '@superset-ui/chart-controls/lib';
const config: ControlPanelConfig = { const config: ControlPanelConfig = {
// For control input types, see: superset-frontend/src/explore/components/controls/index.js // For control input types, see: superset-frontend/src/explore/components/controls/index.js
controlPanelSections: [ controlPanelSections: [
{
label: t('Query'),
expanded: true,
controlSetRows: [
[
{
name: 'groupby',
config: {
...sharedControls.groupby,
label: 'Column',
required: true,
},
},
],
],
},
{ {
label: t('UI Configuration'), label: t('UI Configuration'),
expanded: true, expanded: true,