diff --git a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx index fb0c1f597fc..c7819929074 100644 --- a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx +++ b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx @@ -17,7 +17,7 @@ * under the License. */ import rison from 'rison'; -import { PureComponent, useCallback, ReactNode } from 'react'; +import { PureComponent, useCallback, type ReactNode } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import type { JsonObject } from '@superset-ui/core'; import type { SupersetTheme } from '@apache-superset/core/ui'; @@ -77,6 +77,12 @@ import { import Mousetrap from 'mousetrap'; import { clearDatasetCache } from 'src/utils/cachedSupersetGet'; import { makeUrl } from 'src/utils/pathUtils'; +import { + OwnerSelectLabel, + OWNER_TEXT_LABEL_PROP, + OWNER_EMAIL_PROP, + OWNER_OPTION_FILTER_PROPS, +} from 'src/features/owners/OwnerSelectLabel'; import { DatabaseSelector } from '../../../DatabaseSelector'; import CollectionTable from '../CollectionTable'; import Fieldset from '../Fieldset'; @@ -98,9 +104,11 @@ const extensionsRegistry = getExtensionsRegistry(); interface Owner { id?: number; value?: number; - label?: string; + label?: ReactNode; first_name?: string; last_name?: string; + email?: string; + [key: string]: unknown; } interface Currency { @@ -757,7 +765,12 @@ function OwnersSelector({ .filter(item => item.extra.active) .map(item => ({ value: item.value as number, - label: item.text as string, + label: OwnerSelectLabel({ + name: item.text as string, + email: item.extra?.email as string | undefined, + }), + [OWNER_TEXT_LABEL_PROP]: item.text as string, + [OWNER_EMAIL_PROP]: (item.extra?.email as string) ?? '', })), totalCount: response.json.count, })); @@ -775,6 +788,7 @@ function OwnersSelector({ onChange={value => onChange(value as Owner[])} header={{t('Owners')}} allowClear + optionFilterProps={OWNER_OPTION_FILTER_PROPS} /> ); } @@ -847,10 +861,20 @@ class DatasourceEditor extends PureComponent< this.state = { datasource: { ...props.datasource, - owners: props.datasource.owners.map(owner => ({ - value: owner.value || owner.id, - label: owner.label || `${owner.first_name} ${owner.last_name}`, - })), + owners: props.datasource.owners.map(owner => { + const ownerName = + owner.label || `${owner.first_name} ${owner.last_name}`; + return { + value: owner.value || owner.id, + label: OwnerSelectLabel({ + name: typeof ownerName === 'string' ? ownerName : '', + email: owner.email, + }), + [OWNER_TEXT_LABEL_PROP]: + typeof ownerName === 'string' ? ownerName : '', + [OWNER_EMAIL_PROP]: owner.email ?? '', + }; + }), metrics: props.datasource.metrics?.map(metric => { const { certified_by: certifiedByMetric, diff --git a/superset-frontend/src/components/ListView/Filters/Select.tsx b/superset-frontend/src/components/ListView/Filters/Select.tsx index 0d309b1b255..7c7273e5ee2 100644 --- a/superset-frontend/src/components/ListView/Filters/Select.tsx +++ b/superset-frontend/src/components/ListView/Filters/Select.tsx @@ -35,6 +35,7 @@ interface SelectFilterProps extends BaseFilter { fetchSelects?: Filter['fetchSelects']; name?: string; onSelect: (selected: SelectOption | undefined, isClear?: boolean) => void; + optionFilterProps?: string[]; paginate?: boolean; selects: Filter['selects']; loading?: boolean; @@ -48,6 +49,7 @@ function SelectFilter( fetchSelects, initialValue, onSelect, + optionFilterProps, selects = [], loading = false, dropdownStyle, @@ -58,7 +60,15 @@ function SelectFilter( const onChange = (selected: SelectOption) => { onSelect( - selected ? { label: selected.label, value: selected.value } : undefined, + selected + ? { + label: + typeof selected.label === 'string' + ? selected.label + : String(selected.value), + value: selected.value, + } + : undefined, ); setSelectedOption(selected); }; @@ -108,6 +118,7 @@ function SelectFilter( onChange={onChange} onClear={onClear} options={fetchAndFormatSelects} + optionFilterProps={optionFilterProps} placeholder={placeholder} dropdownStyle={dropdownStyle} showSearch diff --git a/superset-frontend/src/components/ListView/Filters/index.tsx b/superset-frontend/src/components/ListView/Filters/index.tsx index de85669a717..a0895fe87ef 100644 --- a/superset-frontend/src/components/ListView/Filters/index.tsx +++ b/superset-frontend/src/components/ListView/Filters/index.tsx @@ -72,6 +72,7 @@ function UIFilters( key, id, input, + optionFilterProps, paginate, selects, toolTipDescription, @@ -109,6 +110,7 @@ function UIFilters( updateFilterValue(index, option); }} + optionFilterProps={optionFilterProps} paginate={paginate} selects={selects} loading={loading ?? false} diff --git a/superset-frontend/src/components/ListView/types.ts b/superset-frontend/src/components/ListView/types.ts index 1d7042798ac..5d72d3dabe2 100644 --- a/superset-frontend/src/components/ListView/types.ts +++ b/superset-frontend/src/components/ListView/types.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { ReactNode } from 'react'; +import { type ReactNode } from 'react'; export interface SortColumn { id: string; @@ -24,8 +24,9 @@ export interface SortColumn { } export interface SelectOption { - label: string; + label: ReactNode; value: any; + [key: string]: unknown; } export interface CardSortSelectOption { @@ -59,6 +60,7 @@ export interface ListViewFilter { page: number, pageSize: number, ) => Promise<{ data: SelectOption[]; totalCount: number }>; + optionFilterProps?: string[]; paginate?: boolean; loading?: boolean; dateFilterValueType?: 'unix' | 'iso'; @@ -81,7 +83,7 @@ export type InnerFilterValue = | undefined | string[] | number[] - | { label: string; value: string | number } + | { label: ReactNode; value: string | number } | [number | null, number | null]; export interface ListViewFilterValue { diff --git a/superset-frontend/src/dashboard/components/PropertiesModal/hooks/useAccessOptions.ts b/superset-frontend/src/dashboard/components/PropertiesModal/hooks/useAccessOptions.ts index 99e3aa11440..13b84a6d036 100644 --- a/superset-frontend/src/dashboard/components/PropertiesModal/hooks/useAccessOptions.ts +++ b/superset-frontend/src/dashboard/components/PropertiesModal/hooks/useAccessOptions.ts @@ -19,6 +19,11 @@ import { useCallback } from 'react'; import { SupersetClient } from '@superset-ui/core'; import rison from 'rison'; +import { + OwnerSelectLabel, + OWNER_TEXT_LABEL_PROP, + OWNER_EMAIL_PROP, +} from 'src/features/owners/OwnerSelectLabel'; /** * Hook for loading dashboard access options (owners and roles) @@ -38,10 +43,29 @@ export const useAccessOptions = () => { .filter((item: { extra: { active: boolean } }) => item.extra.active !== undefined ? item.extra.active : true, ) - .map((item: { value: number; text: string }) => ({ - value: item.value, - label: item.text, - })), + .map( + (item: { + value: number; + text: string; + extra: { email?: string }; + }) => { + if (accessType === 'owners') { + return { + value: item.value, + label: OwnerSelectLabel({ + name: item.text, + email: item.extra?.email, + }), + [OWNER_TEXT_LABEL_PROP]: item.text, + [OWNER_EMAIL_PROP]: item.extra?.email ?? '', + }; + } + return { + value: item.value, + label: item.text, + }; + }, + ), totalCount: response.json.count, })); }, diff --git a/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx b/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx index 4b4ec463f23..9f3b3bbc747 100644 --- a/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx +++ b/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx @@ -38,6 +38,10 @@ import { } from '@superset-ui/core'; import withToasts from 'src/components/MessageToasts/withToasts'; +import { + OWNER_TEXT_LABEL_PROP, + OWNER_EMAIL_PROP, +} from 'src/features/owners/OwnerSelectLabel'; import { fetchTags, OBJECT_TYPES } from 'src/features/tags/tags'; import { applyColors, @@ -79,6 +83,7 @@ type Owners = { full_name?: string; first_name?: string; last_name?: string; + email?: string; }[]; type DashboardInfo = { id: number; @@ -240,10 +245,16 @@ const PropertiesModal = ({ } }; - const handleOnChangeOwners = (owners: { value: number; label: string }[]) => { - const parsedOwners: Owners = ensureIsArray(owners).map(o => ({ + const handleOnChangeOwners = ( + owners: { value: number; label: string }[], + options: Record[], + ) => { + const parsedOwners: Owners = ensureIsArray(owners).map((o, i) => ({ id: o.value, - full_name: o.label, + full_name: + (options?.[i]?.[OWNER_TEXT_LABEL_PROP] as string) || + (typeof o.label === 'string' ? o.label : ''), + email: (options?.[i]?.[OWNER_EMAIL_PROP] as string) || '', })); setOwners(parsedOwners); }; diff --git a/superset-frontend/src/dashboard/components/PropertiesModal/sections/AccessSection.tsx b/superset-frontend/src/dashboard/components/PropertiesModal/sections/AccessSection.tsx index 1798625cec7..1ef452d8bf7 100644 --- a/superset-frontend/src/dashboard/components/PropertiesModal/sections/AccessSection.tsx +++ b/superset-frontend/src/dashboard/components/PropertiesModal/sections/AccessSection.tsx @@ -25,6 +25,12 @@ import { loadTags } from 'src/components/Tag/utils'; import getOwnerName from 'src/utils/getOwnerName'; import Owner from 'src/types/Owner'; import { ModalFormField } from 'src/components/Modal'; +import { + OwnerSelectLabel, + OWNER_TEXT_LABEL_PROP, + OWNER_EMAIL_PROP, + OWNER_OPTION_FILTER_PROPS, +} from 'src/features/owners/OwnerSelectLabel'; import { useAccessOptions } from '../hooks/useAccessOptions'; type Roles = { id: number; name: string }[]; @@ -33,6 +39,7 @@ type Owners = { full_name?: string; first_name?: string; last_name?: string; + email?: string; }[]; interface AccessSectionProps { @@ -40,7 +47,10 @@ interface AccessSectionProps { owners: Owners; roles: Roles; tags: TagType[]; - onChangeOwners: (owners: { value: number; label: string }[]) => void; + onChangeOwners: ( + owners: { value: number; label: string }[], + options: Record[], + ) => void; onChangeRoles: (roles: { value: number; label: string }[]) => void; onChangeTags: (tags: { label: string; value: number }[]) => void; onClearTags: () => void; @@ -60,9 +70,14 @@ const AccessSection = ({ const ownersSelectValue = useMemo( () => - (owners || []).map((owner: Owner) => ({ + (owners || []).map((owner: Owner & { email?: string }) => ({ value: owner.id, - label: getOwnerName(owner), + label: OwnerSelectLabel({ + name: getOwnerName(owner), + email: owner.email, + }), + [OWNER_TEXT_LABEL_PROP]: getOwnerName(owner), + [OWNER_EMAIL_PROP]: owner.email ?? '', })), [owners], ); @@ -107,6 +122,7 @@ const AccessSection = ({ value={ownersSelectValue} showSearch placeholder={t('Search owners')} + optionFilterProps={OWNER_OPTION_FILTER_PROPS} /> {isFeatureEnabled(FeatureFlag.DashboardRbac) && ( diff --git a/superset-frontend/src/explore/components/PropertiesModal/index.tsx b/superset-frontend/src/explore/components/PropertiesModal/index.tsx index f5a08698529..1c16cbe6e63 100644 --- a/superset-frontend/src/explore/components/PropertiesModal/index.tsx +++ b/superset-frontend/src/explore/components/PropertiesModal/index.tsx @@ -37,6 +37,12 @@ import { import Chart, { Slice } from 'src/types/Chart'; import withToasts from 'src/components/MessageToasts/withToasts'; import { type TagType } from 'src/components'; +import { + OwnerSelectLabel, + OWNER_TEXT_LABEL_PROP, + OWNER_EMAIL_PROP, + OWNER_OPTION_FILTER_PROPS, +} from 'src/features/owners/OwnerSelectLabel'; import { TagTypeEnum } from 'src/components/Tag/TagType'; import { loadTags } from 'src/components/Tag/utils'; import { @@ -153,6 +159,7 @@ function PropertiesModal({ 'owners.id', 'owners.first_name', 'owners.last_name', + 'owners.email', 'tags.id', 'tags.name', 'tags.type', @@ -164,10 +171,25 @@ function PropertiesModal({ }); const chart = response.json.result; setSelectedOwners( - chart?.owners?.map((owner: any) => ({ - value: owner.id, - label: `${owner.first_name} ${owner.last_name}`, - })), + chart?.owners?.map( + (owner: { + id: number; + first_name: string; + last_name: string; + email?: string; + }) => { + const ownerName = `${owner.first_name} ${owner.last_name}`; + return { + value: owner.id, + label: OwnerSelectLabel({ + name: ownerName, + email: owner.email, + }), + [OWNER_TEXT_LABEL_PROP]: ownerName, + [OWNER_EMAIL_PROP]: owner.email ?? '', + }; + }, + ), ); if (isFeatureEnabled(FeatureFlag.TaggingSystem)) { const customTags = chart.tags?.filter( @@ -196,10 +218,21 @@ function PropertiesModal({ }).then(response => ({ data: response.json.result .filter((item: { extra: { active: boolean } }) => item.extra.active) - .map((item: { value: number; text: string }) => ({ - value: item.value, - label: item.text, - })), + .map( + (item: { + value: number; + text: string; + extra: { email?: string }; + }) => ({ + value: item.value, + label: OwnerSelectLabel({ + name: item.text, + email: item.extra?.email, + }), + [OWNER_TEXT_LABEL_PROP]: item.text, + [OWNER_EMAIL_PROP]: item.extra?.email ?? '', + }), + ), totalCount: response.json.count, })); }, @@ -372,6 +405,7 @@ function PropertiesModal({ options={loadOptions} disabled={!selectedOwners} allowClear + optionFilterProps={OWNER_OPTION_FILTER_PROPS} /> {isFeatureEnabled(FeatureFlag.TaggingSystem) && ( diff --git a/superset-frontend/src/features/alerts/AlertReportModal.tsx b/superset-frontend/src/features/alerts/AlertReportModal.tsx index 741421e71c1..3974db42e33 100644 --- a/superset-frontend/src/features/alerts/AlertReportModal.tsx +++ b/superset-frontend/src/features/alerts/AlertReportModal.tsx @@ -39,6 +39,12 @@ import rison from 'rison'; import { useSingleViewResource } from 'src/views/CRUD/hooks'; import withToasts from 'src/components/MessageToasts/withToasts'; import Owner from 'src/types/Owner'; +import { + OwnerSelectLabel, + OWNER_TEXT_LABEL_PROP, + OWNER_EMAIL_PROP, + OWNER_OPTION_FILTER_PROPS, +} from 'src/features/owners/OwnerSelectLabel'; // import { Form as AntdForm } from 'src/components/Form'; import { propertyComparator } from '@superset-ui/core/components/Select/utils'; import { @@ -980,9 +986,18 @@ const AlertReportModal: FunctionComponent = ({ endpoint: `/api/v1/report/related/created_by?q=${query}`, }).then(response => ({ data: response.json.result.map( - (item: { value: number; text: string }) => ({ + (item: { + value: number; + text: string; + extra: { email?: string }; + }) => ({ value: item.value, - label: item.text, + label: OwnerSelectLabel({ + name: item.text, + email: item.extra?.email, + }), + [OWNER_TEXT_LABEL_PROP]: item.text, + [OWNER_EMAIL_PROP]: item.extra?.email ?? '', }), ), totalCount: response.json.count, @@ -1850,7 +1865,12 @@ const AlertReportModal: FunctionComponent = ({ ? [ { value: currentUser.userId, - label: `${currentUser.firstName} ${currentUser.lastName}`, + label: OwnerSelectLabel({ + name: `${currentUser.firstName} ${currentUser.lastName}`, + email: currentUser.email, + }), + [OWNER_TEXT_LABEL_PROP]: `${currentUser.firstName} ${currentUser.lastName}`, + [OWNER_EMAIL_PROP]: currentUser.email ?? '', }, ] : [], @@ -1936,12 +1956,21 @@ const AlertReportModal: FunctionComponent = ({ label: (resource.database as DatabaseObject).database_name, } : undefined, - owners: (alert?.owners || []).map(owner => ({ - value: (owner as MetaObject).value || owner.id, - label: + owners: (resource.owners || []).map(owner => { + const ownerName = (owner as MetaObject).label || - `${(owner as Owner).first_name} ${(owner as Owner).last_name}`, - })), + `${(owner as Owner).first_name} ${(owner as Owner).last_name}`; + return { + value: (owner as MetaObject).value || owner.id, + label: OwnerSelectLabel({ + name: typeof ownerName === 'string' ? ownerName : '', + email: (owner as Owner).email, + }), + [OWNER_TEXT_LABEL_PROP]: + typeof ownerName === 'string' ? ownerName : '', + [OWNER_EMAIL_PROP]: (owner as Owner).email ?? '', + }; + }), validator_config_json: resource.validator_type === 'not null' ? { @@ -2108,6 +2137,7 @@ const AlertReportModal: FunctionComponent = ({ options={loadOwnerOptions} onChange={onOwnersChange} data-test="owners-select" + optionFilterProps={OWNER_OPTION_FILTER_PROPS} /> diff --git a/superset-frontend/src/features/alerts/types.ts b/superset-frontend/src/features/alerts/types.ts index 0ea83f8d234..c8bb02c74dc 100644 --- a/superset-frontend/src/features/alerts/types.ts +++ b/superset-frontend/src/features/alerts/types.ts @@ -17,6 +17,7 @@ * under the License. */ +import type { ReactNode } from 'react'; import Owner from 'src/types/Owner'; import { NotificationFormats } from 'src/features/reports/types'; @@ -85,8 +86,9 @@ export type Recipient = { export type MetaObject = { id?: number; - label?: string; + label?: ReactNode; value?: number | string; + [key: string]: unknown; }; export type DashboardState = { diff --git a/superset-frontend/src/features/owners/OwnerSelectLabel/OwnerSelectLabel.test.tsx b/superset-frontend/src/features/owners/OwnerSelectLabel/OwnerSelectLabel.test.tsx new file mode 100644 index 00000000000..35670fb7c0d --- /dev/null +++ b/superset-frontend/src/features/owners/OwnerSelectLabel/OwnerSelectLabel.test.tsx @@ -0,0 +1,38 @@ +/** + * 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 { render, screen } from 'spec/helpers/testing-library'; +import { OwnerSelectLabel } from '.'; + +test('renders name and email', () => { + render(OwnerSelectLabel({ name: 'John Doe', email: 'jdoe@example.com' })); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('jdoe@example.com')).toBeInTheDocument(); +}); + +test('renders only name when email is undefined', () => { + render(OwnerSelectLabel({ name: 'Jane Smith' })); + expect(screen.getByText('Jane Smith')).toBeInTheDocument(); +}); + +test('renders only name when email is empty string', () => { + render(OwnerSelectLabel({ name: 'Jane Smith', email: '' })); + expect(screen.getByText('Jane Smith')).toBeInTheDocument(); + const container = screen.getByText('Jane Smith').parentElement; + expect(container?.children).toHaveLength(1); +}); diff --git a/superset-frontend/src/features/owners/OwnerSelectLabel/index.tsx b/superset-frontend/src/features/owners/OwnerSelectLabel/index.tsx new file mode 100644 index 00000000000..ad1db260f0e --- /dev/null +++ b/superset-frontend/src/features/owners/OwnerSelectLabel/index.tsx @@ -0,0 +1,62 @@ +/** + * 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 { styled } from '@apache-superset/core/ui'; + +const StyledLabelContainer = styled.div` + overflow: hidden; + text-overflow: ellipsis; + display: block; +`; + +const StyledLabel = styled.span` + overflow: hidden; + text-overflow: ellipsis; + display: block; +`; + +const StyledLabelDetail = styled.span` + ${({ theme: { fontSizeSM, colorTextSecondary } }) => ` + overflow: hidden; + text-overflow: ellipsis; + font-size: ${fontSizeSM}px; + color: ${colorTextSecondary}; + line-height: 1.6; + display: block; + `} +`; + +export const OWNER_TEXT_LABEL_PROP = 'textLabel'; +export const OWNER_EMAIL_PROP = 'ownerEmail'; +export const OWNER_OPTION_FILTER_PROPS = [ + OWNER_TEXT_LABEL_PROP, + OWNER_EMAIL_PROP, +]; + +export const OwnerSelectLabel = ({ + name, + email, +}: { + name: string; + email?: string; +}) => ( + + {name} + {email && {email}} + +); diff --git a/superset-frontend/src/pages/AlertReportList/index.tsx b/superset-frontend/src/pages/AlertReportList/index.tsx index 174b9564042..6f8060bd2dd 100644 --- a/superset-frontend/src/pages/AlertReportList/index.tsx +++ b/superset-frontend/src/pages/AlertReportList/index.tsx @@ -53,7 +53,12 @@ import { useListViewResource, useSingleViewResource, } from 'src/views/CRUD/hooks'; -import { createErrorHandler, createFetchRelated } from 'src/views/CRUD/utils'; +import { + createErrorHandler, + createFetchRelated, + createFetchOwners, +} from 'src/views/CRUD/utils'; +import { OWNER_OPTION_FILTER_PROPS } from 'src/features/owners/OwnerSelectLabel'; import { isUserAdmin } from 'src/dashboard/util/permissionUtils'; import Owner from 'src/types/Owner'; import AlertReportModal from 'src/features/alerts/AlertReportModal'; @@ -480,14 +485,14 @@ function AlertList({ input: 'select', operator: FilterOperator.RelationManyMany, unfilteredLabel: t('All'), - fetchSelects: createFetchRelated( + fetchSelects: createFetchOwners( 'report', - 'owners', createErrorHandler(errMsg => t('An error occurred while fetching owners values: %s', errMsg), ), user, ), + optionFilterProps: OWNER_OPTION_FILTER_PROPS, paginate: true, dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH }, }, diff --git a/superset-frontend/src/pages/ChartList/index.tsx b/superset-frontend/src/pages/ChartList/index.tsx index 560fb82b30f..d54a6932d48 100644 --- a/superset-frontend/src/pages/ChartList/index.tsx +++ b/superset-frontend/src/pages/ChartList/index.tsx @@ -33,8 +33,10 @@ import { useSelector } from 'react-redux'; import { createErrorHandler, createFetchRelated, + createFetchOwners, handleChartDelete, } from 'src/views/CRUD/utils'; +import { OWNER_OPTION_FILTER_PROPS } from 'src/features/owners/OwnerSelectLabel'; import { useChartEditModal, useFavoriteStatus, @@ -676,9 +678,8 @@ function ChartList(props: ChartListProps) { input: 'select', operator: FilterOperator.RelationManyMany, unfilteredLabel: t('All'), - fetchSelects: createFetchRelated( + fetchSelects: createFetchOwners( 'chart', - 'owners', createErrorHandler(errMsg => addDangerToast( t( @@ -689,6 +690,7 @@ function ChartList(props: ChartListProps) { ), props.user, ), + optionFilterProps: OWNER_OPTION_FILTER_PROPS, paginate: true, dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH }, }, diff --git a/superset-frontend/src/pages/DashboardList/index.tsx b/superset-frontend/src/pages/DashboardList/index.tsx index 6b9454f7adc..6b96d36e13e 100644 --- a/superset-frontend/src/pages/DashboardList/index.tsx +++ b/superset-frontend/src/pages/DashboardList/index.tsx @@ -29,9 +29,11 @@ import { Link } from 'react-router-dom'; import rison from 'rison'; import { createFetchRelated, + createFetchOwners, createErrorHandler, handleDashboardDelete, } from 'src/views/CRUD/utils'; +import { OWNER_OPTION_FILTER_PROPS } from 'src/features/owners/OwnerSelectLabel'; import { useListViewResource, useFavoriteStatus } from 'src/views/CRUD/hooks'; import { CertifiedBadge, @@ -582,9 +584,8 @@ function DashboardList(props: DashboardListProps) { input: 'select', operator: FilterOperator.RelationManyMany, unfilteredLabel: t('All'), - fetchSelects: createFetchRelated( + fetchSelects: createFetchOwners( 'dashboard', - 'owners', createErrorHandler(errMsg => addDangerToast( t( @@ -595,6 +596,7 @@ function DashboardList(props: DashboardListProps) { ), props.user, ), + optionFilterProps: OWNER_OPTION_FILTER_PROPS, paginate: true, dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH }, }, diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx index 7822e15a9e5..d88a0ae8fb3 100644 --- a/superset-frontend/src/pages/DatasetList/index.tsx +++ b/superset-frontend/src/pages/DatasetList/index.tsx @@ -25,8 +25,10 @@ import rison from 'rison'; import { createFetchRelated, createFetchDistinct, + createFetchOwners, createErrorHandler, } from 'src/views/CRUD/utils'; +import { OWNER_OPTION_FILTER_PROPS } from 'src/features/owners/OwnerSelectLabel'; import { ColumnObject } from 'src/features/datasets/types'; import { useListViewResource } from 'src/views/CRUD/hooks'; import { @@ -579,9 +581,8 @@ const DatasetList: FunctionComponent = ({ input: 'select', operator: FilterOperator.RelationManyMany, unfilteredLabel: 'All', - fetchSelects: createFetchRelated( + fetchSelects: createFetchOwners( 'dataset', - 'owners', createErrorHandler(errMsg => t( 'An error occurred while fetching dataset owner values: %s', @@ -590,6 +591,7 @@ const DatasetList: FunctionComponent = ({ ), user, ), + optionFilterProps: OWNER_OPTION_FILTER_PROPS, paginate: true, dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH }, }, diff --git a/superset-frontend/src/types/Owner.ts b/superset-frontend/src/types/Owner.ts index b8c0f4962cb..a025c77e811 100644 --- a/superset-frontend/src/types/Owner.ts +++ b/superset-frontend/src/types/Owner.ts @@ -26,4 +26,5 @@ export default interface Owner { id: number; last_name?: string; full_name?: string; + email?: string; } diff --git a/superset-frontend/src/views/CRUD/utils.tsx b/superset-frontend/src/views/CRUD/utils.tsx index 4f29f67223b..f234527fa6a 100644 --- a/superset-frontend/src/views/CRUD/utils.tsx +++ b/superset-frontend/src/views/CRUD/utils.tsx @@ -36,6 +36,11 @@ import SupersetText from 'src/utils/textUtils'; import { findPermission } from 'src/utils/findPermission'; import { User } from 'src/types/bootstrapTypes'; import { RecentActivity, WelcomeTable } from 'src/features/home/types'; +import { + OwnerSelectLabel, + OWNER_TEXT_LABEL_PROP, + OWNER_EMAIL_PROP, +} from 'src/features/owners/OwnerSelectLabel'; import { Dashboard, Filter, TableTab } from './types'; // Modifies the rison encoding slightly to match the backend's rison encoding/decoding. Applies globally. @@ -91,6 +96,7 @@ const createFetchResourceMethod = }); let fetchedLoggedUser = false; + let loggedUserExtra: Record | undefined; const loggedUser = user ? { label: `${user.firstName} ${user.lastName}`, @@ -98,26 +104,42 @@ const createFetchResourceMethod = } : undefined; - const data: { label: string; value: string | number }[] = []; + const data: { + label: string; + value: string | number; + extra?: Record; + }[] = []; json?.result ?.filter(({ text }: { text: string }) => text.trim().length > 0) - .forEach(({ text, value }: { text: string; value: string | number }) => { - if ( - loggedUser && - value === loggedUser.value && - text === loggedUser.label - ) { - fetchedLoggedUser = true; - } else { - data.push({ - label: text, - value, - }); - } - }); + .forEach( + ({ + text, + value, + extra, + }: { + text: string; + value: string | number; + extra?: Record; + }) => { + if ( + loggedUser && + value === loggedUser.value && + text === loggedUser.label + ) { + fetchedLoggedUser = true; + loggedUserExtra = extra; + } else { + data.push({ + label: text, + value, + extra, + }); + } + }, + ); if (loggedUser && (!filterValue || fetchedLoggedUser)) { - data.unshift(loggedUser); + data.unshift({ ...loggedUser, extra: loggedUserExtra }); } return { @@ -240,6 +262,35 @@ export const getRecentActivityObjs = ( export const createFetchRelated = createFetchResourceMethod('related'); export const createFetchDistinct = createFetchResourceMethod('distinct'); +export const createFetchOwners = ( + resource: string, + handleError: (error: Response) => void, + user?: { userId: string | number; firstName: string; lastName: string }, +) => { + const fetchRelated = createFetchRelated( + resource, + 'owners', + handleError, + user, + ); + return async (filterValue = '', page: number, pageSize: number) => { + const result = await fetchRelated(filterValue, page, pageSize); + return { + ...result, + data: result.data.map(item => { + const email = item.extra?.email as string | undefined; + return { + label: OwnerSelectLabel({ name: item.label, email }), + value: item.value, + title: item.label, + [OWNER_TEXT_LABEL_PROP]: item.label, + [OWNER_EMAIL_PROP]: email ?? '', + }; + }), + }; + }; +}; + export function createErrorHandler( handleErrorFunc: ( errMsg?: string | Record, diff --git a/superset/charts/api.py b/superset/charts/api.py index 66c0781ffb3..f5dd6ce3cbd 100644 --- a/superset/charts/api.py +++ b/superset/charts/api.py @@ -167,6 +167,7 @@ class ChartRestApi(BaseSupersetModelRestApi): "owners.first_name", "owners.id", "owners.last_name", + "owners.email", "dashboards.id", "dashboards.dashboard_title", "params", diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py index 8cc500ecac1..4f37a810223 100644 --- a/superset/charts/schemas.py +++ b/superset/charts/schemas.py @@ -1671,6 +1671,7 @@ class UserSchema(Schema): id = fields.Int() first_name = fields.String() last_name = fields.String() + email = fields.String() class DashboardSchema(Schema): diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py index 4592138866e..cdbed39552b 100644 --- a/superset/dashboards/api.py +++ b/superset/dashboards/api.py @@ -197,6 +197,7 @@ BASE_LIST_COLUMNS = [ "owners.id", "owners.first_name", "owners.last_name", + "owners.email", "roles.id", "roles.name", "is_managed_externally", diff --git a/superset/datasets/schemas.py b/superset/datasets/schemas.py index 1506ef45d16..82f1e7faa1c 100644 --- a/superset/datasets/schemas.py +++ b/superset/datasets/schemas.py @@ -414,6 +414,7 @@ class DatasetColumnDrillInfoSchema(Schema): class UserSchema(Schema): first_name = fields.String() last_name = fields.String() + email = fields.String() class DatasetDrillInfoSchema(Schema): diff --git a/superset/reports/api.py b/superset/reports/api.py index a0ebabb2028..b0cc9475461 100644 --- a/superset/reports/api.py +++ b/superset/reports/api.py @@ -82,6 +82,11 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi): resource_name = "report" allow_browser_login = True + extra_fields_rel_fields = { + **BaseSupersetModelRestApi.extra_fields_rel_fields, + "created_by": ["email", "active"], + } + base_filters = [ ["id", ReportScheduleFilter, lambda: []], ] @@ -113,6 +118,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi): "owners.first_name", "owners.id", "owners.last_name", + "owners.email", "recipients.id", "recipients.recipient_config_json", "recipients.type", @@ -152,6 +158,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi): "owners.first_name", "owners.id", "owners.last_name", + "owners.email", "recipients.id", "recipients.type", "timezone", diff --git a/tests/integration_tests/charts/api_tests.py b/tests/integration_tests/charts/api_tests.py index b8b60355419..733c9e0e676 100644 --- a/tests/integration_tests/charts/api_tests.py +++ b/tests/integration_tests/charts/api_tests.py @@ -1035,6 +1035,7 @@ class TestChartApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCase): "id": 1, "first_name": "admin", "last_name": "user", + "email": "admin@fab.org", } ], "params": None, diff --git a/tests/integration_tests/reports/api_tests.py b/tests/integration_tests/reports/api_tests.py index 58bde34ac72..41edf717020 100644 --- a/tests/integration_tests/reports/api_tests.py +++ b/tests/integration_tests/reports/api_tests.py @@ -301,12 +301,18 @@ class TestReportSchedulesApi(SupersetTestCase): for key in expected_result: assert data["result"][key] == expected_result[key] # needed because order may vary - assert {"first_name": "admin", "id": 1, "last_name": "user"} in data["result"][ - "owners" - ] - assert {"first_name": "alpha", "id": 5, "last_name": "user"} in data["result"][ - "owners" - ] + assert { + "email": "admin@fab.org", + "first_name": "admin", + "id": 1, + "last_name": "user", + } in data["result"]["owners"] + assert { + "email": "alpha@fab.org", + "first_name": "alpha", + "id": 5, + "last_name": "user", + } in data["result"]["owners"] assert len(data["result"]["owners"]) == 2 def test_info_report_schedule(self): @@ -382,7 +388,7 @@ class TestReportSchedulesApi(SupersetTestCase): assert expected_fields == data_keys # Assert nested fields - expected_owners_fields = ["first_name", "id", "last_name"] + expected_owners_fields = ["email", "first_name", "id", "last_name"] data_keys = sorted(list(data["result"][0]["owners"][0].keys())) # noqa: C414 assert expected_owners_fields == data_keys