diff --git a/frontend/common/stores/project-store.js b/frontend/common/stores/project-store.js index 17e2c69478f7..ce3fb9f18580 100644 --- a/frontend/common/stores/project-store.js +++ b/frontend/common/stores/project-store.js @@ -191,6 +191,9 @@ const store = Object.assign({}, BaseStore, { getMaxSegmentsAllowed: () => { return store.model && store.model.max_segments_allowed }, + getStaleFlagsLimit: () => { + return store.model && store.model.stale_flags_limit_days + }, getTotalFeatures: () => { return store.model && store.model.total_features }, diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 0a1292e0dff7..3a5968f655b1 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -91,6 +91,7 @@ export type Project = { max_features_allowed?: number | null max_segment_overrides_allowed?: number | null total_features?: number + stale_flags_limit_days?: number total_segments?: number environments: Environment[] } @@ -250,6 +251,9 @@ export type Tag = { description: string project: number label: string + is_system_tag: boolean + is_permanent: boolean + type: 'STALE' | 'NONE' } export type MultivariateFeatureStateValue = { diff --git a/frontend/common/utils/hasProtectedTag.ts b/frontend/common/utils/getProtectedTags.ts similarity index 52% rename from frontend/common/utils/hasProtectedTag.ts rename to frontend/common/utils/getProtectedTags.ts index edc0e25adb79..807fb0c05852 100644 --- a/frontend/common/utils/hasProtectedTag.ts +++ b/frontend/common/utils/getProtectedTags.ts @@ -2,7 +2,7 @@ import { getStore } from 'common/store' import { ProjectFlag, Tag } from 'common/types/responses' import { tagService } from 'common/services/useTag' -export const hasProtectedTag = ( +export const getProtectedTags = ( projectFlag: ProjectFlag, projectId: string, ) => { @@ -11,15 +11,12 @@ export const hasProtectedTag = ( tagService.endpoints.getTags.select({ projectId: `${projectId}` })( store.getState(), ).data || [] - return !!projectFlag.tags?.find((id) => { - const tag = tags.find((tag) => tag.id === id) - if (tag) { - const label = tag.label.toLowerCase().replace(/[ _]/g, '') - return ( - label === 'protected' || - label === 'donotdelete' || - label === 'permanent' - ) - } - }) + return projectFlag.tags + ?.filter((id) => { + const tag = tags.find((tag) => tag.id === id) + return tag?.is_permanent + }) + .map((id) => { + return tags.find((tag) => tag.id === id) + }) } diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index f9040a6398ae..4eeae5bb26d4 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -352,6 +352,10 @@ const Utils = Object.assign({}, require('./base/_utils'), { valid = isScaleupOrGreater break } + case 'STALE_FLAGS': { + valid = isEnterprise + break + } default: valid = true break diff --git a/frontend/web/components/FeatureAction.tsx b/frontend/web/components/FeatureAction.tsx index 9e35f856a158..07966317d22f 100644 --- a/frontend/web/components/FeatureAction.tsx +++ b/frontend/web/components/FeatureAction.tsx @@ -7,12 +7,15 @@ import Constants from 'common/constants' import Permission from 'common/providers/Permission' import Button from './base/forms/Button' import Icon from './Icon' +import { Tag } from 'common/types/responses' +import color from 'color' +import { getTagColor } from './tags/Tag' interface FeatureActionProps { projectId: string featureIndex: number readOnly: boolean - isProtected: boolean + protectedTags: Tag[] | undefined hideAudit: boolean hideHistory: boolean hideRemove: boolean @@ -44,12 +47,12 @@ export const FeatureAction: FC = ({ hideHistory, hideRemove, isCompact, - isProtected, onCopyName, onRemove, onShowAudit, onShowHistory, projectId, + protectedTags, readOnly, }) => { const [isOpen, setIsOpen] = useState(false) @@ -90,6 +93,7 @@ export const FeatureAction: FC = ({ listRef.current.style.left = `${listPosition.left}px` }, [isOpen]) + const isProtected = !!protectedTags?.length return (
@@ -169,7 +173,22 @@ export const FeatureAction: FC = ({ } > {isProtected && - 'This feature has been tagged as protected, permanent, do not delete, or read only. Please remove the tag before attempting to delete this flag.'} + `This feature has been tagged with the permanent tag${ + protectedTags?.length > 1 ? 's' : '' + } ${protectedTags + ?.map((tag) => { + const tagColor = getTagColor(tag) + return ` + ${tag.label} + ` + }) + .join('')}. Please remove the tag${ + protectedTags?.length > 1 ? 's' : '' + } before attempting to delete this flag.`} , ) } diff --git a/frontend/web/components/FeatureRow.js b/frontend/web/components/FeatureRow.js index fa32a29bc922..418079cfb1f2 100644 --- a/frontend/web/components/FeatureRow.js +++ b/frontend/web/components/FeatureRow.js @@ -5,7 +5,7 @@ import ConfirmRemoveFeature from './modals/ConfirmRemoveFeature' import CreateFlagModal from './modals/CreateFlag' import ProjectStore from 'common/stores/project-store' import Constants from 'common/constants' -import { hasProtectedTag } from 'common/utils/hasProtectedTag' +import { getProtectedTags } from 'common/utils/getProtectedTags' import Icon from './Icon' import FeatureValue from './FeatureValue' import FeatureAction from './FeatureAction' @@ -132,7 +132,7 @@ class TheComponent extends Component { const { created_date, description, id, name } = this.props.projectFlag const readOnly = this.props.readOnly || Utils.getFlagsmithHasFeature('read_only_mode') - const isProtected = hasProtectedTag(projectFlag, projectId) + const protectedTags = getProtectedTags(projectFlag, projectId) const environment = ProjectStore.getEnvironment(environmentId) const changeRequestsEnabled = Utils.changeRequestsEnabled( environment && environment.minimum_change_request_approvals, @@ -368,7 +368,7 @@ class TheComponent extends Component { projectId={projectId} featureIndex={this.props.index} readOnly={readOnly} - isProtected={isProtected} + protectedTags={protectedTags} isCompact={isCompact} hideAudit={ AccountStore.getOrganisationRole() !== 'ADMIN' || diff --git a/frontend/web/components/Tooltip.tsx b/frontend/web/components/Tooltip.tsx index 458f313fbf35..debc18934548 100644 --- a/frontend/web/components/Tooltip.tsx +++ b/frontend/web/components/Tooltip.tsx @@ -7,9 +7,16 @@ export type TooltipProps = { children: string place?: _TooltipProps['place'] plainText?: boolean + titleClassName?: string } -const Tooltip: FC = ({ children, place, plainText, title }) => { +const Tooltip: FC = ({ + children, + place, + plainText, + title, + titleClassName, +}) => { const id = Utils.GUID() if (!children) { @@ -19,7 +26,7 @@ const Tooltip: FC = ({ children, place, plainText, title }) => { return ( <> {title && ( - + {title} )} diff --git a/frontend/web/components/pages/ProjectSettingsPage.js b/frontend/web/components/pages/ProjectSettingsPage.js index 8dab1a7ef440..2bc7b5ecd8fe 100644 --- a/frontend/web/components/pages/ProjectSettingsPage.js +++ b/frontend/web/components/pages/ProjectSettingsPage.js @@ -20,6 +20,7 @@ import ImportPage from 'components/import-export/ImportPage' import FeatureExport from 'components/import-export/FeatureExport' import ProjectUsage from 'components/ProjectUsage' import ProjectStore from 'common/stores/project-store' +import Tooltip from 'components/Tooltip' const ProjectSettingsPage = class extends Component { static displayName = 'ProjectSettingsPage' @@ -154,7 +155,8 @@ const ProjectSettingsPage = class extends Component { } render() { - const { name } = this.state + const { name, stale_flags_limit_days } = this.state + const hasStaleFlagsPermission = Utils.getPlansPermission('STALE_FLAGS') return (
@@ -163,6 +165,15 @@ const ProjectSettingsPage = class extends Component { onSave={this.onSave} > {({ deleteProject, editProject, isLoading, isSaving, project }) => { + if ( + !this.state.stale_flags_limit_days && + project?.stale_flags_limit_days + ) { + this.state.stale_flags_limit_days = project.stale_flags_limit_days + } + if (!this.state.name && project?.name) { + this.state.name = project.name + } if ( !this.state.populatedProjectState && project?.feature_name_regex @@ -181,11 +192,16 @@ const ProjectSettingsPage = class extends Component { e.preventDefault() !isSaving && name && - editProject(Object.assign({}, project, { name })) + editProject( + Object.assign({}, project, { name, stale_flags_limit_days }), + ) } const featureRegexEnabled = typeof this.state.feature_name_regex === 'string' + + const hasVersioning = + Utils.getFlagsmithHasFeature('feature_versioning') return (
@@ -200,14 +216,13 @@ const ProjectSettingsPage = class extends Component { /> -
- + + (this.input = e)} - defaultValue={project.name} value={this.state.name} - inputClassName='input--wide' + inputClassName='full-width' name='proj-name' onChange={(e) => this.setState({ @@ -220,15 +235,54 @@ const ProjectSettingsPage = class extends Component { placeholder='My Project Name' /> + + {!!hasVersioning && ( + + +
+ (this.input = e)} + value={ + this.state.stale_flags_limit_days + } + onChange={(e) => + this.setState({ + stale_flags_limit_days: parseInt( + Utils.safeParseEventValue(e), + ), + }) + } + isValid={!!stale_flags_limit_days} + type='number' + placeholder='Number of Days' + /> +
+
+ } + > + {`${ + !hasStaleFlagsPermission + ? 'This feature is available with our enterprise plan. ' + : '' + }If no changes have been made to a feature in any environment within this threshold the feature will be tagged as stale. You will need to enable feature versioning in your environments for stale features to be detected.`} + + )} +
- +
diff --git a/frontend/web/components/tables/TableTagFilter.tsx b/frontend/web/components/tables/TableTagFilter.tsx index e25a74ee3924..a20a9b1813dd 100644 --- a/frontend/web/components/tables/TableTagFilter.tsx +++ b/frontend/web/components/tables/TableTagFilter.tsx @@ -8,6 +8,7 @@ import TableFilterItem from './TableFilterItem' import Constants from 'common/constants' import { TagStrategy } from 'common/types/responses' import { AsyncStorage } from 'polyfill-react-native' +import TagContent from 'components/tags/TagContent' type TableFilterType = { projectId: string @@ -167,7 +168,12 @@ const TableTagFilter: FC = ({ className='px-2 py-2 mr-1' tag={tag} /> -
{tag.label}
+
+ +
} key={tag.id} diff --git a/frontend/web/components/tags/AddEditTags.tsx b/frontend/web/components/tags/AddEditTags.tsx index b9161e479387..a0169eb9404a 100644 --- a/frontend/web/components/tags/AddEditTags.tsx +++ b/frontend/web/components/tags/AddEditTags.tsx @@ -1,4 +1,4 @@ -import React, { FC, useMemo, useState } from 'react' +import React, { FC, useEffect, useMemo, useState } from 'react' import { filter as loFilter } from 'lodash' import { useHasPermission } from 'common/providers/Permission' import Utils from 'common/utils/utils' @@ -46,6 +46,11 @@ const AddEditTags: FC = ({ permission: 'ADMIN', }) + useEffect(() => { + if (!isOpen) { + setTab('SELECT') + } + }, [isOpen]) const selectTag = (tag: TTag) => { const _value = value || [] const isSelected = _value?.includes(tag.id) @@ -200,22 +205,24 @@ const AddEditTags: FC = ({ tag={tag} /> - {!readOnly && !!projectAdminPermission && ( - <> -
editTag(tag)} - className='clickable' - > - -
-
confirmDeleteTag(tag)} - className='ml-3 clickable' - > - -
- - )} + {!readOnly && + !!projectAdminPermission && + !tag.is_system_tag && ( + <> +
editTag(tag)} + className='clickable' + > + +
+
confirmDeleteTag(tag)} + className='ml-3 clickable' + > + +
+ + )}
))} diff --git a/frontend/web/components/tags/CreateEditTag.tsx b/frontend/web/components/tags/CreateEditTag.tsx index 65ad7626d807..c4b2a7a1a128 100644 --- a/frontend/web/components/tags/CreateEditTag.tsx +++ b/frontend/web/components/tags/CreateEditTag.tsx @@ -14,6 +14,8 @@ import Button from 'components/base/forms/Button' import Tag from './Tag' import InlineModal from 'components/InlineModal' import ErrorMessage from 'components/ErrorMessage' +import Switch from 'components/Switch' +import Icon from 'components/Icon' type CreateEditTagType = { projectId: string @@ -47,6 +49,9 @@ const CreateEditTag: FC = ({ const { data: tags } = useGetTagsQuery({ projectId }) const existingTag = useMemo(() => { + if (isEdit) { + return false + } if (tag?.label && tags) { const lowercaseTag = tag?.label.toLowerCase() return tags?.find((tag) => { @@ -54,7 +59,7 @@ const CreateEditTag: FC = ({ }) } return false - }, [tags, tag?.label]) + }, [tags, isEdit, tag?.label]) const tagsSaving = creating || saving @@ -159,6 +164,25 @@ const CreateEditTag: FC = ({ title='Name' onChange={(e: InputEvent) => update('label', e)} /> + + update('is_permanent', e)} + /> +
+ Is permanent? +
+ + } + place='top' + > + Flags marked with permanent tags are not monitored for staleness and + have deletion protection. +
+ void selected?: boolean tag: Partial - isTruncated?: boolean isDot?: boolean } +export const getTagColor = (tag: Partial, selected?: boolean) => { + if (Utils.getFlagsmithHasFeature('dark_mode') && tag.color === '#344562') { + return '#9DA4AE' + } + if (selected) { + return tag.color + } + return tag.color +} + const Tag: FC = ({ className, - deselectedColor, hideNames, isDot, - isTruncated, onClick, selected, tag, }) => { - const getColor = () => { - if (Utils.getFlagsmithHasFeature('dark_mode') && tag.color === '#344562') { - return '#9DA4AE' - } - if (selected) { - return tag.color - } - return deselectedColor || tag.color - } - + const tagColor = getTagColor(tag, selected) if (isDot) { return (
) } + if (!hideNames && !!onClick) { return ( onClick(tag as TTag) : null} > - {tag.label} + {!!tag.label && } ) } - return isTruncated && `${tag.label}`.length > 12 ? ( - onClick?.(tag as TTag)} - style={{ - backgroundColor: `${color(getColor()).fade(0.92)}`, - border: `1px solid ${color(getColor()).fade(0.76)}`, - color: `${color(getColor()).darken(0.1)}`, - }} - className={cx('chip', className)} - > - {Format.truncateText(`${tag.label}`, 12)} -
- } - > - {tag.label} - - ) : ( + return (
onClick?.(tag as TTag)} style={{ - backgroundColor: `${color(getColor()).fade(0.92)}`, - border: `1px solid ${color(getColor()).fade(0.76)}`, - color: `${color(getColor()).darken(0.1)}`, + backgroundColor: `${color(tagColor).fade(0.92)}`, + border: `1px solid ${color(tagColor).fade(0.76)}`, + color: `${color(tagColor).darken(0.1)}`, }} className={cx('chip', className)} > - {tag.label} +
) } diff --git a/frontend/web/components/tags/TagContent.tsx b/frontend/web/components/tags/TagContent.tsx new file mode 100644 index 000000000000..73800e3c114f --- /dev/null +++ b/frontend/web/components/tags/TagContent.tsx @@ -0,0 +1,90 @@ +import React, { FC } from 'react' +import { Tag as TTag } from 'common/types/responses' +import color from 'color' +import Format from 'common/utils/format' +import { IonIcon } from '@ionic/react' +import { alarmOutline, lockClosed } from 'ionicons/icons' +import Tooltip from 'components/Tooltip' +import { getTagColor } from './Tag' +import OrganisationStore from 'common/stores/organisation-store' +type TagContent = { + tag: Partial +} + +const getTooltip = (tag: TTag | undefined) => { + if (!tag) { + return null + } + const stale_flags_limit_days = OrganisationStore.getProject( + tag.project, + )?.stale_flags_limit_days + const truncated = Format.truncateText(tag.label, 12) + const isTruncated = truncated !== tag.label ? tag.label : null + let tooltip = null + switch (tag.type) { + case 'STALE': { + tooltip = `A feature is marked as stale if no changes have been made to it in any environment within ${stale_flags_limit_days} days. This is automatically applied and will be re-evaluated if you remove this tag unless you apply a permanent tag to the feature.` + break + } + default: + break + } + if (tag.is_permanent) { + tooltip = + 'Features marked with this tag are not monitored for staleness and have deletion protection.' + } + const tagColor = getTagColor(tag, false) + + if (isTruncated) { + return `
+
+ ${tag.label} +
+ ${tooltip || ''} +
` + } + return tooltip +} + +const TagContent: FC = ({ tag }) => { + const tagLabel = Format.truncateText(tag.label, 12) + + if (!tagLabel) { + return null + } + return ( + + {tagLabel} + {tag.type === 'STALE' ? ( + + ) : ( + tag.is_permanent && ( + + ) + )} + + } + > + {getTooltip(tag)} + + ) +} + +export default TagContent diff --git a/frontend/web/components/tags/TagValues.tsx b/frontend/web/components/tags/TagValues.tsx index 9921f1012c0d..719641b22f1b 100644 --- a/frontend/web/components/tags/TagValues.tsx +++ b/frontend/web/components/tags/TagValues.tsx @@ -33,7 +33,6 @@ const TagValues: FC = ({ hideNames={hideNames} onClick={onAdd} tag={tag} - isTruncated={tag.label.length > 12} /> ), )} diff --git a/frontend/web/styles/project/_tooltips.scss b/frontend/web/styles/project/_tooltips.scss index 554fdebfaa45..0b43f773b6d0 100644 --- a/frontend/web/styles/project/_tooltips.scss +++ b/frontend/web/styles/project/_tooltips.scss @@ -24,6 +24,7 @@ $shadow-dark: 0 4px 4px 0 #00000029; &:after { box-shadow: $shadow; border-top-color: white; + background-color: white; border-right-width: $arrow; border-left-width: $arrow; margin-top: -$arrow; @@ -35,6 +36,7 @@ $shadow-dark: 0 4px 4px 0 #00000029; &:after { box-shadow: $shadow; border-bottom-color: white; + background-color: white; border-right-width: $arrow; border-left-width: $arrow; margin-left: -$arrow; @@ -46,6 +48,7 @@ $shadow-dark: 0 4px 4px 0 #00000029; &:after { box-shadow: $shadow; border-right-color: white; + background-color: white; border-top-width: $arrow; border-bottom-width: $arrow; margin-top: -5px; @@ -57,6 +60,7 @@ $shadow-dark: 0 4px 4px 0 #00000029; &:after { box-shadow: $shadow; border-left-color: white; + background-color: white; border-top-width: $arrow; border-bottom-width: $arrow; margin-top: -5px;