1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-13 13:48:59 +02:00

Feat/tag type frontend display (#9630)

Add frontend for displaying tag colors
This commit is contained in:
Fredrik Strand Oseberg 2025-03-31 11:55:49 +02:00 committed by GitHub
parent 9de0e7435b
commit aa6c422165
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 332 additions and 122 deletions

View File

@ -1,6 +1,9 @@
import type { FC, ReactElement } from 'react';
import type { FeatureSearchResponseSchema } from '../../../../../openapi';
import { Box, IconButton, styled } from '@mui/material';
import type { FC } from 'react';
import type {
FeatureSearchResponseSchema,
TagSchema,
} from '../../../../../openapi';
import { Box, IconButton, styled, Chip } from '@mui/material';
import useFeatureTypes from 'hooks/api/getters/useFeatureTypes/useFeatureTypes';
import { getFeatureTypeIcons } from 'utils/getFeatureTypeIcons';
import { useSearchHighlightContext } from '../../SearchHighlightContext/SearchHighlightContext';
@ -13,6 +16,9 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { getLocalizedDateString } from '../../../util';
import { Tag } from 'component/common/Tag/Tag';
import { useUiFlag } from 'hooks/useUiFlag';
import { formatTag } from 'utils/format-tag';
interface IFeatureNameCellProps {
row: {
@ -36,7 +42,7 @@ const StyledFeatureLink = styled(Link)({
},
});
const Tag = styled('button')(({ theme }) => ({
const CustomTagButton = styled('button')(({ theme }) => ({
marginRight: theme.spacing(0.5),
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadius,
@ -51,6 +57,24 @@ const Tag = styled('button')(({ theme }) => ({
color: 'inherit',
}));
const StyledTag = styled(Chip)(({ theme }) => ({
overflowWrap: 'anywhere',
lineHeight: theme.typography.body1.lineHeight,
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.primary,
padding: theme.spacing(0.25, 0.5),
height: 'auto',
fontSize: theme.fontSizes.smallerBody,
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadius,
'&:hover': {
backgroundColor: theme.palette.background.paper,
},
'& .MuiChip-label': {
padding: 0,
},
}));
const CappedDescription: FC<{ text: string; searchQuery: string }> = ({
text,
searchQuery,
@ -80,19 +104,6 @@ const CappedDescription: FC<{ text: string; searchQuery: string }> = ({
);
};
const CappedTag: FC<{ tag: string; children: ReactElement }> = ({
tag,
children,
}) => {
return (
<ConditionallyRender
condition={tag.length > 30}
show={<HtmlTooltip title={tag}>{children}</HtmlTooltip>}
elseShow={children}
/>
);
};
const Container = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
@ -158,23 +169,82 @@ const ArchivedFeatureName: FC<{
);
};
const RestTags: FC<{ tags: string[]; onClick: (tag: string) => void }> = ({
tags,
onClick,
}) => {
interface ITagItemProps {
tag: TagSchema;
onClick: (tag: TagSchema) => void;
}
const TagItem: FC<ITagItemProps> = ({ tag, onClick }) => {
const isTagTypeColorEnabled = useUiFlag('tagTypeColor');
const tagFullText = formatTag(tag);
if (isTagTypeColorEnabled) {
const tagComponent = (
<Box onClick={() => onClick(tag)} sx={{ cursor: 'pointer' }}>
<Tag tag={tag} maxLength={30} />
</Box>
);
return (
<HtmlTooltip key={tagFullText} title={tagFullText} arrow>
<span>{tagComponent}</span>
</HtmlTooltip>
);
}
// For non-color tags, use the StyledTag approach
const isOverflowing = tagFullText.length > 30;
const displayText = isOverflowing
? `${tagFullText.substring(0, 30)}...`
: tagFullText;
return (
<StyledTag
key={tagFullText}
label={displayText}
size='small'
onClick={() => onClick(tag)}
sx={{ cursor: 'pointer' }}
title={isOverflowing ? tagFullText : undefined}
/>
);
};
const RestTags: FC<{
tags: TagSchema[];
onClick: (tag: string) => void;
}> = ({ tags, onClick }) => {
const isTagTypeColorEnabled = useUiFlag('tagTypeColor');
return (
<HtmlTooltip
title={tags.map((tag) => (
<Box
sx={{ cursor: 'pointer' }}
onClick={() => onClick(tag)}
key={tag}
>
{tag}
</Box>
))}
title={tags.map((tag) => {
const formattedTag = formatTag(tag);
return (
<Box
sx={{ cursor: 'pointer' }}
onClick={() => onClick(formattedTag)}
key={formattedTag}
>
{isTagTypeColorEnabled ? (
<Tag tag={tag} maxLength={30} />
) : (
formattedTag
)}
</Box>
);
})}
>
<Tag sx={{ cursor: 'initial' }}>{tags.length} more...</Tag>
<CustomTagButton
sx={{
cursor: 'initial',
...(isTagTypeColorEnabled && {
borderRadius: (theme) => theme.spacing(2),
}),
}}
>
{tags.length} more...
</CustomTagButton>
</HtmlTooltip>
);
};
@ -183,27 +253,21 @@ const Tags: FC<{
tags: FeatureSearchResponseSchema['tags'];
onClick: (tag: string) => void;
}> = ({ tags, onClick }) => {
const [tag1, tag2, tag3, ...restTags] = (tags || []).map(
({ type, value }) => `${type}:${value}`,
);
if (!tags || tags.length === 0) {
return null;
}
const [tag1, tag2, tag3, ...restTags] = tags;
const handleTagClick = (tag: TagSchema) => {
onClick(formatTag(tag));
};
return (
<TagsContainer>
{tag1 && (
<CappedTag tag={tag1}>
<Tag onClick={() => onClick(tag1)}>{tag1}</Tag>
</CappedTag>
)}
{tag2 && (
<CappedTag tag={tag2}>
<Tag onClick={() => onClick(tag2)}>{tag2}</Tag>
</CappedTag>
)}
{tag3 && (
<CappedTag tag={tag3}>
<Tag onClick={() => onClick(tag3)}>{tag3}</Tag>
</CappedTag>
)}
{tag1 && <TagItem tag={tag1} onClick={handleTagClick} />}
{tag2 && <TagItem tag={tag2} onClick={handleTagClick} />}
{tag3 && <TagItem tag={tag3} onClick={handleTagClick} />}
<ConditionallyRender
condition={restTags.length > 0}
show={<RestTags tags={restTags} onClick={onClick} />}

View File

@ -1,10 +1,11 @@
import type { VFC } from 'react';
import type { FeatureSchema } from 'openapi';
import type { FeatureSchema, TagSchema } from 'openapi';
import { styled, Typography } from '@mui/material';
import { TextCell } from '../TextCell/TextCell';
import { Highlighter } from 'component/common/Highlighter/Highlighter';
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { TooltipLink } from 'component/common/TooltipLink/TooltipLink';
import { formatTag } from 'utils/format-tag';
const StyledTag = styled(Typography)(({ theme }) => ({
fontSize: theme.fontSizes.smallerBody,
@ -23,9 +24,8 @@ export const FeatureTagCell: VFC<IFeatureTagCellProps> = ({ row }) => {
return <TextCell />;
const value =
row.original.tags
?.map(({ type, value }) => `${type}:${value}`)
.join('\n') || '';
row.original.tags?.map((tag: TagSchema) => formatTag(tag)).join('\n') ||
'';
return (
<TextCell>
@ -39,7 +39,7 @@ export const FeatureTagCell: VFC<IFeatureTagCellProps> = ({ row }) => {
{row.original.tags?.map((tag) => (
<StyledTag key={tag.type + tag.value}>
<Highlighter search={searchQuery}>
{`${tag.type}:${tag.value}`}
{formatTag(tag)}
</Highlighter>
</StyledTag>
))}

View File

@ -0,0 +1,88 @@
import { styled } from '@mui/material';
import { Chip } from '@mui/material';
import type { TagSchema } from 'openapi';
import type { ReactElement } from 'react';
import { formatTag } from 'utils/format-tag';
const StyledChip = styled(Chip)<{ $color?: string }>(({ theme, $color }) => ({
borderRadius: theme.spacing(2),
border: `1px solid ${theme.palette.divider}`,
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.primary,
height: '26px',
'& .MuiChip-label': {
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
paddingRight: theme.spacing(1),
},
'& .MuiChip-deleteIcon': {
marginLeft: theme.spacing(0.5),
marginRight: theme.spacing(0.5),
},
}));
const ColorDot = styled('div')<{ $color: string }>(({ theme, $color }) => ({
width: theme.spacing(1.3),
height: theme.spacing(1.3),
borderRadius: '50%',
backgroundColor: $color,
border: `1px solid ${$color === '#FFFFFF' ? theme.palette.divider : $color}`,
flexShrink: 0,
}));
interface ITagProps {
tag: TagSchema;
onDelete?: () => void;
deleteIcon?: ReactElement;
maxLength?: number;
}
export const Tag = ({
tag,
onDelete,
deleteIcon,
maxLength = 30,
}: ITagProps) => {
const fullLabel = formatTag(tag);
const isOverflowing = fullLabel.length > maxLength;
const showColorDot = tag.color && tag.color !== '#FFFFFF';
let displayValue = tag.value;
if (isOverflowing) {
// Calculate how much of the value we can show
const maxValueLength = Math.max(0, maxLength - tag.type.length - 1); // -1 for the colon
displayValue = `${tag.value.substring(0, maxValueLength)}...`;
}
const labelContent = (
<span
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
width: '100%',
}}
>
{showColorDot && <ColorDot $color={tag.color!} />}
<span
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{`${tag.type}:${displayValue}`}
</span>
</span>
) as ReactElement;
return (
<StyledChip
label={labelContent}
onDelete={onDelete}
deleteIcon={deleteIcon}
title={isOverflowing ? fullLabel : undefined}
/>
);
};

View File

@ -7,6 +7,7 @@ import {
Filters,
type IFilterItem,
} from 'component/filter/Filters/Filters';
import { formatTag } from 'utils/format-tag';
interface IFeatureToggleFiltersProps {
state: FilterItemParamHolder;
@ -44,8 +45,8 @@ export const FeatureToggleFilters: VFC<IFeatureToggleFiltersProps> = ({
value: segment.name,
}));
const tagsOptions = (tags || []).map((tag) => ({
label: `${tag.type}:${tag.value}`,
value: `${tag.type}:${tag.value}`,
label: formatTag(tag),
value: formatTag(tag),
}));
const hasMultipleProjects = projectsOptions.length > 1;

View File

@ -1,8 +1,9 @@
import type { IFeatureToggle } from 'interfaces/featureToggle';
import { useContext, useState } from 'react';
import { Chip, styled, Tooltip } from '@mui/material';
import { styled, Tooltip, Chip } from '@mui/material';
import useFeatureTags from 'hooks/api/getters/useFeatureTags/useFeatureTags';
import DeleteTagIcon from '@mui/icons-material/Cancel';
import ClearIcon from '@mui/icons-material/Clear';
import { ManageTagsDialog } from 'component/feature/FeatureView/FeatureOverview/ManageTagsDialog/ManageTagsDialog';
import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions';
import AccessContext from 'contexts/AccessContext';
@ -13,6 +14,9 @@ import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { StyledMetaDataItem } from './FeatureOverviewMetaData';
import { AddTagButton } from './AddTagButton';
import { Tag } from 'component/common/Tag/Tag';
import { useUiFlag } from 'hooks/useUiFlag';
import { formatTag } from 'utils/format-tag';
const StyledLabel = styled('span')(({ theme }) => ({
marginTop: theme.spacing(1),
@ -56,7 +60,6 @@ interface IFeatureOverviewSidePanelTagsProps {
export const TagRow = ({ feature }: IFeatureOverviewSidePanelTagsProps) => {
const { tags, refetch } = useFeatureTags(feature.name);
const { deleteTagFromFeature } = useFeatureApi();
const [manageTagsOpen, setManageTagsOpen] = useState(false);
const [removeTagOpen, setRemoveTagOpen] = useState(false);
const [selectedTag, setSelectedTag] = useState<ITag>();
@ -103,49 +106,17 @@ export const TagRow = ({ feature }: IFeatureOverviewSidePanelTagsProps) => {
<StyledTagRow>
<StyledLabel>Tags:</StyledLabel>
<StyledTagContainer>
{tags.map((tag) => {
const tagLabel = `${tag.type}:${tag.value}`;
const isOverflowing = tagLabel.length > 25;
return (
<StyledTag
key={tagLabel}
label={
<Tooltip
key={tagLabel}
title={
isOverflowing ? tagLabel : ''
}
arrow
>
<span>
{tagLabel.substring(0, 25)}
{isOverflowing ? (
<StyledEllipsis>
</StyledEllipsis>
) : (
''
)}
</span>
</Tooltip>
}
size='small'
deleteIcon={
<Tooltip title='Remove tag' arrow>
<DeleteTagIcon />
</Tooltip>
}
onDelete={
canUpdateTags
? () => {
setRemoveTagOpen(true);
setSelectedTag(tag);
}
: undefined
}
/>
);
})}
{tags.map((tag) => (
<TagItem
key={formatTag(tag)}
tag={tag}
canUpdateTags={canUpdateTags}
onTagRemove={(tag) => {
setRemoveTagOpen(true);
setSelectedTag(tag);
}}
/>
))}
{canUpdateTags ? (
<AddTagButton
project={feature.project}
@ -183,3 +154,66 @@ export const TagRow = ({ feature }: IFeatureOverviewSidePanelTagsProps) => {
</>
);
};
interface ITagItemProps {
tag: ITag;
canUpdateTags: boolean;
onTagRemove: (tag: ITag) => void;
}
const TagItem = ({ tag, canUpdateTags, onTagRemove }: ITagItemProps) => {
const isTagTypeColorEnabled = useUiFlag('tagTypeColor');
const tagLabel = formatTag(tag);
const isOverflowing = tagLabel.length > 25;
const deleteIcon = (
<Tooltip title='Remove tag' arrow>
<DeleteTagIcon />
</Tooltip>
);
const onDelete = canUpdateTags ? () => onTagRemove(tag) : undefined;
if (isTagTypeColorEnabled) {
const deleteIcon = (
<Tooltip title='Remove tag' arrow>
<ClearIcon sx={{ height: '20px', width: '20px' }} />
</Tooltip>
);
return (
<Tooltip key={tagLabel} title={isOverflowing ? tagLabel : ''} arrow>
<span>
<Tag
tag={tag}
onDelete={onDelete}
deleteIcon={deleteIcon}
/>
</span>
</Tooltip>
);
}
return (
<StyledTag
key={tagLabel}
label={
<Tooltip
key={tagLabel}
title={isOverflowing ? tagLabel : ''}
arrow
>
<span>
{tagLabel.substring(0, 25)}
{isOverflowing ? (
<StyledEllipsis></StyledEllipsis>
) : (
''
)}
</span>
</Tooltip>
}
size='small'
deleteIcon={deleteIcon}
onDelete={onDelete}
/>
);
};

View File

@ -13,6 +13,7 @@ import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { formatTag } from 'utils/format-tag';
const StyledContainer = styled('div')(({ theme }) => ({
display: 'flex',
@ -83,7 +84,7 @@ export const FeatureOverviewSidePanelTags = ({
{header}
<StyledTagContainer>
{tags.map((tag) => {
const tagLabel = `${tag.type}:${tag.value}`;
const tagLabel = formatTag(tag);
return (
<StyledChip
key={tagLabel}

View File

@ -14,16 +14,17 @@ import useTagTypes from 'hooks/api/getters/useTagTypes/useTagTypes';
import type { ITag, ITagType } from 'interfaces/tags';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import useTagApi from 'hooks/api/actions/useTagApi/useTagApi';
import type { TagSchema } from 'openapi';
type Payload = {
addedTags: ITag[];
removedTags: ITag[];
addedTags: TagSchema[];
removedTags: TagSchema[];
};
interface IManageBulkTagsDialogProps {
open: boolean;
initialValues: ITag[];
initialIndeterminateValues: ITag[];
initialValues: TagSchema[];
initialIndeterminateValues: TagSchema[];
onCancel: () => void;
onSubmit: (payload: Payload) => void;
}
@ -36,14 +37,14 @@ const StyledDialogFormContent = styled('section')(({ theme }) => ({
const formId = 'manage-tags-form';
const mergeTags = (tags: ITag[], newTag: ITag) => [
const mergeTags = (tags: TagSchema[], newTag: TagSchema) => [
...tags,
...(tags.some((x) => x.value === newTag.value && x.type === newTag.type)
? []
: [newTag]),
];
const filterTags = (tags: ITag[], tag: ITag) =>
const filterTags = (tags: TagSchema[], tag: TagSchema) =>
tags.filter((x) => !(x.value === tag.value && x.type === tag.type));
export const payloadReducer = (
@ -51,11 +52,11 @@ export const payloadReducer = (
action:
| {
type: 'add' | 'remove';
payload: ITag;
payload: TagSchema;
}
| {
type: 'clear';
payload: ITag[];
payload: TagSchema[];
}
| { type: 'reset' },
) => {
@ -171,7 +172,7 @@ export const ManageBulkTagsDialog: FC<IManageBulkTagsDialogProps> = ({
value,
type,
}).then(async () => {
await refetchTags();
refetchTags();
setSelectedTags((prev) => [...prev, { title: value }]);
dispatch({
type: 'add',

View File

@ -10,9 +10,10 @@ import type React from 'react';
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
import CheckBoxIcon from '@mui/icons-material/CheckBox';
import IndeterminateCheckBoxIcon from '@mui/icons-material/IndeterminateCheckBox';
import type { ITag, ITagType } from 'interfaces/tags';
import type { ITagType } from 'interfaces/tags';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import Add from '@mui/icons-material/Add';
import type { TagSchema } from 'openapi';
export type TagOption = {
title: string;
@ -21,7 +22,7 @@ export type TagOption = {
interface ITagsInputProps {
options: TagOption[];
existingTags: ITag[];
existingTags: TagSchema[];
tagType: ITagType;
selectedOptions: TagOption[];
indeterminateOptions?: TagOption[];

View File

@ -30,6 +30,7 @@ import type { ITag } from 'interfaces/tags';
import { ToggleConfigButton } from 'component/common/DialogFormTemplate/ConfigButtons/ToggleConfigButton';
import { useFlagLimits } from './useFlagLimits';
import { useFeatureCreatedFeedback } from './hooks/useFeatureCreatedFeedback';
import { formatTag } from 'utils/format-tag';
interface ICreateFeatureDialogProps {
open: boolean;
@ -295,7 +296,7 @@ const CreateFeatureDialogContent = ({
description={configButtonData.tags.text}
selectedOptions={tags}
options={allTags.map((tag) => ({
label: `${tag.type}:${tag.value}`,
label: formatTag(tag),
value: tag,
}))}
onChange={setTags}

View File

@ -6,6 +6,7 @@ import {
type IFilterItem,
} from 'component/filter/Filters/Filters';
import { useProjectFlagCreators } from 'hooks/api/getters/useProjectFlagCreators/useProjectFlagCreators';
import { formatTag } from 'utils/format-tag';
interface IProjectOverviewFilters {
state: FilterItemParamHolder;
@ -23,10 +24,13 @@ export const ProjectOverviewFilters: VFC<IProjectOverviewFilters> = ({
const [availableFilters, setAvailableFilters] = useState<IFilterItem[]>([]);
useEffect(() => {
const tagsOptions = (tags || []).map((tag) => ({
label: `${tag.type}:${tag.value}`,
value: `${tag.type}:${tag.value}`,
}));
const tagsOptions = (tags || []).map((tag) => {
const tagString = formatTag(tag);
return {
label: tagString,
value: tagString,
};
});
const flagCreatorsOptions = flagCreators.map((creator) => ({
label: creator.name,

View File

@ -1,7 +1,7 @@
import { useMemo, useState, type VFC } from 'react';
import { Button } from '@mui/material';
import { ManageBulkTagsDialog } from 'component/feature/FeatureView/FeatureOverview/ManageTagsDialog/ManageBulkTagsDialog';
import type { FeatureSchema } from 'openapi';
import type { FeatureSchema, TagSchema } from 'openapi';
import type { ITag } from 'interfaces/tags';
import useTagApi from 'hooks/api/actions/useTagApi/useTagApi';
import useToast from 'hooks/useToast';
@ -28,7 +28,7 @@ export const ManageTags: VFC<IManageTagsProps> = ({
const [initialValues, indeterminateValues] = useMemo(() => {
const uniqueTags = data
.flatMap(({ tags }) => tags || [])
.reduce<ITag[]>(
.reduce<TagSchema[]>(
(acc, tag) => [
...acc,
...(acc.some(
@ -56,8 +56,8 @@ export const ManageTags: VFC<IManageTagsProps> = ({
addedTags,
removedTags,
}: {
addedTags: ITag[];
removedTags: ITag[];
addedTags: TagSchema[];
removedTags: TagSchema[];
}) => {
const features = data.map(({ name }) => name);
const payload = { features, tags: { addedTags, removedTags } };

View File

@ -1,6 +1,7 @@
export interface ITag {
value: string;
type: string;
color?: string;
}
export interface ITagType {

View File

@ -8,6 +8,12 @@
* Representation of a [tag](https://docs.getunleash.io/reference/feature-toggles#tags)
*/
export interface TagSchema {
/**
* The hexadecimal color code for the tag type.
* @nullable
* @pattern ^#[0-9A-Fa-f]{6}$
*/
color?: string | null;
/**
* The [type](https://docs.getunleash.io/reference/feature-toggles#tags) of the tag
* @minLength 2

View File

@ -0,0 +1,8 @@
import type { ITag } from 'interfaces/tags';
import type { TagSchema } from 'openapi';
// Use TagSchema or ITag for backwards compatability in components that
// have not yet been refactored to use TagSchema
export const formatTag = (tag: TagSchema | ITag) => {
return `${tag.type}:${tag.value}`;
};