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/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({