feat(ui): Introducing flows for creating and deleting manage tags (#13107)

This commit is contained in:
Anna Everhart 2025-04-15 15:16:33 -07:00 committed by GitHub
parent 4df375df32
commit 4e30fa78ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 650 additions and 25 deletions

View File

@ -0,0 +1,152 @@
import React, { useState } from 'react';
import { message } from 'antd';
import { Modal } from '@components';
import { useCreateTagMutation } from '../../../graphql/tag.generated';
import { useEnterKeyListener } from '../../shared/useEnterKeyListener';
import { useBatchAddOwnersMutation, useSetTagColorMutation } from '../../../graphql/mutations.generated';
import { ModalButton } from './types';
import TagDetailsSection from './TagDetailsSection';
import OwnersSection, { PendingOwner } from './OwnersSection';
type CreateNewTagModalProps = {
open: boolean;
onClose: () => void;
};
/**
* Modal for creating a new tag with owners and applying it to entities
*/
const CreateNewTagModal: React.FC<CreateNewTagModalProps> = ({ onClose, open }) => {
// Tag details state
const [tagName, setTagName] = useState('');
const [tagDescription, setTagDescription] = useState('');
const [tagColor, setTagColor] = useState('#1890ff');
// Owners state
const [selectedOwnerUrns, setSelectedOwnerUrns] = useState<string[]>([]);
const [pendingOwners, setPendingOwners] = useState<PendingOwner[]>([]);
// Loading state
const [isLoading, setIsLoading] = useState(false);
// Mutations
const [createTagMutation] = useCreateTagMutation();
const [setTagColorMutation] = useSetTagColorMutation();
const [batchAddOwnersMutation] = useBatchAddOwnersMutation();
/**
* Handler for creating the tag and applying it to entities
*/
const onOk = async () => {
if (!tagName) {
message.error('Tag name is required');
return;
}
setIsLoading(true);
try {
// Step 1: Create the new tag
const createTagResult = await createTagMutation({
variables: {
input: {
id: tagName.trim(),
name: tagName.trim(),
description: tagDescription,
},
},
});
const newTagUrn = createTagResult.data?.createTag;
if (!newTagUrn) {
message.error('Failed to create tag. An unexpected error occurred');
setIsLoading(false);
return;
}
// Step 2: Add color
if (tagColor) {
await setTagColorMutation({
variables: {
urn: newTagUrn,
colorHex: tagColor,
},
});
}
// Step 3: Add owners if any
if (pendingOwners.length > 0) {
await batchAddOwnersMutation({
variables: {
input: {
owners: pendingOwners,
resources: [{ resourceUrn: newTagUrn }],
},
},
});
}
message.success(`Tag "${tagName}" successfully created`);
onClose();
setTagName('');
setTagDescription('');
setTagColor('#1890ff');
setSelectedOwnerUrns([]);
setPendingOwners([]);
} catch (e: any) {
message.destroy();
message.error('Failed to create tag. An unexpected error occurred');
} finally {
setIsLoading(false);
}
};
// Handle the Enter press
useEnterKeyListener({
querySelectorToExecuteClick: '#createNewTagButton',
});
// Modal buttons configuration
const buttons: ModalButton[] = [
{
text: 'Cancel',
color: 'violet',
variant: 'text',
onClick: onClose,
},
{
text: 'Create',
id: 'createNewTagButton',
color: 'violet',
variant: 'filled',
onClick: onOk,
disabled: !tagName || isLoading,
isLoading,
},
];
return (
<Modal title="Create New Tag" onCancel={onClose} buttons={buttons} open={open} centered width={500}>
{/* Tag Details Section */}
<TagDetailsSection
tagName={tagName}
setTagName={setTagName}
tagDescription={tagDescription}
setTagDescription={setTagDescription}
tagColor={tagColor}
setTagColor={setTagColor}
/>
{/* Owners Section */}
<OwnersSection
selectedOwnerUrns={selectedOwnerUrns}
setSelectedOwnerUrns={setSelectedOwnerUrns}
pendingOwners={pendingOwners}
setPendingOwners={setPendingOwners}
/>
</Modal>
);
};
export default CreateNewTagModal;

View File

@ -0,0 +1,199 @@
import React, { useState } from 'react';
import styled from 'styled-components';
import { Select } from 'antd';
import { Text } from '@components';
import { EntityType, OwnerEntityType } from '../../../types.generated';
import { useGetSearchResultsForMultipleLazyQuery } from '../../../graphql/search.generated';
import { useEntityRegistry } from '../../useEntityRegistry';
import { useListOwnershipTypesQuery } from '../../../graphql/ownership.generated';
import { OwnerLabel } from '../../shared/OwnerLabel';
// Interface for pending owner
export interface PendingOwner {
ownerUrn: string;
ownerEntityType: OwnerEntityType;
ownershipTypeUrn: string;
}
// Owners section props
export interface OwnersSectionProps {
selectedOwnerUrns: string[];
setSelectedOwnerUrns: React.Dispatch<React.SetStateAction<string[]>>;
pendingOwners: PendingOwner[];
setPendingOwners: React.Dispatch<React.SetStateAction<PendingOwner[]>>;
}
const SectionContainer = styled.div`
margin-bottom: 24px;
`;
const SectionHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
`;
const FormSection = styled.div`
margin-bottom: 16px;
`;
const SelectInput = styled(Select)`
width: 100%;
.ant-select-selection-item {
display: flex;
align-items: center;
border-radius: 16px;
margin: 2px;
height: 32px;
padding-left: 4px;
border: none;
}
.ant-select-selection-item-remove {
margin-left: 8px;
margin-right: 8px;
color: rgba(0, 0, 0, 0.45);
}
`;
/**
* Component for owner selection and management
*/
const OwnersSection: React.FC<OwnersSectionProps> = ({
selectedOwnerUrns,
setSelectedOwnerUrns,
pendingOwners,
setPendingOwners,
}) => {
const entityRegistry = useEntityRegistry();
const [inputValue, setInputValue] = useState('');
const [isSearching, setIsSearching] = useState(false);
// Search query for owners across both CorpUser and CorpGroup types
const [searchAcrossEntities, { data: searchData, loading: searchLoading }] =
useGetSearchResultsForMultipleLazyQuery({
fetchPolicy: 'no-cache',
});
// Lazy load ownership types
const { data: ownershipTypesData } = useListOwnershipTypesQuery({
variables: {
input: {},
},
});
const ownershipTypes = ownershipTypesData?.listOwnershipTypes?.ownershipTypes || [];
const defaultOwnerType = ownershipTypes.length > 0 ? ownershipTypes[0].urn : undefined;
// Get search results from the combined query
const searchResults = searchData?.searchAcrossEntities?.searchResults?.map((result) => result.entity) || [];
// Debounced search handler
const handleOwnerSearch = (text: string) => {
setInputValue(text.trim());
setIsSearching(true);
if (text && text.trim().length > 1) {
searchAcrossEntities({
variables: {
input: {
types: [EntityType.CorpUser, EntityType.CorpGroup],
query: text.trim(),
start: 0,
count: 10,
},
},
});
}
};
// Renders a search result in the select dropdown
const renderSearchResult = (entityItem: any) => {
const avatarUrl =
entityItem.type === EntityType.CorpUser ? entityItem.editableProperties?.pictureLink : undefined;
const displayName = entityRegistry.getDisplayName(entityItem.type, entityItem);
return (
<Select.Option
key={entityItem.urn}
value={entityItem.urn}
label={<OwnerLabel name={displayName} avatarUrl={avatarUrl} type={entityItem.type} />}
>
<OwnerLabel name={displayName} avatarUrl={avatarUrl} type={entityItem.type} />
</Select.Option>
);
};
// Handle select change - stores owners as pending until save
const handleSelectChange = (values: any) => {
const newValues = values as string[];
setSelectedOwnerUrns(newValues);
// Find new owner URNs that weren't previously selected
const newOwnerUrns = newValues.filter((urn) => !pendingOwners.some((owner) => owner.ownerUrn === urn));
if (newOwnerUrns.length > 0 && defaultOwnerType) {
const newPendingOwners = newOwnerUrns.map((urn) => {
const foundEntity = searchResults.find((e) => e.urn === urn);
const ownerEntityType =
foundEntity && foundEntity.type === EntityType.CorpGroup
? OwnerEntityType.CorpGroup
: OwnerEntityType.CorpUser;
return {
ownerUrn: urn,
ownerEntityType,
ownershipTypeUrn: defaultOwnerType,
};
});
setPendingOwners([...pendingOwners, ...newPendingOwners]);
}
// Handle removed owners
if (newValues.length < selectedOwnerUrns.length) {
const removedUrns = selectedOwnerUrns.filter((urn) => !newValues.includes(urn));
const updatedPendingOwners = pendingOwners.filter((owner) => !removedUrns.includes(owner.ownerUrn));
setPendingOwners(updatedPendingOwners);
}
};
// Loading state for the select
const isSelectLoading = isSearching && searchLoading;
// Simplified conditional content for notFoundContent
let notFoundContent: React.ReactNode = null;
if (isSelectLoading) {
notFoundContent = 'Loading...';
} else if (inputValue && searchResults.length === 0) {
notFoundContent = 'No results found';
}
return (
<SectionContainer>
<SectionHeader>
<Text>Add Owners</Text>
</SectionHeader>
<FormSection>
<SelectInput
mode="multiple"
placeholder="Search for users or groups"
showSearch
filterOption={false}
onSearch={handleOwnerSearch}
onChange={handleSelectChange}
value={selectedOwnerUrns}
loading={isSelectLoading}
notFoundContent={notFoundContent}
optionLabelProp="label"
>
{searchResults.map((entity) => renderSearchResult(entity))}
</SelectInput>
</FormSection>
</SectionContainer>
);
};
export default OwnersSection;

View File

@ -0,0 +1,83 @@
import React from 'react';
import styled from 'styled-components';
import { Input, ColorPicker } from '@components';
// Tag details section props
export interface TagDetailsProps {
tagName: string;
setTagName: React.Dispatch<React.SetStateAction<string>>;
tagDescription: string;
setTagDescription: React.Dispatch<React.SetStateAction<string>>;
tagColor: string;
setTagColor: (color: string) => void;
}
const SectionContainer = styled.div`
margin-bottom: 24px;
`;
const FormSection = styled.div`
margin-bottom: 16px;
`;
/**
* Component for tag name, description, and color selection
*/
const TagDetailsSection: React.FC<TagDetailsProps> = ({
tagName,
setTagName,
tagDescription,
setTagDescription,
tagColor,
setTagColor,
}) => {
const handleTagNameChange: React.Dispatch<React.SetStateAction<string>> = (value) => {
if (typeof value === 'function') {
setTagName(value);
} else {
setTagName(value);
}
};
const handleDescriptionChange: React.Dispatch<React.SetStateAction<string>> = (value) => {
if (typeof value === 'function') {
setTagDescription(value);
} else {
setTagDescription(value);
}
};
const handleColorChange = (color: string) => {
setTagColor(color);
};
return (
<SectionContainer>
<FormSection>
<Input
label="Name"
value={tagName}
setValue={handleTagNameChange}
placeholder="Enter tag name"
required
/>
</FormSection>
<FormSection>
<Input
label="Description"
value={tagDescription}
setValue={handleDescriptionChange}
placeholder="Add a description for your new tag"
type="textarea"
/>
</FormSection>
<FormSection>
<ColorPicker initialColor={tagColor} onChange={handleColorChange} label="Color" />
</FormSection>
</SectionContainer>
);
};
export default TagDetailsSection;

View File

@ -0,0 +1,22 @@
// Interface for modal buttons matching the expected ButtonProps
export interface ModalButton {
text: string;
color: 'violet' | 'white' | 'black' | 'green' | 'red' | 'blue' | 'yellow' | 'gray';
variant: 'text' | 'filled' | 'outline';
onClick: () => void;
id?: string;
disabled?: boolean;
isLoading?: boolean;
}
// Common styled components
export const FormSection = {
marginBottom: '16px',
};
// Create tag result interface
export interface CreateTagResult {
tagUrn: string;
success: boolean;
error?: Error;
}

View File

@ -446,6 +446,7 @@ const ManageTag = ({ tagUrn, onClose, onSave, isModalOpen = false }: Props) => {
entityUrn={tagUrn}
owner={ownerItem}
hidePopOver
refetch={refetch}
/>
))
) : (

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useShowNavBarRedesign } from '@src/app/useShowNavBarRedesign';
import { SearchBar, PageTitle, Pagination } from '@components';
import { SearchBar, PageTitle, Pagination, Button, Tooltip2 } from '@components';
import { useGetSearchResultsForMultipleQuery } from '@src/graphql/search.generated';
import { EntityType } from '@src/types.generated';
import { Message } from '@src/app/shared/Message';
@ -9,6 +9,8 @@ import styled from 'styled-components';
import { PageContainer } from '../govern/structuredProperties/styledComponents';
import EmptyTags from './EmptyTags';
import TagsTable from './TagsTable';
import CreateNewTagModal from './CreateNewTagModal/CreateNewTagModal';
import { useUserContext } from '../context/useUserContext';
const HeaderContainer = styled.div`
display: flex;
@ -55,6 +57,11 @@ const ManageTags = () => {
const [currentPage, setCurrentPage] = useState(1);
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('*');
const entityRegistry = useEntityRegistry();
const [showCreateTagModal, setShowCreateTagModal] = useState(false);
// Check permissions using UserContext
const userContext = useUserContext();
const canCreateTags = userContext?.platformPrivileges?.createTags || userContext?.platformPrivileges?.manageTags;
// Debounce search query input to reduce unnecessary renders
useEffect(() => {
@ -110,12 +117,45 @@ const ManageTags = () => {
return <Message type="error" content={`Failed to load tags: ${searchError.message}`} />;
}
// Create the Create Tag button with proper permissions handling
const renderCreateTagButton = () => {
if (!canCreateTags) {
return (
<Tooltip2
title="You do not have permission to create tags"
placement="left"
showArrow
mouseEnterDelay={0.1}
mouseLeaveDelay={0.1}
>
<span>
<Button size="md" color="violet" icon={{ icon: 'Plus', source: 'phosphor' }} disabled>
Create Tag
</Button>
</span>
</Tooltip2>
);
}
return (
<Button
onClick={() => setShowCreateTagModal(true)}
size="md"
color="violet"
icon={{ icon: 'Plus', source: 'phosphor' }}
>
Create Tag
</Button>
);
};
return (
<PageContainer $isShowNavBarRedesign={isShowNavBarRedesign}>
{searchLoading && <LoadingBar />}
<HeaderContainer>
<PageTitle title="Manage Tags" subTitle="Create and edit asset & column tags" />
{renderCreateTagButton()}
</HeaderContainer>
<SearchContainer>
@ -148,6 +188,14 @@ const ManageTags = () => {
/>
</>
)}
<CreateNewTagModal
open={showCreateTagModal}
onClose={() => {
setShowCreateTagModal(false);
setTimeout(() => refetch(), 3000);
}}
/>
</PageContainer>
);
};

View File

@ -1,10 +1,13 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useCallback } from 'react';
import { NetworkStatus } from '@apollo/client';
import { Table } from '@components';
import { Table, Modal } from '@components';
import { message } from 'antd';
import { AlignmentOptions } from '@src/alchemy-components/theme/config';
import { useEntityRegistry } from '@src/app/useEntityRegistry';
import { GetSearchResultsForMultipleQuery } from '@src/graphql/search.generated';
import { EntityType } from '@src/types.generated';
import { useDeleteTagMutation } from '../../graphql/tag.generated';
import { useUserContext } from '../context/useUserContext';
import { ManageTag } from './ManageTag';
import {
TagActionsColumn,
@ -25,6 +28,11 @@ interface Props {
const TagsTable = ({ searchQuery, searchData, loading: propLoading, networkStatus, refetch }: Props) => {
const entityRegistry = useEntityRegistry();
const userContext = useUserContext();
const [deleteTagMutation] = useDeleteTagMutation();
// Check if user has permission to manage or delete tags
const canManageTags = Boolean(userContext?.platformPrivileges?.manageTags);
// Optimize the tagsData with useMemo to prevent unnecessary filtering on re-renders
const tagsData = useMemo(() => {
@ -34,6 +42,11 @@ const TagsTable = ({ searchQuery, searchData, loading: propLoading, networkStatu
const [showEdit, setShowEdit] = useState(false);
const [editingTag, setEditingTag] = useState('');
// Simplified state for delete confirmation modal
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [tagUrnToDelete, setTagUrnToDelete] = useState('');
const [tagDisplayName, setTagDisplayName] = useState('');
const [sortedInfo, setSortedInfo] = useState<{
columnKey?: string;
order?: 'ascend' | 'descend';
@ -62,6 +75,43 @@ const TagsTable = ({ searchQuery, searchData, loading: propLoading, networkStatu
const isLoading = propLoading || networkStatus === NetworkStatus.refetch;
// Simplified function to initiate tag deletion
const showDeleteConfirmation = useCallback(
(tagUrn: string) => {
// Find the tag entity from tagsData
const tagData = tagsData.find((result) => result.entity.urn === tagUrn);
if (!tagData) {
message.error('Failed to find tag information');
return;
}
const fullDisplayName = entityRegistry.getDisplayName(EntityType.Tag, tagData.entity);
setTagUrnToDelete(tagUrn);
setTagDisplayName(fullDisplayName);
setShowDeleteModal(true);
},
[entityRegistry, tagsData],
);
// Function to handle the actual tag deletion
const handleDeleteTag = useCallback(() => {
deleteTagMutation({
variables: {
urn: tagUrnToDelete,
},
})
.then(() => {
message.success(`Tag "${tagDisplayName}" has been deleted`);
refetch(); // Refresh the tag list
})
.catch((e: any) => {
message.error(`Failed to delete tag: ${e.message}`);
});
setShowDeleteModal(false);
}, [deleteTagMutation, refetch, tagUrnToDelete, tagDisplayName]);
const columns = useMemo(
() => [
{
@ -119,12 +169,20 @@ const TagsTable = ({ searchQuery, searchData, loading: propLoading, networkStatu
setEditingTag(record.entity.urn);
setShowEdit(true);
}}
onDelete={() => {
if (canManageTags) {
showDeleteConfirmation(record.entity.urn);
} else {
message.error('You do not have permission to delete tags');
}
}}
canManageTags={canManageTags}
/>
);
},
},
],
[entityRegistry, searchQuery, sortedInfo],
[entityRegistry, searchQuery, sortedInfo, canManageTags, showDeleteConfirmation],
);
// Generate table data once with memoization
@ -154,6 +212,30 @@ const TagsTable = ({ searchQuery, searchData, loading: propLoading, networkStatu
isModalOpen={showEdit}
/>
)}
{/* Delete confirmation modal - simplified */}
<Modal
title={`Delete tag ${tagDisplayName}`}
onCancel={() => setShowDeleteModal(false)}
open={showDeleteModal}
centered
buttons={[
{
text: 'Cancel',
color: 'violet',
variant: 'text',
onClick: () => setShowDeleteModal(false),
},
{
text: 'Delete',
color: 'red',
variant: 'filled',
onClick: handleDeleteTag,
},
]}
>
<p>Are you sure you want to delete this tag? This action cannot be undone.</p>
</Modal>
</>
);
};

View File

@ -99,7 +99,11 @@ export const TagDescriptionColumn = React.memo(({ tagUrn }: { tagUrn: string })
});
export const TagOwnersColumn = React.memo(({ tagUrn }: { tagUrn: string }) => {
const { data, loading: ownerLoading } = useGetTagQuery({
const {
data,
loading: ownerLoading,
refetch: refetchOwners,
} = useGetTagQuery({
variables: { urn: tagUrn },
fetchPolicy: 'cache-first',
});
@ -115,7 +119,13 @@ export const TagOwnersColumn = React.memo(({ tagUrn }: { tagUrn: string }) => {
<ColumnContainer>
<OwnersContainer>
{ownershipData.map((ownerItem) => (
<ExpandedOwner key={ownerItem.owner?.urn} entityUrn={tagUrn} owner={ownerItem as any} hidePopOver />
<ExpandedOwner
key={ownerItem.owner?.urn}
entityUrn={tagUrn}
owner={ownerItem as any}
hidePopOver
refetch={refetchOwners}
/>
))}
</OwnersContainer>
</ColumnContainer>
@ -225,26 +235,54 @@ export const TagAppliedToColumn = React.memo(({ tagUrn }: { tagUrn: string }) =>
);
});
export const TagActionsColumn = React.memo(({ tagUrn, onEdit }: { tagUrn: string; onEdit: () => void }) => {
const items = [
{
key: '0',
label: (
<MenuItem onClick={onEdit} data-testid="action-edit">
Edit
</MenuItem>
),
},
];
export const TagActionsColumn = React.memo(
({
tagUrn,
onEdit,
onDelete,
canManageTags,
}: {
tagUrn: string;
onEdit: () => void;
onDelete: () => void;
canManageTags: boolean;
}) => {
const items = [
{
key: '0',
label: (
<MenuItem onClick={onEdit} data-testid="action-edit">
Edit
</MenuItem>
),
},
...(canManageTags
? [
{
key: '1',
label: (
<MenuItem
onClick={onDelete}
data-testid="action-delete"
style={{ color: colors.red[500] }}
>
Delete
</MenuItem>
),
},
]
: []),
];
return (
<CardIcons>
<Dropdown menu={{ items }} trigger={['click']} data-testid={`${tagUrn}-actions-dropdown`}>
<Icon icon="MoreVert" size="md" />
</Dropdown>
</CardIcons>
);
});
return (
<CardIcons>
<Dropdown menu={{ items }} trigger={['click']} data-testid={`${tagUrn}-actions-dropdown`}>
<Icon icon="MoreVert" size="md" />
</Dropdown>
</CardIcons>
);
},
);
export const TagColorColumn = React.memo(({ tag }: { tag: Entity }) => {
const colorHex = getTagColor(tag);