refactor(Dashboard): Native filters form update endpoint (#30609)
This commit is contained in:
parent
47c5334502
commit
d9a1db044a
|
|
@ -3008,6 +3008,33 @@
|
|||
},
|
||||
"type": "object"
|
||||
},
|
||||
"DashboardNativeFiltersConfigUpdateSchema": {
|
||||
"properties": {
|
||||
"deleted": {
|
||||
"description": "List of filter ids to delete",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"modified": {
|
||||
"description": "List of filter objects to update",
|
||||
"items": {
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"reordered": {
|
||||
"description": "List of filter ids in the new order",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": ["deleted", "modified", "reordered"],
|
||||
"type": "object"
|
||||
},
|
||||
"DashboardCopySchema": {
|
||||
"properties": {
|
||||
"css": {
|
||||
|
|
@ -13495,6 +13522,72 @@
|
|||
"tags": ["Dashboards"]
|
||||
}
|
||||
},
|
||||
"/api/v1/dashboard/{id_or_slug}/filters/": {
|
||||
"put": {
|
||||
"description": "Update the filters for a given dashboard",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "id_or_slug",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DashboardNativeFiltersConfigUpdateSchema"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"result": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successfully updated the filters"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/400"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/401"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/403"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/404"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/500"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"jwt": []
|
||||
}
|
||||
],
|
||||
"tags": ["Dashboards"]
|
||||
}
|
||||
},
|
||||
"/api/v1/dashboard/{id_or_slug}/copy/": {
|
||||
"post": {
|
||||
"parameters": [
|
||||
|
|
|
|||
|
|
@ -29,11 +29,17 @@ import {
|
|||
import { onSave } from './dashboardState';
|
||||
|
||||
export const DASHBOARD_INFO_UPDATED = 'DASHBOARD_INFO_UPDATED';
|
||||
export const DASHBOARD_INFO_FILTERS_CHANGED = 'DASHBOARD_INFO_FILTERS_CHANGED';
|
||||
|
||||
// updates partially changed dashboard info
|
||||
export function dashboardInfoChanged(newInfo: { metadata: any }) {
|
||||
return { type: DASHBOARD_INFO_UPDATED, newInfo };
|
||||
}
|
||||
|
||||
export function nativeFiltersConfigChanged(newInfo: Record<string, any>) {
|
||||
return { type: DASHBOARD_INFO_FILTERS_CHANGED, newInfo };
|
||||
}
|
||||
|
||||
export const SAVE_CHART_CONFIG_BEGIN = 'SAVE_CHART_CONFIG_BEGIN';
|
||||
export const SAVE_CHART_CONFIG_COMPLETE = 'SAVE_CHART_CONFIG_COMPLETE';
|
||||
export const SAVE_CHART_CONFIG_FAIL = 'SAVE_CHART_CONFIG_FAIL';
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ import {
|
|||
} from './dashboardInfo';
|
||||
import { fetchDatasourceMetadata, setDatasources } from './datasources';
|
||||
import { updateDirectPathToFilter } from './dashboardFilters';
|
||||
import { SET_FILTER_CONFIG_COMPLETE } from './nativeFilters';
|
||||
import { SET_IN_SCOPE_STATUS_OF_FILTERS } from './nativeFilters';
|
||||
import getOverwriteItems from '../util/getOverwriteItems';
|
||||
import {
|
||||
applyColors,
|
||||
|
|
@ -337,7 +337,7 @@ export function saveDashboardRequest(data, id, saveType) {
|
|||
}
|
||||
if (metadata.native_filter_configuration) {
|
||||
dispatch({
|
||||
type: SET_FILTER_CONFIG_COMPLETE,
|
||||
type: SET_IN_SCOPE_STATUS_OF_FILTERS,
|
||||
filterConfig: metadata.native_filter_configuration,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,31 +16,39 @@
|
|||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { FilterConfiguration, Filters, makeApi } from '@superset-ui/core';
|
||||
import {
|
||||
Filter,
|
||||
FilterConfiguration,
|
||||
Filters,
|
||||
makeApi,
|
||||
} from '@superset-ui/core';
|
||||
import { Dispatch } from 'redux';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import {
|
||||
SET_DATA_MASK_FOR_FILTER_CONFIG_FAIL,
|
||||
setDataMaskForFilterConfigComplete,
|
||||
} from 'src/dataMask/actions';
|
||||
import { setDataMaskForFilterChangesComplete } from 'src/dataMask/actions';
|
||||
import { HYDRATE_DASHBOARD } from './hydrate';
|
||||
import { dashboardInfoChanged } from './dashboardInfo';
|
||||
import { DashboardInfo } from '../types';
|
||||
import {
|
||||
dashboardInfoChanged,
|
||||
nativeFiltersConfigChanged,
|
||||
} from './dashboardInfo';
|
||||
import { SaveFilterChangesType } from '../components/nativeFilters/FiltersConfigModal/types';
|
||||
|
||||
export const SET_FILTER_CONFIG_BEGIN = 'SET_FILTER_CONFIG_BEGIN';
|
||||
export interface SetFilterConfigBegin {
|
||||
type: typeof SET_FILTER_CONFIG_BEGIN;
|
||||
export const SET_NATIVE_FILTERS_CONFIG_BEGIN =
|
||||
'SET_NATIVE_FILTERS_CONFIG_BEGIN';
|
||||
export interface SetNativeFiltersConfigBegin {
|
||||
type: typeof SET_NATIVE_FILTERS_CONFIG_BEGIN;
|
||||
filterConfig: FilterConfiguration;
|
||||
}
|
||||
|
||||
export const SET_FILTER_CONFIG_COMPLETE = 'SET_FILTER_CONFIG_COMPLETE';
|
||||
export interface SetFilterConfigComplete {
|
||||
type: typeof SET_FILTER_CONFIG_COMPLETE;
|
||||
filterConfig: FilterConfiguration;
|
||||
export const SET_NATIVE_FILTERS_CONFIG_COMPLETE =
|
||||
'SET_NATIVE_FILTERS_CONFIG_COMPLETE';
|
||||
export interface SetNativeFiltersConfigComplete {
|
||||
type: typeof SET_NATIVE_FILTERS_CONFIG_COMPLETE;
|
||||
filterChanges: Filter[];
|
||||
}
|
||||
export const SET_FILTER_CONFIG_FAIL = 'SET_FILTER_CONFIG_FAIL';
|
||||
export interface SetFilterConfigFail {
|
||||
type: typeof SET_FILTER_CONFIG_FAIL;
|
||||
|
||||
export const SET_NATIVE_FILTERS_CONFIG_FAIL = 'SET_NATIVE_FILTERS_CONFIG_FAIL';
|
||||
export interface SetNativeFiltersConfigFail {
|
||||
type: typeof SET_NATIVE_FILTERS_CONFIG_FAIL;
|
||||
filterConfig: FilterConfiguration;
|
||||
}
|
||||
export const SET_IN_SCOPE_STATUS_OF_FILTERS = 'SET_IN_SCOPE_STATUS_OF_FILTERS';
|
||||
|
|
@ -49,60 +57,45 @@ export interface SetInScopeStatusOfFilters {
|
|||
filterConfig: FilterConfiguration;
|
||||
}
|
||||
|
||||
const isFilterChangesEmpty = (filterChanges: SaveFilterChangesType) =>
|
||||
Object.values(filterChanges).every(
|
||||
array => Array.isArray(array) && !array.length,
|
||||
);
|
||||
|
||||
export const setFilterConfiguration =
|
||||
(filterConfig: FilterConfiguration) =>
|
||||
(filterChanges: SaveFilterChangesType) =>
|
||||
async (dispatch: Dispatch, getState: () => any) => {
|
||||
dispatch({
|
||||
type: SET_FILTER_CONFIG_BEGIN,
|
||||
filterConfig,
|
||||
});
|
||||
const { id, metadata } = getState().dashboardInfo;
|
||||
if (isFilterChangesEmpty(filterChanges)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = getState().dashboardInfo;
|
||||
const oldFilters = getState().nativeFilters?.filters;
|
||||
|
||||
// TODO extract this out when makeApi supports url parameters
|
||||
const updateDashboard = makeApi<
|
||||
Partial<DashboardInfo>,
|
||||
{ result: DashboardInfo }
|
||||
dispatch({
|
||||
type: SET_NATIVE_FILTERS_CONFIG_BEGIN,
|
||||
filterChanges,
|
||||
});
|
||||
|
||||
const updateFilters = makeApi<
|
||||
SaveFilterChangesType,
|
||||
{ result: SaveFilterChangesType }
|
||||
>({
|
||||
method: 'PUT',
|
||||
endpoint: `/api/v1/dashboard/${id}`,
|
||||
endpoint: `/api/v1/dashboard/${id}/filters`,
|
||||
});
|
||||
|
||||
const mergedFilterConfig = filterConfig.map(filter => {
|
||||
const oldFilter = oldFilters[filter.id];
|
||||
if (!oldFilter) {
|
||||
return filter;
|
||||
}
|
||||
return { ...oldFilter, ...filter };
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await updateDashboard({
|
||||
json_metadata: JSON.stringify({
|
||||
...metadata,
|
||||
native_filter_configuration: mergedFilterConfig,
|
||||
}),
|
||||
});
|
||||
dispatch(
|
||||
dashboardInfoChanged({
|
||||
metadata: JSON.parse(response.result.json_metadata),
|
||||
}),
|
||||
);
|
||||
const response = await updateFilters(filterChanges);
|
||||
dispatch(nativeFiltersConfigChanged(response.result));
|
||||
dispatch({
|
||||
type: SET_FILTER_CONFIG_COMPLETE,
|
||||
filterConfig: mergedFilterConfig,
|
||||
type: SET_NATIVE_FILTERS_CONFIG_COMPLETE,
|
||||
filterChanges: response.result,
|
||||
});
|
||||
dispatch(
|
||||
setDataMaskForFilterConfigComplete(mergedFilterConfig, oldFilters),
|
||||
);
|
||||
dispatch(setDataMaskForFilterChangesComplete(filterChanges, oldFilters));
|
||||
} catch (err) {
|
||||
dispatch({
|
||||
type: SET_FILTER_CONFIG_FAIL,
|
||||
filterConfig: mergedFilterConfig,
|
||||
});
|
||||
dispatch({
|
||||
type: SET_DATA_MASK_FOR_FILTER_CONFIG_FAIL,
|
||||
filterConfig: mergedFilterConfig,
|
||||
type: SET_NATIVE_FILTERS_CONFIG_FAIL,
|
||||
filterConfig: filterChanges,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -221,9 +214,9 @@ export function updateCascadeParentIds(
|
|||
}
|
||||
|
||||
export type AnyFilterAction =
|
||||
| SetFilterConfigBegin
|
||||
| SetFilterConfigComplete
|
||||
| SetFilterConfigFail
|
||||
| SetNativeFiltersConfigBegin
|
||||
| SetNativeFiltersConfigComplete
|
||||
| SetNativeFiltersConfigFail
|
||||
| SetInScopeStatusOfFilters
|
||||
| SetBootstrapData
|
||||
| SetFocusedNativeFilter
|
||||
|
|
|
|||
|
|
@ -21,9 +21,10 @@ import { ReactNode, FC, useCallback, useState, memo } from 'react';
|
|||
import { useDispatch } from 'react-redux';
|
||||
import { setFilterConfiguration } from 'src/dashboard/actions/nativeFilters';
|
||||
import Button from 'src/components/Button';
|
||||
import { FilterConfiguration, styled } from '@superset-ui/core';
|
||||
import { styled } from '@superset-ui/core';
|
||||
import FiltersConfigModal from 'src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal';
|
||||
import { getFilterBarTestId } from '../utils';
|
||||
import { SaveFilterChangesType } from '../../FiltersConfigModal/types';
|
||||
|
||||
export interface FCBProps {
|
||||
createNewOnOpen?: boolean;
|
||||
|
|
@ -46,14 +47,13 @@ export const FilterConfigurationLink: FC<FCBProps> = ({
|
|||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, [setOpen]);
|
||||
|
||||
const submit = useCallback(
|
||||
async (filterConfig: FilterConfiguration) => {
|
||||
dispatch(await setFilterConfiguration(filterConfig));
|
||||
async (filterChanges: SaveFilterChangesType) => {
|
||||
dispatch(await setFilterConfiguration(filterChanges));
|
||||
close();
|
||||
},
|
||||
[dispatch, close],
|
||||
|
|
|
|||
|
|
@ -182,7 +182,6 @@ const FilterValue: FC<FilterControlProps> = ({
|
|||
if (isFeatureEnabled(FeatureFlag.GlobalAsyncQueries)) {
|
||||
// deal with getChartDataRequest transforming the response data
|
||||
const result = 'result' in json ? json.result[0] : json;
|
||||
|
||||
if (response.status === 200) {
|
||||
setState([result]);
|
||||
handleFilterLoadFinish();
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ describe('FilterScope', () => {
|
|||
activeFilterPanelKeys: `DefaultFilterId-${FilterPanels.configuration.key}`,
|
||||
isActive: true,
|
||||
validateDependencies: jest.fn(),
|
||||
onModifyFilter: jest.fn(),
|
||||
};
|
||||
|
||||
const MockModal = ({ scope }: { scope?: object }) => {
|
||||
|
|
|
|||
|
|
@ -39,8 +39,9 @@ import {
|
|||
t,
|
||||
ClientErrorObject,
|
||||
getClientErrorObject,
|
||||
SLOW_DEBOUNCE,
|
||||
} from '@superset-ui/core';
|
||||
import { isEqual } from 'lodash';
|
||||
import { debounce, isEqual } from 'lodash';
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
|
|
@ -306,6 +307,7 @@ export interface FiltersConfigFormProps {
|
|||
filterToEdit?: Filter;
|
||||
removedFilters: Record<string, FilterRemoval>;
|
||||
restoreFilter: (filterId: string) => void;
|
||||
onModifyFilter: (filterId: string) => void;
|
||||
form: FormInstance<NativeFiltersForm>;
|
||||
getAvailableFilters: (
|
||||
filterId: string,
|
||||
|
|
@ -346,6 +348,7 @@ const FiltersConfigForm = (
|
|||
restoreFilter,
|
||||
handleActiveFilterPanelChange,
|
||||
setErroredFilters,
|
||||
onModifyFilter,
|
||||
validateDependencies,
|
||||
getDependencySuggestion,
|
||||
isActive,
|
||||
|
|
@ -372,6 +375,12 @@ const FiltersConfigForm = (
|
|||
const formValues = filters?.[filterId];
|
||||
const formFilter = formValues || undoFormValues || defaultFormFilter;
|
||||
|
||||
const handleModifyFilter = useCallback(() => {
|
||||
if (onModifyFilter) {
|
||||
onModifyFilter(filterId);
|
||||
}
|
||||
}, [onModifyFilter, filterId]);
|
||||
|
||||
const dependencies: string[] =
|
||||
formFilter?.dependencies || filterToEdit?.cascadeParentIds || [];
|
||||
|
||||
|
|
@ -412,12 +421,28 @@ const FiltersConfigForm = (
|
|||
filterToEdit?.targets[0]?.datasetId ??
|
||||
mostUsedDataset(loadedDatasets, charts);
|
||||
|
||||
const formChanged = useCallback(() => {
|
||||
form.setFields([
|
||||
{
|
||||
name: 'changed',
|
||||
value: true,
|
||||
},
|
||||
]);
|
||||
handleModifyFilter();
|
||||
}, [form, handleModifyFilter]);
|
||||
|
||||
const debouncedFormChanged = useCallback(
|
||||
debounce(formChanged, SLOW_DEBOUNCE),
|
||||
[],
|
||||
);
|
||||
|
||||
const { controlItems = {}, mainControlItems = {} } = formFilter
|
||||
? getControlItemsMap({
|
||||
expanded,
|
||||
datasetId,
|
||||
disabled: false,
|
||||
forceUpdate,
|
||||
formChanged,
|
||||
form,
|
||||
filterId,
|
||||
filterType: formFilter?.filterType,
|
||||
|
|
@ -488,7 +513,6 @@ const FiltersConfigForm = (
|
|||
groupby: formFilter?.column,
|
||||
...formFilter,
|
||||
});
|
||||
|
||||
formData.extra_form_data = dependenciesDefaultValues;
|
||||
|
||||
setNativeFilterFieldValuesWrapper({
|
||||
|
|
@ -549,6 +573,7 @@ const FiltersConfigForm = (
|
|||
groupby: hasColumn ? formFilter?.column : undefined,
|
||||
...formFilter,
|
||||
});
|
||||
|
||||
newFormData.extra_form_data = dependenciesDefaultValues;
|
||||
|
||||
const [hasDefaultValue, isRequired, defaultValueTooltip, setHasDefaultValue] =
|
||||
|
|
@ -557,15 +582,6 @@ const FiltersConfigForm = (
|
|||
const showDataset =
|
||||
!datasetId || datasetDetails || formFilter?.dataset?.label;
|
||||
|
||||
const formChanged = useCallback(() => {
|
||||
form.setFields([
|
||||
{
|
||||
name: 'changed',
|
||||
value: true,
|
||||
},
|
||||
]);
|
||||
}, [form]);
|
||||
|
||||
const updateFormValues = useCallback(
|
||||
(values: any) => {
|
||||
setNativeFilterFieldValues(form, filterId, values);
|
||||
|
|
@ -794,6 +810,7 @@ const FiltersConfigForm = (
|
|||
granularity_sqla: column,
|
||||
});
|
||||
forceUpdate();
|
||||
formChanged();
|
||||
}}
|
||||
/>
|
||||
</StyledRowFormItem>
|
||||
|
|
@ -817,7 +834,7 @@ const FiltersConfigForm = (
|
|||
hidden
|
||||
initialValue={NativeFilterType.NativeFilter}
|
||||
>
|
||||
<Input />
|
||||
<Input onChange={formChanged} />
|
||||
</StyledFormItem>
|
||||
<StyledFormItem
|
||||
expanded={expanded}
|
||||
|
|
@ -826,7 +843,10 @@ const FiltersConfigForm = (
|
|||
initialValue={filterToEdit?.name}
|
||||
rules={[{ required: !isRemoved, message: t('Name is required') }]}
|
||||
>
|
||||
<Input {...getFiltersConfigModalTestId('name-input')} />
|
||||
<Input
|
||||
{...getFiltersConfigModalTestId('name-input')}
|
||||
onChange={debouncedFormChanged}
|
||||
/>
|
||||
</StyledFormItem>
|
||||
<StyledFormItem
|
||||
expanded={expanded}
|
||||
|
|
@ -870,6 +890,7 @@ const FiltersConfigForm = (
|
|||
column: null,
|
||||
});
|
||||
forceUpdate();
|
||||
formChanged();
|
||||
}}
|
||||
/>
|
||||
</StyledFormItem>
|
||||
|
|
@ -920,6 +941,7 @@ const FiltersConfigForm = (
|
|||
});
|
||||
}
|
||||
forceUpdate();
|
||||
formChanged();
|
||||
}}
|
||||
/>
|
||||
</StyledFormItem>
|
||||
|
|
@ -1018,6 +1040,7 @@ const FiltersConfigForm = (
|
|||
adhoc_filters: filters,
|
||||
});
|
||||
forceUpdate();
|
||||
formChanged();
|
||||
validatePreFilter();
|
||||
}}
|
||||
label={
|
||||
|
|
@ -1050,6 +1073,7 @@ const FiltersConfigForm = (
|
|||
time_range: timeRange,
|
||||
});
|
||||
forceUpdate();
|
||||
formChanged();
|
||||
validatePreFilter();
|
||||
}}
|
||||
/>
|
||||
|
|
@ -1085,6 +1109,7 @@ const FiltersConfigForm = (
|
|||
<Radio.Group
|
||||
onChange={value => {
|
||||
onSortChanged(value.target.value);
|
||||
formChanged();
|
||||
}}
|
||||
>
|
||||
<Radio value>{t('Sort ascending')}</Radio>
|
||||
|
|
@ -1124,6 +1149,7 @@ const FiltersConfigForm = (
|
|||
});
|
||||
forceUpdate();
|
||||
}
|
||||
formChanged();
|
||||
}}
|
||||
/>
|
||||
</StyledRowSubFormItem>
|
||||
|
|
@ -1156,9 +1182,10 @@ const FiltersConfigForm = (
|
|||
}
|
||||
>
|
||||
<Radio.Group
|
||||
onChange={value =>
|
||||
onEnableSingleValueChanged(value.target.value)
|
||||
}
|
||||
onChange={value => {
|
||||
onEnableSingleValueChanged(value.target.value);
|
||||
formChanged();
|
||||
}}
|
||||
>
|
||||
<Radio value={SingleValueType.Minimum}>
|
||||
{t('Minimum')}
|
||||
|
|
@ -1187,7 +1214,7 @@ const FiltersConfigForm = (
|
|||
initialValue={filterToEdit?.description}
|
||||
label={<StyledLabel>{t('Description')}</StyledLabel>}
|
||||
>
|
||||
<TextArea />
|
||||
<TextArea onChange={debouncedFormChanged} />
|
||||
</StyledFormItem>
|
||||
<CleanFormItem
|
||||
name={['filters', filterId, 'defaultValueQueriesData']}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ const createProps: () => ControlItemsProps = () => ({
|
|||
filterId: 'filterId',
|
||||
filterToEdit: filterMock,
|
||||
filterType: 'filterType',
|
||||
formChanged: jest.fn(),
|
||||
});
|
||||
|
||||
const createControlItems = () => [
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export interface ControlItemsProps {
|
|||
datasetId: number;
|
||||
disabled: boolean;
|
||||
forceUpdate: Function;
|
||||
formChanged: Function;
|
||||
form: FormInstance<NativeFiltersForm>;
|
||||
filterId: string;
|
||||
filterType: string;
|
||||
|
|
@ -65,6 +66,7 @@ export default function getControlItemsMap({
|
|||
datasetId,
|
||||
disabled,
|
||||
forceUpdate,
|
||||
formChanged,
|
||||
form,
|
||||
filterId,
|
||||
filterType,
|
||||
|
|
@ -137,6 +139,7 @@ export default function getControlItemsMap({
|
|||
defaultDataMask: null,
|
||||
});
|
||||
forceUpdate();
|
||||
formChanged();
|
||||
}}
|
||||
/>
|
||||
</StyledFormItem>
|
||||
|
|
@ -200,6 +203,7 @@ export default function getControlItemsMap({
|
|||
defaultDataMask: null,
|
||||
});
|
||||
}
|
||||
formChanged();
|
||||
forceUpdate();
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,12 @@ import chartQueries from 'spec/fixtures/mockChartQueries';
|
|||
import { dashboardLayout } from 'spec/fixtures/mockDashboardLayout';
|
||||
import mockDatasource, { datasourceId, id } from 'spec/fixtures/mockDatasource';
|
||||
import { buildNativeFilter } from 'spec/fixtures/mockNativeFilters';
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import {
|
||||
RangeFilterPlugin,
|
||||
SelectFilterPlugin,
|
||||
|
|
@ -147,6 +152,10 @@ beforeAll(() => {
|
|||
new MainPreset().register();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function defaultRender(initialState: any = defaultState(), modalProps = props) {
|
||||
return render(<FiltersConfigModal {...modalProps} />, {
|
||||
initialState,
|
||||
|
|
@ -373,29 +382,24 @@ test('deletes a filter', async () => {
|
|||
dashboardLayout,
|
||||
};
|
||||
const onSave = jest.fn();
|
||||
|
||||
defaultRender(state, {
|
||||
...props,
|
||||
createNewOnOpen: false,
|
||||
onSave,
|
||||
});
|
||||
const removeButtons = screen.getAllByRole('img', { name: 'trash' });
|
||||
// remove NATIVE_FILTER-3 which isn't a dependency of any other filter
|
||||
userEvent.click(removeButtons[2]);
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'NATIVE_FILTER',
|
||||
id: 'NATIVE_FILTER-1',
|
||||
cascadeParentIds: ['NATIVE_FILTER-2'],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'NATIVE_FILTER',
|
||||
id: 'NATIVE_FILTER-2',
|
||||
cascadeParentIds: [],
|
||||
}),
|
||||
]),
|
||||
expect.objectContaining({
|
||||
deleted: expect.arrayContaining(['NATIVE_FILTER-3']),
|
||||
modified: expect.arrayContaining([]),
|
||||
reordered: expect.arrayContaining([]),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
|
@ -420,23 +424,161 @@ test('deletes a filter including dependencies', async () => {
|
|||
onSave,
|
||||
});
|
||||
const removeButtons = screen.getAllByRole('img', { name: 'trash' });
|
||||
// remove NATIVE_FILTER-2 which is a dependency of NATIVE_FILTER-1
|
||||
userEvent.click(removeButtons[1]);
|
||||
userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
await waitFor(() =>
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'NATIVE_FILTER',
|
||||
id: 'NATIVE_FILTER-1',
|
||||
cascadeParentIds: [],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'NATIVE_FILTER',
|
||||
id: 'NATIVE_FILTER-3',
|
||||
cascadeParentIds: [],
|
||||
}),
|
||||
]),
|
||||
expect.objectContaining({
|
||||
deleted: ['NATIVE_FILTER-2'],
|
||||
modified: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'NATIVE_FILTER-1',
|
||||
}),
|
||||
]),
|
||||
reordered: [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('switches the order between two filters', async () => {
|
||||
const nativeFilterState = [
|
||||
buildNativeFilter('NATIVE_FILTER-1', 'state', []),
|
||||
buildNativeFilter('NATIVE_FILTER-2', 'country', []),
|
||||
];
|
||||
|
||||
const state = {
|
||||
...defaultState(),
|
||||
dashboardInfo: {
|
||||
metadata: { native_filter_configuration: nativeFilterState },
|
||||
},
|
||||
dashboardLayout,
|
||||
};
|
||||
|
||||
const onSave = jest.fn();
|
||||
|
||||
defaultRender(state, {
|
||||
...props,
|
||||
createNewOnOpen: false,
|
||||
onSave,
|
||||
});
|
||||
|
||||
const draggableFilters = screen.getAllByRole('tab');
|
||||
|
||||
fireEvent.dragStart(draggableFilters[0]);
|
||||
|
||||
fireEvent.dragOver(draggableFilters[1]);
|
||||
|
||||
fireEvent.drop(draggableFilters[1]);
|
||||
|
||||
fireEvent.dragEnd(draggableFilters[0]);
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deleted: [],
|
||||
modified: [],
|
||||
reordered: expect.arrayContaining([
|
||||
'NATIVE_FILTER-2',
|
||||
'NATIVE_FILTER-1',
|
||||
]),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('rearranges three filters and deletes one of them', async () => {
|
||||
const nativeFilterState = [
|
||||
buildNativeFilter('NATIVE_FILTER-1', 'state', []),
|
||||
buildNativeFilter('NATIVE_FILTER-2', 'country', []),
|
||||
buildNativeFilter('NATIVE_FILTER-3', 'product', []),
|
||||
];
|
||||
|
||||
const state = {
|
||||
...defaultState(),
|
||||
dashboardInfo: {
|
||||
metadata: { native_filter_configuration: nativeFilterState },
|
||||
},
|
||||
dashboardLayout,
|
||||
};
|
||||
|
||||
const onSave = jest.fn();
|
||||
|
||||
defaultRender(state, {
|
||||
...props,
|
||||
createNewOnOpen: false,
|
||||
onSave,
|
||||
});
|
||||
|
||||
const draggableFilters = screen.getAllByRole('tab');
|
||||
const deleteButtons = screen.getAllByRole('img', { name: 'trash' });
|
||||
userEvent.click(deleteButtons[1]);
|
||||
|
||||
fireEvent.dragStart(draggableFilters[0]);
|
||||
fireEvent.dragOver(draggableFilters[2]);
|
||||
fireEvent.drop(draggableFilters[2]);
|
||||
fireEvent.dragEnd(draggableFilters[0]);
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modified: [],
|
||||
deleted: ['NATIVE_FILTER-2'],
|
||||
reordered: expect.arrayContaining([
|
||||
'NATIVE_FILTER-2',
|
||||
'NATIVE_FILTER-3',
|
||||
'NATIVE_FILTER-1',
|
||||
]),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('modifies the name of a filter', async () => {
|
||||
jest.useFakeTimers();
|
||||
const nativeFilterState = [
|
||||
buildNativeFilter('NATIVE_FILTER-1', 'state', []),
|
||||
buildNativeFilter('NATIVE_FILTER-2', 'country', []),
|
||||
];
|
||||
|
||||
const state = {
|
||||
...defaultState(),
|
||||
dashboardInfo: {
|
||||
metadata: { native_filter_configuration: nativeFilterState },
|
||||
},
|
||||
dashboardLayout,
|
||||
};
|
||||
|
||||
const onSave = jest.fn();
|
||||
|
||||
defaultRender(state, {
|
||||
...props,
|
||||
createNewOnOpen: false,
|
||||
onSave,
|
||||
});
|
||||
|
||||
const filterNameInput = screen.getByRole('textbox', {
|
||||
name: FILTER_NAME_REGEX,
|
||||
});
|
||||
|
||||
userEvent.clear(filterNameInput);
|
||||
userEvent.type(filterNameInput, 'New Filter Name');
|
||||
|
||||
jest.runAllTimers();
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modified: expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'New Filter Name' }),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import { memo, useEffect, useCallback, useMemo, useState, useRef } from 'react';
|
|||
import { uniq, isEqual, sortBy, debounce, isEmpty } from 'lodash';
|
||||
import {
|
||||
Filter,
|
||||
FilterConfiguration,
|
||||
NativeFilterType,
|
||||
Divider,
|
||||
styled,
|
||||
|
|
@ -44,7 +43,12 @@ import FiltersConfigForm, {
|
|||
} from './FiltersConfigForm/FiltersConfigForm';
|
||||
import Footer from './Footer/Footer';
|
||||
import { useOpenModal, useRemoveCurrentFilter } from './state';
|
||||
import { FilterRemoval, NativeFiltersForm } from './types';
|
||||
import {
|
||||
FilterChangesType,
|
||||
FilterRemoval,
|
||||
NativeFiltersForm,
|
||||
SaveFilterChangesType,
|
||||
} from './types';
|
||||
import {
|
||||
createHandleSave,
|
||||
createHandleRemoveItem,
|
||||
|
|
@ -114,7 +118,7 @@ export interface FiltersConfigModalProps {
|
|||
isOpen: boolean;
|
||||
initialFilterId?: string;
|
||||
createNewOnOpen?: boolean;
|
||||
onSave: (filterConfig: FilterConfiguration) => Promise<void>;
|
||||
onSave: (filterChanges: SaveFilterChangesType) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
export const ALLOW_DEPENDENCIES = [
|
||||
|
|
@ -128,6 +132,11 @@ const DEFAULT_REMOVED_FILTERS: Record<string, FilterRemoval> = {};
|
|||
const DEFAULT_FORM_VALUES: NativeFiltersForm = {
|
||||
filters: {},
|
||||
};
|
||||
const DEFAULT_FILTER_CHANGES: FilterChangesType = {
|
||||
modified: [],
|
||||
deleted: [],
|
||||
reordered: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* This is the modal to configure all the dashboard-native filters.
|
||||
|
|
@ -155,6 +164,27 @@ function FiltersConfigModal({
|
|||
const filterConfig = useFilterConfiguration();
|
||||
const filterConfigMap = useFilterConfigMap();
|
||||
|
||||
// this state contains the changes that we'll be sent through the PATCH endpoint
|
||||
const [filterChanges, setFilterChanges] = useState<FilterChangesType>(
|
||||
DEFAULT_FILTER_CHANGES,
|
||||
);
|
||||
|
||||
const resetFilterChanges = () => {
|
||||
setFilterChanges(DEFAULT_FILTER_CHANGES);
|
||||
};
|
||||
|
||||
const handleModifyFilter = useCallback(
|
||||
(filterId: string) => {
|
||||
if (!filterChanges.modified.includes(filterId)) {
|
||||
setFilterChanges(prev => ({
|
||||
...prev,
|
||||
modified: [...prev.modified, filterId],
|
||||
}));
|
||||
}
|
||||
},
|
||||
[filterChanges.modified],
|
||||
);
|
||||
|
||||
// new filter ids belong to filters have been added during
|
||||
// this configuration session, and only exist in the form state until we submit.
|
||||
const [newFilterIds, setNewFilterIds] = useState<string[]>(
|
||||
|
|
@ -200,11 +230,17 @@ function FiltersConfigModal({
|
|||
const restoreFilter = useCallback(
|
||||
(id: string) => {
|
||||
const removal = removedFilters[id];
|
||||
// gotta clear the removal timeout to prevent the filter from getting deleted
|
||||
// Clear the removal timeout if the filter is pending deletion
|
||||
if (removal?.isPending) clearTimeout(removal.timerId);
|
||||
|
||||
setRemovedFilters(current => ({ ...current, [id]: null }));
|
||||
|
||||
setFilterChanges(prev => ({
|
||||
...prev,
|
||||
deleted: prev.deleted.filter(deletedId => deletedId !== id),
|
||||
}));
|
||||
},
|
||||
[removedFilters],
|
||||
[removedFilters, setRemovedFilters],
|
||||
);
|
||||
const initialFilterOrder = useMemo(
|
||||
() => Object.keys(filterConfigMap),
|
||||
|
|
@ -239,18 +275,13 @@ function FiltersConfigModal({
|
|||
(type: NativeFilterType) => {
|
||||
const newFilterId = generateFilterId(type);
|
||||
setNewFilterIds([...newFilterIds, newFilterId]);
|
||||
handleModifyFilter(newFilterId);
|
||||
setCurrentFilterId(newFilterId);
|
||||
setSaveAlertVisible(false);
|
||||
setOrderedFilters([...orderedFilters, newFilterId]);
|
||||
setActiveFilterPanelKey(getActiveFilterPanelKey(newFilterId));
|
||||
},
|
||||
[
|
||||
newFilterIds,
|
||||
orderedFilters,
|
||||
setCurrentFilterId,
|
||||
setOrderedFilters,
|
||||
setNewFilterIds,
|
||||
],
|
||||
[newFilterIds, handleModifyFilter, orderedFilters],
|
||||
);
|
||||
|
||||
useOpenModal(isOpen, addFilter, createNewOnOpen);
|
||||
|
|
@ -266,6 +297,13 @@ function FiltersConfigModal({
|
|||
setRemovedFilters,
|
||||
setOrderedFilters,
|
||||
setSaveAlertVisible,
|
||||
filterChanges,
|
||||
filterId => {
|
||||
setFilterChanges(prev => ({
|
||||
...prev,
|
||||
deleted: [...prev.deleted, filterId],
|
||||
}));
|
||||
},
|
||||
);
|
||||
|
||||
// After this, it should be as if the modal was just opened fresh.
|
||||
|
|
@ -276,6 +314,7 @@ function FiltersConfigModal({
|
|||
setRemovedFilters(DEFAULT_REMOVED_FILTERS);
|
||||
setSaveAlertVisible(false);
|
||||
setFormValues(DEFAULT_FORM_VALUES);
|
||||
resetFilterChanges();
|
||||
setErroredFilters(DEFAULT_EMPTY_FILTERS);
|
||||
if (filterIds.length > 0) {
|
||||
setActiveFilterPanelKey(getActiveFilterPanelKey(filterIds[0]));
|
||||
|
|
@ -329,19 +368,32 @@ function FiltersConfigModal({
|
|||
value: id,
|
||||
type: filterConfigMap[id]?.filterType,
|
||||
})),
|
||||
[canBeUsedAsDependency, filterIds, getFilterTitle],
|
||||
[canBeUsedAsDependency, filterConfigMap, filterIds, getFilterTitle],
|
||||
);
|
||||
|
||||
/**
|
||||
* Manages dependencies of filters associated with a deleted filter.
|
||||
*
|
||||
* @param values the native filters form
|
||||
* @returns the updated filterConfigMap
|
||||
*/
|
||||
const cleanDeletedParents = (values: NativeFiltersForm | null) => {
|
||||
const modifiedParentFilters = new Set<string>();
|
||||
const updatedFilterConfigMap = Object.keys(filterConfigMap).reduce(
|
||||
(acc, key) => {
|
||||
const filter = filterConfigMap[key];
|
||||
const cascadeParentIds = filter.cascadeParentIds?.filter(id =>
|
||||
canBeUsedAsDependency(id),
|
||||
);
|
||||
if (cascadeParentIds) {
|
||||
|
||||
if (
|
||||
cascadeParentIds &&
|
||||
!isEqual(cascadeParentIds, filter.cascadeParentIds)
|
||||
) {
|
||||
dispatch(updateCascadeParentIds(key, cascadeParentIds));
|
||||
modifiedParentFilters.add(key);
|
||||
}
|
||||
|
||||
return {
|
||||
...acc,
|
||||
[key]: {
|
||||
|
|
@ -357,18 +409,24 @@ function FiltersConfigModal({
|
|||
if (filters) {
|
||||
Object.keys(filters).forEach(key => {
|
||||
const filter = filters[key];
|
||||
|
||||
if (!('dependencies' in filter)) {
|
||||
return;
|
||||
}
|
||||
const { dependencies } = filter;
|
||||
if (dependencies) {
|
||||
filter.dependencies = dependencies.filter(id =>
|
||||
canBeUsedAsDependency(id),
|
||||
);
|
||||
|
||||
const originalDependencies = filter.dependencies || [];
|
||||
const cleanedDependencies = originalDependencies.filter(id =>
|
||||
canBeUsedAsDependency(id),
|
||||
);
|
||||
|
||||
if (!isEqual(cleanedDependencies, originalDependencies)) {
|
||||
filter.dependencies = cleanedDependencies;
|
||||
modifiedParentFilters.add(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
return updatedFilterConfigMap;
|
||||
|
||||
return [updatedFilterConfigMap, modifiedParentFilters];
|
||||
};
|
||||
|
||||
const handleErroredFilters = useCallback(() => {
|
||||
|
|
@ -407,15 +465,32 @@ function FiltersConfigModal({
|
|||
handleErroredFilters();
|
||||
|
||||
if (values) {
|
||||
const updatedFilterConfigMap = cleanDeletedParents(values);
|
||||
createHandleSave(
|
||||
updatedFilterConfigMap,
|
||||
orderedFilters,
|
||||
removedFilters,
|
||||
onSave,
|
||||
values,
|
||||
)();
|
||||
const [updatedFilterConfigMap, modifiedParentFilters] =
|
||||
cleanDeletedParents(values);
|
||||
|
||||
const allModified = [
|
||||
...new Set([
|
||||
...(modifiedParentFilters as Set<string>),
|
||||
...filterChanges.modified,
|
||||
]),
|
||||
];
|
||||
|
||||
const actualChanges = {
|
||||
...filterChanges,
|
||||
modified:
|
||||
allModified.length && filterChanges.deleted.length
|
||||
? allModified.filter(id => !filterChanges.deleted.includes(id))
|
||||
: allModified,
|
||||
reordered:
|
||||
filterChanges.reordered.length &&
|
||||
!isEqual(filterChanges.reordered, initialFilterOrder)
|
||||
? filterChanges.reordered
|
||||
: [],
|
||||
};
|
||||
|
||||
createHandleSave(onSave, actualChanges, values, updatedFilterConfigMap)();
|
||||
resetForm(true);
|
||||
resetFilterChanges();
|
||||
} else {
|
||||
configFormRef.current?.changeTab?.('configuration');
|
||||
}
|
||||
|
|
@ -447,6 +522,10 @@ function FiltersConfigModal({
|
|||
const removed = newOrderedFilter.splice(dragIndex, 1)[0];
|
||||
newOrderedFilter.splice(targetIndex, 0, removed);
|
||||
setOrderedFilters(newOrderedFilter);
|
||||
setFilterChanges(prev => ({
|
||||
...prev,
|
||||
reordered: newOrderedFilter,
|
||||
}));
|
||||
};
|
||||
|
||||
const buildDependencyMap = useCallback(() => {
|
||||
|
|
@ -596,28 +675,34 @@ function FiltersConfigModal({
|
|||
setErroredFilters={setErroredFilters}
|
||||
validateDependencies={validateDependencies}
|
||||
getDependencySuggestion={getDependencySuggestion}
|
||||
onModifyFilter={handleModifyFilter}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
[
|
||||
renderedFilters,
|
||||
orderedFilters,
|
||||
renderedFilters,
|
||||
currentFilterId,
|
||||
filterConfigMap,
|
||||
expanded,
|
||||
form,
|
||||
removedFilters,
|
||||
restoreFilter,
|
||||
getAvailableFilters,
|
||||
activeFilterPanelKey,
|
||||
handleActiveFilterPanelChange,
|
||||
validateDependencies,
|
||||
getDependencySuggestion,
|
||||
handleActiveFilterPanelChange,
|
||||
expanded,
|
||||
handleModifyFilter,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
resetFilterChanges();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<StyledModalWrapper
|
||||
visible={isOpen}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
DataMask,
|
||||
NativeFilterType,
|
||||
NativeFilterScope,
|
||||
Filter,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
export interface NativeFiltersFormItem {
|
||||
|
|
@ -60,6 +61,16 @@ export interface NativeFiltersForm {
|
|||
changed?: boolean;
|
||||
}
|
||||
|
||||
export type FilterChangesType = {
|
||||
modified: string[];
|
||||
deleted: string[];
|
||||
reordered: string[];
|
||||
};
|
||||
|
||||
export type SaveFilterChangesType = {
|
||||
modified: Filter[];
|
||||
} & Omit<FilterChangesType, 'modified'>;
|
||||
|
||||
export type FilterRemoval =
|
||||
| null
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -20,15 +20,15 @@ import { FormInstance } from 'src/components';
|
|||
import { nanoid } from 'nanoid';
|
||||
import { getInitialDataMask } from 'src/dataMask/reducer';
|
||||
import {
|
||||
Filter,
|
||||
FilterConfiguration,
|
||||
NativeFilterType,
|
||||
Divider,
|
||||
NativeFilterTarget,
|
||||
logging,
|
||||
Filter,
|
||||
Divider,
|
||||
} from '@superset-ui/core';
|
||||
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
|
||||
import { FilterRemoval, NativeFiltersForm } from './types';
|
||||
import { FilterChangesType, FilterRemoval, NativeFiltersForm } from './types';
|
||||
|
||||
export const REMOVAL_DELAY_SECS = 5;
|
||||
|
||||
|
|
@ -92,62 +92,69 @@ export const validateForm = async (
|
|||
|
||||
export const createHandleSave =
|
||||
(
|
||||
filterConfigMap: Record<string, Filter | Divider>,
|
||||
filterIds: string[],
|
||||
removedFilters: Record<string, FilterRemoval>,
|
||||
saveForm: Function,
|
||||
filterChanges: FilterChangesType,
|
||||
values: NativeFiltersForm,
|
||||
filterConfigMap: Record<string, Filter | Divider>,
|
||||
) =>
|
||||
async () => {
|
||||
const newFilterConfig: FilterConfiguration = filterIds
|
||||
.filter(id => !removedFilters[id])
|
||||
.map(id => {
|
||||
// create a filter config object from the form inputs
|
||||
const formInputs = values.filters?.[id];
|
||||
// if user didn't open a filter, return the original config
|
||||
if (!formInputs) return filterConfigMap[id];
|
||||
if (formInputs.type === NativeFilterType.Divider) {
|
||||
return {
|
||||
id,
|
||||
type: NativeFilterType.Divider,
|
||||
scope: {
|
||||
rootPath: [DASHBOARD_ROOT_ID],
|
||||
excluded: [],
|
||||
},
|
||||
title: formInputs.title,
|
||||
description: formInputs.description,
|
||||
};
|
||||
}
|
||||
const target: Partial<NativeFilterTarget> = {};
|
||||
if (formInputs.dataset) {
|
||||
target.datasetId = formInputs.dataset.value;
|
||||
}
|
||||
if (formInputs.dataset && formInputs.column) {
|
||||
target.column = { name: formInputs.column };
|
||||
}
|
||||
const transformFilter = (id: string) => {
|
||||
const formInputs = values.filters?.[id] || filterConfigMap[id];
|
||||
if (!formInputs) {
|
||||
return undefined;
|
||||
}
|
||||
if (formInputs.type === NativeFilterType.Divider) {
|
||||
return {
|
||||
id,
|
||||
adhoc_filters: formInputs.adhoc_filters,
|
||||
time_range: formInputs.time_range,
|
||||
controlValues: formInputs.controlValues ?? {},
|
||||
granularity_sqla: formInputs.granularity_sqla,
|
||||
requiredFirst: Object.values(formInputs.requiredFirst ?? {}).find(
|
||||
rf => rf,
|
||||
),
|
||||
name: formInputs.name,
|
||||
filterType: formInputs.filterType,
|
||||
// for now there will only ever be one target
|
||||
targets: [target],
|
||||
defaultDataMask: formInputs.defaultDataMask ?? getInitialDataMask(),
|
||||
cascadeParentIds: formInputs.dependencies || [],
|
||||
scope: formInputs.scope,
|
||||
sortMetric: formInputs.sortMetric,
|
||||
type: formInputs.type,
|
||||
description: (formInputs.description || '').trim(),
|
||||
type: NativeFilterType.Divider,
|
||||
scope: {
|
||||
rootPath: [DASHBOARD_ROOT_ID],
|
||||
excluded: [],
|
||||
},
|
||||
title: formInputs.title,
|
||||
description: formInputs.description,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
await saveForm(newFilterConfig);
|
||||
const target: Partial<NativeFilterTarget> = {};
|
||||
if (formInputs.dataset) {
|
||||
target.datasetId = formInputs.dataset.value;
|
||||
}
|
||||
if (formInputs.dataset && formInputs.column) {
|
||||
target.column = { name: formInputs.column };
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
adhoc_filters: formInputs.adhoc_filters,
|
||||
time_range: formInputs.time_range,
|
||||
controlValues: formInputs.controlValues ?? {},
|
||||
granularity_sqla: formInputs.granularity_sqla,
|
||||
requiredFirst: Object.values(formInputs.requiredFirst ?? {}).find(
|
||||
rf => rf,
|
||||
),
|
||||
name: formInputs.name,
|
||||
filterType: formInputs.filterType,
|
||||
targets: [target],
|
||||
defaultDataMask: formInputs.defaultDataMask ?? getInitialDataMask(),
|
||||
cascadeParentIds: formInputs.dependencies || [],
|
||||
scope: formInputs.scope,
|
||||
sortMetric: formInputs.sortMetric,
|
||||
type: formInputs.type,
|
||||
description: (formInputs.description || '').trim(),
|
||||
};
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const transformedModified = filterChanges.modified
|
||||
.map(transformFilter)
|
||||
.filter(Boolean);
|
||||
|
||||
const newFilterChanges = {
|
||||
...filterChanges,
|
||||
modified: transformedModified,
|
||||
};
|
||||
await saveForm(newFilterChanges);
|
||||
};
|
||||
|
||||
export const createHandleRemoveItem =
|
||||
|
|
@ -163,6 +170,8 @@ export const createHandleRemoveItem =
|
|||
val: string[] | ((prevState: string[]) => string[]),
|
||||
) => void,
|
||||
setSaveAlertVisible: Function,
|
||||
filterChanges: FilterChangesType,
|
||||
removeFilter: (filterId: string) => void,
|
||||
) =>
|
||||
(filterId: string) => {
|
||||
const completeFilterRemoval = (filterId: string) => {
|
||||
|
|
@ -187,12 +196,14 @@ export const createHandleRemoveItem =
|
|||
...removedFilters,
|
||||
[filterId]: { isPending: true, timerId },
|
||||
}));
|
||||
removeFilter(filterId);
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
setSaveAlertVisible(false);
|
||||
};
|
||||
|
||||
export const NATIVE_FILTER_PREFIX = 'NATIVE_FILTER-';
|
||||
export const NATIVE_FILTER_DIVIDER_PREFIX = 'NATIVE_FILTER_DIVIDER-';
|
||||
export const generateFilterId = (type: NativeFilterType) => {
|
||||
export const generateFilterId = (type: NativeFilterType): string => {
|
||||
const prefix =
|
||||
type === NativeFilterType.NativeFilter
|
||||
? NATIVE_FILTER_PREFIX
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
DASHBOARD_INFO_UPDATED,
|
||||
SET_FILTER_BAR_ORIENTATION,
|
||||
SET_CROSS_FILTERS_ENABLED,
|
||||
DASHBOARD_INFO_FILTERS_CHANGED,
|
||||
} from '../actions/dashboardInfo';
|
||||
import { HYDRATE_DASHBOARD } from '../actions/hydrate';
|
||||
|
||||
|
|
@ -33,6 +34,16 @@ export default function dashboardStateReducer(state = {}, action) {
|
|||
// server-side compare last_modified_time in second level
|
||||
last_modified_time: Math.round(new Date().getTime() / 1000),
|
||||
};
|
||||
case DASHBOARD_INFO_FILTERS_CHANGED: {
|
||||
return {
|
||||
...state,
|
||||
metadata: {
|
||||
...state.metadata,
|
||||
native_filter_configuration: action.newInfo,
|
||||
},
|
||||
last_modified_time: Math.round(new Date().getTime() / 1000),
|
||||
};
|
||||
}
|
||||
case HYDRATE_DASHBOARD:
|
||||
return {
|
||||
...state,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
*/
|
||||
import {
|
||||
AnyFilterAction,
|
||||
SET_FILTER_CONFIG_COMPLETE,
|
||||
SET_NATIVE_FILTERS_CONFIG_COMPLETE,
|
||||
SET_IN_SCOPE_STATUS_OF_FILTERS,
|
||||
SET_FOCUSED_NATIVE_FILTER,
|
||||
UNSET_FOCUSED_NATIVE_FILTER,
|
||||
|
|
@ -26,7 +26,11 @@ import {
|
|||
UNSET_HOVERED_NATIVE_FILTER,
|
||||
UPDATE_CASCADE_PARENT_IDS,
|
||||
} from 'src/dashboard/actions/nativeFilters';
|
||||
import { FilterConfiguration, NativeFiltersState } from '@superset-ui/core';
|
||||
import {
|
||||
Filter,
|
||||
FilterConfiguration,
|
||||
NativeFiltersState,
|
||||
} from '@superset-ui/core';
|
||||
import { HYDRATE_DASHBOARD } from '../actions/hydrate';
|
||||
|
||||
export function getInitialState({
|
||||
|
|
@ -37,7 +41,6 @@ export function getInitialState({
|
|||
state?: NativeFiltersState;
|
||||
}): NativeFiltersState {
|
||||
const state: Partial<NativeFiltersState> = {};
|
||||
|
||||
const filters = {};
|
||||
if (filterConfig) {
|
||||
filterConfig.forEach(filter => {
|
||||
|
|
@ -52,6 +55,20 @@ export function getInitialState({
|
|||
return state as NativeFiltersState;
|
||||
}
|
||||
|
||||
function handleFilterChangesComplete(
|
||||
state: NativeFiltersState,
|
||||
filters: Filter[],
|
||||
) {
|
||||
const modifiedFilters = Object.fromEntries(
|
||||
filters.map(filter => [filter.id, filter]),
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
filters: modifiedFilters,
|
||||
} as NativeFiltersState;
|
||||
}
|
||||
|
||||
export default function nativeFilterReducer(
|
||||
state: NativeFiltersState = {
|
||||
filters: {},
|
||||
|
|
@ -64,10 +81,12 @@ export default function nativeFilterReducer(
|
|||
filters: action.data.nativeFilters.filters,
|
||||
};
|
||||
|
||||
case SET_FILTER_CONFIG_COMPLETE:
|
||||
case SET_IN_SCOPE_STATUS_OF_FILTERS:
|
||||
return getInitialState({ filterConfig: action.filterConfig, state });
|
||||
|
||||
case SET_NATIVE_FILTERS_CONFIG_COMPLETE:
|
||||
return handleFilterChangesComplete(state, action.filterChanges);
|
||||
|
||||
case SET_FOCUSED_NATIVE_FILTER:
|
||||
return {
|
||||
...state,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@
|
|||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { DataMask, FilterConfiguration, Filters } from '@superset-ui/core';
|
||||
import { DataMask, Filters } from '@superset-ui/core';
|
||||
import { SaveFilterChangesType } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/types';
|
||||
import { getInitialDataMask } from './reducer';
|
||||
|
||||
export const CLEAR_DATA_MASK_STATE = 'CLEAR_DATA_MASK_STATE';
|
||||
|
|
@ -37,32 +38,25 @@ export interface INITDATAMASK {
|
|||
dataMask: DataMask;
|
||||
}
|
||||
|
||||
export const SET_DATA_MASK_FOR_FILTER_CONFIG_COMPLETE =
|
||||
'SET_DATA_MASK_FOR_FILTER_CONFIG_COMPLETE';
|
||||
|
||||
export interface SetDataMaskForFilterConfigComplete {
|
||||
type: typeof SET_DATA_MASK_FOR_FILTER_CONFIG_COMPLETE;
|
||||
filterConfig: FilterConfiguration;
|
||||
export const SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE =
|
||||
'SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE';
|
||||
export interface SetDataMaskForFilterChangesComplete {
|
||||
type: typeof SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE;
|
||||
filterChanges: SaveFilterChangesType;
|
||||
filters?: Filters;
|
||||
}
|
||||
|
||||
export const SET_DATA_MASK_FOR_FILTER_CONFIG_FAIL =
|
||||
'SET_DATA_MASK_FOR_FILTER_CONFIG_FAIL';
|
||||
|
||||
export interface SetDataMaskForFilterConfigFail {
|
||||
type: typeof SET_DATA_MASK_FOR_FILTER_CONFIG_FAIL;
|
||||
filterConfig: FilterConfiguration;
|
||||
}
|
||||
export function setDataMaskForFilterConfigComplete(
|
||||
filterConfig: FilterConfiguration,
|
||||
export function setDataMaskForFilterChangesComplete(
|
||||
filterChanges: SaveFilterChangesType,
|
||||
filters?: Filters,
|
||||
): SetDataMaskForFilterConfigComplete {
|
||||
): SetDataMaskForFilterChangesComplete {
|
||||
return {
|
||||
type: SET_DATA_MASK_FOR_FILTER_CONFIG_COMPLETE,
|
||||
filterConfig,
|
||||
type: SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE,
|
||||
filterChanges,
|
||||
filters,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateDataMask(
|
||||
filterId: string | number,
|
||||
dataMask: DataMask,
|
||||
|
|
@ -87,5 +81,4 @@ export function clearDataMaskState(): ClearDataMaskState {
|
|||
export type AnyDataMaskAction =
|
||||
| ClearDataMaskState
|
||||
| UpdateDataMask
|
||||
| SetDataMaskForFilterConfigFail
|
||||
| SetDataMaskForFilterConfigComplete;
|
||||
| SetDataMaskForFilterChangesComplete;
|
||||
|
|
|
|||
|
|
@ -32,10 +32,11 @@ import {
|
|||
} from '@superset-ui/core';
|
||||
import { NATIVE_FILTER_PREFIX } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/utils';
|
||||
import { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate';
|
||||
import { SaveFilterChangesType } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/types';
|
||||
import {
|
||||
AnyDataMaskAction,
|
||||
CLEAR_DATA_MASK_STATE,
|
||||
SET_DATA_MASK_FOR_FILTER_CONFIG_COMPLETE,
|
||||
SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE,
|
||||
UPDATE_DATA_MASK,
|
||||
} from './actions';
|
||||
import { areObjectsEqual } from '../reduxUtils';
|
||||
|
|
@ -100,6 +101,37 @@ function fillNativeFilters(
|
|||
});
|
||||
}
|
||||
|
||||
function updateDataMaskForFilterChanges(
|
||||
filterChanges: SaveFilterChangesType,
|
||||
mergedDataMask: DataMaskStateWithId,
|
||||
draftDataMask: DataMaskStateWithId,
|
||||
initialDataMask?: Filters,
|
||||
) {
|
||||
const dataMask = initialDataMask || {};
|
||||
|
||||
Object.entries(dataMask).forEach(([key, value]) => {
|
||||
mergedDataMask[key] = { ...value, ...value.defaultDataMask };
|
||||
});
|
||||
|
||||
filterChanges.deleted.forEach((filterId: string) => {
|
||||
delete mergedDataMask[filterId];
|
||||
});
|
||||
|
||||
filterChanges.modified.forEach((filter: Filter) => {
|
||||
mergedDataMask[filter.id] = {
|
||||
...getInitialDataMask(filter.id),
|
||||
...filter.defaultDataMask,
|
||||
...filter,
|
||||
};
|
||||
});
|
||||
|
||||
Object.values(draftDataMask).forEach(filter => {
|
||||
if (!String(filter?.id).startsWith(NATIVE_FILTER_PREFIX)) {
|
||||
mergedDataMask[filter?.id] = filter;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const dataMaskReducer = produce(
|
||||
(draft: DataMaskStateWithId, action: AnyDataMaskAction) => {
|
||||
const cleanState = {};
|
||||
|
|
@ -136,15 +168,14 @@ const dataMaskReducer = produce(
|
|||
action.data.dataMask,
|
||||
);
|
||||
return cleanState;
|
||||
case SET_DATA_MASK_FOR_FILTER_CONFIG_COMPLETE:
|
||||
fillNativeFilters(
|
||||
action.filterConfig ?? [],
|
||||
case SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE:
|
||||
updateDataMaskForFilterChanges(
|
||||
action.filterChanges,
|
||||
cleanState,
|
||||
draft,
|
||||
action.filters,
|
||||
);
|
||||
return cleanState;
|
||||
|
||||
default:
|
||||
return draft;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,10 @@ class DashboardUpdateFailedError(UpdateFailedError):
|
|||
message = _("Dashboard could not be updated.")
|
||||
|
||||
|
||||
class DashboardNativeFiltersUpdateFailedError(UpdateFailedError):
|
||||
message = _("Dashboard native filters could not be patched.")
|
||||
|
||||
|
||||
class DashboardDeleteFailedError(DeleteFailedError):
|
||||
message = _("Dashboard could not be deleted.")
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ from superset.commands.base import BaseCommand, UpdateMixin
|
|||
from superset.commands.dashboard.exceptions import (
|
||||
DashboardForbiddenError,
|
||||
DashboardInvalidError,
|
||||
DashboardNativeFiltersUpdateFailedError,
|
||||
DashboardNotFoundError,
|
||||
DashboardSlugExistsValidationError,
|
||||
DashboardUpdateFailedError,
|
||||
|
|
@ -67,7 +68,6 @@ class UpdateDashboardCommand(UpdateMixin, BaseCommand):
|
|||
dashboard,
|
||||
data=json.loads(self._properties.get("json_metadata", "{}")),
|
||||
)
|
||||
|
||||
return dashboard
|
||||
|
||||
def validate(self) -> None:
|
||||
|
|
@ -187,3 +187,18 @@ class UpdateDashboardCommand(UpdateMixin, BaseCommand):
|
|||
deleted_tabs = find_deleted_tabs()
|
||||
reports = find_reports_containing_tabs(deleted_tabs)
|
||||
deactivate_reports(reports)
|
||||
|
||||
|
||||
class UpdateDashboardNativeFiltersCommand(UpdateDashboardCommand):
|
||||
@transaction(
|
||||
on_error=partial(on_error, reraise=DashboardNativeFiltersUpdateFailedError)
|
||||
)
|
||||
def run(self) -> Model:
|
||||
super().validate()
|
||||
assert self._model
|
||||
|
||||
configuration = DashboardDAO.update_native_filters_config(
|
||||
self._model, self._properties
|
||||
)
|
||||
|
||||
return configuration
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ MODEL_API_RW_METHOD_PERMISSION_MAP = {
|
|||
"columnar_metadata": "columnar_upload",
|
||||
"csv_metadata": "csv_upload",
|
||||
"slack_channels": "write",
|
||||
"put_filters": "write",
|
||||
}
|
||||
|
||||
EXTRA_FORM_DATA_APPEND_KEYS = {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ from superset.commands.dashboard.exceptions import (
|
|||
DashboardAccessDeniedError,
|
||||
DashboardForbiddenError,
|
||||
DashboardNotFoundError,
|
||||
DashboardUpdateFailedError,
|
||||
)
|
||||
from superset.daos.base import BaseDAO
|
||||
from superset.dashboards.filters import DashboardAccessFilter, is_uuid
|
||||
|
|
@ -318,6 +319,77 @@ class DashboardDAO(BaseDAO[Dashboard]):
|
|||
db.session.add(dash)
|
||||
return dash
|
||||
|
||||
@classmethod
|
||||
def update_native_filters_config(
|
||||
cls,
|
||||
dashboard: Dashboard | None = None,
|
||||
attributes: dict[str, Any] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
if not dashboard:
|
||||
raise DashboardUpdateFailedError("Dashboard not found")
|
||||
|
||||
if attributes:
|
||||
metadata = json.loads(dashboard.json_metadata or "{}")
|
||||
native_filter_configuration = metadata.get(
|
||||
"native_filter_configuration", []
|
||||
)
|
||||
reordered_filter_ids: list[int] = attributes.get("reordered", [])
|
||||
updated_configuration = []
|
||||
|
||||
# Modify / Delete existing filters
|
||||
for conf in native_filter_configuration:
|
||||
deleted_filter = next(
|
||||
(f for f in attributes.get("deleted", []) if f == conf.get("id")),
|
||||
None,
|
||||
)
|
||||
if deleted_filter:
|
||||
continue
|
||||
|
||||
modified_filter = next(
|
||||
(
|
||||
f
|
||||
for f in attributes.get("modified", [])
|
||||
if f.get("id") == conf.get("id")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if modified_filter:
|
||||
# Filter was modified, substitute it
|
||||
updated_configuration.append(modified_filter)
|
||||
else:
|
||||
# Filter was not modified, keep it as is
|
||||
updated_configuration.append(conf)
|
||||
|
||||
# Append new filters
|
||||
for new_filter in attributes.get("modified", []):
|
||||
new_filter_id = new_filter.get("id")
|
||||
if new_filter_id not in [f.get("id") for f in updated_configuration]:
|
||||
updated_configuration.append(new_filter)
|
||||
|
||||
if (
|
||||
reordered_filter_ids
|
||||
and new_filter_id not in reordered_filter_ids
|
||||
):
|
||||
reordered_filter_ids.append(new_filter_id)
|
||||
|
||||
# Reorder filters
|
||||
if reordered_filter_ids:
|
||||
filter_map = {
|
||||
filter_config["id"]: filter_config
|
||||
for filter_config in updated_configuration
|
||||
}
|
||||
|
||||
updated_configuration = [
|
||||
filter_map[filter_id]
|
||||
for filter_id in reordered_filter_ids
|
||||
if filter_id in filter_map
|
||||
]
|
||||
|
||||
metadata["native_filter_configuration"] = updated_configuration
|
||||
dashboard.json_metadata = json.dumps(metadata)
|
||||
|
||||
return updated_configuration
|
||||
|
||||
@staticmethod
|
||||
def add_favorite(dashboard: Dashboard) -> None:
|
||||
ids = DashboardDAO.favorited_ids([dashboard])
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ from superset.commands.dashboard.exceptions import (
|
|||
DashboardDeleteFailedError,
|
||||
DashboardForbiddenError,
|
||||
DashboardInvalidError,
|
||||
DashboardNativeFiltersUpdateFailedError,
|
||||
DashboardNotFoundError,
|
||||
DashboardUpdateFailedError,
|
||||
)
|
||||
|
|
@ -55,7 +56,10 @@ from superset.commands.dashboard.fave import AddFavoriteDashboardCommand
|
|||
from superset.commands.dashboard.importers.dispatcher import ImportDashboardsCommand
|
||||
from superset.commands.dashboard.permalink.create import CreateDashboardPermalinkCommand
|
||||
from superset.commands.dashboard.unfave import DelFavoriteDashboardCommand
|
||||
from superset.commands.dashboard.update import UpdateDashboardCommand
|
||||
from superset.commands.dashboard.update import (
|
||||
UpdateDashboardCommand,
|
||||
UpdateDashboardNativeFiltersCommand,
|
||||
)
|
||||
from superset.commands.database.exceptions import DatasetValidationError
|
||||
from superset.commands.exceptions import TagForbiddenError
|
||||
from superset.commands.importers.exceptions import NoValidFilesFoundError
|
||||
|
|
@ -80,6 +84,7 @@ from superset.dashboards.schemas import (
|
|||
DashboardCopySchema,
|
||||
DashboardDatasetSchema,
|
||||
DashboardGetResponseSchema,
|
||||
DashboardNativeFiltersConfigUpdateSchema,
|
||||
DashboardPostSchema,
|
||||
DashboardPutSchema,
|
||||
EmbeddedDashboardConfigSchema,
|
||||
|
|
@ -175,6 +180,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
|
|||
"copy_dash",
|
||||
"cache_dashboard_screenshot",
|
||||
"screenshot",
|
||||
"put_filters",
|
||||
}
|
||||
resource_name = "dashboard"
|
||||
allow_browser_login = True
|
||||
|
|
@ -262,6 +268,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
|
|||
|
||||
add_model_schema = DashboardPostSchema()
|
||||
edit_model_schema = DashboardPutSchema()
|
||||
update_filters_model_schema = DashboardNativeFiltersConfigUpdateSchema()
|
||||
chart_entity_response_schema = ChartEntityResponseSchema()
|
||||
dashboard_get_response_schema = DashboardGetResponseSchema()
|
||||
dashboard_dataset_schema = DashboardDatasetSchema()
|
||||
|
|
@ -675,6 +682,85 @@ class DashboardRestApi(BaseSupersetModelRestApi):
|
|||
response = self.response_422(message=str(ex))
|
||||
return response
|
||||
|
||||
@expose("/<pk>/filters", methods=("PUT",))
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.put_filters",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
@requires_json
|
||||
def put_filters(self, pk: int) -> Response:
|
||||
"""
|
||||
Modify native filters configuration for a dashboard.
|
||||
---
|
||||
put:
|
||||
summary: Update native filters configuration for a dashboard.
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
type: integer
|
||||
name: pk
|
||||
requestBody:
|
||||
description: Native filters configuration
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DashboardNativeFiltersConfigUpdateSchema'
|
||||
responses:
|
||||
200:
|
||||
description: Dashboard native filters updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
type: array
|
||||
400:
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
try:
|
||||
item = self.update_filters_model_schema.load(request.json, partial=True)
|
||||
except ValidationError as error:
|
||||
return self.response_400(message=error.messages)
|
||||
|
||||
try:
|
||||
configuration = UpdateDashboardNativeFiltersCommand(pk, item).run()
|
||||
response = self.response(
|
||||
200,
|
||||
result=configuration,
|
||||
)
|
||||
except DashboardNotFoundError:
|
||||
response = self.response_404()
|
||||
except DashboardForbiddenError:
|
||||
response = self.response_403()
|
||||
except TagForbiddenError as ex:
|
||||
response = self.response(403, message=str(ex))
|
||||
except DashboardInvalidError as ex:
|
||||
return self.response_422(message=ex.normalized_messages())
|
||||
except DashboardNativeFiltersUpdateFailedError as ex:
|
||||
logger.error(
|
||||
"Error changing native filters for dashboard %s: %s",
|
||||
self.__class__.__name__,
|
||||
str(ex),
|
||||
exc_info=True,
|
||||
)
|
||||
response = self.response_422(message=str(ex))
|
||||
return response
|
||||
|
||||
@expose("/<pk>", methods=("DELETE",))
|
||||
@protect()
|
||||
@safe
|
||||
|
|
|
|||
|
|
@ -399,6 +399,12 @@ class DashboardPutSchema(BaseDashboardSchema):
|
|||
)
|
||||
|
||||
|
||||
class DashboardNativeFiltersConfigUpdateSchema(BaseDashboardSchema):
|
||||
deleted = fields.List(fields.String(), allow_none=False)
|
||||
modified = fields.List(fields.Raw(), allow_none=False)
|
||||
reordered = fields.List(fields.String(), allow_none=False)
|
||||
|
||||
|
||||
class DashboardScreenshotPostSchema(Schema):
|
||||
dataMask = fields.Dict(
|
||||
keys=fields.Str(),
|
||||
|
|
|
|||
|
|
@ -85,6 +85,15 @@ class TestDashboardApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCas
|
|||
"published": False,
|
||||
}
|
||||
|
||||
dashboard_put_filters_data = {
|
||||
"modified": [
|
||||
{"id": "native_filter_1", "name": "Filter 1"},
|
||||
{"id": "native_filter_2", "name": "Filter 2"},
|
||||
],
|
||||
"deleted": [],
|
||||
"reordered": [],
|
||||
}
|
||||
|
||||
@pytest.fixture()
|
||||
def create_dashboards(self):
|
||||
with self.create_app().app_context():
|
||||
|
|
@ -1719,6 +1728,175 @@ class TestDashboardApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCas
|
|||
db.session.delete(model)
|
||||
db.session.commit()
|
||||
|
||||
def test_add_dashboard_filters(self):
|
||||
"""
|
||||
Dashboard API: Test that a filter was added
|
||||
"""
|
||||
admin = self.get_user("admin")
|
||||
admin_role = self.get_role("Admin")
|
||||
dashboard_id = self.insert_dashboard(
|
||||
"title1", "slug1", [admin.id], roles=[admin_role.id]
|
||||
).id
|
||||
self.login(ADMIN_USERNAME)
|
||||
uri = f"api/v1/dashboard/{dashboard_id}/filters"
|
||||
rv = self.put_assert_metric(uri, self.dashboard_put_filters_data, "put_filters")
|
||||
assert rv.status_code == 200
|
||||
model = db.session.query(Dashboard).get(dashboard_id)
|
||||
json_metadata = model.json_metadata
|
||||
native_filter_config = json.loads(json_metadata)["native_filter_configuration"]
|
||||
|
||||
assert native_filter_config[0]["name"] == "Filter 1"
|
||||
db.session.delete(model)
|
||||
db.session.commit()
|
||||
|
||||
def test_modify_dashboard_filters_values(self):
|
||||
"""
|
||||
Dashboard API: Test that a filter was added
|
||||
"""
|
||||
admin = self.get_user("admin")
|
||||
admin_role = self.get_role("Admin")
|
||||
json_metadata = {
|
||||
"native_filter_configuration": [
|
||||
{
|
||||
"id": "native_filter_1",
|
||||
"name": "Filter X",
|
||||
"filterType": "filter_select",
|
||||
"cascadeParentIds": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
dashboard_id = self.insert_dashboard(
|
||||
"title1",
|
||||
"slug1",
|
||||
[admin.id],
|
||||
roles=[admin_role.id],
|
||||
json_metadata=json.dumps(json_metadata),
|
||||
).id
|
||||
self.login(ADMIN_USERNAME)
|
||||
uri = f"api/v1/dashboard/{dashboard_id}/filters"
|
||||
rv = self.put_assert_metric(uri, self.dashboard_put_filters_data, "put_filters")
|
||||
|
||||
assert rv.status_code == 200
|
||||
model = db.session.query(Dashboard).get(dashboard_id)
|
||||
json_metadata = model.json_metadata
|
||||
native_filter_config = json.loads(json_metadata)["native_filter_configuration"]
|
||||
|
||||
assert native_filter_config[0]["name"] == "Filter 1"
|
||||
|
||||
db.session.delete(model)
|
||||
db.session.commit()
|
||||
|
||||
def test_modfify_dashboard_filters_order(self):
|
||||
"""
|
||||
Dashboard API: Test filters reordered
|
||||
"""
|
||||
admin = self.get_user("admin")
|
||||
admin_role = self.get_role("Admin")
|
||||
json_metadata = {
|
||||
"native_filter_configuration": [
|
||||
{
|
||||
"id": "native_filter_1",
|
||||
"name": "Filter 1",
|
||||
"filterType": "filter_select",
|
||||
"cascadeParentIds": [],
|
||||
},
|
||||
{
|
||||
"id": "native_filter_2",
|
||||
"name": "Filter 2",
|
||||
"filterType": "filter_select",
|
||||
"cascadeParentIds": [],
|
||||
},
|
||||
]
|
||||
}
|
||||
dashboard_id = self.insert_dashboard(
|
||||
"title1",
|
||||
"slug1",
|
||||
[admin.id],
|
||||
roles=[admin_role.id],
|
||||
json_metadata=json.dumps(json_metadata),
|
||||
).id
|
||||
self.login(ADMIN_USERNAME)
|
||||
uri = f"api/v1/dashboard/{dashboard_id}/filters"
|
||||
put_data = {
|
||||
**self.dashboard_put_filters_data,
|
||||
"reordered": ["native_filter_2", "native_filter_1"],
|
||||
}
|
||||
rv = self.put_assert_metric(uri, put_data, "put_filters")
|
||||
assert rv.status_code == 200
|
||||
model = db.session.query(Dashboard).get(dashboard_id)
|
||||
json_metadata = model.json_metadata
|
||||
native_filter_config = json.loads(json_metadata)["native_filter_configuration"]
|
||||
|
||||
assert native_filter_config[0]["name"] == "Filter 2"
|
||||
|
||||
db.session.delete(model)
|
||||
db.session.commit()
|
||||
|
||||
def test_dashboard_filters_deleted(self):
|
||||
"""
|
||||
Dashboard API: Test filters deleted
|
||||
"""
|
||||
admin = self.get_user("admin")
|
||||
admin_role = self.get_role("Admin")
|
||||
json_metadata = {
|
||||
"native_filter_configuration": [
|
||||
{
|
||||
"id": "native_filter_1",
|
||||
"name": "Filter 1",
|
||||
"filterType": "filter_select",
|
||||
"cascadeParentIds": [],
|
||||
},
|
||||
{
|
||||
"id": "native_filter_2",
|
||||
"name": "Filter 2",
|
||||
"filterType": "filter_select",
|
||||
"cascadeParentIds": [],
|
||||
},
|
||||
]
|
||||
}
|
||||
dashboard_id = self.insert_dashboard(
|
||||
"title1",
|
||||
"slug1",
|
||||
[admin.id],
|
||||
roles=[admin_role.id],
|
||||
json_metadata=json.dumps(json_metadata),
|
||||
).id
|
||||
self.login(ADMIN_USERNAME)
|
||||
uri = f"api/v1/dashboard/{dashboard_id}/filters"
|
||||
put_data = {
|
||||
**self.dashboard_put_filters_data,
|
||||
"deleted": ["native_filter_1"],
|
||||
}
|
||||
rv = self.put_assert_metric(uri, put_data, "put_filters")
|
||||
assert rv.status_code == 200
|
||||
model = db.session.query(Dashboard).get(dashboard_id)
|
||||
json_metadata = model.json_metadata
|
||||
native_filter_config = json.loads(json_metadata)["native_filter_configuration"]
|
||||
|
||||
assert native_filter_config[0]["name"] == "Filter 2"
|
||||
|
||||
db.session.delete(model)
|
||||
db.session.commit()
|
||||
|
||||
def test_modify_dashboard_filters_invalid_data(self):
|
||||
"""
|
||||
Dashboard API: Test modify filters with invalid data
|
||||
"""
|
||||
admin = self.get_user("admin")
|
||||
admin_role = self.get_role("Admin")
|
||||
dashboard_id = self.insert_dashboard(
|
||||
"title1", "slug1", [admin.id], roles=[admin_role.id]
|
||||
).id
|
||||
self.login(ADMIN_USERNAME)
|
||||
uri = f"api/v1/dashboard/{dashboard_id}/filters"
|
||||
put_data = {"invalid_key": "invalid_value"}
|
||||
rv = self.put_assert_metric(uri, put_data, "put_filters")
|
||||
assert rv.status_code == 400
|
||||
|
||||
model = db.session.query(Dashboard).get(dashboard_id)
|
||||
db.session.delete(model)
|
||||
db.session.commit()
|
||||
|
||||
def test_dashboard_get_list_no_username(self):
|
||||
"""
|
||||
Dashboard API: Tests that no username is returned
|
||||
|
|
|
|||
Loading…
Reference in New Issue