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

feat: support for passing color in tags

This commit is contained in:
FredrikOseberg 2025-03-19 15:14:56 +01:00
parent aad5a6a1a9
commit 8360e3b6c5
No known key found for this signature in database
GPG Key ID: 282FD8A6D8F9BCF0
9 changed files with 224 additions and 78 deletions

View File

@ -0,0 +1,51 @@
import { styled } from '@mui/material';
import { Chip } from '@mui/material';
import type { ITag } from 'interfaces/tags';
import type { ReactElement } from 'react';
const StyledChip = styled(Chip)<{ $color?: string }>(({ theme, $color }) => ({
borderRadius: theme.spacing(2),
border: `1px solid ${theme.palette.divider}`,
backgroundColor: theme.palette.common.white,
color: theme.palette.text.primary,
'& .MuiChip-label': {
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
},
}));
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: ITag;
onDelete?: () => void;
deleteIcon?: ReactElement;
}
export const Tag = ({ tag, onDelete, deleteIcon }: ITagProps) => {
const label = `${tag.type}:${tag.value}`;
const showColorDot = tag.color && tag.color !== '#FFFFFF';
const labelContent = (
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{showColorDot && <ColorDot $color={tag.color!} />}
<span>{label}</span>
</span>
) as ReactElement;
return (
<StyledChip
label={labelContent}
onDelete={onDelete}
deleteIcon={deleteIcon}
/>
);
};

View File

@ -1,8 +1,9 @@
import type { IFeatureToggle } from 'interfaces/featureToggle'; import type { IFeatureToggle } from 'interfaces/featureToggle';
import { useContext, useState } from 'react'; 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 useFeatureTags from 'hooks/api/getters/useFeatureTags/useFeatureTags';
import DeleteTagIcon from '@mui/icons-material/Cancel'; import DeleteTagIcon from '@mui/icons-material/Cancel';
import ClearIcon from '@mui/icons-material/Clear';
import { ManageTagsDialog } from 'component/feature/FeatureView/FeatureOverview/ManageTagsDialog/ManageTagsDialog'; import { ManageTagsDialog } from 'component/feature/FeatureView/FeatureOverview/ManageTagsDialog/ManageTagsDialog';
import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions'; import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions';
import AccessContext from 'contexts/AccessContext'; import AccessContext from 'contexts/AccessContext';
@ -13,6 +14,8 @@ import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import { StyledMetaDataItem } from './FeatureOverviewMetaData'; import { StyledMetaDataItem } from './FeatureOverviewMetaData';
import { AddTagButton } from './AddTagButton'; import { AddTagButton } from './AddTagButton';
import { Tag } from 'component/common/Tag/Tag';
import { useUiFlag } from 'hooks/useUiFlag';
const StyledLabel = styled('span')(({ theme }) => ({ const StyledLabel = styled('span')(({ theme }) => ({
marginTop: theme.spacing(1), marginTop: theme.spacing(1),
@ -56,6 +59,7 @@ interface IFeatureOverviewSidePanelTagsProps {
export const TagRow = ({ feature }: IFeatureOverviewSidePanelTagsProps) => { export const TagRow = ({ feature }: IFeatureOverviewSidePanelTagsProps) => {
const { tags, refetch } = useFeatureTags(feature.name); const { tags, refetch } = useFeatureTags(feature.name);
const { deleteTagFromFeature } = useFeatureApi(); const { deleteTagFromFeature } = useFeatureApi();
const isTagTypeColorEnabled = useUiFlag('tagTypeColor');
const [manageTagsOpen, setManageTagsOpen] = useState(false); const [manageTagsOpen, setManageTagsOpen] = useState(false);
const [removeTagOpen, setRemoveTagOpen] = useState(false); const [removeTagOpen, setRemoveTagOpen] = useState(false);
@ -87,6 +91,19 @@ export const TagRow = ({ feature }: IFeatureOverviewSidePanelTagsProps) => {
} }
}; };
const renderTag = (tag: ITag) => (
<TagItem
key={`${tag.type}:${tag.value}`}
tag={tag}
isTagTypeColorEnabled={isTagTypeColorEnabled}
canUpdateTags={canUpdateTags}
onTagRemove={(tag) => {
setRemoveTagOpen(true);
setSelectedTag(tag);
}}
/>
);
return ( return (
<> <>
{!tags.length ? ( {!tags.length ? (
@ -103,49 +120,7 @@ export const TagRow = ({ feature }: IFeatureOverviewSidePanelTagsProps) => {
<StyledTagRow> <StyledTagRow>
<StyledLabel>Tags:</StyledLabel> <StyledLabel>Tags:</StyledLabel>
<StyledTagContainer> <StyledTagContainer>
{tags.map((tag) => { {tags.map(renderTag)}
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
}
/>
);
})}
{canUpdateTags ? ( {canUpdateTags ? (
<AddTagButton <AddTagButton
project={feature.project} project={feature.project}
@ -183,3 +158,71 @@ export const TagRow = ({ feature }: IFeatureOverviewSidePanelTagsProps) => {
</> </>
); );
}; };
interface ITagItemProps {
tag: ITag;
isTagTypeColorEnabled: boolean;
canUpdateTags: boolean;
onTagRemove: (tag: ITag) => void;
}
const TagItem = ({
tag,
isTagTypeColorEnabled,
canUpdateTags,
onTagRemove,
}: ITagItemProps) => {
const tagLabel = `${tag.type}:${tag.value}`;
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

@ -1,6 +1,6 @@
import type { IFeatureToggle } from 'interfaces/featureToggle'; import type { IFeatureToggle } from 'interfaces/featureToggle';
import { useContext, useState } from 'react'; import { useContext, useState } from 'react';
import { Button, Chip, Divider, styled } from '@mui/material'; import { Button, Divider, styled } from '@mui/material';
import useFeatureTags from 'hooks/api/getters/useFeatureTags/useFeatureTags'; import useFeatureTags from 'hooks/api/getters/useFeatureTags/useFeatureTags';
import Add from '@mui/icons-material/Add'; import Add from '@mui/icons-material/Add';
import Cancel from '@mui/icons-material/Cancel'; import Cancel from '@mui/icons-material/Cancel';
@ -13,6 +13,7 @@ import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Tag } from 'component/common/Tag/Tag';
const StyledContainer = styled('div')(({ theme }) => ({ const StyledContainer = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
@ -26,10 +27,6 @@ const StyledTagContainer = styled('div')(({ theme }) => ({
flexWrap: 'wrap', flexWrap: 'wrap',
})); }));
const StyledChip = styled(Chip)(({ theme }) => ({
fontSize: theme.fontSizes.smallBody,
}));
const StyledDivider = styled(Divider)(({ theme }) => ({ const StyledDivider = styled(Divider)(({ theme }) => ({
margin: theme.spacing(3), margin: theme.spacing(3),
borderStyle: 'dashed', borderStyle: 'dashed',
@ -82,24 +79,21 @@ export const FeatureOverviewSidePanelTags = ({
<StyledContainer> <StyledContainer>
{header} {header}
<StyledTagContainer> <StyledTagContainer>
{tags.map((tag) => { {tags.map((tag) => (
const tagLabel = `${tag.type}:${tag.value}`; <Tag
return ( key={`${tag.type}:${tag.value}`}
<StyledChip tag={tag}
key={tagLabel} onDelete={
label={tagLabel} canUpdateTags
deleteIcon={<Cancel titleAccess='Remove' />} ? () => {
onDelete={ setShowDelDialog(true);
canUpdateTags setSelectedTag(tag);
? () => { }
setShowDelDialog(true); : undefined
setSelectedTag(tag); }
} deleteIcon={<Cancel titleAccess='Remove' />}
: undefined />
} ))}
/>
);
})}
</StyledTagContainer> </StyledTagContainer>
<ConditionallyRender <ConditionallyRender
condition={canUpdateTags} condition={canUpdateTags}
@ -123,18 +117,18 @@ export const FeatureOverviewSidePanelTags = ({
<ManageTagsDialog open={openTagDialog} setOpen={setOpenTagDialog} /> <ManageTagsDialog open={openTagDialog} setOpen={setOpenTagDialog} />
<Dialogue <Dialogue
open={showDelDialog} open={showDelDialog}
primaryButtonText='Delete tag' primaryButtonText='Delete'
secondaryButtonText='Cancel' secondaryButtonText='Cancel'
onClose={() => {
setShowDelDialog(false);
setSelectedTag(undefined);
}}
onClick={() => { onClick={() => {
setShowDelDialog(false); setShowDelDialog(false);
handleDelete(); handleDelete();
setSelectedTag(undefined); setSelectedTag(undefined);
}} }}
title='Delete tag?' onClose={() => {
setShowDelDialog(false);
setSelectedTag(undefined);
}}
title='Delete tag'
> >
You are about to delete tag:{' '} You are about to delete tag:{' '}
<strong> <strong>

View File

@ -8,7 +8,7 @@ import { ProjectCardFooter } from './ProjectCardFooter/ProjectCardFooter';
import { ProjectModeBadge } from './ProjectModeBadge/ProjectModeBadge'; import { ProjectModeBadge } from './ProjectModeBadge/ProjectModeBadge';
import { ProjectIcon } from 'component/common/ProjectIcon/ProjectIcon'; import { ProjectIcon } from 'component/common/ProjectIcon/ProjectIcon';
import { FavoriteAction } from './FavoriteAction/FavoriteAction'; import { FavoriteAction } from './FavoriteAction/FavoriteAction';
import { Box, styled } from '@mui/material'; import { Box, styled, Chip, type ChipProps } from '@mui/material';
import { flexColumn } from 'themes/themeStyles'; import { flexColumn } from 'themes/themeStyles';
import { TimeAgo } from 'component/common/TimeAgo/TimeAgo'; import { TimeAgo } from 'component/common/TimeAgo/TimeAgo';
import { ProjectLastSeen } from './ProjectLastSeen/ProjectLastSeen'; import { ProjectLastSeen } from './ProjectLastSeen/ProjectLastSeen';
@ -18,6 +18,7 @@ import { ProjectMembers } from './ProjectCardFooter/ProjectMembers/ProjectMember
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { DEFAULT_PROJECT_ID } from 'hooks/api/getters/useDefaultProject/useDefaultProjectId'; import { DEFAULT_PROJECT_ID } from 'hooks/api/getters/useDefaultProject/useDefaultProjectId';
import type { ProjectSchema } from 'openapi'; import type { ProjectSchema } from 'openapi';
import type { ITag } from 'interfaces/tags';
const StyledUpdated = styled('span')(({ theme }) => ({ const StyledUpdated = styled('span')(({ theme }) => ({
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
@ -45,7 +46,30 @@ const StyledHeader = styled('div')(({ theme }) => ({
alignItems: 'center', alignItems: 'center',
})); }));
type ProjectCardProps = ProjectSchema & { onHover?: () => void }; const StyledTags = styled('div')(({ theme }) => ({
display: 'flex',
flexWrap: 'wrap',
gap: theme.spacing(0.5),
marginTop: theme.spacing(1),
}));
interface StyledTagProps extends ChipProps {
tagColor?: string;
}
const StyledTag = styled(Chip)<StyledTagProps>(({ theme, tagColor }) => ({
backgroundColor: tagColor || theme.palette.primary.main,
color: theme.palette.primary.contrastText,
'& .MuiChip-label': {
padding: theme.spacing(0.5, 1),
fontSize: theme.fontSizes.smallerBody,
},
}));
interface ProjectCardProps extends ProjectSchema {
onHover?: () => void;
tags?: ITag[];
}
export const ProjectCard = ({ export const ProjectCard = ({
name, name,
@ -60,6 +84,7 @@ export const ProjectCard = ({
createdAt, createdAt,
lastUpdatedAt, lastUpdatedAt,
lastReportedFlagUsage, lastReportedFlagUsage,
tags = [],
}: ProjectCardProps) => { }: ProjectCardProps) => {
const { searchQuery } = useSearchHighlightContext(); const { searchQuery } = useSearchHighlightContext();
@ -103,6 +128,21 @@ export const ProjectCard = ({
<ProjectLastSeen date={lastReportedFlagUsage} /> <ProjectLastSeen date={lastReportedFlagUsage} />
</div> </div>
</StyledInfo> </StyledInfo>
<ConditionallyRender
condition={tags.length > 0}
show={
<StyledTags>
{tags.map((tag: ITag) => (
<StyledTag
key={`${tag.type}-${tag.value}`}
label={tag.value}
tagColor={tag.color}
size='small'
/>
))}
</StyledTags>
}
/>
</StyledProjectCardBody> </StyledProjectCardBody>
<ProjectCardFooter id={id} owners={owners}> <ProjectCardFooter id={id} owners={owners}>
<ConditionallyRender <ConditionallyRender

View File

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

View File

@ -102,11 +102,16 @@ class FeatureTagStore implements IFeatureTagStore {
const stopTimer = this.timer('getAllForFeature'); const stopTimer = this.timer('getAllForFeature');
if (await this.featureExists(featureName)) { if (await this.featureExists(featureName)) {
const rows = await this.db const rows = await this.db
.select(COLUMNS) .select([...COLUMNS, 'tag_types.color as color'])
.from<FeatureTagTable>(TABLE) .from<FeatureTagTable>(TABLE)
.leftJoin('tag_types', 'tag_types.name', 'feature_tag.tag_type')
.where({ feature_name: featureName }); .where({ feature_name: featureName });
stopTimer(); stopTimer();
return rows.map(this.featureTagRowToTag); return rows.map((row) => ({
type: row.tag_type,
value: row.tag_value,
color: row.color,
}));
} else { } else {
throw new NotFoundError( throw new NotFoundError(
`Could not find feature with name ${featureName}`, `Could not find feature with name ${featureName}`,

View File

@ -134,6 +134,7 @@ class FeatureSearchStore implements IFeatureSearchStore {
'environments.sort_order as environment_sort_order', 'environments.sort_order as environment_sort_order',
'ft.tag_value as tag_value', 'ft.tag_value as tag_value',
'ft.tag_type as tag_type', 'ft.tag_type as tag_type',
'tag_types.color as tag_type_color',
'segments.name as segment_name', 'segments.name as segment_name',
'users.id as user_id', 'users.id as user_id',
'users.name as user_name', 'users.name as user_name',
@ -207,6 +208,7 @@ class FeatureSearchStore implements IFeatureSearchStore {
'ft.feature_name', 'ft.feature_name',
'features.name', 'features.name',
) )
.leftJoin('tag_types', 'tag_types.name', 'ft.tag_type')
.leftJoin( .leftJoin(
'feature_strategies', 'feature_strategies',
'feature_strategies.feature_name', 'feature_strategies.feature_name',
@ -548,6 +550,7 @@ class FeatureSearchStore implements IFeatureSearchStore {
return { return {
value: r.tag_value, value: r.tag_value,
type: r.tag_type, type: r.tag_type,
color: r.tag_type_color,
}; };
} }
@ -711,7 +714,7 @@ const applyMultiQueryParams = (
(Array.isArray(fields) (Array.isArray(fields)
? val!.split(/:(.+)/).filter(Boolean) ? val!.split(/:(.+)/).filter(Boolean)
: [val] : [val]
).map((s) => s.trim()), ).map((s) => s?.trim() || ''),
); );
const baseSubQuery = createBaseQuery(values); const baseSubQuery = createBaseQuery(values);

View File

@ -24,6 +24,13 @@ export const tagSchema = {
'The [type](https://docs.getunleash.io/reference/feature-toggles#tags) of the tag', 'The [type](https://docs.getunleash.io/reference/feature-toggles#tags) of the tag',
example: 'simple', example: 'simple',
}, },
color: {
type: 'string',
description: 'The hexadecimal color code for the tag type.',
example: '#FFFFFF',
pattern: '^#[0-9A-Fa-f]{6}$',
nullable: true,
},
}, },
components: {}, components: {},
} as const; } as const;

View File

@ -253,6 +253,7 @@ export interface IFeatureOverview {
lastSeenAt: Date; lastSeenAt: Date;
environments: IEnvironmentOverview[]; environments: IEnvironmentOverview[];
lifecycle?: IFeatureLifecycleStage; lifecycle?: IFeatureLifecycleStage;
tags?: ITag[];
} }
export type IFeatureSearchOverview = Exclude< export type IFeatureSearchOverview = Exclude<
@ -353,6 +354,7 @@ export interface IFeatureToggleDeltaQuery extends IFeatureToggleQuery {
export interface ITag { export interface ITag {
value: string; value: string;
type: string; type: string;
color?: string | null;
} }
export interface IAddonParameterDefinition { export interface IAddonParameterDefinition {