From 55ac01b6751229433db580f82da0375a68a6f17c Mon Sep 17 00:00:00 2001 From: "Hugh A. Miles II" Date: Tue, 12 Sep 2023 14:48:07 +0200 Subject: [PATCH] feat: Tags ListView Page (#24964) --- .../src/components/ListView/ListView.tsx | 40 ++++- .../components/MetadataBar/ContentConfig.tsx | 4 +- .../src/components/MetadataBar/ContentType.ts | 2 +- .../PageHeaderWithActions/index.tsx | 40 +++-- superset-frontend/src/components/Tags/Tag.tsx | 2 +- .../components/Header/Header.test.tsx | 1 + .../src/features/tags/BulkTagModal.tsx | 118 ++++++++++++++ .../src/features/tags/TagModal.test.tsx | 28 +++- .../src/features/tags/TagModal.tsx | 62 ++++--- superset-frontend/src/features/tags/tags.ts | 6 +- .../src/pages/AlertReportList/index.tsx | 3 + .../src/pages/AllEntities/index.tsx | 154 ++++++++++++++---- .../src/pages/AnnotationLayerList/index.tsx | 3 + .../src/pages/AnnotationList/index.tsx | 3 + .../src/pages/ChartList/index.tsx | 5 + .../src/pages/CssTemplateList/index.tsx | 3 + .../src/pages/DashboardList/index.tsx | 5 + .../src/pages/DatabaseList/index.tsx | 3 + .../src/pages/DatasetList/index.tsx | 3 + .../src/pages/ExecutionLogList/index.tsx | 9 +- .../src/pages/QueryHistoryList/index.tsx | 4 + .../src/pages/RowLevelSecurityList/index.tsx | 3 + .../src/pages/SavedQueryList/index.tsx | 5 + superset-frontend/src/pages/Tags/index.tsx | 18 +- superset-frontend/src/views/CRUD/types.ts | 10 +- superset/daos/tag.py | 14 +- superset/tags/api.py | 104 +++++++++--- superset/tags/commands/create.py | 7 +- superset/tags/exceptions.py | 10 ++ superset/tags/schemas.py | 12 +- tests/integration_tests/tags/api_tests.py | 54 ++++++ 31 files changed, 605 insertions(+), 130 deletions(-) create mode 100644 superset-frontend/src/features/tags/BulkTagModal.tsx diff --git a/superset-frontend/src/components/ListView/ListView.tsx b/superset-frontend/src/components/ListView/ListView.tsx index 0a1c14ddb..82cf6b187 100644 --- a/superset-frontend/src/components/ListView/ListView.tsx +++ b/superset-frontend/src/components/ListView/ListView.tsx @@ -17,7 +17,7 @@ * under the License. */ import { t, styled } from '@superset-ui/core'; -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import Alert from 'src/components/Alert'; import cx from 'classnames'; import Button from 'src/components/Button'; @@ -25,6 +25,7 @@ import Icons from 'src/components/Icons'; import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox'; import Pagination from 'src/components/Pagination'; import TableCollection from 'src/components/TableCollection'; +import BulkTagModal from 'src/features/tags/BulkTagModal'; import CardCollection from './CardCollection'; import FilterControls from './Filters'; import { CardSortSelect } from './CardSortSelect'; @@ -98,7 +99,7 @@ const BulkSelectWrapper = styled(Alert)` padding: ${theme.gridUnit * 2}px 0; } - .deselect-all { + .deselect-all, .tag-btn { color: ${theme.colors.primary.base}; margin-left: ${theme.gridUnit * 4}px; } @@ -207,6 +208,9 @@ export interface ListViewProps { count: number; pageSize: number; fetchData: (conf: FetchDataConfig) => any; + refreshData: () => void; + addSuccessToast: (msg: string) => void; + addDangerToast: (msg: string) => void; loading: boolean; className?: string; initialSort?: SortColumn[]; @@ -227,6 +231,8 @@ export interface ListViewProps { showThumbnails?: boolean; emptyState?: EmptyStateProps; columnsForWrapText?: string[]; + enableBulkTag?: boolean; + bulkTagResourceName?: string; } function ListView({ @@ -235,6 +241,7 @@ function ListView({ count, pageSize: initialPageSize, fetchData, + refreshData, loading, initialSort = [], className = '', @@ -250,6 +257,10 @@ function ListView({ highlightRowId, emptyState, columnsForWrapText, + enableBulkTag = false, + bulkTagResourceName, + addSuccessToast, + addDangerToast, }: ListViewProps) { const { getTableProps, @@ -278,6 +289,7 @@ function ListView({ renderCard: Boolean(renderCard), defaultViewMode, }); + const allowBulkTagActions = bulkTagResourceName && enableBulkTag; const filterable = Boolean(filters.length); if (filterable) { const columnAccessors = columns.reduce( @@ -302,6 +314,7 @@ function ListView({ }, [query.filters]); const cardViewEnabled = Boolean(renderCard); + const [showBulkTagModal, setShowBulkTagModal] = useState(false); useEffect(() => { // discard selections if bulk select is disabled @@ -310,6 +323,17 @@ function ListView({ return ( + {allowBulkTagActions && ( + setShowBulkTagModal(false)} + /> + )}
{cardViewEnabled && ( @@ -375,6 +399,17 @@ function ListView({ {action.name} ))} + {enableBulkTag && ( + setShowBulkTagModal(true)} + > + {t('Add Tag')} + + )} )} @@ -425,7 +460,6 @@ function ListView({ )}
- {rows.length > 0 && (
{ tooltip: (
- + {!!contentType.owners && ( + + )}
), diff --git a/superset-frontend/src/components/MetadataBar/ContentType.ts b/superset-frontend/src/components/MetadataBar/ContentType.ts index 13c070739..f5428aa61 100644 --- a/superset-frontend/src/components/MetadataBar/ContentType.ts +++ b/superset-frontend/src/components/MetadataBar/ContentType.ts @@ -51,7 +51,7 @@ export type LastModified = { export type Owner = { type: MetadataType.OWNER; createdBy: string; - owners: string[]; + owners?: string[]; createdOn: string; onClick?: (type: string) => void; }; diff --git a/superset-frontend/src/components/PageHeaderWithActions/index.tsx b/superset-frontend/src/components/PageHeaderWithActions/index.tsx index 9209ab818..6e515efa9 100644 --- a/superset-frontend/src/components/PageHeaderWithActions/index.tsx +++ b/superset-frontend/src/components/PageHeaderWithActions/index.tsx @@ -108,6 +108,7 @@ export type PageHeaderWithActionsProps = { showTitlePanelItems: boolean; certificatiedBadgeProps?: CertifiedBadgeProps; showFaveStar: boolean; + showMenuDropdown?: boolean; faveStarProps: FaveStarProps; titlePanelAdditionalItems: ReactNode; rightPanelAdditionalItems: ReactNode; @@ -129,6 +130,7 @@ export const PageHeaderWithActions = ({ rightPanelAdditionalItems, additionalActionsMenu, menuDropdownProps, + showMenuDropdown = true, tooltipProps, }: PageHeaderWithActionsProps) => { const theme = useTheme(); @@ -149,25 +151,27 @@ export const PageHeaderWithActions = ({
{rightPanelAdditionalItems}
- - - + + + )}
diff --git a/superset-frontend/src/components/Tags/Tag.tsx b/superset-frontend/src/components/Tags/Tag.tsx index eaa00b973..ad3df8af6 100644 --- a/superset-frontend/src/components/Tags/Tag.tsx +++ b/superset-frontend/src/components/Tags/Tag.tsx @@ -58,7 +58,7 @@ const Tag = ({ {id ? ( diff --git a/superset-frontend/src/dashboard/components/Header/Header.test.tsx b/superset-frontend/src/dashboard/components/Header/Header.test.tsx index f8c474272..2df5fa831 100644 --- a/superset-frontend/src/dashboard/components/Header/Header.test.tsx +++ b/superset-frontend/src/dashboard/components/Header/Header.test.tsx @@ -95,6 +95,7 @@ const createProps = () => ({ maxUndoHistoryToast: jest.fn(), dashboardInfoChanged: jest.fn(), dashboardTitleChanged: jest.fn(), + showMenuDropdown: true, }); const props = createProps(); const editableProps = { diff --git a/superset-frontend/src/features/tags/BulkTagModal.tsx b/superset-frontend/src/features/tags/BulkTagModal.tsx new file mode 100644 index 000000000..adacef1f4 --- /dev/null +++ b/superset-frontend/src/features/tags/BulkTagModal.tsx @@ -0,0 +1,118 @@ +/** + * 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, { useState, useEffect } from 'react'; +import { t, SupersetClient } from '@superset-ui/core'; +import { FormLabel } from 'src/components/Form'; +import Modal from 'src/components/Modal'; +import AsyncSelect from 'src/components/Select/AsyncSelect'; +import Button from 'src/components/Button'; +import { loadTags } from 'src/components/Tags/utils'; +import { TaggableResourceOption } from 'src/features/tags/TagModal'; + +interface BulkTagModalProps { + onHide: () => void; + refreshData: () => void; + addSuccessToast: (msg: string) => void; + addDangerToast: (msg: string) => void; + show: boolean; + selected: any[]; + resourceName: string; +} + +const BulkTagModal: React.FC = ({ + show, + selected = [], + onHide, + refreshData, + resourceName, + addSuccessToast, + addDangerToast, +}) => { + useEffect(() => {}, []); + + const onSave = async () => { + await SupersetClient.post({ + endpoint: `/api/v1/tag/bulk_create`, + jsonPayload: { + tags: tags.map(tag => tag.value), + objects_to_tag: selected.map(item => [resourceName, +item.original.id]), + }, + }) + .then(({ json = {} }) => { + addSuccessToast(t('Tagged %s items', selected.length)); + }) + .catch(err => { + addDangerToast(t('Failed to tag items')); + }); + + refreshData(); + onHide(); + setTags([]); + }; + + const [tags, setTags] = useState([]); + + return ( + { + setTags([]); + onHide(); + }} + footer={ +
+ + +
+ } + > + <> + <>{t('You are adding tags to the %s entities', selected.length)} +
+ {t('tags')} + setTags(tags)} + placeholder={t('Select Tags')} + mode="multiple" + /> + +
+ ); +}; + +export default BulkTagModal; diff --git a/superset-frontend/src/features/tags/TagModal.test.tsx b/superset-frontend/src/features/tags/TagModal.test.tsx index a033b44ce..5f4fd4e2b 100644 --- a/superset-frontend/src/features/tags/TagModal.test.tsx +++ b/superset-frontend/src/features/tags/TagModal.test.tsx @@ -1,3 +1,21 @@ +/** + * 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 { render, screen } from 'spec/helpers/testing-library'; import TagModal from 'src/features/tags/TagModal'; @@ -36,7 +54,15 @@ test('renders correctly in edit mode', () => { description: 'A test tag', type: 'dashboard', changed_on_delta_humanized: '', - created_by: {}, + created_on_delta_humanized: '', + created_by: { + first_name: 'joe', + last_name: 'smith', + }, + changed_by: { + first_name: 'tom', + last_name: 'brown', + }, }; render(); diff --git a/superset-frontend/src/features/tags/TagModal.tsx b/superset-frontend/src/features/tags/TagModal.tsx index bbe32102c..90d6391e8 100644 --- a/superset-frontend/src/features/tags/TagModal.tsx +++ b/superset-frontend/src/features/tags/TagModal.tsx @@ -21,14 +21,20 @@ import rison from 'rison'; import Modal from 'src/components/Modal'; import AsyncSelect from 'src/components/Select/AsyncSelect'; import { FormLabel } from 'src/components/Form'; -import { t, SupersetClient } from '@superset-ui/core'; +import { t, styled, SupersetClient } from '@superset-ui/core'; import { Input } from 'antd'; import { Divider } from 'src/components'; import Button from 'src/components/Button'; import { Tag } from 'src/views/CRUD/types'; import { fetchObjects } from 'src/features/tags/tags'; -interface TaggableResourceOption { +const StyledModalBody = styled.div` + .ant-select-dropdown { + max-height: ${({ theme }) => theme.gridUnit * 25}px; + } +`; + +export interface TaggableResourceOption { label: string; value: number; key: number; @@ -46,6 +52,7 @@ interface TagModalProps { addSuccessToast: (msg: string) => void; addDangerToast: (msg: string) => void; show: boolean; + clearOnHide?: boolean; editTag?: Tag | null; } @@ -56,6 +63,7 @@ const TagModal: React.FC = ({ refreshData, addSuccessToast, addDangerToast, + clearOnHide = false, }) => { const [dashboardsToTag, setDashboardsToTag] = useState< TaggableResourceOption[] @@ -235,11 +243,13 @@ const TagModal: React.FC = ({ { - setTagName(''); - setDescription(''); - setDashboardsToTag([]); - setChartsToTag([]); - setSavedQueriesToTag([]); + if (clearOnHide) { + setTagName(''); + setDescription(''); + setDashboardsToTag([]); + setChartsToTag([]); + setSavedQueriesToTag([]); + } onHide(); }} show={show} @@ -262,22 +272,22 @@ const TagModal: React.FC = ({ } > - <> - {t('Tag Name')} - - {t('Description')} - - + {t('Tag name')} + + {t('Description')} + + + = ({ allowClear /> = ({ allowClear /> = ({ onChange={value => handleOptionChange(TaggableResources.SavedQuery, value) } - header={{t('Saved Queries')}} + header={{t('Saved queries')}} allowClear /> - + ); }; diff --git a/superset-frontend/src/features/tags/tags.ts b/superset-frontend/src/features/tags/tags.ts index 97b5b094b..45c4e88fc 100644 --- a/superset-frontend/src/features/tags/tags.ts +++ b/superset-frontend/src/features/tags/tags.ts @@ -56,12 +56,12 @@ export function fetchAllTags( } export function fetchSingleTag( - name: string, + id: number, callback: (json: JsonObject) => void, error: (response: Response) => void, ) { - SupersetClient.get({ endpoint: `/api/v1/tag` }) - .then(({ json }) => callback(json)) + SupersetClient.get({ endpoint: `/api/v1/tag/${id}` }) + .then(({ json }) => callback(json.result)) .catch(response => error(response)); } diff --git a/superset-frontend/src/pages/AlertReportList/index.tsx b/superset-frontend/src/pages/AlertReportList/index.tsx index 45504eb3c..b0cd0a462 100644 --- a/superset-frontend/src/pages/AlertReportList/index.tsx +++ b/superset-frontend/src/pages/AlertReportList/index.tsx @@ -612,6 +612,9 @@ function AlertList({ bulkActions={bulkActions} bulkSelectEnabled={bulkSelectEnabled} disableBulkSelect={toggleBulkSelect} + refreshData={refreshData} + addDangerToast={addDangerToast} + addSuccessToast={addSuccessToast} pageSize={PAGE_SIZE} /> ); diff --git a/superset-frontend/src/pages/AllEntities/index.tsx b/superset-frontend/src/pages/AllEntities/index.tsx index 7dfef8eb9..eaa8e3570 100644 --- a/superset-frontend/src/pages/AllEntities/index.tsx +++ b/superset-frontend/src/pages/AllEntities/index.tsx @@ -16,15 +16,31 @@ * specific language governing permissions and limitations * under the License. */ -import React, { useMemo } from 'react'; -import { ensureIsArray, styled, t } from '@superset-ui/core'; -import { StringParam, useQueryParam } from 'use-query-params'; -import withToasts from 'src/components/MessageToasts/withToasts'; -import AsyncSelect from 'src/components/Select/AsyncSelect'; -import { SelectValue } from 'antd/lib/select'; -import { loadTags } from 'src/components/Tags/utils'; -import { getValue } from 'src/components/Select/utils'; +import React, { useEffect, useState } from 'react'; +import { styled, t, css, SupersetTheme } from '@superset-ui/core'; +import { NumberParam, useQueryParam } from 'use-query-params'; import AllEntitiesTable from 'src/features/allEntities/AllEntitiesTable'; +import Button from 'src/components/Button'; +import MetadataBar, { + MetadataType, + Description, + Owner, + LastModified, +} from 'src/components/MetadataBar'; +import { PageHeaderWithActions } from 'src/components/PageHeaderWithActions'; +import { fetchSingleTag } from 'src/features/tags/tags'; +import { Tag } from 'src/views/CRUD/types'; +import TagModal from 'src/features/tags/TagModal'; +import withToasts, { useToasts } from 'src/components/MessageToasts/withToasts'; + +const additionalItemsStyles = (theme: SupersetTheme) => css` + display: flex; + align-items: center; + margin-left: ${theme.gridUnit}px; + & > span { + margin-right: ${theme.gridUnit * 3}px; + } +`; const AllEntitiesContainer = styled.div` ${({ theme }) => ` @@ -39,7 +55,11 @@ const AllEntitiesContainer = styled.div` font-size: ${theme.gridUnit * 3}px; color: ${theme.colors.grayscale.base}; margin-bottom: ${theme.gridUnit * 1}px; - }`} + } + .entities { + margin: ${theme.gridUnit * 7.5}px; 0px; + } + `} `; const AllEntitiesNav = styled.div` @@ -50,43 +70,107 @@ const AllEntitiesNav = styled.div` .navbar-brand { margin-left: ${theme.gridUnit * 2}px; font-weight: ${theme.typography.weights.bold}; - }`}; + } + .header { + font-weight: ${theme.typography.weights.bold}; + margin-right: ${theme.gridUnit * 3}px; + text-align: left; + font-size: ${theme.gridUnit * 4.5}px; + padding: ${theme.gridUnit * 3}px; + display: inline-block; + line-height: ${theme.gridUnit * 9}px; + } + `}; `; function AllEntities() { - const [tagsQuery, setTagsQuery] = useQueryParam('tags', StringParam); - - const onTagSearchChange = (value: SelectValue) => { - const tags = ensureIsArray(value).map(tag => getValue(tag)); - const tagSearch = tags.join(','); - setTagsQuery(tagSearch); + const [tagId] = useQueryParam('id', NumberParam); + const [tag, setTag] = useState(null); + const [showTagModal, setShowTagModal] = useState(false); + const { addSuccessToast, addDangerToast } = useToasts(); + const editableTitleProps = { + title: tag?.name || '', + placeholder: 'testing', + onSave: (newDatasetName: string) => {}, + canEdit: false, + label: t('dataset name'), }; - const tagsValue = useMemo( - () => - tagsQuery - ? tagsQuery.split(',').map(tag => ({ value: tag, label: tag })) - : [], - [tagsQuery], - ); + const description: Description = { + type: MetadataType.DESCRIPTION, + value: tag?.description || '', + }; + + const owner: Owner = { + type: MetadataType.OWNER, + createdBy: `${tag?.created_by.first_name} ${tag?.created_by.last_name}`, + createdOn: tag?.created_on_delta_humanized || '', + }; + const lastModified: LastModified = { + type: MetadataType.LAST_MODIFIED, + value: tag?.changed_on_delta_humanized || '', + modifiedBy: `${tag?.changed_by.first_name} ${tag?.changed_by.last_name}`, + }; + const items = [description, owner, lastModified]; + + useEffect(() => { + // fetch single tag met + if (tagId) { + fetchSingleTag( + tagId, + (tag: Tag) => { + setTag(tag); + }, + (error: Response) => { + addDangerToast(t('Error Fetching Tagged Objects')); + }, + ); + } + }, [tagId]); return ( + { + setShowTagModal(false); + }} + editTag={tag} + addSuccessToast={addSuccessToast} + addDangerToast={addDangerToast} + refreshData={() => {}} // todo(hugh): implement refreshData on table reload + /> - {t('All Entities')} - -
-
{t('search by tags')}
- } + editableTitleProps={editableTitleProps} + faveStarProps={{ itemId: 1, saveFaveStar: () => {} }} + showFaveStar={false} + showTitlePanelItems + titlePanelAdditionalItems={ +
+ +
+ } + rightPanelAdditionalItems={ + <> + + + } + menuDropdownProps={{ + disabled: true, + }} /> + +
+
- ); } diff --git a/superset-frontend/src/pages/AnnotationLayerList/index.tsx b/superset-frontend/src/pages/AnnotationLayerList/index.tsx index 47981a206..fc909538c 100644 --- a/superset-frontend/src/pages/AnnotationLayerList/index.tsx +++ b/superset-frontend/src/pages/AnnotationLayerList/index.tsx @@ -384,7 +384,10 @@ function AnnotationLayersList({ bulkActions={bulkActions} bulkSelectEnabled={bulkSelectEnabled} disableBulkSelect={toggleBulkSelect} + addDangerToast={addDangerToast} + addSuccessToast={addSuccessToast} emptyState={emptyState} + refreshData={refreshData} /> ); }} diff --git a/superset-frontend/src/pages/AnnotationList/index.tsx b/superset-frontend/src/pages/AnnotationList/index.tsx index ee2550216..980a18ba7 100644 --- a/superset-frontend/src/pages/AnnotationList/index.tsx +++ b/superset-frontend/src/pages/AnnotationList/index.tsx @@ -321,6 +321,9 @@ function AnnotationList({ disableBulkSelect={toggleBulkSelect} emptyState={emptyState} fetchData={fetchData} + addDangerToast={addDangerToast} + addSuccessToast={addSuccessToast} + refreshData={refreshData} initialSort={initialSort} loading={loading} pageSize={PAGE_SIZE} diff --git a/superset-frontend/src/pages/ChartList/index.tsx b/superset-frontend/src/pages/ChartList/index.tsx index e47f47981..6f97b938b 100644 --- a/superset-frontend/src/pages/ChartList/index.tsx +++ b/superset-frontend/src/pages/ChartList/index.tsx @@ -851,12 +851,17 @@ function ChartList(props: ChartListProps) { count={chartCount} data={charts} disableBulkSelect={toggleBulkSelect} + refreshData={refreshData} fetchData={fetchData} filters={filters} initialSort={initialSort} loading={loading} pageSize={PAGE_SIZE} renderCard={renderCard} + enableBulkTag + bulkTagResourceName="chart" + addSuccessToast={addSuccessToast} + addDangerToast={addDangerToast} showThumbnails={ userSettings ? userSettings.thumbnails diff --git a/superset-frontend/src/pages/CssTemplateList/index.tsx b/superset-frontend/src/pages/CssTemplateList/index.tsx index 7e2882d21..f777f8e74 100644 --- a/superset-frontend/src/pages/CssTemplateList/index.tsx +++ b/superset-frontend/src/pages/CssTemplateList/index.tsx @@ -356,6 +356,9 @@ function CssTemplatesList({ bulkActions={bulkActions} bulkSelectEnabled={bulkSelectEnabled} disableBulkSelect={toggleBulkSelect} + addDangerToast={addDangerToast} + addSuccessToast={addSuccessToast} + refreshData={refreshData} /> ); }} diff --git a/superset-frontend/src/pages/DashboardList/index.tsx b/superset-frontend/src/pages/DashboardList/index.tsx index 2b6252309..29bb51b96 100644 --- a/superset-frontend/src/pages/DashboardList/index.tsx +++ b/superset-frontend/src/pages/DashboardList/index.tsx @@ -755,10 +755,13 @@ function DashboardList(props: DashboardListProps) { data={dashboards} disableBulkSelect={toggleBulkSelect} fetchData={fetchData} + refreshData={refreshData} filters={filters} initialSort={initialSort} loading={loading} pageSize={PAGE_SIZE} + addSuccessToast={addSuccessToast} + addDangerToast={addDangerToast} showThumbnails={ userKey ? userKey.thumbnails @@ -770,6 +773,8 @@ function DashboardList(props: DashboardListProps) { ? 'card' : 'table' } + enableBulkTag + bulkTagResourceName="dashboard" /> ); diff --git a/superset-frontend/src/pages/DatabaseList/index.tsx b/superset-frontend/src/pages/DatabaseList/index.tsx index b2c18cece..d2308bd11 100644 --- a/superset-frontend/src/pages/DatabaseList/index.tsx +++ b/superset-frontend/src/pages/DatabaseList/index.tsx @@ -570,6 +570,9 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { filters={filters} initialSort={initialSort} loading={loading} + addDangerToast={addDangerToast} + addSuccessToast={addSuccessToast} + refreshData={() => {}} pageSize={PAGE_SIZE} /> diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx index 76d96757c..d86d7a7b0 100644 --- a/superset-frontend/src/pages/DatasetList/index.tsx +++ b/superset-frontend/src/pages/DatasetList/index.tsx @@ -811,6 +811,9 @@ const DatasetList: FunctionComponent = ({ bulkActions={bulkActions} bulkSelectEnabled={bulkSelectEnabled} disableBulkSelect={toggleBulkSelect} + addDangerToast={addDangerToast} + addSuccessToast={addSuccessToast} + refreshData={refreshData} renderBulkSelectCopy={selected => { const { virtualCount, physicalCount } = selected.reduce( (acc, e) => { diff --git a/superset-frontend/src/pages/ExecutionLogList/index.tsx b/superset-frontend/src/pages/ExecutionLogList/index.tsx index 2e32fe01c..a94cc1509 100644 --- a/superset-frontend/src/pages/ExecutionLogList/index.tsx +++ b/superset-frontend/src/pages/ExecutionLogList/index.tsx @@ -56,7 +56,11 @@ interface ExecutionLogProps { isReportEnabled: boolean; } -function ExecutionLog({ addDangerToast, isReportEnabled }: ExecutionLogProps) { +function ExecutionLog({ + addDangerToast, + addSuccessToast, + isReportEnabled, +}: ExecutionLogProps) { const { alertId }: any = useParams(); const { state: { loading, resourceCount: logCount, resourceCollection: logs }, @@ -191,6 +195,9 @@ function ExecutionLog({ addDangerToast, isReportEnabled }: ExecutionLogProps) { fetchData={fetchData} initialSort={initialSort} loading={loading} + addDangerToast={addDangerToast} + addSuccessToast={addSuccessToast} + refreshData={() => {}} pageSize={PAGE_SIZE} /> diff --git a/superset-frontend/src/pages/QueryHistoryList/index.tsx b/superset-frontend/src/pages/QueryHistoryList/index.tsx index 95944dd32..1d735fd69 100644 --- a/superset-frontend/src/pages/QueryHistoryList/index.tsx +++ b/superset-frontend/src/pages/QueryHistoryList/index.tsx @@ -50,6 +50,7 @@ import { QueryObject, QueryObjectColumns } from 'src/views/CRUD/types'; import Icons from 'src/components/Icons'; import QueryPreviewModal from 'src/features/queries/QueryPreviewModal'; +import { addSuccessToast } from 'src/components/MessageToasts/actions'; const PAGE_SIZE = 25; const SQL_PREVIEW_MAX_LINES = 4; @@ -443,6 +444,9 @@ function QueryList({ addDangerToast }: QueryListProps) { loading={loading} pageSize={PAGE_SIZE} highlightRowId={queryCurrentlyPreviewing?.id} + refreshData={() => {}} + addDangerToast={addDangerToast} + addSuccessToast={addSuccessToast} /> ); diff --git a/superset-frontend/src/pages/RowLevelSecurityList/index.tsx b/superset-frontend/src/pages/RowLevelSecurityList/index.tsx index e07f7e0e4..3c1e3b8aa 100644 --- a/superset-frontend/src/pages/RowLevelSecurityList/index.tsx +++ b/superset-frontend/src/pages/RowLevelSecurityList/index.tsx @@ -337,6 +337,9 @@ function RowLevelSecurityList(props: RLSProps) { filters={filters} initialSort={initialSort} loading={loading} + addDangerToast={addDangerToast} + addSuccessToast={addSuccessToast} + refreshData={() => {}} pageSize={PAGE_SIZE} /> diff --git a/superset-frontend/src/pages/SavedQueryList/index.tsx b/superset-frontend/src/pages/SavedQueryList/index.tsx index f0d89dba2..a2f3479b9 100644 --- a/superset-frontend/src/pages/SavedQueryList/index.tsx +++ b/superset-frontend/src/pages/SavedQueryList/index.tsx @@ -569,9 +569,14 @@ function SavedQueryList({ loading={loading} pageSize={PAGE_SIZE} bulkActions={bulkActions} + addSuccessToast={addSuccessToast} + addDangerToast={addDangerToast} bulkSelectEnabled={bulkSelectEnabled} disableBulkSelect={toggleBulkSelect} highlightRowId={savedQueryCurrentlyPreviewing?.id} + enableBulkTag + bulkTagResourceName="query" + refreshData={refreshData} /> ); }} diff --git a/superset-frontend/src/pages/Tags/index.tsx b/superset-frontend/src/pages/Tags/index.tsx index bbd79d0e7..e252e3d9f 100644 --- a/superset-frontend/src/pages/Tags/index.tsx +++ b/superset-frontend/src/pages/Tags/index.tsx @@ -145,13 +145,11 @@ function TagList(props: TagListProps) { { Cell: ({ row: { - original: { name: tagName }, + original: { id, name: tagName }, }, }: any) => ( - - {tagName} - + {tagName} ), Header: t('Name'), @@ -309,7 +307,11 @@ function TagList(props: TagListProps) { // render new 'New Tag' btn subMenuButtons.push({ - name: t('New Tag'), + name: ( + <> + {t('Tag')} + + ), buttonStyle: 'primary', 'data-test': 'bulk-select', onClick: () => setShowTagModal(true), @@ -330,6 +332,7 @@ function TagList(props: TagListProps) { refreshData={refreshData} addSuccessToast={addSuccessToast} addDangerToast={addDangerToast} + clearOnHide /> !tag.name.includes(':'))} disableBulkSelect={toggleBulkSelect} + refreshData={refreshData} emptyState={emptyState} fetchData={fetchData} filters={filters} initialSort={initialSort} loading={loading} + addDangerToast={addDangerToast} + addSuccessToast={addSuccessToast} pageSize={PAGE_SIZE} showThumbnails={ userKey diff --git a/superset-frontend/src/views/CRUD/types.ts b/superset-frontend/src/views/CRUD/types.ts index 0b37997f1..5a53b5769 100644 --- a/superset-frontend/src/views/CRUD/types.ts +++ b/superset-frontend/src/views/CRUD/types.ts @@ -138,9 +138,17 @@ export type ImportResourceName = export interface Tag { changed_on_delta_humanized: string; + changed_by: { + first_name: string; + last_name: string; + }; + created_on_delta_humanized: string; name: string; id: number; - created_by: object; + created_by: { + first_name: string; + last_name: string; + }; description: string; type: string; } diff --git a/superset/daos/tag.py b/superset/daos/tag.py index 1a0843cc0..b55397a32 100644 --- a/superset/daos/tag.py +++ b/superset/daos/tag.py @@ -367,7 +367,9 @@ class TagDAO(BaseDAO[Tag]): @staticmethod def create_tag_relationship( - objects_to_tag: list[tuple[ObjectTypes, int]], tag: Tag + objects_to_tag: list[tuple[ObjectTypes, int]], + tag: Tag, + bulk_create: bool = False, ) -> None: """ Creates a tag relationship between the given objects and the specified tag. @@ -401,9 +403,13 @@ class TagDAO(BaseDAO[Tag]): TaggedObject(object_id=object_id, object_type=object_type, tag=tag) ) - for object_type, object_id in tagged_objects_to_delete: - # delete objects that were removed - TagDAO.delete_tagged_object(object_type, object_id, tag.name) # type: ignore + if not bulk_create: + # delete relationships that aren't retained from single tag create + for object_type, object_id in tagged_objects_to_delete: + # delete objects that were removed + TagDAO.delete_tagged_object( + object_type, object_id, tag.name # type: ignore + ) db.session.add_all(tagged_objects) db.session.commit() diff --git a/superset/tags/api.py b/superset/tags/api.py index a760b3392..50807ec07 100644 --- a/superset/tags/api.py +++ b/superset/tags/api.py @@ -32,7 +32,6 @@ from superset.tags.commands.create import ( ) from superset.tags.commands.delete import DeleteTaggedObjectCommand, DeleteTagsCommand from superset.tags.commands.exceptions import ( - TagCreateFailedError, TagDeleteFailedError, TaggedObjectDeleteFailedError, TaggedObjectNotFoundError, @@ -47,6 +46,7 @@ from superset.tags.schemas import ( openapi_spec_methods_override, TaggedObjectEntityResponseSchema, TagGetResponseSchema, + TagPostBulkSchema, TagPostSchema, TagPutSchema, ) @@ -72,6 +72,7 @@ class TagRestApi(BaseSupersetModelRestApi): "add_favorite", "remove_favorite", "favorite_status", + "bulk_create", } resource_name = "tag" @@ -88,6 +89,7 @@ class TagRestApi(BaseSupersetModelRestApi): "changed_by.first_name", "changed_by.last_name", "changed_on_delta_humanized", + "created_on_delta_humanized", "created_by.first_name", "created_by.last_name", ] @@ -102,6 +104,7 @@ class TagRestApi(BaseSupersetModelRestApi): "changed_by.first_name", "changed_by.last_name", "changed_on_delta_humanized", + "created_on_delta_humanized", "created_by.first_name", "created_by.last_name", "created_by", @@ -192,14 +195,81 @@ class TagRestApi(BaseSupersetModelRestApi): return self.response(201) except TagInvalidError as ex: return self.response_422(message=ex.normalized_messages()) - except TagCreateFailedError as ex: - logger.error( - "Error creating model %s: %s", - self.__class__.__name__, - str(ex), - exc_info=True, - ) - return self.response_500(message=str(ex)) + + @expose("/bulk_create", methods=("POST",)) + @protect() + @safe + @statsd_metrics + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.bulk_create", + log_to_statsd=False, + ) + def bulk_create(self) -> Response: + """Bulk create tags and tagged objects + --- + post: + summary: Get all objects associated with a tag + parameters: + - in: path + schema: + type: integer + name: tag_id + requestBody: + description: Tag schema + required: true + content: + application/json: + schema: + type: object + properties: + tags: + description: list of tag names to add to object + type: array + items: + type: string + objects_to_tag: + description: list of object names to add to object + type: array + items: + type: array + responses: + 200: + description: Tag added to favorites + content: + application/json: + schema: + type: object + properties: + result: + type: object + 302: + description: Redirects to the current digest + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 500: + $ref: '#/components/responses/500' + """ + try: + item = TagPostBulkSchema().load(request.json) + except ValidationError as error: + return self.response_400(message=error.messages) + try: + for tag in item.get("tags"): + tagged_item: dict[str, Any] = self.add_model_schema.load( + {"name": tag, "objects_to_tag": item.get("objects_to_tag")} + ) + CreateCustomTagWithRelationshipsCommand( + tagged_item, bulk_create=True + ).run() + return self.response(201) + except TagNotFoundError: + return self.response_404() + except TagInvalidError as ex: + return self.response_422(message=ex.message) @expose("/", methods=("PUT",)) @protect() @@ -329,14 +399,6 @@ class TagRestApi(BaseSupersetModelRestApi): ) except TagInvalidError: return self.response(422, message="Invalid tag") - except TagCreateFailedError as ex: - logger.error( - "Error creating model %s: %s", - self.__class__.__name__, - str(ex), - exc_info=True, - ) - return self.response_500(message=str(ex)) @expose("////", methods=("DELETE",)) @protect() @@ -515,14 +577,6 @@ class TagRestApi(BaseSupersetModelRestApi): return self.response(200, result=result) except TagInvalidError as ex: return self.response_422(message=ex.normalized_messages()) - except TagCreateFailedError as ex: - logger.error( - "Error creating model %s: %s", - self.__class__.__name__, - str(ex), - exc_info=True, - ) - return self.response_500(message=str(ex)) @expose("/favorite_status/", methods=("GET",)) @protect() diff --git a/superset/tags/commands/create.py b/superset/tags/commands/create.py index 5c30b548b..3f05ccd23 100644 --- a/superset/tags/commands/create.py +++ b/superset/tags/commands/create.py @@ -65,10 +65,11 @@ class CreateCustomTagCommand(CreateMixin, BaseCommand): class CreateCustomTagWithRelationshipsCommand(CreateMixin, BaseCommand): - def __init__(self, data: dict[str, Any]): + def __init__(self, data: dict[str, Any], bulk_create: bool = False): self._tag = data["name"] self._objects_to_tag = data.get("objects_to_tag") self._description = data.get("description") + self._bulk_create = bulk_create def run(self) -> None: self.validate() @@ -76,7 +77,9 @@ class CreateCustomTagWithRelationshipsCommand(CreateMixin, BaseCommand): tag = TagDAO.get_by_name(self._tag.strip(), TagTypes.custom) if self._objects_to_tag: TagDAO.create_tag_relationship( - objects_to_tag=self._objects_to_tag, tag=tag + objects_to_tag=self._objects_to_tag, + tag=tag, + bulk_create=self._bulk_create, ) if self._description: diff --git a/superset/tags/exceptions.py b/superset/tags/exceptions.py index d1d9005bb..f252ecd0a 100644 --- a/superset/tags/exceptions.py +++ b/superset/tags/exceptions.py @@ -17,6 +17,8 @@ from flask_babel import lazy_gettext as _ from marshmallow.validate import ValidationError +from superset.commands.exceptions import CommandException, UpdateFailedError + class InvalidTagNameError(ValidationError): """ @@ -27,3 +29,11 @@ class InvalidTagNameError(ValidationError): super().__init__( [_("Tag name is invalid (cannot contain ':')")], field_name="name" ) + + +class TagUpdateFailedError(UpdateFailedError): + message = _("Tag could not be updated.") + + +class TagNotFoundError(CommandException): + message = _("Tag could not be found.") diff --git a/superset/tags/schemas.py b/superset/tags/schemas.py index 89f15d4bf..8aafbb76b 100644 --- a/superset/tags/schemas.py +++ b/superset/tags/schemas.py @@ -56,7 +56,15 @@ class TagGetResponseSchema(Schema): class TagPostSchema(Schema): name = fields.String() - description = fields.String(required=False) + description = fields.String(required=False, allow_none=True) + # resource id's to tag with tag + objects_to_tag = fields.List( + fields.Tuple((fields.String(), fields.Int())), required=False + ) + + +class TagPostBulkSchema(Schema): + tags = fields.List(fields.String()) # resource id's to tag with tag objects_to_tag = fields.List( fields.Tuple((fields.String(), fields.Int())), required=False @@ -65,7 +73,7 @@ class TagPostSchema(Schema): class TagPutSchema(Schema): name = fields.String() - description = fields.String(required=False) + description = fields.String(required=False, allow_none=True) # resource id's to tag with tag objects_to_tag = fields.List( fields.Tuple((fields.String(), fields.Int())), required=False diff --git a/tests/integration_tests/tags/api_tests.py b/tests/integration_tests/tags/api_tests.py index e0f4de87e..06e4a73e1 100644 --- a/tests/integration_tests/tags/api_tests.py +++ b/tests/integration_tests/tags/api_tests.py @@ -46,6 +46,8 @@ from tests.integration_tests.fixtures.world_bank_dashboard import ( ) from tests.integration_tests.fixtures.tags import with_tagging_system_feature from tests.integration_tests.base_tests import SupersetTestCase +from superset.daos.tag import TagDAO +from superset.tags.models import ObjectTypes TAGS_FIXTURE_COUNT = 10 @@ -57,6 +59,7 @@ TAGS_LIST_COLUMNS = [ "changed_by.first_name", "changed_by.last_name", "changed_on_delta_humanized", + "created_on_delta_humanized", "created_by.first_name", "created_by.last_name", ] @@ -501,3 +504,54 @@ class TestTagApi(SupersetTestCase): .one_or_none() ) assert tag is not None + + @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") + @pytest.mark.usefixtures("create_tags") + def test_failed_put_tag(self): + self.login(username="admin") + + tag_to_update = db.session.query(Tag).first() + uri = f"api/v1/tag/{tag_to_update.id}" + rv = self.client.put(uri, json={"foo": "bar"}) + + self.assertEqual(rv.status_code, 400) + + @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") + def test_post_bulk_tag(self): + self.login(username="admin") + uri = "api/v1/tag/bulk_create" + dashboard = ( + db.session.query(Dashboard) + .filter(Dashboard.dashboard_title == "World Bank's Data") + .first() + ) + chart = db.session.query(Slice).first() + tags = ["tag1", "tag2", "tag3"] + rv = self.client.post( + uri, + json={ + "tags": ["tag1", "tag2", "tag3"], + "objects_to_tag": [["dashboard", dashboard.id], ["chart", chart.id]], + }, + ) + + self.assertEqual(rv.status_code, 201) + + result = TagDAO.get_tagged_objects_for_tags(tags, ["dashboard"]) + assert len(result) == 1 + + result = TagDAO.get_tagged_objects_for_tags(tags, ["chart"]) + assert len(result) == 1 + + tagged_objects = db.session.query(TaggedObject).filter( + TaggedObject.object_id == dashboard.id, + TaggedObject.object_type == ObjectTypes.dashboard, + ) + assert tagged_objects.count() == 3 + + tagged_objects = db.session.query(TaggedObject).filter( + # TaggedObject.tag_id.in_([tag.id for tag in tags]), + TaggedObject.object_id == chart.id, + TaggedObject.object_type == ObjectTypes.chart, + ) + assert tagged_objects.count() == 3