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:
parent
aad5a6a1a9
commit
8360e3b6c5
51
frontend/src/component/common/Tag/Tag.tsx
Normal file
51
frontend/src/component/common/Tag/Tag.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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,8 @@ 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';
|
||||
|
||||
const StyledLabel = styled('span')(({ theme }) => ({
|
||||
marginTop: theme.spacing(1),
|
||||
@ -56,6 +59,7 @@ interface IFeatureOverviewSidePanelTagsProps {
|
||||
export const TagRow = ({ feature }: IFeatureOverviewSidePanelTagsProps) => {
|
||||
const { tags, refetch } = useFeatureTags(feature.name);
|
||||
const { deleteTagFromFeature } = useFeatureApi();
|
||||
const isTagTypeColorEnabled = useUiFlag('tagTypeColor');
|
||||
|
||||
const [manageTagsOpen, setManageTagsOpen] = 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 (
|
||||
<>
|
||||
{!tags.length ? (
|
||||
@ -103,49 +120,7 @@ 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(renderTag)}
|
||||
{canUpdateTags ? (
|
||||
<AddTagButton
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -1,6 +1,6 @@
|
||||
import type { IFeatureToggle } from 'interfaces/featureToggle';
|
||||
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 Add from '@mui/icons-material/Add';
|
||||
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 { formatUnknownError } from 'utils/formatUnknownError';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { Tag } from 'component/common/Tag/Tag';
|
||||
|
||||
const StyledContainer = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
@ -26,10 +27,6 @@ const StyledTagContainer = styled('div')(({ theme }) => ({
|
||||
flexWrap: 'wrap',
|
||||
}));
|
||||
|
||||
const StyledChip = styled(Chip)(({ theme }) => ({
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
}));
|
||||
|
||||
const StyledDivider = styled(Divider)(({ theme }) => ({
|
||||
margin: theme.spacing(3),
|
||||
borderStyle: 'dashed',
|
||||
@ -82,24 +79,21 @@ export const FeatureOverviewSidePanelTags = ({
|
||||
<StyledContainer>
|
||||
{header}
|
||||
<StyledTagContainer>
|
||||
{tags.map((tag) => {
|
||||
const tagLabel = `${tag.type}:${tag.value}`;
|
||||
return (
|
||||
<StyledChip
|
||||
key={tagLabel}
|
||||
label={tagLabel}
|
||||
deleteIcon={<Cancel titleAccess='Remove' />}
|
||||
onDelete={
|
||||
canUpdateTags
|
||||
? () => {
|
||||
setShowDelDialog(true);
|
||||
setSelectedTag(tag);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{tags.map((tag) => (
|
||||
<Tag
|
||||
key={`${tag.type}:${tag.value}`}
|
||||
tag={tag}
|
||||
onDelete={
|
||||
canUpdateTags
|
||||
? () => {
|
||||
setShowDelDialog(true);
|
||||
setSelectedTag(tag);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
deleteIcon={<Cancel titleAccess='Remove' />}
|
||||
/>
|
||||
))}
|
||||
</StyledTagContainer>
|
||||
<ConditionallyRender
|
||||
condition={canUpdateTags}
|
||||
@ -123,18 +117,18 @@ export const FeatureOverviewSidePanelTags = ({
|
||||
<ManageTagsDialog open={openTagDialog} setOpen={setOpenTagDialog} />
|
||||
<Dialogue
|
||||
open={showDelDialog}
|
||||
primaryButtonText='Delete tag'
|
||||
primaryButtonText='Delete'
|
||||
secondaryButtonText='Cancel'
|
||||
onClose={() => {
|
||||
setShowDelDialog(false);
|
||||
setSelectedTag(undefined);
|
||||
}}
|
||||
onClick={() => {
|
||||
setShowDelDialog(false);
|
||||
handleDelete();
|
||||
setSelectedTag(undefined);
|
||||
}}
|
||||
title='Delete tag?'
|
||||
onClose={() => {
|
||||
setShowDelDialog(false);
|
||||
setSelectedTag(undefined);
|
||||
}}
|
||||
title='Delete tag'
|
||||
>
|
||||
You are about to delete tag:{' '}
|
||||
<strong>
|
||||
|
@ -8,7 +8,7 @@ import { ProjectCardFooter } from './ProjectCardFooter/ProjectCardFooter';
|
||||
import { ProjectModeBadge } from './ProjectModeBadge/ProjectModeBadge';
|
||||
import { ProjectIcon } from 'component/common/ProjectIcon/ProjectIcon';
|
||||
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 { TimeAgo } from 'component/common/TimeAgo/TimeAgo';
|
||||
import { ProjectLastSeen } from './ProjectLastSeen/ProjectLastSeen';
|
||||
@ -18,6 +18,7 @@ import { ProjectMembers } from './ProjectCardFooter/ProjectMembers/ProjectMember
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { DEFAULT_PROJECT_ID } from 'hooks/api/getters/useDefaultProject/useDefaultProjectId';
|
||||
import type { ProjectSchema } from 'openapi';
|
||||
import type { ITag } from 'interfaces/tags';
|
||||
|
||||
const StyledUpdated = styled('span')(({ theme }) => ({
|
||||
color: theme.palette.text.secondary,
|
||||
@ -45,7 +46,30 @@ const StyledHeader = styled('div')(({ theme }) => ({
|
||||
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 = ({
|
||||
name,
|
||||
@ -60,6 +84,7 @@ export const ProjectCard = ({
|
||||
createdAt,
|
||||
lastUpdatedAt,
|
||||
lastReportedFlagUsage,
|
||||
tags = [],
|
||||
}: ProjectCardProps) => {
|
||||
const { searchQuery } = useSearchHighlightContext();
|
||||
|
||||
@ -103,6 +128,21 @@ export const ProjectCard = ({
|
||||
<ProjectLastSeen date={lastReportedFlagUsage} />
|
||||
</div>
|
||||
</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>
|
||||
<ProjectCardFooter id={id} owners={owners}>
|
||||
<ConditionallyRender
|
||||
|
@ -1,6 +1,7 @@
|
||||
export interface ITag {
|
||||
value: string;
|
||||
type: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface ITagType {
|
||||
|
@ -102,11 +102,16 @@ class FeatureTagStore implements IFeatureTagStore {
|
||||
const stopTimer = this.timer('getAllForFeature');
|
||||
if (await this.featureExists(featureName)) {
|
||||
const rows = await this.db
|
||||
.select(COLUMNS)
|
||||
.select([...COLUMNS, 'tag_types.color as color'])
|
||||
.from<FeatureTagTable>(TABLE)
|
||||
.leftJoin('tag_types', 'tag_types.name', 'feature_tag.tag_type')
|
||||
.where({ feature_name: featureName });
|
||||
stopTimer();
|
||||
return rows.map(this.featureTagRowToTag);
|
||||
return rows.map((row) => ({
|
||||
type: row.tag_type,
|
||||
value: row.tag_value,
|
||||
color: row.color,
|
||||
}));
|
||||
} else {
|
||||
throw new NotFoundError(
|
||||
`Could not find feature with name ${featureName}`,
|
||||
|
@ -134,6 +134,7 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
||||
'environments.sort_order as environment_sort_order',
|
||||
'ft.tag_value as tag_value',
|
||||
'ft.tag_type as tag_type',
|
||||
'tag_types.color as tag_type_color',
|
||||
'segments.name as segment_name',
|
||||
'users.id as user_id',
|
||||
'users.name as user_name',
|
||||
@ -207,6 +208,7 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
||||
'ft.feature_name',
|
||||
'features.name',
|
||||
)
|
||||
.leftJoin('tag_types', 'tag_types.name', 'ft.tag_type')
|
||||
.leftJoin(
|
||||
'feature_strategies',
|
||||
'feature_strategies.feature_name',
|
||||
@ -548,6 +550,7 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
||||
return {
|
||||
value: r.tag_value,
|
||||
type: r.tag_type,
|
||||
color: r.tag_type_color,
|
||||
};
|
||||
}
|
||||
|
||||
@ -711,7 +714,7 @@ const applyMultiQueryParams = (
|
||||
(Array.isArray(fields)
|
||||
? val!.split(/:(.+)/).filter(Boolean)
|
||||
: [val]
|
||||
).map((s) => s.trim()),
|
||||
).map((s) => s?.trim() || ''),
|
||||
);
|
||||
const baseSubQuery = createBaseQuery(values);
|
||||
|
||||
|
@ -24,6 +24,13 @@ export const tagSchema = {
|
||||
'The [type](https://docs.getunleash.io/reference/feature-toggles#tags) of the tag',
|
||||
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: {},
|
||||
} as const;
|
||||
|
@ -253,6 +253,7 @@ export interface IFeatureOverview {
|
||||
lastSeenAt: Date;
|
||||
environments: IEnvironmentOverview[];
|
||||
lifecycle?: IFeatureLifecycleStage;
|
||||
tags?: ITag[];
|
||||
}
|
||||
|
||||
export type IFeatureSearchOverview = Exclude<
|
||||
@ -353,6 +354,7 @@ export interface IFeatureToggleDeltaQuery extends IFeatureToggleQuery {
|
||||
export interface ITag {
|
||||
value: string;
|
||||
type: string;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export interface IAddonParameterDefinition {
|
||||
|
Loading…
Reference in New Issue
Block a user