diff --git a/frontend/src/component/common/Tag/Tag.tsx b/frontend/src/component/common/Tag/Tag.tsx new file mode 100644 index 0000000000..0632e1d52c --- /dev/null +++ b/frontend/src/component/common/Tag/Tag.tsx @@ -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 = ( + + {showColorDot && } + {label} + + ) as ReactElement; + + return ( + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/TagRow.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/TagRow.tsx index 788354d316..33fb673995 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/TagRow.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/TagRow.tsx @@ -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) => ( + { + setRemoveTagOpen(true); + setSelectedTag(tag); + }} + /> + ); + return ( <> {!tags.length ? ( @@ -103,49 +120,7 @@ export const TagRow = ({ feature }: IFeatureOverviewSidePanelTagsProps) => { Tags: - {tags.map((tag) => { - const tagLabel = `${tag.type}:${tag.value}`; - const isOverflowing = tagLabel.length > 25; - return ( - - - {tagLabel.substring(0, 25)} - {isOverflowing ? ( - - … - - ) : ( - '' - )} - - - } - size='small' - deleteIcon={ - - - - } - onDelete={ - canUpdateTags - ? () => { - setRemoveTagOpen(true); - setSelectedTag(tag); - } - : undefined - } - /> - ); - })} + {tags.map(renderTag)} {canUpdateTags ? ( { ); }; + +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 = ( + + + + ); + const onDelete = canUpdateTags ? () => onTagRemove(tag) : undefined; + + if (isTagTypeColorEnabled) { + const deleteIcon = ( + + + + ); + + return ( + + + + + + ); + } + + return ( + + + {tagLabel.substring(0, 25)} + {isOverflowing ? ( + + ) : ( + '' + )} + + + } + size='small' + deleteIcon={deleteIcon} + onDelete={onDelete} + /> + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelTags/FeatureOverviewSidePanelTags.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelTags/FeatureOverviewSidePanelTags.tsx index 6a91257fd2..00206d4137 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelTags/FeatureOverviewSidePanelTags.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelTags/FeatureOverviewSidePanelTags.tsx @@ -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 = ({ {header} - {tags.map((tag) => { - const tagLabel = `${tag.type}:${tag.value}`; - return ( - } - onDelete={ - canUpdateTags - ? () => { - setShowDelDialog(true); - setSelectedTag(tag); - } - : undefined - } - /> - ); - })} + {tags.map((tag) => ( + { + setShowDelDialog(true); + setSelectedTag(tag); + } + : undefined + } + deleteIcon={} + /> + ))} { - 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:{' '} diff --git a/frontend/src/component/project/ProjectCard/ProjectCard.tsx b/frontend/src/component/project/ProjectCard/ProjectCard.tsx index afa3caba06..9d9464d7c2 100644 --- a/frontend/src/component/project/ProjectCard/ProjectCard.tsx +++ b/frontend/src/component/project/ProjectCard/ProjectCard.tsx @@ -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)(({ 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 = ({ + 0} + show={ + + {tags.map((tag: ITag) => ( + + ))} + + } + /> (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}`, diff --git a/src/lib/features/feature-search/feature-search-store.ts b/src/lib/features/feature-search/feature-search-store.ts index e7dbda8daa..c27baa7d78 100644 --- a/src/lib/features/feature-search/feature-search-store.ts +++ b/src/lib/features/feature-search/feature-search-store.ts @@ -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); diff --git a/src/lib/openapi/spec/tag-schema.ts b/src/lib/openapi/spec/tag-schema.ts index e08342f080..f402fb54ab 100644 --- a/src/lib/openapi/spec/tag-schema.ts +++ b/src/lib/openapi/spec/tag-schema.ts @@ -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; diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index d15867d1a4..d894c7678c 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -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 {