diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 6510b3a4177d..aad13ba57607 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -623,7 +623,8 @@ export type APIKey = { name: string } -export type TagType = 'STALE' | 'UNHEALTHY' | 'NONE' +// Mirrors TagType in api/projects/tags/models.py. +export type TagType = 'NONE' | 'STALE' | 'GITHUB' | 'UNHEALTHY' | 'GITLAB' export type Tag = { id: number diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index a7718160284e..e8eacce64f7f 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -762,6 +762,11 @@ const Utils = Object.assign({}, BaseUtils, { return tag?.type === 'STALE' && !hasStaleFlagsPermission }, + // Unhealthy tags exist only where Feature Health does. Asked at the list + // rather than inside Tag: a chip has no business reading feature flags. + tagVisible: (tag: Tag | undefined) => + tag?.type !== 'UNHEALTHY' || Utils.getFlagsmithHasFeature('feature_health'), + toKebabCase: (string: string) => string .replace(/([a-z])([A-Z])/g, '$1-$2') diff --git a/frontend/documentation/CategoricalPalette.stories.tsx b/frontend/documentation/CategoricalPalette.stories.tsx index 4965b6c6bd4f..78dc919e13a7 100644 --- a/frontend/documentation/CategoricalPalette.stories.tsx +++ b/frontend/documentation/CategoricalPalette.stories.tsx @@ -6,6 +6,7 @@ import Chip from 'components/base/Chip' import DocPage from './components/DocPage' import Swatch from './components/Swatch' import tokens from 'common/theme/tokens.json' +import { contentColourNames, contentColours } from 'common/theme/tokens' import { AA_NORMAL_TEXT, contrastRatio } from 'common/theme/contrast' // --------------------------------------------------------------------------- @@ -45,9 +46,9 @@ const PRIMITIVES = tokens.primitives as Record // own surface, so it does not follow the page. One ink serves all of them. const TAG_INK_NAME = 'content-always-dark' const TAG_INK = PRIMITIVES[TAG_INK_NAME] -const TAG_FILLS = Object.entries(PRIMITIVES) - .filter(([name]) => name.startsWith('content-') && name !== TAG_INK_NAME) - .map(([name, hex]) => [name.replace('content-', ''), hex] as const) +const TAG_FILLS = contentColourNames.map( + (name) => [name, contentColours[name]] as const, +) export const TagSwatches: StoryObj = { name: 'Tag swatches', @@ -72,7 +73,7 @@ export const TagSwatches: StoryObj = { className='d-flex flex-column align-items-center gap-1' key={name} > - + {name} @@ -83,15 +84,12 @@ export const TagSwatches: StoryObj = {

System tags (Issue, PR, Stale, Unhealthy) are not on this scale. They - stay on existing tokens — bg-surface-default,{' '} - border-default, text-default — plus a - coloured icon, so the state is carried by the icon rather than the fill. + take no fill at all: border-default and{' '} + text-default plus a coloured icon, so the state is carried + by the icon and the border rather than the fill.

- + System tag
diff --git a/frontend/documentation/components/Chip.stories.tsx b/frontend/documentation/components/Chip.stories.tsx index c88fe9860935..ebaa1163a850 100644 --- a/frontend/documentation/components/Chip.stories.tsx +++ b/frontend/documentation/components/Chip.stories.tsx @@ -2,6 +2,8 @@ import React from 'react' import type { Meta, StoryObj } from 'storybook' import Chip from 'components/base/Chip' +import Icon, { IconName } from 'components/icons/Icon' +import { contentColourNames } from 'common/theme/tokens' const meta: Meta = { args: { children: 'Production' }, @@ -11,7 +13,7 @@ const meta: Meta = { docs: { description: { component: - 'Canonical token-based chip primitive: a small labelled pill token. Layout via Bootstrap utilities, colour/radius via token utilities, padding/sizes/border/truncation in SCSS. Leading/trailing icons go in as children. Selection lives in ToggleChip and count badges are a separate Badge concern. The legacy `.chip` (old SCSS vars + manual dark-mode block, ~35×) migrates onto this under #6606.', + 'A small labelled pill. Layout comes from Bootstrap utilities and colour from token utilities; padding, sizes, border and truncation are in SCSS. Icons go in as children. `none` leaves the colour to the caller, for a colour a user picked rather than a semantic role.', }, }, layout: 'centered', @@ -28,10 +30,53 @@ export const Accent: Story = { args: { children: '"hello"', variant: 'accent' }, } +// How Tag composes Chip: a decorative colour a user picked is not a semantic +// variant, so it arrives on `colour`. System tags take neither, and carry +// their state in the icon instead. +const SYSTEM_TAGS: { label: string; icon: IconName }[] = [ + { icon: 'issue-closed', label: 'Issue closed' }, + { icon: 'issue-linked', label: 'Issue open' }, + { icon: 'pr-closed', label: 'PR closed' }, + { icon: 'pr-dequeued', label: 'PR dequeued' }, + { icon: 'pr-draft', label: 'PR draft' }, + { icon: 'stale', label: 'Stale' }, + { icon: 'pr-linked', label: 'PR open' }, + { icon: 'pr-merged', label: 'PR merged' }, +] + +export const AsSystemTag: Story = { + name: 'As a system tag', + parameters: { chromatic: { disableSnapshot: false } }, + render: () => ( +
+ {SYSTEM_TAGS.map(({ icon, label }) => ( + + {label} + + + ))} +
+ ), +} + +export const AsCustomTag: Story = { + name: 'As a custom tag', + parameters: { chromatic: { disableSnapshot: false } }, + render: () => ( +
+ {contentColourNames.map((colour) => ( + + Custom + + ))} +
+ ), +} + export const Sizes: Story = { render: () => (
- Default + Medium Small Extra small
diff --git a/frontend/documentation/components/Tag.stories.tsx b/frontend/documentation/components/Tag.stories.tsx new file mode 100644 index 000000000000..ce29c46f662b --- /dev/null +++ b/frontend/documentation/components/Tag.stories.tsx @@ -0,0 +1,99 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from 'storybook' + +import Tag from 'components/tags/Tag' +import Constants from 'common/constants' +import { contentColourNames, contentColours } from 'common/theme/tokens' +import type { Tag as TTag } from 'common/types/responses' + +const meta: Meta = { + component: Tag, + parameters: { + chromatic: { disableSnapshot: false }, + docs: { + description: { + component: + 'A project tag. Custom tags take a fill from the design system Content palette, keyed on the colour stored on the tag so nothing needs migrating. System tags (Stale, GitHub, GitLab, Unhealthy) take no fill and carry their state in a coloured icon and their border rather than the fill, so the state survives for anyone who cannot tell the fills apart.', + }, + }, + layout: 'padded', + }, + title: 'Components/Tags/Tag', +} +export default meta + +type Story = StoryObj + +const tag = (over: Partial): Partial => ({ + color: Constants.tagColors[0], + label: 'Checkout', + type: 'NONE', + ...over, +}) + +export const Custom: Story = { args: { tag: tag({}) } } + +export const EveryColour: Story = { + name: 'Every colour', + render: () => ( +
+ {contentColourNames.map((name) => ( + + ))} +
+ ), +} + +// The VCS icon is keyed on the tag's label, which the integration sets, so the +// examples use labels it actually produces rather than the type name. +// No Unhealthy example: Tag returns null for it unless the feature_health flag +// is on, and Storybook's Utils stub answers false to every flag. +const SYSTEM_EXAMPLES: Partial[] = [ + { label: 'Stale', type: 'STALE' }, + { label: 'PR Open', type: 'GITHUB' }, + { label: 'PR Merged', type: 'GITHUB' }, + { label: 'Issue Open', type: 'GITLAB' }, + { label: 'Issue Closed', type: 'GITLAB' }, +] + +export const SystemTags: Story = { + name: 'System tags', + render: () => ( +
+ {SYSTEM_EXAMPLES.map((over) => ( + + ))} +
+ ), +} + +// A colour we never issued, which the API allows. It takes the neutral rather +// than a guess: the label still reads, and the tag claims no category. +export const UnknownColour: Story = { + args: { tag: tag({ color: '#123456', label: 'Set via API' }) }, + name: 'Colour outside the scale', +} + +/** Hooks cannot live in a story's render, so selection state gets a component. */ +const SelectableTags: React.FC = () => { + const [picked, setPicked] = useState('Checkout') + return ( +
+ {['Checkout', 'Billing', 'Search'].map((label, i) => ( + setPicked(label)} + selected={picked === label} + tag={tag({ color: Constants.tagColors[i], label })} + /> + ))} +
+ ) +} + +export const Selectable: Story = { + render: () => , +} diff --git a/frontend/documentation/components/ToggleChip.stories.tsx b/frontend/documentation/components/ToggleChip.stories.tsx deleted file mode 100644 index b9d153a091aa..000000000000 --- a/frontend/documentation/components/ToggleChip.stories.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React, { useState } from 'react' -import type { Meta, StoryObj } from 'storybook' - -import ToggleChip from 'components/ToggleChip' -import { - colorChart2, - colorChart3, - colorSurfaceAction, -} from 'common/theme/tokens' - -const meta: Meta = { - argTypes: { - color: { control: 'color' }, - }, - args: { active: false, children: 'Feature flag', color: colorSurfaceAction }, - component: ToggleChip, - parameters: { layout: 'centered' }, - title: 'Components/Data Display/ToggleChip', -} -export default meta - -type Story = StoryObj - -const Interactive = (args: React.ComponentProps) => { - const [active, setActive] = useState(args.active ?? false) - return ( - setActive(!active)} /> - ) -} - -export const Default: Story = { - render: (args: React.ComponentProps) => ( - - ), -} - -export const AllStates: Story = { - render: () => ( -
- - Active - - Inactive - - Danger - - Success -
- ), -} diff --git a/frontend/documentation/components/VCSProviderTag.stories.tsx b/frontend/documentation/components/VCSProviderTag.stories.tsx index ad9b4fc08d67..42544992486f 100644 --- a/frontend/documentation/components/VCSProviderTag.stories.tsx +++ b/frontend/documentation/components/VCSProviderTag.stories.tsx @@ -11,7 +11,7 @@ const meta: Meta = { }, component: VCSProviderTag, parameters: { layout: 'centered' }, - title: 'Components/Data Display/VCSProviderTag', + title: 'Components/Tags/VCSProviderTag', } export default meta diff --git a/frontend/web/components/ColorSwatch.tsx b/frontend/web/components/ColorSwatch.tsx index 3b2ffa5e4e10..0696182e897f 100644 --- a/frontend/web/components/ColorSwatch.tsx +++ b/frontend/web/components/ColorSwatch.tsx @@ -1,11 +1,11 @@ import React, { FC } from 'react' import classNames from 'classnames' -type ColorSwatchSize = 'sm' | 'md' | 'lg' +type ColorSwatchSize = 'sm' | 'md' | 'lg' | 'xl' type ColorSwatchShape = 'square' | 'circle' type ColorSwatchProps = { - color: string + color?: string size?: ColorSwatchSize shape?: ColorSwatchShape className?: string @@ -15,6 +15,7 @@ const SIZE_MAP: Record = { lg: 16, md: 12, sm: 8, + xl: 32, } const SHAPE_CLASS: Record = { diff --git a/frontend/web/components/ToggleChip.tsx b/frontend/web/components/ToggleChip.tsx deleted file mode 100644 index a56708d9e83d..000000000000 --- a/frontend/web/components/ToggleChip.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React, { FC, ReactNode } from 'react' -import cx from 'classnames' -import Icon from './icons/Icon' -import Utils from 'common/utils/utils' - -type ToggleChipProps = { - color?: string - active?: boolean - onClick?: () => void - className?: string - children?: ReactNode -} - -const ToggleChip: FC = ({ - active, - children, - className, - color, - onClick, -}) => { - const colour = Utils.colour(color) - return ( - - - {active && } - - {children} - - ) -} - -export default ToggleChip diff --git a/frontend/web/components/base/Chip/Chip.scss b/frontend/web/components/base/Chip/Chip.scss index 854427ce3f98..f6501efc6a19 100644 --- a/frontend/web/components/base/Chip/Chip.scss +++ b/frontend/web/components/base/Chip/Chip.scss @@ -1,14 +1,9 @@ -// Canonical token-based chip. Layout and bg/text colour are utilities in the -// markup; this holds padding, sizes, the variant border colour, truncation and -// the remove-button reset. Distinct class (`ds-chip`) until the legacy `.chip` -// (_chip.scss) migrates here under #6606. .ds-chip { padding: 4px 10px; font-size: 0.8125rem; white-space: nowrap; - border: 1px solid var(--color-border-default); + border: 1px solid var(--ds-chip-border, var(--color-border-default)); - // Colour variant - bg/text are utilities; only the border colour is here. &--accent { border-color: var(--color-border-action); } diff --git a/frontend/web/components/base/Chip/Chip.tsx b/frontend/web/components/base/Chip/Chip.tsx index 296219372dda..0ba18290d818 100644 --- a/frontend/web/components/base/Chip/Chip.tsx +++ b/frontend/web/components/base/Chip/Chip.tsx @@ -2,14 +2,18 @@ import React, { KeyboardEvent, ReactNode, Ref } from 'react' import classNames from 'classnames' import Icon from 'components/icons/Icon' import { colorIconSecondary } from 'common/theme/tokens' +import type { ContentColour } from 'common/theme/tokens' +import { chipColourClass, chipVariantClass, ChipVariant } from './chipColour' import './Chip.scss' -export type ChipSize = 'default' | 'sm' | 'xs' -export type ChipVariant = 'neutral' | 'accent' +export type ChipSize = 'md' | 'sm' | 'xs' -export type ChipProps = { +type ChipColour = + | { variant?: ChipVariant; colour?: never } + | { variant?: never; colour: ContentColour } + +export type ChipProps = ChipColour & { children: ReactNode - variant?: ChipVariant size?: ChipSize truncate?: boolean onRemove?: () => void @@ -18,52 +22,46 @@ export type ChipProps = { // Opt into membership of a keyboard group (e.g. a radiogroup): supply the // role, roving tabIndex, checked state, key handler and ref. These override // the button semantics onClick applies by default, so the group owner can - // drive arrow-key navigation. See SdkPicker. + // drive arrow-key navigation. role?: 'button' | 'radio' tabIndex?: number 'aria-checked'?: boolean + 'aria-pressed'?: boolean 'aria-expanded'?: boolean + 'aria-label'?: string onKeyDown?: (e: KeyboardEvent) => void ref?: Ref } -// bg + text come from token utilities; the variant border lives in Chip.scss. -const VARIANT_UTILITIES: Record = { - accent: 'bg-surface-action-subtle text-action', - neutral: 'bg-surface-subtle text-default', -} - -// Token-based chip primitive. Uses `ds-chip` rather than the legacy `.chip` -// (old SCSS vars + a manual `.dark {}` block, ~35 usages) so the two coexist -// until those migrate under #6606. Clickable on its own (role=button), or a -// member of a caller-driven keyboard group via the role/tabIndex/onKeyDown/ref -// props. Count badges are out of scope. const Chip = ({ 'aria-checked': ariaChecked, 'aria-expanded': ariaExpanded, + 'aria-label': ariaLabel, + 'aria-pressed': ariaPressed, children, className, + colour, onClick, onKeyDown, onRemove, ref, role, - size = 'default', + size = 'md', tabIndex, truncate = false, - variant = 'neutral', + variant, }: ChipProps) => { const interactive = !!onClick || !!role return ( = { + accent: 'bg-surface-action-subtle text-action', + + neutral: 'bg-surface-subtle text-default', + // No fill: the border carries the edge, so a system tag takes no hue. + outline: 'text-default', +} + +export const chipVariantClass = (variant: ChipVariant = 'neutral') => + VARIANT_UTILITIES[variant] + +export const chipColourClass = (colour: ContentColour) => `tag-${colour}` + +// The two channels as one value, so a caller that cannot pass props (markup +// built for innerHTML) resolves them the same way the component does. +export type ChipColourChoice = + | { variant: ChipVariant } + | { colour: ContentColour } + +export const chipColourUtilities = (choice: ChipColourChoice) => + 'colour' in choice + ? chipColourClass(choice.colour) + : chipVariantClass(choice.variant) diff --git a/frontend/web/components/base/Chip/index.ts b/frontend/web/components/base/Chip/index.ts index 7b7ac073a3c4..af79f67a430e 100644 --- a/frontend/web/components/base/Chip/index.ts +++ b/frontend/web/components/base/Chip/index.ts @@ -1,2 +1,3 @@ export { default } from './Chip' -export type { ChipProps, ChipSize, ChipVariant } from './Chip' +export type { ChipProps, ChipSize } from './Chip' +export type { ChipVariant } from './chipColour' diff --git a/frontend/web/components/feature-summary/FeatureAction.tsx b/frontend/web/components/feature-summary/FeatureAction.tsx index 63216aae803d..b214b14cf9f4 100644 --- a/frontend/web/components/feature-summary/FeatureAction.tsx +++ b/frontend/web/components/feature-summary/FeatureAction.tsx @@ -6,8 +6,7 @@ import Constants from 'common/constants' import Permission from 'common/providers/Permission' import Icon from 'components/icons/Icon' import { Tag } from 'common/types/responses' -import color from 'color' -import { getTagColor } from 'components/tags/Tag' +import { tagChipHtml } from 'components/tags/utils' import ActionButton from 'components/ActionButton' import ActionItem from 'components/shared/ActionItem' import { calculateListPosition } from 'common/utils/calculateListPosition' @@ -171,16 +170,9 @@ const FeatureAction: FC = ({ ? `This feature has been tagged with the permanent tag${ protectedTags?.length > 1 ? 's' : '' } ${protectedTags - ?.map((tag) => { - const tagColor = Utils.colour(getTagColor(tag)) - return ` - ${tag.label} - ` - }) + ?.map((tag) => + tagChipHtml(tag, { className: 'ms-1' }), + ) .join('')}. Please remove the tag${ protectedTags?.length > 1 ? 's' : '' } before attempting to delete this flag.` diff --git a/frontend/web/components/pages/environment-settings/EnvironmentSettingsPage.tsx b/frontend/web/components/pages/environment-settings/EnvironmentSettingsPage.tsx index 471e64266887..7ebb5d2747c9 100644 --- a/frontend/web/components/pages/environment-settings/EnvironmentSettingsPage.tsx +++ b/frontend/web/components/pages/environment-settings/EnvironmentSettingsPage.tsx @@ -521,7 +521,7 @@ const EnvironmentSettingsPage: React.FC = () => { } /> {typeof currentEnv?.banner_text === 'string' && ( - + { }} className='full-width' /> -
- - updateCurrentEnv({ banner_colour }, false) - } - /> -
+ + updateCurrentEnv({ banner_colour }, false) + } + /> diff --git a/frontend/web/components/tables/TableTagFilter.tsx b/frontend/web/components/tables/TableTagFilter.tsx index 8458ec01083f..8eef4fbbd691 100644 --- a/frontend/web/components/tables/TableTagFilter.tsx +++ b/frontend/web/components/tables/TableTagFilter.tsx @@ -1,9 +1,10 @@ -import React, { FC, useMemo, useState } from 'react' +import React, { FC, useState } from 'react' import TableFilter from './TableFilter' import Input from 'components/base/forms/Input' import Utils from 'common/utils/utils' import { useGetTagsQuery } from 'common/services/useTag' -import Tag from 'components/tags/Tag' +import ColorSwatch from 'components/ColorSwatch' +import { getTagColor } from 'components/tags/Tag' import TableFilterItem from './TableFilterItem' import Constants from 'common/constants' import { TagStrategy } from 'common/types/responses' @@ -40,20 +41,15 @@ const TableTagFilter: FC = ({ { skip: !projectId }, ) - const isFeatureHealthEnabled = Utils.getFlagsmithHasFeature('feature_health') - const flagGatedTags = useMemo(() => { - let tags = data - if (!isFeatureHealthEnabled) - tags = tags?.filter((tag) => tag.type !== 'UNHEALTHY') - if (excludeTag) tags = tags?.filter((tag) => !excludeTag(tag)) - return tags - }, [data, isFeatureHealthEnabled, excludeTag]) + // Not memoised: tagVisible reads a feature flag that arrives after the first + // render, and a dependency array cannot see that. The list is small. + const flagGatedTags = data?.filter( + (tag) => Utils.tagVisible(tag) && (!excludeTag || !excludeTag(tag)), + ) - const filteredTags = useMemo(() => { - return filter - ? flagGatedTags?.filter((v) => v.label.toLowerCase().includes(filter)) - : flagGatedTags?.filter((tag) => tag) - }, [flagGatedTags, filter]) + const filteredTags = filter + ? flagGatedTags?.filter((v) => v.label.toLowerCase().includes(filter)) + : flagGatedTags const length = (value?.length || 0) + (showArchived ? 1 : 0) return (
@@ -128,11 +124,10 @@ const TableTagFilter: FC = ({ isActive={showArchived} title={ -
archived
@@ -149,11 +144,10 @@ const TableTagFilter: FC = ({ isActive={value?.includes('')} title={ -
untagged
@@ -178,12 +172,11 @@ const TableTagFilter: FC = ({ isActive={value?.includes(tag.id)} title={ -
= ({
= ({ selectTag(tag)} + size='md' + selected={!!value?.includes(tag.id)} tag={tag} /> diff --git a/frontend/web/components/tags/ColourSelect.tsx b/frontend/web/components/tags/ColourSelect.tsx index 1c95ef15a630..84cbfd61feab 100644 --- a/frontend/web/components/tags/ColourSelect.tsx +++ b/frontend/web/components/tags/ColourSelect.tsx @@ -1,20 +1,36 @@ import React, { FC, useState } from 'react' import InlineModal from 'components/InlineModal' import Constants from 'common/constants' -import Tag from './Tag' +import BareButton from 'components/base/forms/BareButton' +import ColorSwatch from 'components/ColorSwatch' +import Icon from 'components/icons/Icon' +import { contentColours, contentInk } from 'common/theme/tokens' +import { contrastRatio } from 'common/theme/contrast' type ColourSelectType = { value: string onChange: (colour: string) => void } +// The tick sits on the colour itself, which is a hex a user picked rather than +// a token, so neither ink nor pale reads on all of them. Pick per colour. +const tickFill = (colour: string) => + contrastRatio(contentInk, colour) > + contrastRatio(contentColours['light-grey'], colour) + ? contentInk + : contentColours['light-grey'] + +// The banner colour, not a tag: these are raw hexes rather than the tag +// palette, so they render as themselves rather than through a swatch lookup. const ColourSelect: FC = ({ onChange, value: _value }) => { const [isOpen, setIsOpen] = useState(false) const value = _value || Constants.tagColors[0] return ( <> - setIsOpen(true)} tag={{ color: value }} /> + setIsOpen(true)}> + + = ({ onChange, value: _value }) => { onClose={() => setIsOpen(false)} className='inline-modal--sm' > -
- - {Constants.tagColors.map((color) => ( -
- { - onChange(tag.color) - setIsOpen(false) - }} - selected={value === color} - tag={{ color }} + + {Constants.tagColors.map((colour) => ( + { + onChange(colour) + setIsOpen(false) + }} + > + + {value === colour && ( + -
- ))} -
-
+ )} + + ))} +
) diff --git a/frontend/web/components/tags/CreateEditTag.tsx b/frontend/web/components/tags/CreateEditTag.tsx index f350c7a6f5b7..a928d976a146 100644 --- a/frontend/web/components/tags/CreateEditTag.tsx +++ b/frontend/web/components/tags/CreateEditTag.tsx @@ -2,6 +2,7 @@ import React, { FC, KeyboardEvent, useEffect, useMemo, useState } from 'react' import FieldLabel from 'components/base/forms/FieldLabel' import { Tag as TTag } from 'common/types/responses' import Constants from 'common/constants' +import { contentColourNames, contentColours } from 'common/theme/tokens' import Permission from 'common/providers/Permission' import Utils from 'common/utils/utils' import { @@ -192,15 +193,18 @@ const CreateEditTag: FC = ({
Select a color - {Constants.tagColors.map((color) => ( -
- update('color', e.color)} - selected={tag?.color === color} - tag={{ color }} - /> -
- ))} + {contentColourNames.map((name) => { + const colour = contentColours[name] + return ( +
+ update('color', colour)} + selected={tag?.color === colour} + tag={{ color: colour }} + /> +
+ ) + })}
{existingTag && ( diff --git a/frontend/web/components/tags/Tag.tsx b/frontend/web/components/tags/Tag.tsx deleted file mode 100644 index 5e8edefbf35b..000000000000 --- a/frontend/web/components/tags/Tag.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import React, { FC } from 'react' -import cx from 'classnames' - -import { Tag as TTag } from 'common/types/responses' -import ToggleChip from 'components/ToggleChip' -import Utils from 'common/utils/utils' -import TagContent from './TagContent' -import Constants from 'common/constants' -import { getDarkMode } from 'project/darkMode' -import Color from 'color' - -type TagType = { - className?: string - hideNames?: boolean - onClick?: (tag: TTag) => void - selected?: boolean - tag: Partial - isDot?: boolean -} - -export const getTagColor = (tag: Partial, selected?: boolean) => { - if (getDarkMode() && tag.color === '#344562') { - return '#9DA4AE' - } - if (tag.type === 'UNHEALTHY') { - return Constants.featureHealth.unhealthyColor - } - if (selected) { - return tag.color - } - return tag.color -} - -export const TagWrapper = ({ - children, - className, - disabled, - onClick, - tag, - tagColor, -}: any) => { - return ( -
{ - if (!disabled) { - onClick?.(tag as TTag) - } - }} - style={{ - backgroundColor: `${tagColor.fade(0.92)}`, - border: `1px solid ${tagColor.fade(0.76)}`, - color: `${tagColor.darken(0.1)}`, - }} - className={cx('chip', className)} - > - {children} -
- ) -} - -const Tag: FC = ({ - className, - hideNames, - isDot, - onClick, - selected, - tag, -}) => { - const shouldLighten = (color: Color) => getDarkMode() && color.isDark() - const tagColor = Utils.colour(getTagColor(tag, selected)) - if (isDot) { - return ( -
- ) - } - - const disabled = Utils.tagDisabled(tag) - - if (!hideNames && !!onClick) { - return ( - { - if (!disabled) { - onClick?.(tag as TTag) - } - }} - > - {!!tag.label && } - - ) - } - - // Hide unhealthy tags if feature is disabled - if ( - !Utils.getFlagsmithHasFeature('feature_health') && - tag.type === 'UNHEALTHY' - ) { - return null - } - - return ( - - - - ) -} - -export default Tag diff --git a/frontend/web/components/tags/Tag/Tag.scss b/frontend/web/components/tags/Tag/Tag.scss new file mode 100644 index 000000000000..a946fc025aae --- /dev/null +++ b/frontend/web/components/tags/Tag/Tag.scss @@ -0,0 +1,22 @@ +.tag-check { + width: 20px; + height: 20px; + // color-mix, not the oklch(from …) below: that derives from a named token, + // and `from currentColor` failing would leave no border at all. + border: 1px solid color-mix(in srgb, currentColor 32%, transparent); + + // A system tag sets no hue, so this falls through to the ink. + &--on { + border-color: transparent; + background-color: oklch( + from var(--ds-chip-border, currentColor) calc(l - 0.35) calc(c * 2.2) h + ); + } +} + +// The picker's cell size, not a property of every swatch. +.tag--select .ds-chip.tag--swatch { + width: 100%; + height: 34px; + justify-content: center; +} diff --git a/frontend/web/components/tags/Tag/Tag.tsx b/frontend/web/components/tags/Tag/Tag.tsx new file mode 100644 index 000000000000..e74743360915 --- /dev/null +++ b/frontend/web/components/tags/Tag/Tag.tsx @@ -0,0 +1,70 @@ +import React, { FC } from 'react' +import cx from 'classnames' + +import { Tag as TTag } from 'common/types/responses' +import Chip, { ChipSize } from 'components/base/Chip' +import Icon from 'components/icons/Icon' +import { colorSurfaceDefault } from 'common/theme/tokens' +import Constants from 'common/constants' +import TagContent from 'components/tags/TagContent' +import './Tag.scss' +import { swatchName, tagChipColour } from 'components/tags/utils' + +type TagType = { + className?: string + // A plan entitlement, not a UI state. See Utils.tagDisabled. + disabled?: boolean + onClick?: (tag: Partial) => void + selected?: boolean + // Small where a tag labels something, md where it is the thing chosen. + size?: ChipSize + // Partial: the archived and untagged pseudo-tags have no id. + tag: Partial +} + +// Here rather than utils: Constants would drag the app tree into the swatch test. +export const getTagColor = (tag: Partial) => + tag.type === 'UNHEALTHY' ? Constants.featureHealth.unhealthyColor : tag.color + +const Tag: FC = ({ + className, + disabled = false, + onClick, + selected, + size = 'sm', + tag, +}) => { + const isInteractive = !disabled && !!onClick + const isToggle = isInteractive && selected !== undefined + return ( + onClick?.(tag) : undefined} + size={size} + > + {/* A swatch takes a box only once chosen, or the grid fills with empty + outlines. */} + {selected !== undefined && (tag.label || selected) && ( + + {selected && ( + + )} + + )} + + + ) +} + +export default Tag diff --git a/frontend/web/components/tags/Tag/index.ts b/frontend/web/components/tags/Tag/index.ts new file mode 100644 index 000000000000..0f5ab79ba91e --- /dev/null +++ b/frontend/web/components/tags/Tag/index.ts @@ -0,0 +1,2 @@ +export { default } from './Tag' +export { getTagColor } from './Tag' diff --git a/frontend/web/components/tags/TagContent.tsx b/frontend/web/components/tags/TagContent.tsx index e7c5dc289845..41de66eec373 100644 --- a/frontend/web/components/tags/TagContent.tsx +++ b/frontend/web/components/tags/TagContent.tsx @@ -1,69 +1,51 @@ import React, { FC } from 'react' import { Tag as TTag } from 'common/types/responses' import Format from 'common/utils/format' -import { IonIcon } from '@ionic/react' -import { alarmOutline, lockClosed, warning } from 'ionicons/icons' import Tooltip from 'components/Tooltip' -import { getTagColor } from './Tag' import OrganisationStore from 'common/stores/organisation-store' -import Utils from 'common/utils/utils' import classNames from 'classnames' -import Icon from 'components/icons/Icon' -import Color from 'color' -type TagContent = { +import Icon, { IconName } from 'components/icons/Icon' +import { tagChipHtml } from './utils' + +type TagContentProps = { + disabled?: boolean tag: Partial } -function escapeHTML(unsafe: string) { - return unsafe.replace( - /[\u0000-\u002F\u003A-\u0040\u005B-\u0060\u007B-\u00FF]/g, - (c) => `&#${`000${c.charCodeAt(0)}`.slice(-4)};`, - ) + +const VCS_ICON_BY_LABEL: Record = { + 'Issue Closed': 'issue-closed', + 'Issue Open': 'issue-linked', + 'PR Closed': 'pr-closed', + 'PR Dequeued': 'pr-dequeued', + 'PR Draft': 'pr-draft', + 'PR Merged': 'pr-merged', + 'PR Open': 'pr-linked', } -const renderIcon = ( - tagType: string, - tagColor: string, - tagLabel: string, - isPermanent: boolean, -) => { - const darkened = tagColor.darken(0.1).string() - switch (tagType) { +const renderIcon = (tag: Partial) => { + switch (tag.type) { case 'STALE': - return + return case 'UNHEALTHY': - return + return case 'GITHUB': - switch (tagLabel) { - case 'PR Open': - return - case 'PR Merged': - return - case 'PR Closed': - return - case 'PR Draft': - return - case 'Issue Open': - return - case 'Issue Closed': - return - default: - return - } + case 'GITLAB': { + const icon = VCS_ICON_BY_LABEL[tag.label ?? ''] + return icon ? : null + } default: - return isPermanent ? ( - - ) : null + // Outline at 12px: a padlock heavier than its label reads as a sticker. + return tag.is_permanent ? : null } } -const getTooltip = (tag: TTag | undefined) => { +const getTooltip = (tag: Partial, disabled: boolean) => { if (!tag) { return null } const stale_flags_limit_days = OrganisationStore.getProject( tag.project, )?.stale_flags_limit_days - const disabled = Utils.tagDisabled(tag) const truncated = Format.truncateText(tag.label, 12) const isTruncated = truncated !== tag.label ? tag.label : null let tooltip = null @@ -83,49 +65,37 @@ const getTooltip = (tag: TTag | undefined) => { tooltip = 'Features marked with this tag are not monitored for staleness and have deletion protection.' } - const tagColor = Utils.colour(getTagColor(tag, false)) - if (isTruncated) { - return `
- - ${`${escapeHTML(tag.label)}`} - - ${tooltip ?? ''} -
` + return `
${tagChipHtml(tag, { className: 'me-1', disabled })}${ + tooltip ?? '' + }
` } return tooltip } -const TagContent: FC = ({ tag }) => { +const TagContent: FC = ({ disabled = false, tag }) => { const tagLabel = Format.truncateText(tag.label, 12) if (!tagLabel) { return null } - const disabled = Utils.tagDisabled(tag) - return ( {tagLabel} - {renderIcon(tag.type!, Utils.colour(tag.color), tag.label!)} + {renderIcon(tag)} } > - {getTooltip(tag)} + {getTooltip(tag, disabled)} ) } diff --git a/frontend/web/components/tags/TagFilter.tsx b/frontend/web/components/tags/TagFilter.tsx index 6cd6670e9c49..eff2039a5a92 100644 --- a/frontend/web/components/tags/TagFilter.tsx +++ b/frontend/web/components/tags/TagFilter.tsx @@ -1,19 +1,26 @@ import React, { FC, ReactNode } from 'react' import { filter } from 'lodash' -import { Tag as TTag, TagStrategy } from 'common/types/responses' +import { TagStrategy } from 'common/types/responses' import { useGetTagsQuery } from 'common/services/useTag' +import Utils from 'common/utils/utils' +import Constants from 'common/constants' import Tag from './Tag' import Button from 'components/base/forms/Button' +type TagId = number | string + +// Not a tag: it stands for the absence of one, so it has no row behind it. +const UNTAGGED_ID = '' + type TagFilterType = { - value?: (number | string)[] + value?: TagId[] onClearAll?: () => void showClearAll?: boolean showUntagged?: boolean tagStrategy: TagStrategy onChangeStrategy?: (value: TagStrategy) => void projectId: string - onChange: (value: (number | string)[]) => void + onChange: (value: TagId[]) => void children?: ReactNode } @@ -32,26 +39,24 @@ const TagFilter: FC = ({ projectId, }) - const isSelected = (tag: TTag) => _value?.includes(tag?.id) - const onSelect = (tag: TTag) => { + const visibleTags = projectTags?.filter(Utils.tagVisible) + + // Both only ever read the id, and the untagged pseudo-tag has a string one. + const isSelected = (id: TagId) => !!_value?.includes(id) + const onSelect = (id: TagId) => { const value = _value || [] - if (value.includes(tag.id)) { - onChange(filter(value, (v) => v !== tag.id)) + if (value.includes(id)) { + onChange(filter(value, (v) => v !== id)) } else { - onChange(value.concat([tag.id])) + onChange(value.concat([id])) } } - const unTagged = !!showUntagged && { - color: '#656D7B', - id: '', - label: 'Untagged', - } return ( - +
- + {!!onChangeStrategy && (