diff --git a/docs/src/pages/docs/Connecting to Databases/elasticsearch.mdx b/docs/src/pages/docs/Connecting to Databases/elasticsearch.mdx index 1704412baa5d3..720964a367038 100644 --- a/docs/src/pages/docs/Connecting to Databases/elasticsearch.mdx +++ b/docs/src/pages/docs/Connecting to Databases/elasticsearch.mdx @@ -48,3 +48,23 @@ POST /_aliases ``` Then register your table with the alias name logstasg_all + +**Time zone** + +By default, Superset uses UTC time zone for elasticsearch query. If you need to specify a time zone, +please edit your Database and enter the settings of your specified time zone in the Other > ENGINE PARAMETERS: + + +``` +{ + "connect_args": { + "time_zone": "Asia/Shanghai" + } +} +``` + +Another issue to note about the time zone problem is that before elasticsearch7.8, if you want to convert a string into a `DATETIME` object, +you need to use the `CAST` function,but this function does not support our `time_zone` setting. So it is recommended to upgrade to the version after elasticsearch7.8. +After elasticsearch7.8, you can use the `DATETIME_PARSE` function to solve this problem. +The DATETIME_PARSE function is to support our `time_zone` setting, and here you need to fill in your elasticsearch version number in the Other > VERSION setting. +the superset will use the `DATETIME_PARSE` function for conversion. diff --git a/superset-frontend/src/components/ListView/types.ts b/superset-frontend/src/components/ListView/types.ts index ade33df610242..53710b84d271d 100644 --- a/superset-frontend/src/components/ListView/types.ts +++ b/superset-frontend/src/components/ListView/types.ts @@ -111,4 +111,6 @@ export enum FilterOperator { between = 'between', dashboardIsFav = 'dashboard_is_favorite', chartIsFav = 'chart_is_favorite', + chartIsCertified = 'chart_is_certified', + dashboardIsCertified = 'dashboard_is_certified', } diff --git a/superset-frontend/src/components/ListViewCard/index.tsx b/superset-frontend/src/components/ListViewCard/index.tsx index bfd6d6a82da31..f40cead9c81d2 100644 --- a/superset-frontend/src/components/ListViewCard/index.tsx +++ b/superset-frontend/src/components/ListViewCard/index.tsx @@ -21,6 +21,7 @@ import { styled, useTheme } from '@superset-ui/core'; import { AntdCard, Skeleton, ThinSkeleton } from 'src/common/components'; import { Tooltip } from 'src/components/Tooltip'; import ImageLoader, { BackgroundPosition } from './ImageLoader'; +import CertifiedIcon from '../CertifiedIcon'; const ActionsWrapper = styled.div` width: 64px; @@ -161,6 +162,8 @@ interface CardProps { rows?: number | string; avatar?: React.ReactElement | null; cover?: React.ReactNode | null; + certifiedBy?: string; + certificationDetails?: string; } function ListViewCard({ @@ -178,6 +181,8 @@ function ListViewCard({ loading, imgPosition = 'top', cover, + certifiedBy, + certificationDetails, }: CardProps) { const Link = url && linkComponent ? linkComponent : AnchorLink; const theme = useTheme(); @@ -249,7 +254,17 @@ function ListViewCard({ - {title} + + {certifiedBy && ( + <> + {' '} + + )} + {title} + {titleRight && {titleRight}} diff --git a/superset-frontend/src/dashboard/components/Header/index.jsx b/superset-frontend/src/dashboard/components/Header/index.jsx index 845a1ada8089c..075bb125fb458 100644 --- a/superset-frontend/src/dashboard/components/Header/index.jsx +++ b/superset-frontend/src/dashboard/components/Header/index.jsx @@ -22,6 +22,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { styled, t } from '@superset-ui/core'; import ButtonGroup from 'src/components/ButtonGroup'; +import CertifiedIcon from 'src/components/CertifiedIcon'; import { LOG_ACTIONS_PERIODIC_RENDER_DASHBOARD, @@ -498,6 +499,14 @@ class Header extends React.PureComponent { data-test-id={`${dashboardInfo.id}`} >
+ {dashboardInfo.certified_by && ( + <> + {' '} + + )} ({ + certified_by: 'John Doe', + certification_details: 'Sample certification', dashboardId: 26, show: true, colorScheme: 'supersetColors', @@ -155,7 +159,10 @@ test('should render - FeatureFlag disabled', async () => { expect(screen.getByRole('heading', { name: 'Access' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Colors' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Advanced' })).toBeInTheDocument(); - expect(screen.getAllByRole('heading')).toHaveLength(4); + expect( + screen.getByRole('heading', { name: 'Certification' }), + ).toBeInTheDocument(); + expect(screen.getAllByRole('heading')).toHaveLength(5); expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Advanced' })).toBeInTheDocument(); @@ -163,7 +170,7 @@ test('should render - FeatureFlag disabled', async () => { expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument(); expect(screen.getAllByRole('button')).toHaveLength(4); - expect(screen.getAllByRole('textbox')).toHaveLength(2); + expect(screen.getAllByRole('textbox')).toHaveLength(4); expect(screen.getByRole('combobox')).toBeInTheDocument(); expect(spyColorSchemeControlWrapper).toBeCalledTimes(4); @@ -192,7 +199,10 @@ test('should render - FeatureFlag enabled', async () => { ).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Access' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Advanced' })).toBeInTheDocument(); - expect(screen.getAllByRole('heading')).toHaveLength(3); + expect( + screen.getByRole('heading', { name: 'Certification' }), + ).toBeInTheDocument(); + expect(screen.getAllByRole('heading')).toHaveLength(4); expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Advanced' })).toBeInTheDocument(); @@ -200,7 +210,7 @@ test('should render - FeatureFlag enabled', async () => { expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument(); expect(screen.getAllByRole('button')).toHaveLength(4); - expect(screen.getAllByRole('textbox')).toHaveLength(2); + expect(screen.getAllByRole('textbox')).toHaveLength(4); expect(screen.getAllByRole('combobox')).toHaveLength(2); expect(spyColorSchemeControlWrapper).toBeCalledTimes(4); @@ -220,10 +230,10 @@ test('should open advance', async () => { await screen.findByTestId('dashboard-edit-properties-form'), ).toBeInTheDocument(); - expect(screen.getAllByRole('textbox')).toHaveLength(2); + expect(screen.getAllByRole('textbox')).toHaveLength(4); expect(screen.getAllByRole('combobox')).toHaveLength(2); userEvent.click(screen.getByRole('button', { name: 'Advanced' })); - expect(screen.getAllByRole('textbox')).toHaveLength(3); + expect(screen.getAllByRole('textbox')).toHaveLength(5); expect(screen.getAllByRole('combobox')).toHaveLength(2); }); @@ -319,3 +329,18 @@ test('submitting with onlyApply:true', async () => { expect(props.onSubmit).toBeCalledTimes(1); }); }); + +test('Empty "Certified by" should clear "Certification details"', async () => { + const props = createProps(); + const noCertifiedByProps = { + ...props, + certified_by: '', + }; + render(, { + useRedux: true, + }); + + expect( + screen.getByRole('textbox', { name: 'Certification details' }), + ).toHaveValue(''); +}); diff --git a/superset-frontend/src/dashboard/components/PropertiesModal/index.jsx b/superset-frontend/src/dashboard/components/PropertiesModal/index.jsx index 73483c18b9687..71581d689b581 100644 --- a/superset-frontend/src/dashboard/components/PropertiesModal/index.jsx +++ b/superset-frontend/src/dashboard/components/PropertiesModal/index.jsx @@ -121,6 +121,8 @@ class PropertiesModal extends React.PureComponent { roles: [], json_metadata: '', colorScheme: props.colorScheme, + certified_by: '', + certification_details: '', }, isDashboardLoaded: false, isAdvancedOpen: false, @@ -221,6 +223,8 @@ class PropertiesModal extends React.PureComponent { ? jsonStringify(jsonMetadataObj) : '', colorScheme: jsonMetadataObj.color_scheme, + certified_by: dashboard.certified_by || '', + certification_details: dashboard.certification_details || '', }, })); const initialSelectedOwners = dashboard.owners.map(owner => ({ @@ -260,6 +264,8 @@ class PropertiesModal extends React.PureComponent { slug, dashboard_title: dashboardTitle, colorScheme, + certified_by: certifiedBy, + certification_details: certificationDetails, owners: ownersValue, roles: rolesValue, }, @@ -294,6 +300,8 @@ class PropertiesModal extends React.PureComponent { jsonMetadata, ownerIds: owners, colorScheme: currentColorScheme, + certifiedBy, + certificationDetails, ...moreProps, }); this.props.onHide(); @@ -306,6 +314,9 @@ class PropertiesModal extends React.PureComponent { slug: slug || null, json_metadata: jsonMetadata || null, owners, + certified_by: certifiedBy || null, + certification_details: + certifiedBy && certificationDetails ? certificationDetails : null, ...morePutProps, }), }).then(({ json: { result } }) => { @@ -321,6 +332,8 @@ class PropertiesModal extends React.PureComponent { jsonMetadata: result.json_metadata, ownerIds: result.owners, colorScheme: currentColorScheme, + certifiedBy: result.certified_by, + certificationDetails: result.certification_details, ...moreResultProps, }); this.props.onHide(); @@ -515,6 +528,45 @@ class PropertiesModal extends React.PureComponent { {isFeatureEnabled(FeatureFlag.DASHBOARD_RBAC) ? this.getRowsWithRoles() : this.getRowsWithoutRoles()} + + +

{t('Certification')}

+ +
+ + + + +

+ {t('Person or group that has certified this dashboard.')} +

+
+ + + + +

+ {t( + 'Any additional detail to show in the certification tooltip.', + )} +

+
+ +

diff --git a/superset-frontend/src/dashboard/components/dnd/DragHandle.jsx b/superset-frontend/src/dashboard/components/dnd/DragHandle.jsx deleted file mode 100644 index a255b6a004186..0000000000000 --- a/superset-frontend/src/dashboard/components/dnd/DragHandle.jsx +++ /dev/null @@ -1,58 +0,0 @@ -/** - * 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 React from 'react'; -import PropTypes from 'prop-types'; -import cx from 'classnames'; - -const propTypes = { - position: PropTypes.oneOf(['left', 'top']), - innerRef: PropTypes.func, - dotCount: PropTypes.number, -}; - -const defaultProps = { - position: 'left', - innerRef: null, - dotCount: 8, -}; - -export default class DragHandle extends React.PureComponent { - render() { - const { innerRef, position, dotCount } = this.props; - return ( -
- {Array(dotCount) - .fill(null) - .map((_, i) => ( -
- ))} -
- ); - } -} - -DragHandle.propTypes = propTypes; -DragHandle.defaultProps = defaultProps; diff --git a/superset-frontend/src/dashboard/components/dnd/DragHandle.tsx b/superset-frontend/src/dashboard/components/dnd/DragHandle.tsx new file mode 100644 index 0000000000000..08a224e4b80a3 --- /dev/null +++ b/superset-frontend/src/dashboard/components/dnd/DragHandle.tsx @@ -0,0 +1,49 @@ +/** + * 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 React, { LegacyRef } from 'react'; +import cx from 'classnames'; + +interface DragHandleProps { + position: 'left' | 'top'; + innerRef: LegacyRef | undefined; + dotCount: number; +} + +export default function DragHandle({ + position = 'left', + innerRef = null, + dotCount = 8, +}: DragHandleProps) { + return ( +
+ {Array(dotCount) + .fill(null) + .map((_, i) => ( +
+ ))} +
+ ); +} diff --git a/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx b/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx index be713614a4178..7a53faa125a4d 100644 --- a/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx +++ b/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx @@ -44,6 +44,7 @@ import Timer from 'src/components/Timer'; import CachedLabel from 'src/components/CachedLabel'; import PropertiesModal from 'src/explore/components/PropertiesModal'; import { sliceUpdated } from 'src/explore/actions/exploreActions'; +import CertifiedIcon from 'src/components/CertifiedIcon'; import ExploreActionButtons from '../ExploreActionButtons'; import RowCountLabel from '../RowCountLabel'; @@ -165,7 +166,10 @@ export class ExploreChartHeader extends React.PureComponent { } getSliceName() { - return this.props.sliceName || t('%s - untitled', this.props.table_name); + const { sliceName, table_name: tableName } = this.props; + const title = sliceName || t('%s - untitled', tableName); + + return title; } postChartFormData() { @@ -241,7 +245,7 @@ export class ExploreChartHeader extends React.PureComponent { } render() { - const { user, form_data: formData } = this.props; + const { user, form_data: formData, slice } = this.props; const { chartStatus, chartUpdateEndTime, @@ -257,6 +261,14 @@ export class ExploreChartHeader extends React.PureComponent { return (
+ {slice?.certified_by && ( + <> + {' '} + + )} ({ slice: { cache_timeout: null, + certified_by: 'John Doe', + certification_details: 'Sample certification', changed_on: '2021-03-19T16:30:56.750230', changed_on_humanized: '7 days ago', datasource: 'FCC 2018 Survey', @@ -87,6 +89,8 @@ fetchMock.get('http://localhost/api/v1/chart/318', { }, result: { cache_timeout: null, + certified_by: 'John Doe', + certification_details: 'Sample certification', dashboards: [ { dashboard_title: 'FCC New Coder Survey 2018', @@ -145,6 +149,8 @@ fetchMock.put('http://localhost/api/v1/chart/318', { id: 318, result: { cache_timeout: null, + certified_by: 'John Doe', + certification_details: 'Sample certification', description: null, owners: [], slice_name: 'Age distribution of respondents', @@ -211,7 +217,7 @@ test('Should render all elements inside modal', async () => { const props = createProps(); render(); await waitFor(() => { - expect(screen.getAllByRole('textbox')).toHaveLength(3); + expect(screen.getAllByRole('textbox')).toHaveLength(5); expect(screen.getByRole('combobox')).toBeInTheDocument(); expect( screen.getByRole('heading', { name: 'Basic information' }), @@ -226,6 +232,12 @@ test('Should render all elements inside modal', async () => { expect(screen.getByRole('heading', { name: 'Access' })).toBeVisible(); expect(screen.getByText('Owners')).toBeVisible(); + + expect( + screen.getByRole('heading', { name: 'Configuration' }), + ).toBeVisible(); + expect(screen.getByText('Certified by')).toBeVisible(); + expect(screen.getByText('Certification details')).toBeVisible(); }); }); @@ -275,3 +287,19 @@ test('"Save" button should call only "onSave"', async () => { expect(props.onHide).toBeCalledTimes(1); }); }); + +test('Empty "Certified by" should clear "Certification details"', async () => { + const props = createProps(); + const noCertifiedByProps = { + ...props, + slice: { + ...props.slice, + certified_by: '', + }, + }; + render(); + + expect( + screen.getByRole('textbox', { name: 'Certification details' }), + ).toHaveValue(''); +}); diff --git a/superset-frontend/src/explore/components/PropertiesModal/index.tsx b/superset-frontend/src/explore/components/PropertiesModal/index.tsx index 928589856390c..2b2e9b56ea512 100644 --- a/superset-frontend/src/explore/components/PropertiesModal/index.tsx +++ b/superset-frontend/src/explore/components/PropertiesModal/index.tsx @@ -18,14 +18,13 @@ */ import React, { useMemo, useState, useCallback, useEffect } from 'react'; import Modal from 'src/components/Modal'; -import { Row, Col, Input, TextArea } from 'src/common/components'; +import { Form, Row, Col, Input, TextArea } from 'src/common/components'; import Button from 'src/components/Button'; import { Select } from 'src/components'; import { SelectValue } from 'antd/lib/select'; import rison from 'rison'; -import { t, SupersetClient } from '@superset-ui/core'; +import { t, SupersetClient, styled } from '@superset-ui/core'; import Chart, { Slice } from 'src/types/Chart'; -import { Form, FormItem } from 'src/components/Form'; import { getClientErrorObject } from 'src/utils/getClientErrorObject'; type PropertiesModalProps = { @@ -37,6 +36,16 @@ type PropertiesModalProps = { existingOwners?: SelectValue; }; +const FormItem = Form.Item; + +const StyledFormItem = styled(Form.Item)` + margin-bottom: 0; +`; + +const StyledHelpBlock = styled.span` + margin-bottom: 0; +`; + export default function PropertiesModal({ slice, onHide, @@ -44,14 +53,11 @@ export default function PropertiesModal({ show, }: PropertiesModalProps) { const [submitting, setSubmitting] = useState(false); - const [selectedOwners, setSelectedOwners] = useState( - null, - ); + const [form] = Form.useForm(); // values of form inputs const [name, setName] = useState(slice.slice_name || ''); - const [description, setDescription] = useState(slice.description || ''); - const [cacheTimeout, setCacheTimeout] = useState( - slice.cache_timeout != null ? slice.cache_timeout : '', + const [selectedOwners, setSelectedOwners] = useState( + null, ); function showError({ error, statusText, message }: any) { @@ -110,14 +116,26 @@ export default function PropertiesModal({ [], ); - const onSubmit = async (event: React.FormEvent) => { - event.stopPropagation(); - event.preventDefault(); + const onSubmit = async (values: { + certified_by?: string; + certification_details?: string; + description?: string; + cache_timeout?: number; + }) => { setSubmitting(true); + const { + certified_by: certifiedBy, + certification_details: certificationDetails, + description, + cache_timeout: cacheTimeout, + } = values; const payload: { [key: string]: any } = { slice_name: name || null, description: description || null, cache_timeout: cacheTimeout || null, + certified_by: certifiedBy || null, + certification_details: + certifiedBy && certificationDetails ? certificationDetails : null, }; if (selectedOwners) { payload.owners = ( @@ -177,11 +195,10 @@ export default function PropertiesModal({