diff --git a/docs/installation.rst b/docs/installation.rst index 7dff638b041e4..edfedce43965d 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -433,7 +433,7 @@ For other strategies, check the `superset/tasks/cache.py` file. Caching Thumbnails ------------------ -This is an optional feature that can be turned on by activating it's feature flag on config: +This is an optional feature that can be turned on by activating its feature flag on config: .. code-block:: python @@ -972,7 +972,7 @@ environment variable: :: Event Logging ------------- -Superset by default logs special action event on it's database. These log can be accessed on the UI navigating to +Superset by default logs special action event on its database. These logs can be accessed on the UI navigating to "Security" -> "Action Log". You can freely customize these logs by implementing your own event log class. Example of a simple JSON to Stdout class:: @@ -1358,7 +1358,7 @@ The available validators and names can be found in `sql_validators/`. **Scheduling queries** You can optionally allow your users to schedule queries directly in SQL Lab. -This is done by addding extra metadata to saved queries, which are then picked +This is done by adding extra metadata to saved queries, which are then picked up by an external scheduled (like [Apache Airflow](https://airflow.apache.org/)). To allow scheduled queries, add the following to your `config.py`: diff --git a/docs/src/pages/docs/installation/configuring.mdx b/docs/src/pages/docs/installation/configuring.mdx index c725bf5950311..daea083d2ead6 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 @@ -134,7 +136,7 @@ OAUTH_PROVIDERS = [ 'access_token_headers':{ # Additional headers for calls to access_token_url 'Authorization': 'Basic Base64EncodedClientIdAndSecret' }, - 'base_url':'https://myAuthorizationServer/oauth2AuthorizationServer/', + 'api_base_url':'https://myAuthorizationServer/oauth2AuthorizationServer/', 'access_token_url':'https://myAuthorizationServer/oauth2AuthorizationServer/token', 'authorize_url':'https://myAuthorizationServer/oauth2AuthorizationServer/authorize' } @@ -175,6 +177,29 @@ from custom_sso_security_manager import CustomSsoSecurityManager CUSTOM_SECURITY_MANAGER = CustomSsoSecurityManager ``` +**Notes** + +- 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. + +- If an OAuth2 authorization server supports OpenID Connect 1.0, you could configure its configuration + document URL only without providing `api_base_url`, `access_token_url`, `authorize_url` and other + required options like user info endpoint, jwks uri etc. For instance: + ```python + OAUTH_PROVIDERS = [ + { 'name':'egaSSO', + 'token_key':'access_token', # Name of the token in the response of access_token_url + 'icon':'fa-address-card', # Icon for the provider + 'remote_app': { + 'client_id':'myClientId', # Client Id (Identify Superset application) + 'client_secret':'MySecret', # Secret for this Client Id (Identify Superset application) + 'server_metadata_url': 'https://myAuthorizationServer/.well-known/openid-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 601753917ad2b..81bbf600a28ab 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/scripts/benchmark_migration.py b/scripts/benchmark_migration.py index 0faa92a88552b..d226efbfd3058 100644 --- a/scripts/benchmark_migration.py +++ b/scripts/benchmark_migration.py @@ -25,10 +25,12 @@ from typing import Dict, List, Set, Type import click +from flask import current_app from flask_appbuilder import Model from flask_migrate import downgrade, upgrade from graphlib import TopologicalSorter # pylint: disable=wrong-import-order -from sqlalchemy import inspect +from sqlalchemy import create_engine, inspect, Table +from sqlalchemy.ext.automap import automap_base from superset import db from superset.utils.mock_data import add_sample_rows @@ -83,11 +85,18 @@ def find_models(module: ModuleType) -> List[Type[Model]]: elif isinstance(obj, dict): queue.extend(obj.values()) - # add implicit models - # pylint: disable=no-member, protected-access - for obj in Model._decl_class_registry.values(): - if hasattr(obj, "__table__") and obj.__table__.fullname in tables: - models.append(obj) + # build models by automapping the existing tables, instead of using current + # code; this is needed for migrations that modify schemas (eg, add a column), + # where the current model is out-of-sync with the existing table after a + # downgrade + sqlalchemy_uri = current_app.config["SQLALCHEMY_DATABASE_URI"] + engine = create_engine(sqlalchemy_uri) + Base = automap_base() + Base.prepare(engine, reflect=True) + for table in tables: + model = getattr(Base.classes, table) + model.__tablename__ = table + models.append(model) # sort topologically so we can create entities in order and # maintain relationships (eg, create a database before creating @@ -133,15 +142,6 @@ def main( ).scalar() print(f"Current version of the DB is {current_revision}") - print("\nIdentifying models used in the migration:") - models = find_models(module) - model_rows: Dict[Type[Model], int] = {} - for model in models: - rows = session.query(model).count() - print(f"- {model.__name__} ({rows} rows in table {model.__tablename__})") - model_rows[model] = rows - session.close() - if current_revision != down_revision: if not force: click.confirm( @@ -152,6 +152,15 @@ def main( ) downgrade(revision=down_revision) + print("\nIdentifying models used in the migration:") + models = find_models(module) + model_rows: Dict[Type[Model], int] = {} + for model in models: + rows = session.query(model).count() + print(f"- {model.__name__} ({rows} rows in table {model.__tablename__})") + model_rows[model] = rows + session.close() + print("Benchmarking migration") results: Dict[str, float] = {} start = time.time() diff --git a/superset-frontend/images/icons/default_db_image.svg b/superset-frontend/images/icons/default_db_image.svg new file mode 100644 index 0000000000000..3cd24f4888d8c --- /dev/null +++ b/superset-frontend/images/icons/default_db_image.svg @@ -0,0 +1,22 @@ + + + + + diff --git a/superset-frontend/spec/fixtures/mockDatasource.js b/superset-frontend/spec/fixtures/mockDatasource.js index 99183edd5185b..e434ad73b0676 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 2ab5ea296bd59..39bcfad8658ec 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 b3bbed99cdba0..d23fd49ca6d24 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/components/ErrorMessage/DatabaseErrorMessage.test.tsx b/superset-frontend/src/components/ErrorMessage/DatabaseErrorMessage.test.tsx index 0b23866352142..6959c5351a7ee 100644 --- a/superset-frontend/src/components/ErrorMessage/DatabaseErrorMessage.test.tsx +++ b/superset-frontend/src/components/ErrorMessage/DatabaseErrorMessage.test.tsx @@ -44,6 +44,7 @@ const mockedProps = { message: 'Error message', }, source: 'dashboard' as ErrorSource, + subtitle: 'Error message', }; test('should render', () => { diff --git a/superset-frontend/src/components/ErrorMessage/DatabaseErrorMessage.tsx b/superset-frontend/src/components/ErrorMessage/DatabaseErrorMessage.tsx index 0454aca59ef0f..5798f3b4106fc 100644 --- a/superset-frontend/src/components/ErrorMessage/DatabaseErrorMessage.tsx +++ b/superset-frontend/src/components/ErrorMessage/DatabaseErrorMessage.tsx @@ -35,6 +35,7 @@ interface DatabaseErrorExtra { function DatabaseErrorMessage({ error, source = 'dashboard', + subtitle, }: ErrorMessageComponentProps) { const { extra, level, message } = error; @@ -81,7 +82,7 @@ ${extra.issue_codes.map(issueCode => issueCode.message).join('\n')}`; return ( ; + return ( + + ); } } diff --git a/superset-frontend/src/components/ErrorMessage/ParameterErrorMessage.test.tsx b/superset-frontend/src/components/ErrorMessage/ParameterErrorMessage.test.tsx index d4664d53c62e6..17f38c4d23c57 100644 --- a/superset-frontend/src/components/ErrorMessage/ParameterErrorMessage.test.tsx +++ b/superset-frontend/src/components/ErrorMessage/ParameterErrorMessage.test.tsx @@ -44,6 +44,7 @@ const mockedProps = { message: 'Error message', }, source: 'dashboard' as ErrorSource, + subtitle: 'Error message', }; test('should render', () => { diff --git a/superset-frontend/src/components/ErrorMessage/ParameterErrorMessage.tsx b/superset-frontend/src/components/ErrorMessage/ParameterErrorMessage.tsx index 1eb44371ed7e6..666ad32169ade 100644 --- a/superset-frontend/src/components/ErrorMessage/ParameterErrorMessage.tsx +++ b/superset-frontend/src/components/ErrorMessage/ParameterErrorMessage.tsx @@ -54,6 +54,7 @@ const findMatches = (undefinedParameters: string[], templateKeys: string[]) => { function ParameterErrorMessage({ error, source = 'sqllab', + subtitle, }: ErrorMessageComponentProps) { const { extra, level, message } = error; @@ -112,7 +113,7 @@ ${extra.issue_codes.map(issueCode => issueCode.message).join('\n')}`; return ( = { error: SupersetError; source?: ErrorSource; + subtitle?: React.ReactNode; }; export type ErrorMessageComponent = React.ComponentType; diff --git a/superset-frontend/src/components/Form/LabeledErrorBoundInput.tsx b/superset-frontend/src/components/Form/LabeledErrorBoundInput.tsx index 75df2bb088cbb..f71df9d18ebae 100644 --- a/superset-frontend/src/components/Form/LabeledErrorBoundInput.tsx +++ b/superset-frontend/src/components/Form/LabeledErrorBoundInput.tsx @@ -62,6 +62,11 @@ const alertIconStyles = (theme: SupersetTheme, hasError: boolean) => css` }`} `; const StyledFormGroup = styled('div')` + input::-webkit-outer-spin-button, + input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } margin-bottom: ${({ theme }) => theme.gridUnit * 5}px; .ant-form-item { margin-bottom: 0; diff --git a/superset-frontend/src/components/Icon/index.tsx b/superset-frontend/src/components/Icon/index.tsx index 693354d9a7367..e83dde1131381 100644 --- a/superset-frontend/src/components/Icon/index.tsx +++ b/superset-frontend/src/components/Icon/index.tsx @@ -145,6 +145,7 @@ import { ReactComponent as WarningIcon } from 'images/icons/warning.svg'; import { ReactComponent as WarningSolidIcon } from 'images/icons/warning_solid.svg'; import { ReactComponent as XLargeIcon } from 'images/icons/x-large.svg'; import { ReactComponent as XSmallIcon } from 'images/icons/x-small.svg'; +import { ReactComponent as DefaultDatabaseIcon } from 'images/icons/default_db_image.svg'; export type IconName = | 'alert' @@ -184,6 +185,7 @@ export type IconName = | 'copy' | 'cursor-target' | 'database' + | 'default-database' | 'dataset-physical' | 'dataset-virtual' | 'dataset-virtual-greyscale' @@ -299,6 +301,7 @@ export const iconsRegistry: Record< 'circle-check-solid': CircleCheckSolidIcon, 'color-palette': ColorPaletteIcon, 'cursor-target': CursorTargeIcon, + 'default-database': DefaultDatabaseIcon, 'dataset-physical': DatasetPhysicalIcon, 'dataset-virtual': DatasetVirtualIcon, 'dataset-virtual-greyscale': DatasetVirtualGreyscaleIcon, diff --git a/superset-frontend/src/components/IconButton/index.tsx b/superset-frontend/src/components/IconButton/index.tsx index fb10b77a4a2a3..164d93e67d86f 100644 --- a/superset-frontend/src/components/IconButton/index.tsx +++ b/superset-frontend/src/components/IconButton/index.tsx @@ -17,9 +17,10 @@ * under the License. */ import React from 'react'; -import { styled } from '@superset-ui/core'; +import { styled, supersetTheme } from '@superset-ui/core'; import Button from 'src/components/Button'; import { ButtonProps as AntdButtonProps } from 'antd/lib/button'; +import Icon from 'src/components/Icon'; export interface IconButtonProps extends AntdButtonProps { buttonText: string; @@ -35,7 +36,6 @@ const StyledButton = styled(Button)` width: 33%; `; const StyledImage = styled.div` - margin: ${({ theme }) => theme.gridUnit * 8}px 0; padding: ${({ theme }) => theme.gridUnit * 4}px; &:first-of-type { @@ -43,7 +43,8 @@ const StyledImage = styled.div` } img { - width: fit-content; + width: 100%; + height: 100%; &:first-of-type { margin-right: 0; @@ -82,7 +83,6 @@ const StyledBottom = styled.div` background-color: ${({ theme }) => theme.colors.grayscale.light4}; width: 100%; line-height: 1.5em; - overflow: hidden; white-space: no-wrap; text-overflow: ellipsis; @@ -95,8 +95,17 @@ const IconButton = styled( ({ icon, altText, buttonText, ...props }: IconButtonProps) => ( - {altText} + {icon && {altText}} + {!icon && ( + + )} + {buttonText} diff --git a/superset-frontend/src/components/ListView/ListView.tsx b/superset-frontend/src/components/ListView/ListView.tsx index 8d9182ac70e4c..22729bd192e46 100644 --- a/superset-frontend/src/components/ListView/ListView.tsx +++ b/superset-frontend/src/components/ListView/ListView.tsx @@ -114,6 +114,10 @@ const BulkSelectWrapper = styled(Alert)` vertical-align: middle; position: relative; } + + .ant-alert-close-icon { + margin-top: ${({ theme }) => theme.gridUnit * 1.5}px; + } `; const bulkSelectColumnConfig = { diff --git a/superset-frontend/src/components/Menu/Menu.tsx b/superset-frontend/src/components/Menu/Menu.tsx index 0875b85f8a661..38c826b5ce4af 100644 --- a/superset-frontend/src/components/Menu/Menu.tsx +++ b/superset-frontend/src/components/Menu/Menu.tsx @@ -91,6 +91,9 @@ const StyledHeader = styled.header` flex-direction: column; justify-content: center; } + .main-nav .ant-menu-submenu-title > svg { + top: ${({ theme }) => theme.gridUnit * 5.25}px; + } @media (max-width: 767px) { .navbar-brand { float: none; diff --git a/superset-frontend/src/components/Menu/MenuRight.tsx b/superset-frontend/src/components/Menu/MenuRight.tsx index a722834bdf442..0507097343be4 100644 --- a/superset-frontend/src/components/Menu/MenuRight.tsx +++ b/superset-frontend/src/components/Menu/MenuRight.tsx @@ -59,6 +59,9 @@ const StyledDiv = styled.div<{ align: string }>` justify-content: ${({ align }) => align}; align-items: center; margin-right: ${({ theme }) => theme.gridUnit}px; + .ant-menu-submenu-title > svg { + top: ${({ theme }) => theme.gridUnit * 5.25}px; + } `; const StyledAnchor = styled.a` diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx index 807fe46b75dea..b38191c82f601 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,18 +46,21 @@ 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; type DashboardBuilderProps = {}; -const StyledDashboardContent = styled.div<{ dashboardFiltersOpen: boolean }>` +const StyledDashboardContent = styled.div<{ + dashboardFiltersOpen: boolean; + editMode: boolean; +}>` display: flex; flex-direction: row; flex-wrap: nowrap; @@ -76,13 +78,15 @@ const StyledDashboardContent = styled.div<{ dashboardFiltersOpen: boolean }>` width: 100%; flex-grow: 1; position: relative; - margin: ${({ theme }) => theme.gridUnit * 6}px - ${({ theme }) => theme.gridUnit * 8}px - ${({ theme }) => theme.gridUnit * 6}px - ${({ theme, dashboardFiltersOpen }) => { - if (dashboardFiltersOpen) return theme.gridUnit * 8; + margin-top: ${({ theme }) => theme.gridUnit * 6}px; + margin-right: ${({ theme }) => theme.gridUnit * 8}px; + margin-bottom: ${({ theme }) => theme.gridUnit * 6}px; + margin-left: ${({ theme, dashboardFiltersOpen, editMode }) => { + if (!dashboardFiltersOpen && !editMode) { return 0; - }}px; + } + return theme.gridUnit * 8; + }}px; } .dashboard-component-chart-holder { @@ -99,12 +103,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 +110,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 +143,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 ( = () => { {nativeFiltersEnabled && !editMode && ( = () => { )} - + {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 0000000000000..874525d93d484 --- /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 c41abf83a4b08..f103653492f09 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 495f50f437bf4..bb071315c6289 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 44a4f82aed723..631179b898292 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 8edf7145000af..aa4894fd9ee69 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/FilterScope/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/utils.ts index 8c74aa7511c16..8cb48c1381553 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/utils.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/utils.ts @@ -45,7 +45,11 @@ export const buildTree = ( ) { const currentTreeItem = { key: node.id, - title: node.meta.sliceName || node.meta.text || node.id.toString(), + title: + node.meta.sliceNameOverride || + node.meta.sliceName || + node.meta.text || + node.id.toString(), children: [], }; treeItem.children.push(currentTreeItem); 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 76fa33bdc7097..296ee8e6ad251 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 15e877ee57478..097d80b8d84cb 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 60051e73f0ab6..0ba091f4eebc1 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 852d4e15f661d..b218ad4944dc8 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 ac772dcd73491..a1e206bab073d 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 580df62b3e0dd..e5897d18c4647 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/types.ts b/superset-frontend/src/dashboard/types.ts index 9a15b1da7ef03..bd6eeedd27c98 100644 --- a/superset-frontend/src/dashboard/types.ts +++ b/superset-frontend/src/dashboard/types.ts @@ -93,6 +93,7 @@ export type LayoutItem = { chartId: number; height: number; sliceName?: string; + sliceNameOverride?: string; text?: string; uuid: string; width: number; 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 4f6238aad522a..b7db2b03d020a 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 a0b0e38609db6..275787e0dba1d 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 94d3cd976769f..3fb02364398cb 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 64cc7c205eeb4..c1b073459cf00 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 fe486c3ef86d3..b9269a440487a 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 c42c1ce3e10bd..e2ed21c90bc9b 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 2f7d5651c0166..305667c2947b5 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 335e44483f888..74891b0ed9464 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 aac5aa905a882..36052e8696a90 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 008a39e6575f4..9ab653da5c04f 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/database/DatabaseModal/DatabaseConnectionForm.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/DatabaseConnectionForm.tsx index 61e9d35cd8f16..1b0a1f8f85789 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/DatabaseConnectionForm.tsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/DatabaseConnectionForm.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import React, { FormEvent } from 'react'; +import React, { FormEvent, useState } from 'react'; import { SupersetTheme, JsonObject, t } from '@superset-ui/core'; import { InputProps } from 'antd/lib/input'; import { Switch, Select, Button } from 'src/common/components'; @@ -69,90 +69,90 @@ interface FieldPropTypes { setFileToUpload: (obj: any) => void; } -const credentialsInfo = ({ - changeMethods, - uploadOption, - setUploadOption, - fileToUpload, - setFileToUpload, -}: FieldPropTypes) => ( - - - - {uploadOption === 'paste' ? ( -
- Service Account -