From 03699c8e809e5252be3b61935228d028a8f477a7 Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Fri, 21 Mar 2025 14:54:13 +0100 Subject: [PATCH 1/2] chore(1-3516): add release plan / strategy count to env header (#9589) Adds an optional `environmentMetadata` property to the env header component, which is used to populate the release plan / strategy counter. If no env metadata is passed (such as for default strategy configuration) nothing is rendered. ![image](https://github.com/user-attachments/assets/9be29a7a-aa11-46a4-87b4-4596c12552f6) With long env names, the project name will be cut off before the chip: ![image](https://github.com/user-attachments/assets/0711972b-66d6-4874-9c47-0c4c768807ff) There's some issues with narrow screens, but I'll handle that in a follow-up: ![image](https://github.com/user-attachments/assets/0de8aeae-1025-4c7e-9fcb-86dd22952f97) --- .../EnvironmentHeader/EnvironmentHeader.tsx | 65 ++++++++++++++++++- .../FeatureOverviewEnvironment.tsx | 8 ++- 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentHeader/EnvironmentHeader.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentHeader/EnvironmentHeader.tsx index 3cc872b2e1..80d490b570 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentHeader/EnvironmentHeader.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentHeader/EnvironmentHeader.tsx @@ -36,15 +36,17 @@ const StyledHeader = styled('header')(({ theme }) => ({ })); const StyledHeaderTitle = styled('hgroup')(({ theme }) => ({ - display: 'flex', + display: 'grid', + gridTemplateColumns: 'auto 1fr', flexDirection: 'column', flex: 1, + columnGap: theme.spacing(1), })); const StyledHeaderTitleLabel = styled('p')(({ theme }) => ({ fontSize: theme.fontSizes.smallerBody, color: theme.palette.text.secondary, - margin: 0, + gridColumn: '1/-1', })); const StyledTruncator = styled(Truncator)(({ theme }) => ({ @@ -52,14 +54,68 @@ const StyledTruncator = styled(Truncator)(({ theme }) => ({ fontWeight: theme.typography.fontWeightMedium, })); +const StyledStrategyCount = styled('p')(({ theme }) => ({ + fontSize: theme.fontSizes.smallerBody, + color: theme.palette.info.contrastText, + backgroundColor: theme.palette.info.light, + whiteSpace: 'nowrap', + width: 'min-content', + borderRadius: theme.shape.borderRadiusExtraLarge, + padding: theme.spacing(0.5, 1), +})); + +const NeutralStrategyCount = styled(StyledStrategyCount)(({ theme }) => ({ + fontSize: theme.fontSizes.smallerBody, + color: theme.palette.text.secondary, + backgroundColor: theme.palette.neutral.light, +})); + +type EnvironmentMetadata = { + strategyCount: number; + releasePlanCount: number; +}; + type EnvironmentHeaderProps = { environmentId: string; expandable?: boolean; + environmentMetadata?: EnvironmentMetadata; } & AccordionSummaryProps; +const MetadataChip = ({ + strategyCount, + releasePlanCount, +}: EnvironmentMetadata) => { + if (strategyCount === 0 && releasePlanCount === 0) { + return 0 strategies added; + } + + const releasePlanText = releasePlanCount > 0 ? 'Release plan' : undefined; + + const strategyText = () => { + switch (strategyCount) { + case 0: + return undefined; + case 1: + return `1 strategy`; + default: + return `${strategyCount} strategies`; + } + }; + + const text = `${[releasePlanText, strategyText()].filter(Boolean).join(', ')} added`; + + return {text}; +}; + export const EnvironmentHeader: FC< PropsWithChildren -> = ({ environmentId, children, expandable = true, ...props }) => { +> = ({ + environmentId, + children, + expandable = true, + environmentMetadata, + ...props +}) => { const id = useId(); return ( {environmentId} + {environmentMetadata ? ( + + ) : null} {children} diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx index 3d46d26eac..8ba0dbb1c9 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx @@ -66,7 +66,7 @@ export const FeatureOverviewEnvironment = ({ metrics = { yes: 0, no: 0 }, otherEnvironments = [], }: FeatureOverviewEnvironmentProps) => { - const [isOpen, setIsOopen] = useState(false); + const [isOpen, setIsOpen] = useState(false); const projectId = useRequiredPathParam('projectId'); const featureId = useRequiredPathParam('featureId'); const { isOss } = useUiConfig(); @@ -83,9 +83,13 @@ export const FeatureOverviewEnvironment = ({ data-testid={`${FEATURE_ENVIRONMENT_ACCORDION}_${environment.name}`} expanded={isOpen && hasActivations} disabled={!hasActivations} - onChange={() => setIsOopen(isOpen ? !isOpen : hasActivations)} + onChange={() => setIsOpen(isOpen ? !isOpen : hasActivations)} > From 19d2a553f0917ab40e506e9771c83dcae3f364a1 Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Fri, 21 Mar 2025 15:26:05 +0100 Subject: [PATCH 2/2] chore(1-3431): rework constraint equality and case sensitivity (#9591) --- frontend/src/assets/icons/case-sensitive.svg | 1 + .../formatOperatorDescription.ts | 26 +++++++++- .../ConstraintItemHeader.tsx | 51 +++++++++++++------ .../isCaseSensitive.test.ts | 45 ++++++++++++++++ .../ConstraintItemHeader/isCaseSensitive.ts | 12 +++++ 5 files changed, 119 insertions(+), 16 deletions(-) create mode 100644 frontend/src/assets/icons/case-sensitive.svg create mode 100644 frontend/src/component/common/ConstraintsList/ConstraintItemHeader/isCaseSensitive.test.ts create mode 100644 frontend/src/component/common/ConstraintsList/ConstraintItemHeader/isCaseSensitive.ts diff --git a/frontend/src/assets/icons/case-sensitive.svg b/frontend/src/assets/icons/case-sensitive.svg new file mode 100644 index 0000000000..1383f16794 --- /dev/null +++ b/frontend/src/assets/icons/case-sensitive.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintOperator/formatOperatorDescription.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintOperator/formatOperatorDescription.ts index 845d2d14bb..5e01186ad6 100644 --- a/frontend/src/component/common/ConstraintAccordion/ConstraintOperator/formatOperatorDescription.ts +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintOperator/formatOperatorDescription.ts @@ -1,6 +1,12 @@ import type { Operator } from 'constants/operators'; -export const formatOperatorDescription = (operator: Operator): string => { +export const formatOperatorDescription = ( + operator: Operator, + inverted?: boolean, +): string => { + if (inverted) { + return invertedConstraintOperatorDescriptions[operator]; + } return constraintOperatorDescriptions[operator]; }; @@ -21,3 +27,21 @@ const constraintOperatorDescriptions = { SEMVER_GT: 'is a SemVer greater than', SEMVER_LT: 'is a SemVer less than', }; + +const invertedConstraintOperatorDescriptions = { + IN: 'is not one of', + NOT_IN: 'is one of', + STR_CONTAINS: 'is a string that does not contain', + STR_STARTS_WITH: 'is a string that does not start with', + STR_ENDS_WITH: 'is a string that does not end with', + NUM_EQ: 'is a number not equal to', + NUM_GT: 'is a number not greater than', + NUM_GTE: 'is a number less than', + NUM_LT: 'is a number not less than', + NUM_LTE: 'is a number greater than', + DATE_BEFORE: 'is a date not before', + DATE_AFTER: 'is a date not after', + SEMVER_EQ: 'is a SemVer not equal to', + SEMVER_GT: 'is a SemVer not greater than', + SEMVER_LT: 'is a SemVer not less than', +}; diff --git a/frontend/src/component/common/ConstraintsList/ConstraintItemHeader/ConstraintItemHeader.tsx b/frontend/src/component/common/ConstraintsList/ConstraintItemHeader/ConstraintItemHeader.tsx index bcaf83b751..880de40317 100644 --- a/frontend/src/component/common/ConstraintsList/ConstraintItemHeader/ConstraintItemHeader.tsx +++ b/frontend/src/component/common/ConstraintsList/ConstraintItemHeader/ConstraintItemHeader.tsx @@ -7,24 +7,44 @@ import type { ConstraintSchema } from 'openapi'; import { formatOperatorDescription } from 'component/common/ConstraintAccordion/ConstraintOperator/formatOperatorDescription'; import { StrategyEvaluationChip } from '../StrategyEvaluationChip/StrategyEvaluationChip'; import { styled, Tooltip } from '@mui/material'; +import { ReactComponent as CaseSensitiveIcon } from 'assets/icons/case-sensitive.svg'; +import { isCaseSensitive } from './isCaseSensitive'; -const Inverted: FC = () => ( - - +const Operator: FC<{ + label: ConstraintSchema['operator']; + inverted?: boolean; +}> = ({ label, inverted }) => ( + + ); -const Operator: FC<{ label: ConstraintSchema['operator'] }> = ({ label }) => ( - - - +const StrategyEvalChipLessInlinePadding = styled(StrategyEvaluationChip)( + ({ theme }) => ({ + '> span': { + paddingInline: theme.spacing(0.5), + }, + }), ); -const CaseInsensitive: FC = () => ( - - Aa} /> - -); +const CaseSensitive: FC = () => { + return ( + + + } + /> + + ); +}; const StyledOperatorGroup = styled('div')(({ theme }) => ({ display: 'flex', @@ -53,9 +73,10 @@ export const ConstraintItemHeader: FC< > {contextName} - {inverted ? : null} - - {caseInsensitive ? : null} + + {isCaseSensitive(operator, caseInsensitive) ? ( + + ) : null} ); diff --git a/frontend/src/component/common/ConstraintsList/ConstraintItemHeader/isCaseSensitive.test.ts b/frontend/src/component/common/ConstraintsList/ConstraintItemHeader/isCaseSensitive.test.ts new file mode 100644 index 0000000000..70578bd2a2 --- /dev/null +++ b/frontend/src/component/common/ConstraintsList/ConstraintItemHeader/isCaseSensitive.test.ts @@ -0,0 +1,45 @@ +import { + allOperators, + inOperators, + stringOperators, +} from 'constants/operators'; +import { isCaseSensitive } from './isCaseSensitive'; + +test('`IN` and `NOT_IN` are always case sensitive', () => { + expect( + inOperators + .flatMap((operator) => + [true, false, undefined].map((caseInsensitive) => + isCaseSensitive(operator, caseInsensitive), + ), + ) + .every((result) => result === true), + ).toBe(true); +}); + +test('If `caseInsensitive` is true, all operators except for `IN` and `NOT_IN` are considered case insensitive', () => { + expect( + allOperators + .filter((operator) => !inOperators.includes(operator)) + .map((operator) => isCaseSensitive(operator, true)) + .every((result) => result === false), + ).toBe(true); +}); + +test.each([false, undefined])( + 'If `caseInsensitive` is %s, only string (and in) operators are considered case sensitive', + (caseInsensitive) => { + const stringResults = stringOperators.map((operator) => + isCaseSensitive(operator, caseInsensitive), + ); + const nonStringResults = allOperators + .filter( + (operator) => + ![...stringOperators, ...inOperators].includes(operator), + ) + .map((operator) => isCaseSensitive(operator, caseInsensitive)); + + expect(stringResults.every((result) => result === true)).toBe(true); + expect(nonStringResults.every((result) => result === false)).toBe(true); + }, +); diff --git a/frontend/src/component/common/ConstraintsList/ConstraintItemHeader/isCaseSensitive.ts b/frontend/src/component/common/ConstraintsList/ConstraintItemHeader/isCaseSensitive.ts new file mode 100644 index 0000000000..d9dfbf0ae1 --- /dev/null +++ b/frontend/src/component/common/ConstraintsList/ConstraintItemHeader/isCaseSensitive.ts @@ -0,0 +1,12 @@ +import { + inOperators, + stringOperators, + type Operator, +} from 'constants/operators'; + +export const isCaseSensitive = ( + operator: Operator, + caseInsensitive?: boolean, +) => + inOperators.includes(operator) || + (stringOperators.includes(operator) && !caseInsensitive);