feat: Tags ListView Page (#24964)

This commit is contained in:
Hugh A. Miles II 2023-09-12 14:48:07 +02:00 committed by GitHub
parent baf713aad5
commit 55ac01b675
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 605 additions and 130 deletions

View File

@ -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<T extends object = any> {
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<T extends object = any> {
showThumbnails?: boolean;
emptyState?: EmptyStateProps;
columnsForWrapText?: string[];
enableBulkTag?: boolean;
bulkTagResourceName?: string;
}
function ListView<T extends object = any>({
@ -235,6 +241,7 @@ function ListView<T extends object = any>({
count,
pageSize: initialPageSize,
fetchData,
refreshData,
loading,
initialSort = [],
className = '',
@ -250,6 +257,10 @@ function ListView<T extends object = any>({
highlightRowId,
emptyState,
columnsForWrapText,
enableBulkTag = false,
bulkTagResourceName,
addSuccessToast,
addDangerToast,
}: ListViewProps<T>) {
const {
getTableProps,
@ -278,6 +289,7 @@ function ListView<T extends object = any>({
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<T extends object = any>({
}, [query.filters]);
const cardViewEnabled = Boolean(renderCard);
const [showBulkTagModal, setShowBulkTagModal] = useState<boolean>(false);
useEffect(() => {
// discard selections if bulk select is disabled
@ -310,6 +323,17 @@ function ListView<T extends object = any>({
return (
<ListViewStyles>
{allowBulkTagActions && (
<BulkTagModal
show={showBulkTagModal}
selected={selectedFlatRows}
refreshData={refreshData}
resourceName={bulkTagResourceName}
addSuccessToast={addSuccessToast}
addDangerToast={addDangerToast}
onHide={() => setShowBulkTagModal(false)}
/>
)}
<div data-test={className} className={`superset-list-view ${className}`}>
<div className="header">
{cardViewEnabled && (
@ -375,6 +399,17 @@ function ListView<T extends object = any>({
{action.name}
</Button>
))}
{enableBulkTag && (
<span
data-test="bulk-select-tag-btn"
role="button"
tabIndex={0}
className="tag-btn"
onClick={() => setShowBulkTagModal(true)}
>
{t('Add Tag')}
</span>
)}
</>
)}
</>
@ -425,7 +460,6 @@ function ListView<T extends object = any>({
)}
</div>
</div>
{rows.length > 0 && (
<div className="pagination-container">
<Pagination

View File

@ -90,7 +90,9 @@ const config = (contentType: ContentType) => {
tooltip: (
<div>
<Info header={t('Created by')} text={contentType.createdBy} />
<Info header={t('Owners')} text={contentType.owners} />
{!!contentType.owners && (
<Info header={t('Owners')} text={contentType.owners} />
)}
<Info header={t('Created on')} text={contentType.createdOn} />
</div>
),

View File

@ -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;
};

View File

@ -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 = ({
<div className="right-button-panel">
{rightPanelAdditionalItems}
<div css={additionalActionsContainerStyles}>
<AntdDropdown
trigger={['click']}
overlay={additionalActionsMenu}
{...menuDropdownProps}
>
<Button
css={menuTriggerStyles}
buttonStyle="tertiary"
aria-label={t('Menu actions trigger')}
tooltip={tooltipProps?.text}
placement={tooltipProps?.placement}
data-test="actions-trigger"
{showMenuDropdown && (
<AntdDropdown
trigger={['click']}
overlay={additionalActionsMenu}
{...menuDropdownProps}
>
<Icons.MoreHoriz
iconColor={theme.colors.primary.dark2}
iconSize="l"
/>
</Button>
</AntdDropdown>
<Button
css={menuTriggerStyles}
buttonStyle="tertiary"
aria-label={t('Menu actions trigger')}
tooltip={tooltipProps?.text}
placement={tooltipProps?.placement}
data-test="actions-trigger"
>
<Icons.MoreHoriz
iconColor={theme.colors.primary.dark2}
iconSize="l"
/>
</Button>
</AntdDropdown>
)}
</div>
</div>
</div>

View File

@ -58,7 +58,7 @@ const Tag = ({
<StyledTag role="link" key={id} onClick={onClick}>
{id ? (
<a
href={`/superset/all_entities/?tags=${name}`}
href={`/superset/all_entities/?id=${id}`}
target="_blank"
rel="noreferrer"
>

View File

@ -95,6 +95,7 @@ const createProps = () => ({
maxUndoHistoryToast: jest.fn(),
dashboardInfoChanged: jest.fn(),
dashboardTitleChanged: jest.fn(),
showMenuDropdown: true,
});
const props = createProps();
const editableProps = {

View File

@ -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<BulkTagModalProps> = ({
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<TaggableResourceOption[]>([]);
return (
<Modal
title={t('Bulk tag')}
show={show}
onHide={() => {
setTags([]);
onHide();
}}
footer={
<div>
<Button
data-test="modal-save-dashboard-button"
buttonStyle="secondary"
onClick={onHide}
>
{t('Cancel')}
</Button>
<Button
data-test="modal-save-dashboard-button"
buttonStyle="primary"
onClick={onSave}
>
{t('Save')}
</Button>
</div>
}
>
<>
<>{t('You are adding tags to the %s entities', selected.length)}</>
<br />
<FormLabel>{t('tags')}</FormLabel>
<AsyncSelect
ariaLabel="tags"
// @ts-ignore
value={tags}
options={loadTags}
onHide={onHide}
// @ts-ignore
onChange={tags => setTags(tags)}
placeholder={t('Select Tags')}
mode="multiple"
/>
</>
</Modal>
);
};
export default BulkTagModal;

View File

@ -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(<TagModal {...mockedProps} editTag={editTag} />);

View File

@ -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<TagModalProps> = ({
refreshData,
addSuccessToast,
addDangerToast,
clearOnHide = false,
}) => {
const [dashboardsToTag, setDashboardsToTag] = useState<
TaggableResourceOption[]
@ -235,11 +243,13 @@ const TagModal: React.FC<TagModalProps> = ({
<Modal
title={modalTitle}
onHide={() => {
setTagName('');
setDescription('');
setDashboardsToTag([]);
setChartsToTag([]);
setSavedQueriesToTag([]);
if (clearOnHide) {
setTagName('');
setDescription('');
setDashboardsToTag([]);
setChartsToTag([]);
setSavedQueriesToTag([]);
}
onHide();
}}
show={show}
@ -262,22 +272,22 @@ const TagModal: React.FC<TagModalProps> = ({
</div>
}
>
<>
<FormLabel>{t('Tag Name')}</FormLabel>
<Input
onChange={handleTagNameChange}
placeholder={t('Name of your tag')}
value={tagName}
/>
<FormLabel>{t('Description')}</FormLabel>
<Input
onChange={handleDescriptionChange}
placeholder={t('Add description of your tag')}
value={description}
/>
<Divider />
<FormLabel>{t('Tag name')}</FormLabel>
<Input
onChange={handleTagNameChange}
placeholder={t('Name of your tag')}
value={tagName}
/>
<FormLabel>{t('Description')}</FormLabel>
<Input
onChange={handleDescriptionChange}
placeholder={t('Add description of your tag')}
value={description}
/>
<Divider />
<StyledModalBody>
<AsyncSelect
ariaLabel={t('Select Dashboards')}
ariaLabel={t('Select dashboards')}
mode="multiple"
name="dashboards"
// @ts-ignore
@ -290,7 +300,7 @@ const TagModal: React.FC<TagModalProps> = ({
allowClear
/>
<AsyncSelect
ariaLabel={t('Select Charts')}
ariaLabel={t('Select charts')}
mode="multiple"
name="charts"
// @ts-ignore
@ -301,7 +311,7 @@ const TagModal: React.FC<TagModalProps> = ({
allowClear
/>
<AsyncSelect
ariaLabel={t('Select Saved Queries')}
ariaLabel={t('Select saved queries')}
mode="multiple"
name="savedQueries"
// @ts-ignore
@ -310,10 +320,10 @@ const TagModal: React.FC<TagModalProps> = ({
onChange={value =>
handleOptionChange(TaggableResources.SavedQuery, value)
}
header={<FormLabel>{t('Saved Queries')}</FormLabel>}
header={<FormLabel>{t('Saved queries')}</FormLabel>}
allowClear
/>
</>
</StyledModalBody>
</Modal>
);
};

View File

@ -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));
}

View File

@ -612,6 +612,9 @@ function AlertList({
bulkActions={bulkActions}
bulkSelectEnabled={bulkSelectEnabled}
disableBulkSelect={toggleBulkSelect}
refreshData={refreshData}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
pageSize={PAGE_SIZE}
/>
);

View File

@ -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<Tag | null>(null);
const [showTagModal, setShowTagModal] = useState<boolean>(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 (
<AllEntitiesContainer>
<TagModal
show={showTagModal}
onHide={() => {
setShowTagModal(false);
}}
editTag={tag}
addSuccessToast={addSuccessToast}
addDangerToast={addDangerToast}
refreshData={() => {}} // todo(hugh): implement refreshData on table reload
/>
<AllEntitiesNav>
<span className="navbar-brand">{t('All Entities')}</span>
</AllEntitiesNav>
<div className="select-control">
<div className="select-control-label">{t('search by tags')}</div>
<AsyncSelect
ariaLabel="tags"
value={tagsValue}
onChange={onTagSearchChange}
options={loadTags}
placeholder="Select"
mode="multiple"
<PageHeaderWithActions
additionalActionsMenu={<></>}
editableTitleProps={editableTitleProps}
faveStarProps={{ itemId: 1, saveFaveStar: () => {} }}
showFaveStar={false}
showTitlePanelItems
titlePanelAdditionalItems={
<div css={additionalItemsStyles}>
<MetadataBar items={items} tooltipPlacement="bottom" />
</div>
}
rightPanelAdditionalItems={
<>
<Button
data-test="bulk-select-action"
buttonStyle="secondary"
onClick={() => setShowTagModal(true)}
>
{t('Edit Tag')}{' '}
</Button>
</>
}
menuDropdownProps={{
disabled: true,
}}
/>
</AllEntitiesNav>
<div className="entities">
<AllEntitiesTable search={tag?.name || ''} />
</div>
<AllEntitiesTable search={tagsQuery || ''} />
</AllEntitiesContainer>
);
}

View File

@ -384,7 +384,10 @@ function AnnotationLayersList({
bulkActions={bulkActions}
bulkSelectEnabled={bulkSelectEnabled}
disableBulkSelect={toggleBulkSelect}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
emptyState={emptyState}
refreshData={refreshData}
/>
);
}}

View File

@ -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}

View File

@ -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

View File

@ -356,6 +356,9 @@ function CssTemplatesList({
bulkActions={bulkActions}
bulkSelectEnabled={bulkSelectEnabled}
disableBulkSelect={toggleBulkSelect}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
refreshData={refreshData}
/>
);
}}

View File

@ -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"
/>
</>
);

View File

@ -570,6 +570,9 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
filters={filters}
initialSort={initialSort}
loading={loading}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
refreshData={() => {}}
pageSize={PAGE_SIZE}
/>

View File

@ -811,6 +811,9 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
bulkActions={bulkActions}
bulkSelectEnabled={bulkSelectEnabled}
disableBulkSelect={toggleBulkSelect}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
refreshData={refreshData}
renderBulkSelectCopy={selected => {
const { virtualCount, physicalCount } = selected.reduce(
(acc, e) => {

View File

@ -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}
/>
</>

View File

@ -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}
/>
</>
);

View File

@ -337,6 +337,9 @@ function RowLevelSecurityList(props: RLSProps) {
filters={filters}
initialSort={initialSort}
loading={loading}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
refreshData={() => {}}
pageSize={PAGE_SIZE}
/>
</>

View File

@ -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}
/>
);
}}

View File

@ -145,13 +145,11 @@ function TagList(props: TagListProps) {
{
Cell: ({
row: {
original: { name: tagName },
original: { id, name: tagName },
},
}: any) => (
<AntdTag>
<Link to={`/superset/all_entities/?tags=${tagName}`}>
{tagName}
</Link>
<Link to={`/superset/all_entities/?id=${id}`}>{tagName}</Link>
</AntdTag>
),
Header: t('Name'),
@ -309,7 +307,11 @@ function TagList(props: TagListProps) {
// render new 'New Tag' btn
subMenuButtons.push({
name: t('New Tag'),
name: (
<>
<i className="fa fa-plus" /> {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
/>
<SubMenu name={t('Tags')} buttons={subMenuButtons} />
<ConfirmStatusChange
@ -353,16 +356,19 @@ function TagList(props: TagListProps) {
bulkActions={bulkActions}
bulkSelectEnabled={bulkSelectEnabled}
cardSortSelectOptions={sortTypes}
className="dashboard-list-view"
className="tags-list-view"
columns={columns}
count={tagCount}
data={tags.filter(tag => !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

View File

@ -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;
}

View File

@ -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()

View File

@ -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("/<pk>", 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("/<int:object_type>/<int:object_id>/<tag>/", 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()

View File

@ -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:

View File

@ -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.")

View File

@ -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

View File

@ -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