feat: Tags ListView Page (#24964)
This commit is contained in:
parent
baf713aad5
commit
55ac01b675
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ const createProps = () => ({
|
|||
maxUndoHistoryToast: jest.fn(),
|
||||
dashboardInfoChanged: jest.fn(),
|
||||
dashboardTitleChanged: jest.fn(),
|
||||
showMenuDropdown: true,
|
||||
});
|
||||
const props = createProps();
|
||||
const editableProps = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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} />);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -612,6 +612,9 @@ function AlertList({
|
|||
bulkActions={bulkActions}
|
||||
bulkSelectEnabled={bulkSelectEnabled}
|
||||
disableBulkSelect={toggleBulkSelect}
|
||||
refreshData={refreshData}
|
||||
addDangerToast={addDangerToast}
|
||||
addSuccessToast={addSuccessToast}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -384,7 +384,10 @@ function AnnotationLayersList({
|
|||
bulkActions={bulkActions}
|
||||
bulkSelectEnabled={bulkSelectEnabled}
|
||||
disableBulkSelect={toggleBulkSelect}
|
||||
addDangerToast={addDangerToast}
|
||||
addSuccessToast={addSuccessToast}
|
||||
emptyState={emptyState}
|
||||
refreshData={refreshData}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -356,6 +356,9 @@ function CssTemplatesList({
|
|||
bulkActions={bulkActions}
|
||||
bulkSelectEnabled={bulkSelectEnabled}
|
||||
disableBulkSelect={toggleBulkSelect}
|
||||
addDangerToast={addDangerToast}
|
||||
addSuccessToast={addSuccessToast}
|
||||
refreshData={refreshData}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -570,6 +570,9 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
|
|||
filters={filters}
|
||||
initialSort={initialSort}
|
||||
loading={loading}
|
||||
addDangerToast={addDangerToast}
|
||||
addSuccessToast={addSuccessToast}
|
||||
refreshData={() => {}}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -337,6 +337,9 @@ function RowLevelSecurityList(props: RLSProps) {
|
|||
filters={filters}
|
||||
initialSort={initialSort}
|
||||
loading={loading}
|
||||
addDangerToast={addDangerToast}
|
||||
addSuccessToast={addSuccessToast}
|
||||
refreshData={() => {}}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue