diff --git a/docs/src/pages/docs/installation/configuring.mdx b/docs/src/pages/docs/installation/configuring.mdx index 53340a5c9cda..d4fc778f5889 100644 --- a/docs/src/pages/docs/installation/configuring.mdx +++ b/docs/src/pages/docs/installation/configuring.mdx @@ -113,6 +113,8 @@ RequestHeader set X-Forwarded-Proto "https" Beyond FAB supported providers (Github, Twitter, LinkedIn, Google, Azure, etc), its easy to connect Superset with other OAuth2 Authorization Server implementations that support “code” authorization. +Make sure the pip package [`Authlib`](https://authlib.org/) is installed on the webserver. + First, configure authorization in Superset `superset_config.py`. ```python @@ -175,6 +177,10 @@ from custom_sso_security_manager import CustomSsoSecurityManager CUSTOM_SECURITY_MANAGER = CustomSsoSecurityManager ``` +Notice that the redirect URL will be `https:///oauth-authorized/` +When configuring an OAuth2 authorization provider if needed. For instance, the redirect URL will +be `https:///oauth-authorized/egaSSO` for the above configuration. + ### Feature Flags To support a diverse set of users, Superset has some features that are not enabled by default. For diff --git a/docs/src/pages/docs/installation/index.mdx b/docs/src/pages/docs/installation/index.mdx index 601753917ad2..81bbf600a28a 100644 --- a/docs/src/pages/docs/installation/index.mdx +++ b/docs/src/pages/docs/installation/index.mdx @@ -79,7 +79,7 @@ The following is for users who want to configure how Superset starts up in Docke You can configure the Docker Compose settings for dev and non-dev mode with `docker/.env` and `docker/.env-non-dev` respectively. These environment files set the environment for most containers in the Docker Compose setup, and some variables affect multiple containers and others only single ones. -One important variable is `SUPERSET_LOAD_EXAMPLES` which determines whether the `superset_init` container will load example data and visualizations into the database and Superset. Thiese examples are quite helpful for most people, but probably unnecessary for experienced users. The loading process can sometimes take a few minutes and a good amount of CPU, so you may want to disable it on a resource-constrained device. +One important variable is `SUPERSET_LOAD_EXAMPLES` which determines whether the `superset_init` container will load example data and visualizations into the database and Superset. These examples are quite helpful for most people, but probably unnecessary for experienced users. The loading process can sometimes take a few minutes and a good amount of CPU, so you may want to disable it on a resource-constrained device. **Note:** Users often want to connect to other databases from Superset. Currently, the easiest way to do this is to modify the `docker-compose-non-dev.yml` file and add your database as a service that the other services depend on (via `x-superset-depends-on`). Others have attempted to set `network_mode: host` on the Superset services, but these generally break the installation, because the configuration requires use of the Docker Compose DNS resolver for the service names. If you have a good solution for this, let us know! diff --git a/superset-frontend/spec/fixtures/mockDatasource.js b/superset-frontend/spec/fixtures/mockDatasource.js index 99183edd5185..e434ad73b067 100644 --- a/superset-frontend/spec/fixtures/mockDatasource.js +++ b/superset-frontend/spec/fixtures/mockDatasource.js @@ -45,6 +45,8 @@ export default { verbose_name: 'sum__num', metric_name: 'sum__num', description: null, + extra: + '{"certification":{"details":"foo", "certified_by":"someone"},"warning_markdown":"bar"}', }, { expression: 'AVG(birth_names.num)', diff --git a/superset-frontend/spec/javascripts/datasource/DatasourceEditor_spec.jsx b/superset-frontend/spec/javascripts/datasource/DatasourceEditor_spec.jsx index 2ab5ea296bd5..39bcfad8658e 100644 --- a/superset-frontend/spec/javascripts/datasource/DatasourceEditor_spec.jsx +++ b/superset-frontend/spec/javascripts/datasource/DatasourceEditor_spec.jsx @@ -21,6 +21,9 @@ import { shallow } from 'enzyme'; import configureStore from 'redux-mock-store'; import fetchMock from 'fetch-mock'; import thunk from 'redux-thunk'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from 'spec/helpers/testing-library'; + import { Radio } from 'src/components/Radio'; import Icon from 'src/components/Icon'; @@ -56,6 +59,10 @@ describe('DatasourceEditor', () => { inst = wrapper.instance(); }); + afterEach(() => { + wrapper.unmount(); + }); + it('is valid', () => { expect(React.isValidElement(el)).toBe(true); }); @@ -209,3 +216,21 @@ describe('DatasourceEditor', () => { isFeatureEnabledMock.mockRestore(); }); }); + +describe('DatasourceEditor RTL', () => { + it('properly renders the metric information', async () => { + render(, { useRedux: true }); + const metricButton = screen.getByTestId('collection-tab-Metrics'); + userEvent.click(metricButton); + const expandToggle = await screen.findAllByLabelText(/toggle expand/i); + userEvent.click(expandToggle[0]); + const certificationDetails = await screen.findByPlaceholderText( + /certification details/i, + ); + expect(certificationDetails.value).toEqual('foo'); + const warningMarkdown = await await screen.findByPlaceholderText( + /certified by/i, + ); + expect(warningMarkdown.value).toEqual('someone'); + }); +}); diff --git a/superset-frontend/src/SqlLab/components/QueryTable/index.jsx b/superset-frontend/src/SqlLab/components/QueryTable/index.jsx index b3bbed99cdba..d23fd49ca6d2 100644 --- a/superset-frontend/src/SqlLab/components/QueryTable/index.jsx +++ b/superset-frontend/src/SqlLab/components/QueryTable/index.jsx @@ -88,6 +88,14 @@ const statusAttributes = { status: 'running', }, }, + fetching: { + color: ({ theme }) => theme.colors.primary.base, + config: { + name: 'queued', + label: t('fetching'), + status: 'fetching', + }, + }, timed_out: { color: ({ theme }) => theme.colors.grayscale.light1, config: { @@ -97,14 +105,20 @@ const statusAttributes = { }, }, scheduled: { - name: 'queued', - label: t('Scheduled'), - status: 'queued', + color: ({ theme }) => theme.colors.greyscale.base, + config: { + name: 'queued', + label: t('Scheduled'), + status: 'queued', + }, }, pending: { - name: 'queued', - label: t('Scheduled'), - status: 'queued', + color: ({ theme }) => theme.colors.greyscale.base, + config: { + name: 'queued', + label: t('Scheduled'), + status: 'queued', + }, }, }; diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx index 807fe46b75de..9873dfbd4ff9 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx @@ -18,7 +18,7 @@ */ /* eslint-env browser */ import cx from 'classnames'; -import React, { FC, useEffect, useState } from 'react'; +import React, { FC } from 'react'; import { Sticky, StickyContainer } from 'react-sticky'; import { JsonObject, styled } from '@superset-ui/core'; import ErrorBoundary from 'src/components/ErrorBoundary'; @@ -30,7 +30,6 @@ import DashboardComponent from 'src/dashboard/containers/DashboardComponent'; import ToastPresenter from 'src/messageToasts/containers/ToastPresenter'; import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu'; import getDirectPathToTabIndex from 'src/dashboard/util/getDirectPathToTabIndex'; -import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags'; import { URL_PARAMS } from 'src/constants'; import { useDispatch, useSelector } from 'react-redux'; import { getUrlParam } from 'src/utils/urlUtils'; @@ -47,11 +46,11 @@ import { DashboardStandaloneMode, } from 'src/dashboard/util/constants'; import FilterBar from 'src/dashboard/components/nativeFilters/FilterBar'; +import Loading from 'src/components/Loading'; import { StickyVerticalBar } from '../StickyVerticalBar'; import { shouldFocusTabs, getRootLevelTabsComponent } from './utils'; -import { useFilters } from '../nativeFilters/FilterBar/state'; -import { Filter } from '../nativeFilters/types'; import DashboardContainer from './DashboardContainer'; +import { useNativeFilters } from './state'; const TABS_HEIGHT = 47; const HEADER_HEIGHT = 67; @@ -99,12 +98,6 @@ const DashboardBuilder: FC = () => { const dashboardLayout = useSelector( state => state.dashboardLayout.present, ); - const showNativeFilters = useSelector( - state => state.dashboardInfo.metadata?.show_native_filters, - ); - const canEdit = useSelector( - ({ dashboardInfo }) => dashboardInfo.dash_edit_perm, - ); const editMode = useSelector( state => state.dashboardState.editMode, ); @@ -112,22 +105,6 @@ const DashboardBuilder: FC = () => { state => state.dashboardState.directPathToChild, ); - const filters = useFilters(); - const filterValues = Object.values(filters); - - const nativeFiltersEnabled = - showNativeFilters && - isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS) && - (canEdit || (!canEdit && filterValues.length !== 0)); - - const [dashboardFiltersOpen, setDashboardFiltersOpen] = useState( - getUrlParam(URL_PARAMS.showFilters) ?? true, - ); - - const toggleDashboardFiltersOpen = (visible?: boolean) => { - setDashboardFiltersOpen(visible ?? !dashboardFiltersOpen); - }; - const handleChangeTab = ({ pathToTabIndex, }: { @@ -161,15 +138,12 @@ const DashboardBuilder: FC = () => { (hideDashboardHeader ? 0 : HEADER_HEIGHT) + (topLevelTabs ? TABS_HEIGHT : 0); - useEffect(() => { - if ( - filterValues.length === 0 && - dashboardFiltersOpen && - nativeFiltersEnabled - ) { - toggleDashboardFiltersOpen(false); - } - }, [filterValues.length]); + const { + showDashboard, + dashboardFiltersOpen, + toggleDashboardFiltersOpen, + nativeFiltersEnabled, + } = useNativeFilters(); return ( = () => { )} - + {showDashboard ? ( + + ) : ( + + )} {editMode && } diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts b/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts new file mode 100644 index 000000000000..874525d93d48 --- /dev/null +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts @@ -0,0 +1,93 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useSelector } from 'react-redux'; +import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags'; +import { useEffect, useState } from 'react'; +import { URL_PARAMS } from 'src/constants'; +import { getUrlParam } from 'src/utils/urlUtils'; +import { RootState } from 'src/dashboard/types'; +import { + useFilters, + useNativeFiltersDataMask, +} from '../nativeFilters/FilterBar/state'; +import { Filter } from '../nativeFilters/types'; + +// eslint-disable-next-line import/prefer-default-export +export const useNativeFilters = () => { + const [isInitialized, setIsInitialized] = useState(false); + const [dashboardFiltersOpen, setDashboardFiltersOpen] = useState( + getUrlParam(URL_PARAMS.showFilters) ?? true, + ); + const showNativeFilters = useSelector( + state => state.dashboardInfo.metadata?.show_native_filters, + ); + const canEdit = useSelector( + ({ dashboardInfo }) => dashboardInfo.dash_edit_perm, + ); + + const filters = useFilters(); + const filterValues = Object.values(filters); + + const nativeFiltersEnabled = + showNativeFilters && + isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS) && + (canEdit || (!canEdit && filterValues.length !== 0)); + + const requiredFirstFilter = filterValues.filter( + ({ requiredFirst }) => requiredFirst, + ); + const dataMask = useNativeFiltersDataMask(); + const showDashboard = + isInitialized || + !nativeFiltersEnabled || + !( + nativeFiltersEnabled && + requiredFirstFilter.length && + requiredFirstFilter.find( + ({ id }) => dataMask[id]?.filterState?.value === undefined, + ) + ); + + const toggleDashboardFiltersOpen = (visible?: boolean) => { + setDashboardFiltersOpen(visible ?? !dashboardFiltersOpen); + }; + + useEffect(() => { + if ( + filterValues.length === 0 && + dashboardFiltersOpen && + nativeFiltersEnabled + ) { + toggleDashboardFiltersOpen(false); + } + }, [filterValues.length]); + + useEffect(() => { + if (showDashboard) { + setIsInitialized(true); + } + }, [showDashboard]); + + return { + showDashboard, + dashboardFiltersOpen, + toggleDashboardFiltersOpen, + nativeFiltersEnabled, + }; +}; diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx b/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx index c41abf83a4b0..f103653492f0 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx @@ -93,6 +93,10 @@ const StyledTabsContainer = styled.div` .ant-tabs { overflow: visible; + .ant-tabs-nav-wrap { + min-height: ${({ theme }) => theme.gridUnit * 12.5}px; + } + .ant-tabs-content-holder { overflow: visible; } diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx index 495f50f437bf..bb071315c628 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx @@ -74,7 +74,7 @@ const FilterValue: React.FC = ({ const { name: groupby } = column; const hasDataSource = !!datasetId; const [isLoading, setIsLoading] = useState(hasDataSource); - const [isRefreshing, setIsRefreshing] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(true); const dispatch = useDispatch(); useEffect(() => { const newFormData = getFormData({ diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx index 44a4f82aed72..631179b89829 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx @@ -18,7 +18,7 @@ */ /* eslint-disable no-param-reassign */ -import { HandlerFunction, styled, t } from '@superset-ui/core'; +import { DataMask, HandlerFunction, styled, t } from '@superset-ui/core'; import React, { useEffect, useState } from 'react'; import { useDispatch } from 'react-redux'; import cx from 'classnames'; @@ -26,11 +26,7 @@ import Icon from 'src/components/Icon'; import { Tabs } from 'src/common/components'; import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags'; import { updateDataMask } from 'src/dataMask/actions'; -import { - DataMaskState, - DataMaskStateWithId, - DataMaskWithId, -} from 'src/dataMask/types'; +import { DataMaskStateWithId, DataMaskWithId } from 'src/dataMask/types'; import { useImmer } from 'use-immer'; import { areObjectsEqual } from 'src/reduxUtils'; import { testWithId } from 'src/utils/testUtils'; @@ -178,10 +174,21 @@ const FilterBar: React.FC = ({ const handleFilterSelectionChange = ( filter: Pick & Partial, - dataMask: Partial, + dataMask: Partial, ) => { setIsFilterSetChanged(tab !== TabIds.AllFilters); setDataMaskSelected(draft => { + // force instant updating on initialization for filters with `requiredFirst` is true or instant filters + if ( + (dataMaskSelected[filter.id] && filter.isInstant) || + // filterState.value === undefined - means that value not initialized + (dataMask.filterState?.value !== undefined && + dataMaskSelected[filter.id]?.filterState?.value === undefined && + filter.requiredFirst) + ) { + dispatch(updateDataMask(filter.id, dataMask)); + } + draft[filter.id] = { ...(getInitialDataMask(filter.id) as DataMaskWithId), ...dataMask, diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/state.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/state.ts index 8edf7145000a..aa4894fd9ee6 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/state.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/state.ts @@ -76,6 +76,7 @@ export const useFilterUpdates = ( // Load filters after charts loaded export const useInitialization = () => { const [isInitialized, setIsInitialized] = useState(false); + const filters = useFilters(); const charts = useSelector(state => state.charts); // We need to know how much charts now shown on dashboard to know how many of all charts should be loaded @@ -90,6 +91,11 @@ export const useInitialization = () => { return; } + if (Object.values(filters).find(({ requiredFirst }) => requiredFirst)) { + setIsInitialized(true); + return; + } + // For some dashboards may be there are no charts on first page, // so we check up to 1 sec if there is at least on chart to load let filterTimeout: NodeJS.Timeout; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx index 76fa33bdc709..296ee8e6ad25 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx @@ -443,6 +443,7 @@ const FiltersConfigForm = ( filterId, filterType: formFilter.filterType, filterToEdit, + formFilter, }) : {}; @@ -592,6 +593,7 @@ const FiltersConfigForm = ( expandIconPosition="right" > @@ -625,7 +627,11 @@ const FiltersConfigForm = ( { validator: (rule, value) => { const hasValue = !!value.filterState?.value; - if (hasValue) { + if ( + hasValue || + // TODO: do more generic + formFilter.controlValues?.defaultToFirstItem + ) { return Promise.resolve(); } return Promise.reject( @@ -673,6 +679,7 @@ const FiltersConfigForm = ( {((hasDataset && hasAdditionalFilters) || hasMetrics) && ( diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.tsx index 15e877ee5747..097d80b8d84c 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.tsx @@ -23,10 +23,11 @@ import { import React from 'react'; import { Checkbox } from 'src/common/components'; import { FormInstance } from 'antd/lib/form'; -import { getChartControlPanelRegistry, t } from '@superset-ui/core'; +import { getChartControlPanelRegistry, styled, t } from '@superset-ui/core'; import { Tooltip } from 'src/components/Tooltip'; +import { FormItem } from 'src/components/Form'; import { getControlItems, setNativeFilterFieldValues } from './utils'; -import { NativeFiltersForm } from '../types'; +import { NativeFiltersForm, NativeFiltersFormItem } from '../types'; import { StyledRowFormItem } from './FiltersConfigForm'; import { Filter } from '../../types'; @@ -37,8 +38,13 @@ export interface ControlItemsProps { filterId: string; filterType: string; filterToEdit?: Filter; + formFilter?: NativeFiltersFormItem; } +const CleanFormItem = styled(FormItem)` + margin-bottom: 0; +`; + export default function getControlItemsMap({ disabled, forceUpdate, @@ -46,6 +52,7 @@ export default function getControlItemsMap({ filterId, filterType, filterToEdit, + formFilter, }: ControlItemsProps) { const controlPanelRegistry = getChartControlPanelRegistry(); const controlItems = @@ -66,46 +73,61 @@ export default function getControlItemsMap({ filterToEdit?.controlValues?.[controlItem.name] ?? controlItem?.config?.default; const element = ( - - + - + { + if (controlItem.config.requiredFirst) { + setNativeFilterFieldValues(form, filterId, { + requiredFirst: { + ...formFilter?.requiredFirst, + [controlItem.name]: checked, + }, + }); + } + if (controlItem.config.resetConfig) { + setNativeFilterFieldValues(form, filterId, { + defaultDataMask: null, + }); + } + forceUpdate(); + }} + > + {controlItem.config.label}{' '} + {controlItem.config.description && ( + + )} + + + + ); map[controlItem.name] = { element, checked: initialValue }; }); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/types.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/types.ts index 60051e73f0ab..0ba091f4eebc 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/types.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/types.ts @@ -31,6 +31,9 @@ export interface NativeFiltersFormItem { controlValues: { [key: string]: any; }; + requiredFirst: { + [key: string]: boolean; + }; defaultValue: any; defaultDataMask: DataMask; parentFilter: { diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts index 852d4e15f661..b218ad4944dc 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts @@ -140,6 +140,9 @@ export const createHandleSave = ( adhoc_filters: formInputs.adhoc_filters, time_range: formInputs.time_range, controlValues: formInputs.controlValues ?? {}, + requiredFirst: Object.values(formInputs.requiredFirst ?? {}).find( + rf => rf, + ), name: formInputs.name, filterType: formInputs.filterType, // for now there will only ever be one target diff --git a/superset-frontend/src/dashboard/components/nativeFilters/types.ts b/superset-frontend/src/dashboard/components/nativeFilters/types.ts index ac772dcd7349..a1e206bab073 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/types.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/types.ts @@ -56,6 +56,7 @@ export interface Filter { sortMetric?: string | null; adhoc_filters?: AdhocFilter[]; time_range?: string; + requiredFirst?: boolean; tabsInScope?: string[]; chartsInScope?: number[]; } diff --git a/superset-frontend/src/dashboard/containers/DashboardPage.tsx b/superset-frontend/src/dashboard/containers/DashboardPage.tsx index 580df62b3e0d..e5897d18c464 100644 --- a/superset-frontend/src/dashboard/containers/DashboardPage.tsx +++ b/superset-frontend/src/dashboard/containers/DashboardPage.tsx @@ -26,7 +26,6 @@ import { useDashboardDatasets, } from 'src/common/hooks/apiResources'; import { ResourceStatus } from 'src/common/hooks/apiResources/apiResources'; -import { usePrevious } from 'src/common/hooks/usePrevious'; import { hydrateDashboard } from 'src/dashboard/actions/hydrate'; import injectCustomCss from 'src/dashboard/util/injectCustomCss'; @@ -42,14 +41,10 @@ const DashboardContainer = React.lazy( const DashboardPage: FC = () => { const dispatch = useDispatch(); const { idOrSlug } = useParams<{ idOrSlug: string }>(); - const [isLoaded, setLoaded] = useState(false); + const [isHydrated, setHydrated] = useState(false); const dashboardResource = useDashboard(idOrSlug); const chartsResource = useDashboardCharts(idOrSlug); const datasetsResource = useDashboardDatasets(idOrSlug); - const isLoading = [dashboardResource, chartsResource, datasetsResource].some( - resource => resource.status === ResourceStatus.LOADING, - ); - const wasLoading = usePrevious(isLoading); const error = [dashboardResource, chartsResource, datasetsResource].find( resource => resource.status === ResourceStatus.ERROR, )?.error; @@ -57,16 +52,22 @@ const DashboardPage: FC = () => { useEffect(() => { if (dashboardResource.result) { document.title = dashboardResource.result.dashboard_title; + if (dashboardResource.result.css) { + // returning will clean up custom css + // when dashboard unmounts or changes + return injectCustomCss(dashboardResource.result.css); + } } + return () => {}; }, [dashboardResource.result]); + const shouldBeHydrated = + dashboardResource.status === ResourceStatus.COMPLETE && + chartsResource.status === ResourceStatus.COMPLETE && + datasetsResource.status === ResourceStatus.COMPLETE; + useEffect(() => { - if ( - wasLoading && - dashboardResource.status === ResourceStatus.COMPLETE && - chartsResource.status === ResourceStatus.COMPLETE && - datasetsResource.status === ResourceStatus.COMPLETE - ) { + if (shouldBeHydrated) { dispatch( hydrateDashboard( dashboardResource.result, @@ -74,20 +75,13 @@ const DashboardPage: FC = () => { datasetsResource.result, ), ); - injectCustomCss(dashboardResource.result.css); - setLoaded(true); + setHydrated(true); } - }, [ - dispatch, - wasLoading, - dashboardResource, - chartsResource, - datasetsResource, - ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shouldBeHydrated]); if (error) throw error; // caught in error boundary - - if (!isLoaded) return ; + if (!isHydrated) return ; return ; }; diff --git a/superset-frontend/src/dashboard/util/injectCustomCss.js b/superset-frontend/src/dashboard/util/injectCustomCss.ts similarity index 66% rename from superset-frontend/src/dashboard/util/injectCustomCss.js rename to superset-frontend/src/dashboard/util/injectCustomCss.ts index 4f6238aad522..b7db2b03d020 100644 --- a/superset-frontend/src/dashboard/util/injectCustomCss.js +++ b/superset-frontend/src/dashboard/util/injectCustomCss.ts @@ -16,19 +16,31 @@ * specific language governing permissions and limitations * under the License. */ -export default function injectCustomCss(css) { + +function createStyleElement(className: string) { + const style = document.createElement('style'); + style.className = className; + style.type = 'text/css'; + return style; +} + +// The original, non-typescript code referenced `style.styleSheet`. +// I can't find what sort of element would have a styleSheet property, +// so have created this type to satisfy TS without changing behavior. +type MysteryStyleElement = { + styleSheet: { + cssText: string; + }; +}; + +export default function injectCustomCss(css: string) { const className = 'CssEditor-css'; const head = document.head || document.getElementsByTagName('head')[0]; - let style = document.querySelector(`.${className}`); + const style: HTMLStyleElement = + document.querySelector(`.${className}`) || createStyleElement(className); - if (!style) { - style = document.createElement('style'); - style.className = className; - style.type = 'text/css'; - } - - if (style.styleSheet) { - style.styleSheet.cssText = css; + if ('styleSheet' in style) { + (style as MysteryStyleElement).styleSheet.cssText = css; } else { style.innerHTML = css; } @@ -45,4 +57,8 @@ export default function injectCustomCss(css) { */ head.appendChild(style); + + return function removeCustomCSS() { + style.remove(); + }; } diff --git a/superset-frontend/src/dataMask/reducer.ts b/superset-frontend/src/dataMask/reducer.ts index a0b0e38609db..275787e0dba1 100644 --- a/superset-frontend/src/dataMask/reducer.ts +++ b/superset-frontend/src/dataMask/reducer.ts @@ -49,7 +49,7 @@ export function getInitialDataMask(id: string): DataMaskWithId { ...otherProps, extraFormData: {}, filterState: { - value: null, + value: undefined, }, ownState: {}, } as DataMaskWithId; diff --git a/superset-frontend/src/datasource/DatasourceEditor.jsx b/superset-frontend/src/datasource/DatasourceEditor.jsx index 94d3cd976769..3fb02364398c 100644 --- a/superset-frontend/src/datasource/DatasourceEditor.jsx +++ b/superset-frontend/src/datasource/DatasourceEditor.jsx @@ -936,7 +936,18 @@ class DatasourceEditor extends React.PureComponent { } - collection={this.state.datasource.metrics} + collection={this.state.datasource.metrics?.map(metric => { + const { + certification: { details, certified_by: certifiedBy } = {}, + warning_markdown: warningMarkdown, + } = JSON.parse(metric.extra || '{}') || {}; + return { + ...metric, + certification_details: details || '', + warning_markdown: warningMarkdown || '', + certified_by: certifiedBy, + }; + })} allowAddItem onChange={this.onDatasourcePropChange.bind(this, 'metrics')} itemGenerator={() => ({ diff --git a/superset-frontend/src/explore/components/ControlHeader.jsx b/superset-frontend/src/explore/components/ControlHeader.jsx index 64cc7c205eeb..c1b073459cf0 100644 --- a/superset-frontend/src/explore/components/ControlHeader.jsx +++ b/superset-frontend/src/explore/components/ControlHeader.jsx @@ -98,7 +98,6 @@ export default class ControlHeader extends React.Component { css={{ marginBottom: 0, position: 'relative', - whiteSpace: 'nowrap', }} > {this.props.leftNode && {this.props.leftNode}} diff --git a/superset-frontend/src/explore/components/controls/DateFilterControl/components/CustomFrame.tsx b/superset-frontend/src/explore/components/controls/DateFilterControl/components/CustomFrame.tsx index fe486c3ef86d..b9269a440487 100644 --- a/superset-frontend/src/explore/components/controls/DateFilterControl/components/CustomFrame.tsx +++ b/superset-frontend/src/explore/components/controls/DateFilterControl/components/CustomFrame.tsx @@ -130,7 +130,7 @@ export function CustomFrame(props: FrameComponentProps) { onChange('sinceDatetime', datetime.format(MOMENT_FORMAT)) } @@ -188,7 +188,7 @@ export function CustomFrame(props: FrameComponentProps) { onChange('untilDatetime', datetime.format(MOMENT_FORMAT)) } @@ -247,7 +247,7 @@ export function CustomFrame(props: FrameComponentProps) { onChange('anchorValue', datetime.format(MOMENT_FORMAT)) } diff --git a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx index c42c1ce3e10b..e2ed21c90bc9 100644 --- a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx +++ b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx @@ -30,7 +30,7 @@ const selectMultipleProps = { enableEmptyFilter: true, defaultToFirstItem: false, inverseSelection: false, - searchAllOptions: true, + searchAllOptions: false, datasource: '3__table', groupby: ['gender'], adhocFilters: [], @@ -48,7 +48,7 @@ const selectMultipleProps = { }, height: 20, hooks: {}, - ownState: { coltypeMap: { gender: 1 }, search: null }, + ownState: {}, filterState: { value: ['boy'] }, queriesData: [ { @@ -88,16 +88,15 @@ describe('SelectFilterPlugin', () => { it('Add multiple values with first render', () => { getWrapper(); expect(setDataMask).toHaveBeenCalledWith({ + extraFormData: {}, filterState: { value: ['boy'], }, - ownState: { - coltypeMap: { - gender: 1, - }, - }, }); expect(setDataMask).toHaveBeenCalledWith({ + __cache: { + value: ['boy'], + }, extraFormData: { filters: [ { @@ -111,15 +110,13 @@ describe('SelectFilterPlugin', () => { label: 'boy', value: ['boy'], }, - ownState: { - coltypeMap: { - gender: 1, - }, - }, }); userEvent.click(screen.getByRole('combobox')); userEvent.click(screen.getByTitle('girl')); expect(setDataMask).toHaveBeenCalledWith({ + __cache: { + value: ['boy'], + }, extraFormData: { filters: [ { @@ -133,12 +130,6 @@ describe('SelectFilterPlugin', () => { label: 'boy, girl', value: ['boy', 'girl'], }, - ownState: { - coltypeMap: { - gender: 1, - }, - search: null, - }, }); }); @@ -146,6 +137,9 @@ describe('SelectFilterPlugin', () => { getWrapper(); userEvent.click(document.querySelector('[data-icon="close"]')!); expect(setDataMask).toHaveBeenCalledWith({ + __cache: { + value: ['boy'], + }, extraFormData: { adhoc_filters: [ { @@ -159,11 +153,6 @@ describe('SelectFilterPlugin', () => { label: '', value: null, }, - ownState: { - coltypeMap: { - gender: 1, - }, - }, }); }); @@ -171,16 +160,14 @@ describe('SelectFilterPlugin', () => { getWrapper({ enableEmptyFilter: false }); userEvent.click(document.querySelector('[data-icon="close"]')!); expect(setDataMask).toHaveBeenCalledWith({ + __cache: { + value: ['boy'], + }, extraFormData: {}, filterState: { label: '', value: null, }, - ownState: { - coltypeMap: { - gender: 1, - }, - }, }); }); @@ -189,6 +176,9 @@ describe('SelectFilterPlugin', () => { userEvent.click(screen.getByRole('combobox')); userEvent.click(screen.getByTitle('girl')); expect(setDataMask).toHaveBeenCalledWith({ + __cache: { + value: ['boy'], + }, extraFormData: { filters: [ { @@ -202,6 +192,30 @@ describe('SelectFilterPlugin', () => { label: 'girl (excluded)', value: ['girl'], }, + }); + }); + + it('Add ownState with column types when search all options', () => { + getWrapper({ searchAllOptions: true, multiSelect: false }); + userEvent.click(screen.getByRole('combobox')); + userEvent.click(screen.getByTitle('girl')); + expect(setDataMask).toHaveBeenCalledWith({ + __cache: { + value: ['boy'], + }, + extraFormData: { + filters: [ + { + col: 'gender', + op: 'IN', + val: ['girl'], + }, + ], + }, + filterState: { + label: 'girl', + value: ['girl'], + }, ownState: { coltypeMap: { gender: 1, diff --git a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx index 2f7d5651c016..305667c2947b 100644 --- a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx +++ b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +/* eslint-disable no-param-reassign */ import { AppSection, DataMask, @@ -28,16 +29,11 @@ import { t, tn, } from '@superset-ui/core'; -import React, { - useCallback, - useEffect, - useMemo, - useReducer, - useState, -} from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Select } from 'src/common/components'; import debounce from 'lodash/debounce'; import { SLOW_DEBOUNCE } from 'src/constants'; +import { useImmerReducer } from 'use-immer'; import Icons from 'src/components/Icons'; import { PluginFilterSelectProps, SelectValue } from './types'; import { StyledSelect, Styles } from '../common'; @@ -49,41 +45,33 @@ type DataMaskAction = | { type: 'ownState'; ownState: JsonObject } | { type: 'filterState'; + __cache: JsonObject; extraFormData: ExtraFormData; filterState: { value: SelectValue; label?: string }; }; -function reducer(state: DataMask, action: DataMaskAction): DataMask { +function reducer( + draft: DataMask & { __cache?: JsonObject }, + action: DataMaskAction, +) { switch (action.type) { case 'ownState': - return { - ...state, - ownState: { - ...(state.ownState || {}), - ...action.ownState, - }, + draft.ownState = { + ...draft.ownState, + ...action.ownState, }; + return draft; case 'filterState': - return { - ...state, - extraFormData: action.extraFormData, - filterState: { - ...(state.filterState || {}), - ...action.filterState, - }, - }; + draft.extraFormData = action.extraFormData; + // eslint-disable-next-line no-underscore-dangle + draft.__cache = action.__cache; + draft.filterState = { ...draft.filterState, ...action.filterState }; + return draft; default: - return { - ...state, - }; + return draft; } } -type DataMaskReducer = ( - prevState: DataMask, - action: DataMaskAction, -) => DataMask; - export default function PluginFilterSelect(props: PluginFilterSelectProps) { const { coltypeMap, @@ -109,6 +97,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { } = formData; const groupby = ensureIsArray(formData.groupby); const [col] = groupby; + const [initialColtypeMap] = useState(coltypeMap); const [selectedValues, setSelectedValues] = useState( filterState.value, ); @@ -127,32 +116,46 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { }, [col, selectedValues, data]); const [isDropdownVisible, setIsDropdownVisible] = useState(false); const [currentSuggestionSearch, setCurrentSuggestionSearch] = useState(''); - const [dataMask, dispatchDataMask] = useReducer(reducer, { + const [dataMask, dispatchDataMask] = useImmerReducer(reducer, { + extraFormData: {}, filterState, - ownState: { - coltypeMap, - }, }); - const updateDataMask = (values: SelectValue) => { - const emptyFilter = - enableEmptyFilter && !inverseSelection && !values?.length; - const suffix = - inverseSelection && values?.length ? ` (${t('excluded')})` : ''; + const updateDataMask = useCallback( + (values: SelectValue) => { + const emptyFilter = + enableEmptyFilter && !inverseSelection && !values?.length; - dispatchDataMask({ - type: 'filterState', - extraFormData: getSelectExtraFormData( - col, - values, - emptyFilter, - inverseSelection, - ), - filterState: { - value: values, - label: `${(values || []).join(', ')}${suffix}`, - }, - }); - }; + const suffix = + inverseSelection && values?.length ? ` (${t('excluded')})` : ''; + + dispatchDataMask({ + type: 'filterState', + __cache: filterState, + extraFormData: getSelectExtraFormData( + col, + values, + emptyFilter, + inverseSelection, + ), + filterState: { + label: `${(values || []).join(', ')}${suffix}`, + value: + appSection === AppSection.FILTER_CONFIG_MODAL && defaultToFirstItem + ? undefined + : values, + }, + }); + }, + [ + appSection, + col, + defaultToFirstItem, + dispatchDataMask, + enableEmptyFilter, + inverseSelection, + JSON.stringify(filterState), + ], + ); useEffect(() => { if (!isDropdownVisible) { @@ -169,6 +172,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { dispatchDataMask({ type: 'ownState', ownState: { + coltypeMap: initialColtypeMap, search: val, }, }); @@ -189,6 +193,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { dispatchDataMask({ type: 'ownState', ownState: { + coltypeMap: initialColtypeMap, search: null, }, }); @@ -216,15 +221,19 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { }; useEffect(() => { - const firstItem: SelectValue = data[0] - ? (groupby.map(col => data[0][col]) as string[]) - : null; - if (isDisabled) { + if (defaultToFirstItem && filterState.value === undefined) { + // initialize to first value if set to default to first item + const firstItem: SelectValue = data[0] + ? (groupby.map(col => data[0][col]) as string[]) + : null; + // firstItem[0] !== undefined for a case when groupby changed but new data still not fetched + // TODO: still need repopulate default value in config modal when column changed + if (firstItem && firstItem[0] !== undefined) { + updateDataMask(firstItem); + } + } else if (isDisabled) { // empty selection if filter is disabled updateDataMask(null); - } else if (!isDisabled && defaultToFirstItem && firstItem) { - // initialize to first value if set to default to first item - updateDataMask(firstItem); } else { // reset data mask based on filter state updateDataMask(filterState.value); @@ -235,6 +244,10 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { defaultToFirstItem, enableEmptyFilter, inverseSelection, + updateDataMask, + data, + groupby, + JSON.stringify(filterState), ]); useEffect(() => { diff --git a/superset-frontend/src/filters/components/Select/controlPanel.ts b/superset-frontend/src/filters/components/Select/controlPanel.ts index 335e44483f88..74891b0ed946 100644 --- a/superset-frontend/src/filters/components/Select/controlPanel.ts +++ b/superset-frontend/src/filters/components/Select/controlPanel.ts @@ -93,7 +93,10 @@ const config: ControlPanelConfig = { resetConfig: true, affectsDataMask: true, renderTrigger: true, - description: t('Select first item by default'), + requiredFirst: true, + description: t( + 'Select first item by default (when using this option, default value can’t be set)', + ), }, }, ], diff --git a/superset-frontend/src/filters/components/Select/types.ts b/superset-frontend/src/filters/components/Select/types.ts index aac5aa905a88..36052e8696a9 100644 --- a/superset-frontend/src/filters/components/Select/types.ts +++ b/superset-frontend/src/filters/components/Select/types.ts @@ -29,7 +29,7 @@ import { import { RefObject } from 'react'; import { PluginFilterHooks, PluginFilterStylesProps } from '../types'; -export type SelectValue = (number | string)[] | null; +export type SelectValue = (number | string)[] | null | undefined; interface PluginFilterSelectCustomizeProps { defaultValue?: SelectValue; diff --git a/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx b/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx index 008a39e6575f..9ab653da5c04 100644 --- a/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx +++ b/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx @@ -1070,7 +1070,7 @@ const AlertReportModal: FunctionComponent = ({
- {t('Source')} + {t('Database')} *
diff --git a/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx b/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx index fd18765bf0de..7b1da3621472 100644 --- a/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx +++ b/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx @@ -275,7 +275,7 @@ const DatasetList: FunctionComponent = ({ size: 'md', }, { - Header: t('Source'), + Header: t('Database'), accessor: 'database.database_name', size: 'lg', }, diff --git a/superset-frontend/src/visualizations/FilterBox/controlPanel.jsx b/superset-frontend/src/visualizations/FilterBox/controlPanel.jsx index 51c41d5c35ed..34a814a43efe 100644 --- a/superset-frontend/src/visualizations/FilterBox/controlPanel.jsx +++ b/superset-frontend/src/visualizations/FilterBox/controlPanel.jsx @@ -20,6 +20,36 @@ import React from 'react'; import { t } from '@superset-ui/core'; import { sections } from '@superset-ui/chart-controls'; +const appContainer = document.getElementById('app'); +const bootstrapData = JSON.parse(appContainer.getAttribute('data-bootstrap')); +const druidIsActive = !!bootstrapData?.common?.conf?.DRUID_IS_ACTIVE; +const druidSection = druidIsActive + ? [ + [ + { + name: 'show_druid_time_granularity', + config: { + type: 'CheckboxControl', + label: t('Show Druid granularity dropdown'), + default: false, + description: t('Check to include Druid granularity dropdown'), + }, + }, + ], + [ + { + name: 'show_druid_time_origin', + config: { + type: 'CheckboxControl', + label: t('Show Druid time origin'), + default: false, + description: t('Check to include time origin dropdown'), + }, + }, + ], + ] + : []; + export default { controlPanelSections: [ sections.legacyTimeseriesTime, @@ -51,6 +81,8 @@ export default { description: t('Whether to include a time filter'), }, }, + ], + [ { name: 'instant_filtering', config: { @@ -69,41 +101,30 @@ export default { name: 'show_sqla_time_granularity', config: { type: 'CheckboxControl', - label: t('Show SQL granularity dropdown'), + label: druidIsActive + ? t('Show SQL time grain dropdown') + : t('Show time grain dropdown'), default: false, - description: t('Check to include SQL granularity dropdown'), - }, - }, - { - name: 'show_sqla_time_column', - config: { - type: 'CheckboxControl', - label: t('Show SQL time column'), - default: false, - description: t('Check to include time column dropdown'), + description: druidIsActive + ? t('Check to include SQL time grain dropdown') + : t('Check to include time grain dropdown'), }, }, ], [ { - name: 'show_druid_time_granularity', - config: { - type: 'CheckboxControl', - label: t('Show Druid granularity dropdown'), - default: false, - description: t('Check to include Druid granularity dropdown'), - }, - }, - { - name: 'show_druid_time_origin', + name: 'show_sqla_time_column', config: { type: 'CheckboxControl', - label: t('Show Druid time origin'), + label: druidIsActive + ? t('Show SQL time column') + : t('Show time column'), default: false, - description: t('Check to include time origin dropdown'), + description: t('Check to include time column dropdown'), }, }, ], + ...druidSection, ['adhoc_filters'], ], }, diff --git a/superset/db_engine_specs/base.py b/superset/db_engine_specs/base.py index 75d0953260c6..3f123c1e394f 100644 --- a/superset/db_engine_specs/base.py +++ b/superset/db_engine_specs/base.py @@ -1254,6 +1254,14 @@ def is_readonly_query(cls, parsed_query: ParsedQuery) -> bool: or parsed_query.is_show() ) + @classmethod + def is_select_query(cls, parsed_query: ParsedQuery) -> bool: + """ + Determine if the statement should be considered as SELECT statement. + Some query dialects do not contain "SELECT" word in queries (eg. Kusto) + """ + return parsed_query.is_select() + @classmethod @utils.memoized def get_column_spec( diff --git a/superset/sql_lab.py b/superset/sql_lab.py index 832962760bea..30cec61c929b 100644 --- a/superset/sql_lab.py +++ b/superset/sql_lab.py @@ -217,7 +217,7 @@ def execute_sql_statement( query.select_as_cta_used = True # Do not apply limit to the CTA queries when SQLLAB_CTAS_NO_LIMIT is set to true - if parsed_query.is_select() and not ( + if db_engine_spec.is_select_query(parsed_query) and not ( query.select_as_cta_used and SQLLAB_CTAS_NO_LIMIT ): if SQL_MAX_ROW and (not query.limit or query.limit > SQL_MAX_ROW): diff --git a/superset/views/base.py b/superset/views/base.py index 74e05db5402a..949406a999f2 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -87,6 +87,7 @@ "SUPERSET_DASHBOARD_PERIODICAL_REFRESH_LIMIT", "SUPERSET_DASHBOARD_PERIODICAL_REFRESH_WARNING_MESSAGE", "DISABLE_DATASET_SOURCE_EDIT", + "DRUID_IS_ACTIVE", "ENABLE_JAVASCRIPT_CONTROLS", "DEFAULT_SQLLAB_LIMIT", "DEFAULT_VIZ_TYPE",