1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-23 13:46:45 +02:00

Merge branch 'main' into feat/impact-metrics-page

This commit is contained in:
Tymoteusz Czech 2025-06-30 17:15:12 +02:00
commit 6b097846e9
No known key found for this signature in database
GPG Key ID: 133555230D88D75F
47 changed files with 2673 additions and 369 deletions

22
.github/workflows/ai-flag-cleanup.yml vendored Normal file
View File

@ -0,0 +1,22 @@
name: AI flag cleanup
on:
issues:
types: [labeled]
permissions:
pull-requests: write
contents: write
issues: read
jobs:
flag-cleanup:
uses: mirrajabi/aider-github-workflows/.github/workflows/aider-issue-to-pr.yml@v1.0.0
if: github.event.label.name == 'unleash-flag-completed'
with:
issue-number: ${{ github.event.issue.number }}
base-branch: ${{ github.event.repository.default_branch }}
chat-timeout: 10
api_key_env_name: GEMINI_API_KEY
model: gemini
secrets:
api_key_env_value: ${{ secrets.GEMINI_API_KEY }}

View File

@ -16,7 +16,7 @@ export const ArchiveFeatureChange: FC<IArchiveFeatureChange> = ({
actions,
}) => (
<ChangeItemWrapper>
<ArchiveBox>Archiving feature</ArchiveBox>
<ArchiveBox>Archiving flag</ArchiveBox>
{actions}
</ChangeItemWrapper>
);

View File

@ -0,0 +1,64 @@
import {
Tab as MuiTab,
TabPanel as MuiTabPanel,
Tabs as MuiTabs,
TabsList as MuiTabsList,
} from '@mui/base';
import { Button, type ButtonProps, styled } from '@mui/material';
import type { PropsWithChildren } from 'react';
export const TabList = styled(MuiTabsList)(({ theme }) => ({
display: 'inline-flex',
flexDirection: 'row',
gap: theme.spacing(0.5),
}));
const StyledButton = styled(Button)(({ theme }) => ({
whiteSpace: 'nowrap',
color: theme.palette.text.secondary,
fontWeight: 'normal',
'&[aria-selected="true"]': {
fontWeight: 'bold',
color: theme.palette.primary.main,
background: theme.palette.background.elevation1,
},
}));
export const Tab = styled(({ children }: ButtonProps) => (
<MuiTab slots={{ root: StyledButton }}>{children}</MuiTab>
))(({ theme }) => ({
position: 'absolute',
top: theme.spacing(-0.5),
left: theme.spacing(2),
transform: 'translateY(-50%)',
padding: theme.spacing(0.75, 1),
lineHeight: 1,
fontSize: theme.fontSizes.smallerBody,
color: theme.palette.text.primary,
background: theme.palette.background.application,
borderRadius: theme.shape.borderRadiusExtraLarge,
zIndex: theme.zIndex.fab,
textTransform: 'uppercase',
}));
export const Tabs = ({ children }: PropsWithChildren) => (
<MuiTabs
aria-label='View rendered change or JSON diff'
selectionFollowsFocus
defaultValue={0}
>
{children}
</MuiTabs>
);
export const TabPanel = styled(MuiTabPanel, {
shouldForwardProp: (prop) => prop !== 'variant',
})<{ variant?: 'diff' | 'change' }>(({ theme, variant }) =>
variant === 'diff'
? {
padding: theme.spacing(2),
borderRadius: theme.shape.borderRadiusLarge,
border: `1px solid ${theme.palette.divider}`,
}
: {},
);

View File

@ -26,6 +26,7 @@ import { FeatureStrategyForm } from '../../../../feature/FeatureStrategy/Feature
import { NewStrategyVariants } from 'component/feature/StrategyTypes/NewStrategyVariants';
import { v4 as uuidv4 } from 'uuid';
import { constraintId } from 'constants/constraintId.ts';
import { apiPayloadConstraintReplacer } from 'utils/api-payload-constraint-replacer.ts';
interface IEditChangeProps {
change: IChangeRequestAddStrategy | IChangeRequestUpdateStrategy;
@ -208,7 +209,7 @@ export const formatUpdateStrategyApiCode = (
}
const url = `${unleashUrl}/api/admin/projects/${projectId}/change-requests/${changeRequestId}/changes/${changeId}`;
const payload = JSON.stringify(strategy, undefined, 2);
const payload = JSON.stringify(strategy, apiPayloadConstraintReplacer, 2);
return `curl --location --request PUT '${url}' \\
--header 'Authorization: INSERT_API_KEY' \\

View File

@ -6,6 +6,9 @@ import { Box, styled } from '@mui/material';
import { EnvironmentStrategyOrderDiff } from './EnvironmentStrategyOrderDiff.tsx';
import { StrategyExecution } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/StrategyExecution';
import { formatStrategyName } from '../../../../../../utils/strategyNames.tsx';
import type { IFeatureStrategy } from 'interfaces/strategy.ts';
import { Tab, TabList, TabPanel, Tabs } from '../ChangeTabComponents.tsx';
import { useUiFlag } from 'hooks/useUiFlag.ts';
const ChangeItemInfo = styled(Box)({
display: 'flex',
@ -51,6 +54,7 @@ export const EnvironmentStrategyExecutionOrder = ({
actions,
}: IEnvironmentStrategyExecutionOrderProps) => {
const { feature: featureData, loading } = useFeature(project, feature);
const useDiffableComponent = useUiFlag('crDiffView');
if (loading) return null;
@ -74,16 +78,53 @@ export const EnvironmentStrategyExecutionOrder = ({
.map((strategy) => strategy.id) ?? [],
};
const updatedStrategies = change.payload
const updatedStrategies: IFeatureStrategy[] = change.payload
.map(({ id }) => {
return environmentStrategies.find((s) => s.id === id);
})
.filter(Boolean);
.filter((strategy): strategy is IFeatureStrategy => Boolean(strategy));
const data = {
strategyIds: updatedStrategies.map((strategy) => strategy!.id),
strategyIds: updatedStrategies.map((strategy) => strategy.id),
};
if (useDiffableComponent) {
return (
<Tabs>
<ChangeItemInfo>
<StyledChangeHeader>
<p>Updating strategy execution order to:</p>
<div>
<TabList>
<Tab>Change</Tab>
<Tab>View diff</Tab>
</TabList>
{actions}
</div>
</StyledChangeHeader>
<TabPanel>
<StyledStrategyExecutionWrapper>
{updatedStrategies.map((strategy, index) => (
<StyledStrategyContainer key={strategy.id}>
{`${index + 1}: `}
{formatStrategyName(strategy?.name || '')}
{strategy?.title && ` - ${strategy.title}`}
<StrategyExecution strategy={strategy!} />
</StyledStrategyContainer>
))}
</StyledStrategyExecutionWrapper>
</TabPanel>
<TabPanel variant='diff'>
<EnvironmentStrategyOrderDiff
preData={preData}
data={data}
/>
</TabPanel>
</ChangeItemInfo>
</Tabs>
);
}
return (
<ChangeItemInfo>
<StyledChangeHeader>
@ -105,7 +146,7 @@ export const EnvironmentStrategyExecutionOrder = ({
</StyledChangeHeader>
<StyledStrategyExecutionWrapper>
{updatedStrategies.map((strategy, index) => (
<StyledStrategyContainer>
<StyledStrategyContainer key={strategy.id}>
{`${index + 1}: `}
{formatStrategyName(strategy?.name || '')}
{strategy?.title && ` - ${strategy.title}`}

View File

@ -1,5 +1,7 @@
import { styled } from '@mui/material';
import EventDiff from 'component/events/EventDiff/EventDiff';
import { EventDiff } from 'component/events/EventDiff/EventDiff';
import { useUiFlag } from 'hooks/useUiFlag';
import { Fragment } from 'react';
const StyledCodeSection = styled('div')(({ theme }) => ({
overflowX: 'auto',
@ -17,14 +19,19 @@ interface IDiffProps {
data: StrategyIds;
}
export const EnvironmentStrategyOrderDiff = ({ preData, data }: IDiffProps) => (
<StyledCodeSection>
<EventDiff
entry={{
preData: preData.strategyIds,
data: data.strategyIds,
}}
sort={(a, b) => a.index - b.index}
/>
</StyledCodeSection>
);
export const EnvironmentStrategyOrderDiff = ({ preData, data }: IDiffProps) => {
const useNewDiff = useUiFlag('improvedJsonDiff');
const Wrapper = useNewDiff ? Fragment : StyledCodeSection;
return (
<Wrapper>
<EventDiff
entry={{
preData: preData.strategyIds,
data: data.strategyIds,
}}
sort={(a, b) => a.index - b.index}
/>
</Wrapper>
);
};

View File

@ -8,13 +8,16 @@ import { objectId } from 'utils/objectId';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Alert, Box, styled } from '@mui/material';
import { ToggleStatusChange } from './ToggleStatusChange.tsx';
import { StrategyChange } from './StrategyChange.tsx';
import { LegacyStrategyChange } from './LegacyStrategyChange.tsx';
import { VariantPatch } from './VariantPatch/VariantPatch.tsx';
import { EnvironmentStrategyExecutionOrder } from './EnvironmentStrategyExecutionOrder/EnvironmentStrategyExecutionOrder.tsx';
import { ArchiveFeatureChange } from './ArchiveFeatureChange.tsx';
import { DependencyChange } from './DependencyChange.tsx';
import { Link } from 'react-router-dom';
import { LegacyReleasePlanChange } from './LegacyReleasePlanChange.tsx';
import { ReleasePlanChange } from './ReleasePlanChange.tsx';
import { StrategyChange } from './StrategyChange.tsx';
import { useUiFlag } from 'hooks/useUiFlag.ts';
const StyledSingleChangeBox = styled(Box, {
shouldForwardProp: (prop: string) => !prop.startsWith('$'),
@ -70,6 +73,7 @@ const InlineList = styled('ul')(({ theme }) => ({
const ChangeInnerBox = styled(Box)(({ theme }) => ({
padding: theme.spacing(3),
// todo: remove with flag crDiffView
'&:has(.delete-strategy-information-wrapper)': {
backgroundColor: theme.palette.error.light,
},
@ -87,6 +91,15 @@ export const FeatureChange: FC<{
? feature.changes.length + 1
: feature.changes.length;
const useDiffableChangeComponent = useUiFlag('crDiffView');
const StrategyChangeComponent = useDiffableChangeComponent
? StrategyChange
: LegacyStrategyChange;
const ReleasePlanChangeComponent = useDiffableChangeComponent
? ReleasePlanChange
: LegacyReleasePlanChange;
return (
<StyledSingleChangeBox
key={objectId(change)}
@ -166,7 +179,7 @@ export const FeatureChange: FC<{
{change.action === 'addStrategy' ||
change.action === 'deleteStrategy' ||
change.action === 'updateStrategy' ? (
<StrategyChange
<StrategyChangeComponent
actions={actions}
change={change}
featureName={feature.name}
@ -197,7 +210,7 @@ export const FeatureChange: FC<{
{(change.action === 'addReleasePlan' ||
change.action === 'deleteReleasePlan' ||
change.action === 'startMilestone') && (
<ReleasePlanChange
<ReleasePlanChangeComponent
actions={actions}
change={change}
featureName={feature.name}

View File

@ -0,0 +1,316 @@
import type React from 'react';
import { useRef, useState, type FC, type ReactNode } from 'react';
import { Box, styled, Typography } from '@mui/material';
import type {
ChangeRequestState,
IChangeRequestAddReleasePlan,
IChangeRequestDeleteReleasePlan,
IChangeRequestStartMilestone,
} from 'component/changeRequest/changeRequest.types';
import { useReleasePlanPreview } from 'hooks/useReleasePlanPreview';
import { useReleasePlans } from 'hooks/api/getters/useReleasePlans/useReleasePlans';
import { TooltipLink } from 'component/common/TooltipLink/TooltipLink';
import EventDiff from 'component/events/EventDiff/EventDiff';
import { ReleasePlan } from 'component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan';
import { ReleasePlanMilestone } from 'component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestone';
import type { IReleasePlan } from 'interfaces/releasePlans';
export const ChangeItemWrapper = styled(Box)({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
});
const ChangeItemCreateEditDeleteWrapper = styled(Box)(({ theme }) => ({
display: 'grid',
gridTemplateColumns: 'auto auto',
justifyContent: 'space-between',
gap: theme.spacing(1),
alignItems: 'center',
marginBottom: theme.spacing(2),
width: '100%',
}));
const ChangeItemInfo: FC<{ children?: React.ReactNode }> = styled(Box)(
({ theme }) => ({
display: 'flex',
gap: theme.spacing(1),
}),
);
const ViewDiff = styled('span')(({ theme }) => ({
color: theme.palette.primary.main,
marginLeft: theme.spacing(1),
}));
const StyledCodeSection = styled('div')(({ theme }) => ({
overflowX: 'auto',
'& code': {
wordWrap: 'break-word',
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
lineHeight: 1.5,
fontSize: theme.fontSizes.smallBody,
},
}));
const DeleteReleasePlan: FC<{
change: IChangeRequestDeleteReleasePlan;
currentReleasePlan?: IReleasePlan;
changeRequestState: ChangeRequestState;
actions?: ReactNode;
}> = ({ change, currentReleasePlan, changeRequestState, actions }) => {
const releasePlan =
changeRequestState === 'Applied' && change.payload.snapshot
? change.payload.snapshot
: currentReleasePlan;
if (!releasePlan) return;
return (
<>
<ChangeItemCreateEditDeleteWrapper>
<ChangeItemInfo>
<Typography
sx={(theme) => ({
color: theme.palette.error.main,
})}
>
- Deleting release plan:
</Typography>
<Typography>{releasePlan.name}</Typography>
</ChangeItemInfo>
<div>{actions}</div>
</ChangeItemCreateEditDeleteWrapper>
<ReleasePlan plan={releasePlan} readonly />
</>
);
};
const StartMilestone: FC<{
change: IChangeRequestStartMilestone;
currentReleasePlan?: IReleasePlan;
changeRequestState: ChangeRequestState;
actions?: ReactNode;
}> = ({ change, currentReleasePlan, changeRequestState, actions }) => {
const releasePlan =
changeRequestState === 'Applied' && change.payload.snapshot
? change.payload.snapshot
: currentReleasePlan;
if (!releasePlan) return;
const previousMilestone = releasePlan.milestones.find(
(milestone) => milestone.id === releasePlan.activeMilestoneId,
);
const newMilestone = releasePlan.milestones.find(
(milestone) => milestone.id === change.payload.milestoneId,
);
if (!newMilestone) return;
return (
<>
<ChangeItemCreateEditDeleteWrapper>
<ChangeItemInfo>
<Typography color='success.dark'>
+ Start milestone:
</Typography>
<Typography>{newMilestone.name}</Typography>
<TooltipLink
tooltip={
<StyledCodeSection>
<EventDiff
entry={{
preData: previousMilestone,
data: newMilestone,
}}
/>
</StyledCodeSection>
}
tooltipProps={{
maxWidth: 500,
maxHeight: 600,
}}
>
<ViewDiff>View Diff</ViewDiff>
</TooltipLink>
</ChangeItemInfo>
<div>{actions}</div>
</ChangeItemCreateEditDeleteWrapper>
<ReleasePlanMilestone readonly milestone={newMilestone} />
</>
);
};
const AddReleasePlan: FC<{
change: IChangeRequestAddReleasePlan;
currentReleasePlan?: IReleasePlan;
environmentName: string;
featureName: string;
actions?: ReactNode;
}> = ({
change,
currentReleasePlan,
environmentName,
featureName,
actions,
}) => {
const [currentTooltipOpen, setCurrentTooltipOpen] = useState(false);
const currentTooltipCloseTimeoutRef = useRef<NodeJS.Timeout>();
const openCurrentTooltip = () => {
if (currentTooltipCloseTimeoutRef.current) {
clearTimeout(currentTooltipCloseTimeoutRef.current);
}
setCurrentTooltipOpen(true);
};
const closeCurrentTooltip = () => {
currentTooltipCloseTimeoutRef.current = setTimeout(() => {
setCurrentTooltipOpen(false);
}, 100);
};
const planPreview = useReleasePlanPreview(
change.payload.templateId,
featureName,
environmentName,
);
const planPreviewDiff = {
...planPreview,
discriminator: 'plan',
releasePlanTemplateId: change.payload.templateId,
};
return (
<>
<ChangeItemCreateEditDeleteWrapper>
<ChangeItemInfo>
{currentReleasePlan ? (
<Typography>
Replacing{' '}
<TooltipLink
tooltip={
<div
onMouseEnter={() =>
openCurrentTooltip()
}
onMouseLeave={() =>
closeCurrentTooltip()
}
>
<ReleasePlan
plan={currentReleasePlan}
readonly
/>
</div>
}
tooltipProps={{
open: currentTooltipOpen,
maxWidth: 500,
maxHeight: 600,
}}
>
<span
onMouseEnter={() => openCurrentTooltip()}
onMouseLeave={() => closeCurrentTooltip()}
>
current
</span>
</TooltipLink>{' '}
release plan with:
</Typography>
) : (
<Typography color='success.dark'>
+ Adding release plan:
</Typography>
)}
<Typography>{planPreview.name}</Typography>
{currentReleasePlan && (
<TooltipLink
tooltip={
<StyledCodeSection>
<EventDiff
entry={{
preData: currentReleasePlan,
data: planPreviewDiff,
}}
/>
</StyledCodeSection>
}
tooltipProps={{
maxWidth: 500,
maxHeight: 600,
}}
>
<ViewDiff>View Diff</ViewDiff>
</TooltipLink>
)}
</ChangeItemInfo>
<div>{actions}</div>
</ChangeItemCreateEditDeleteWrapper>
<ReleasePlan plan={planPreview} readonly />
</>
);
};
/**
* Deprecated: use ReleasePlanChange instead. Remove file with flag crDiffView
* @deprecated
*/
export const LegacyReleasePlanChange: FC<{
actions?: ReactNode;
change:
| IChangeRequestAddReleasePlan
| IChangeRequestDeleteReleasePlan
| IChangeRequestStartMilestone;
environmentName: string;
featureName: string;
projectId: string;
changeRequestState: ChangeRequestState;
}> = ({
actions,
change,
featureName,
environmentName,
projectId,
changeRequestState,
}) => {
const { releasePlans } = useReleasePlans(
projectId,
featureName,
environmentName,
);
const currentReleasePlan = releasePlans[0];
return (
<>
{change.action === 'addReleasePlan' && (
<AddReleasePlan
change={change}
currentReleasePlan={currentReleasePlan}
environmentName={environmentName}
featureName={featureName}
actions={actions}
/>
)}
{change.action === 'deleteReleasePlan' && (
<DeleteReleasePlan
change={change}
currentReleasePlan={currentReleasePlan}
changeRequestState={changeRequestState}
actions={actions}
/>
)}
{change.action === 'startMilestone' && (
<StartMilestone
change={change}
currentReleasePlan={currentReleasePlan}
changeRequestState={changeRequestState}
actions={actions}
/>
)}
</>
);
};

View File

@ -0,0 +1,129 @@
import type React from 'react';
import type { FC, ReactNode } from 'react';
import { Box, styled, Typography } from '@mui/material';
import type {
ChangeRequestState,
IChangeRequestDeleteSegment,
IChangeRequestUpdateSegment,
} from 'component/changeRequest/changeRequest.types';
import { useSegment } from 'hooks/api/getters/useSegment/useSegment';
import { SegmentDiff, SegmentTooltipLink } from '../../SegmentTooltipLink.tsx';
import { ViewableConstraintsList } from 'component/common/NewConstraintAccordion/ConstraintsList/ViewableConstraintsList';
import { ChangeOverwriteWarning } from './ChangeOverwriteWarning/ChangeOverwriteWarning.tsx';
const ChangeItemCreateEditWrapper = styled(Box)(({ theme }) => ({
display: 'grid',
gridTemplateColumns: 'auto 40px',
gap: theme.spacing(1),
alignItems: 'center',
width: '100%',
margin: theme.spacing(0, 0, 1, 0),
}));
export const ChangeItemWrapper = styled(Box)({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
});
const ChangeItemInfo: FC<{ children?: React.ReactNode }> = styled(Box)(
({ theme }) => ({
display: 'grid',
gridTemplateColumns: '150px auto',
gridAutoFlow: 'column',
alignItems: 'center',
flexGrow: 1,
gap: theme.spacing(1),
}),
);
const SegmentContainer = styled(Box, {
shouldForwardProp: (prop) => prop !== 'conflict',
})<{ conflict: string | undefined }>(({ theme, conflict }) => ({
borderLeft: '1px solid',
borderRight: '1px solid',
borderTop: '1px solid',
borderBottom: '1px solid',
borderColor: conflict
? theme.palette.warning.border
: theme.palette.divider,
borderTopColor: theme.palette.divider,
padding: theme.spacing(3),
borderRadius: `0 0 ${theme.shape.borderRadiusLarge}px ${theme.shape.borderRadiusLarge}px`,
}));
/**
* Deprecated: use SegmentChangeDetails instead. Remove file with flag crDiffView
* @deprecated
*/
export const LegacySegmentChangeDetails: FC<{
actions?: ReactNode;
change: IChangeRequestUpdateSegment | IChangeRequestDeleteSegment;
changeRequestState: ChangeRequestState;
}> = ({ actions, change, changeRequestState }) => {
const { segment: currentSegment } = useSegment(change.payload.id);
const snapshotSegment = change.payload.snapshot;
const previousName =
changeRequestState === 'Applied'
? change.payload?.snapshot?.name
: currentSegment?.name;
const referenceSegment =
changeRequestState === 'Applied' ? snapshotSegment : currentSegment;
return (
<SegmentContainer conflict={change.conflict}>
{change.action === 'deleteSegment' && (
<ChangeItemWrapper>
<ChangeItemInfo>
<Typography
sx={(theme) => ({
color: theme.palette.error.main,
})}
>
- Deleting segment:
</Typography>
<SegmentTooltipLink
name={change.payload.name}
previousName={previousName}
>
<SegmentDiff
change={change}
currentSegment={referenceSegment}
/>
</SegmentTooltipLink>
</ChangeItemInfo>
<div>{actions}</div>
</ChangeItemWrapper>
)}
{change.action === 'updateSegment' && (
<>
<ChangeOverwriteWarning
data={{
current: currentSegment,
change,
changeType: 'segment',
}}
changeRequestState={changeRequestState}
/>
<ChangeItemCreateEditWrapper>
<ChangeItemInfo>
<Typography>Editing segment:</Typography>
<SegmentTooltipLink name={change.payload.name}>
<SegmentDiff
change={change}
currentSegment={referenceSegment}
/>
</SegmentTooltipLink>
</ChangeItemInfo>
<div>{actions}</div>
</ChangeItemCreateEditWrapper>
<ViewableConstraintsList
constraints={change.payload.constraints}
/>
</>
)}
</SegmentContainer>
);
};

View File

@ -0,0 +1,357 @@
import type React from 'react';
import type { FC, ReactNode } from 'react';
import { Box, styled, Tooltip, Typography } from '@mui/material';
import BlockIcon from '@mui/icons-material/Block';
import TrackChangesIcon from '@mui/icons-material/TrackChanges';
import {
StrategyDiff,
StrategyTooltipLink,
} from '../../StrategyTooltipLink/StrategyTooltipLink.tsx';
import { StrategyExecution } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/StrategyExecution';
import type {
ChangeRequestState,
IChangeRequestAddStrategy,
IChangeRequestDeleteStrategy,
IChangeRequestUpdateStrategy,
} from 'component/changeRequest/changeRequest.types';
import { useCurrentStrategy } from './hooks/useCurrentStrategy.ts';
import { Badge } from 'component/common/Badge/Badge';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { flexRow } from 'themes/themeStyles';
import { EnvironmentVariantsTable } from 'component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/EnvironmentVariantsTable';
import { ChangeOverwriteWarning } from './ChangeOverwriteWarning/ChangeOverwriteWarning.tsx';
import type { IFeatureStrategy } from 'interfaces/strategy';
export const ChangeItemWrapper = styled(Box)({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
});
const ChangeItemCreateEditDeleteWrapper = styled(Box)(({ theme }) => ({
display: 'grid',
gridTemplateColumns: 'auto auto',
justifyContent: 'space-between',
gap: theme.spacing(1),
alignItems: 'center',
marginBottom: theme.spacing(2),
width: '100%',
}));
const ChangeItemInfo: FC<{ children?: React.ReactNode }> = styled(Box)(
({ theme }) => ({
display: 'grid',
gridTemplateColumns: '150px auto',
gridAutoFlow: 'column',
alignItems: 'center',
flexGrow: 1,
gap: theme.spacing(1),
}),
);
const StyledBox: FC<{ children?: React.ReactNode }> = styled(Box)(
({ theme }) => ({
marginTop: theme.spacing(2),
}),
);
const StyledTypography: FC<{ children?: React.ReactNode }> = styled(Typography)(
({ theme }) => ({
margin: `${theme.spacing(1)} 0`,
}),
);
const DisabledEnabledState: FC<{ show?: boolean; disabled: boolean }> = ({
show = true,
disabled,
}) => {
if (!show) {
return null;
}
if (disabled) {
return (
<Tooltip
title='This strategy will not be taken into account when evaluating feature flag.'
arrow
sx={{ cursor: 'pointer' }}
>
<Badge color='disabled' icon={<BlockIcon />}>
Disabled
</Badge>
</Tooltip>
);
}
return (
<Tooltip
title='This was disabled before and with this change it will be taken into account when evaluating feature flag.'
arrow
sx={{ cursor: 'pointer' }}
>
<Badge color='success' icon={<TrackChangesIcon />}>
Enabled
</Badge>
</Tooltip>
);
};
const EditHeader: FC<{
wasDisabled?: boolean;
willBeDisabled?: boolean;
}> = ({ wasDisabled = false, willBeDisabled = false }) => {
if (wasDisabled && willBeDisabled) {
return (
<Typography color='action.disabled'>Editing strategy:</Typography>
);
}
if (!wasDisabled && willBeDisabled) {
return <Typography color='error.dark'>Editing strategy:</Typography>;
}
if (wasDisabled && !willBeDisabled) {
return <Typography color='success.dark'>Editing strategy:</Typography>;
}
return <Typography>Editing strategy:</Typography>;
};
const hasDiff = (object: unknown, objectToCompare: unknown) =>
JSON.stringify(object) !== JSON.stringify(objectToCompare);
const DeleteStrategy: FC<{
change: IChangeRequestDeleteStrategy;
changeRequestState: ChangeRequestState;
currentStrategy: IFeatureStrategy | undefined;
actions?: ReactNode;
}> = ({ change, changeRequestState, currentStrategy, actions }) => {
const name =
changeRequestState === 'Applied'
? change.payload?.snapshot?.name
: currentStrategy?.name;
const title =
changeRequestState === 'Applied'
? change.payload?.snapshot?.title
: currentStrategy?.title;
const referenceStrategy =
changeRequestState === 'Applied'
? change.payload.snapshot
: currentStrategy;
return (
<>
<ChangeItemCreateEditDeleteWrapper className='delete-strategy-information-wrapper'>
<ChangeItemInfo>
<Typography
sx={(theme) => ({
color: theme.palette.error.main,
})}
>
- Deleting strategy:
</Typography>
<StrategyTooltipLink name={name || ''} title={title}>
<StrategyDiff
change={change}
currentStrategy={referenceStrategy}
/>
</StrategyTooltipLink>
</ChangeItemInfo>
<div>{actions}</div>
</ChangeItemCreateEditDeleteWrapper>
{referenceStrategy && (
<StrategyExecution strategy={referenceStrategy} />
)}
</>
);
};
const UpdateStrategy: FC<{
change: IChangeRequestUpdateStrategy;
changeRequestState: ChangeRequestState;
currentStrategy: IFeatureStrategy | undefined;
actions?: ReactNode;
}> = ({ change, changeRequestState, currentStrategy, actions }) => {
const previousTitle =
changeRequestState === 'Applied'
? change.payload.snapshot?.title
: currentStrategy?.title;
const referenceStrategy =
changeRequestState === 'Applied'
? change.payload.snapshot
: currentStrategy;
const hasVariantDiff = hasDiff(
referenceStrategy?.variants || [],
change.payload.variants || [],
);
return (
<>
<ChangeOverwriteWarning
data={{
current: currentStrategy,
change,
changeType: 'strategy',
}}
changeRequestState={changeRequestState}
/>
<ChangeItemCreateEditDeleteWrapper>
<ChangeItemInfo>
<EditHeader
wasDisabled={currentStrategy?.disabled}
willBeDisabled={change.payload?.disabled}
/>
<StrategyTooltipLink
name={change.payload.name}
title={change.payload.title}
previousTitle={previousTitle}
>
<StrategyDiff
change={change}
currentStrategy={referenceStrategy}
/>
</StrategyTooltipLink>
</ChangeItemInfo>
<div>{actions}</div>
</ChangeItemCreateEditDeleteWrapper>
<ConditionallyRender
condition={
change.payload?.disabled !== currentStrategy?.disabled
}
show={
<Typography
sx={{
marginTop: (theme) => theme.spacing(2),
marginBottom: (theme) => theme.spacing(2),
...flexRow,
gap: (theme) => theme.spacing(1),
}}
>
This strategy will be{' '}
<DisabledEnabledState
disabled={change.payload?.disabled || false}
/>
</Typography>
}
/>
<StrategyExecution strategy={change.payload} />
{hasVariantDiff ? (
<StyledBox>
{change.payload.variants?.length ? (
<>
<StyledTypography>
{currentStrategy?.variants?.length
? 'Updating strategy variants to:'
: 'Adding strategy variants:'}
</StyledTypography>
<EnvironmentVariantsTable
variants={change.payload.variants || []}
/>
</>
) : (
<StyledTypography>
Removed all strategy variants.
</StyledTypography>
)}
</StyledBox>
) : null}
</>
);
};
const AddStrategy: FC<{
change: IChangeRequestAddStrategy;
actions?: ReactNode;
}> = ({ change, actions }) => (
<>
<ChangeItemCreateEditDeleteWrapper>
<ChangeItemInfo>
<Typography
color={
change.payload?.disabled
? 'action.disabled'
: 'success.dark'
}
>
+ Adding strategy:
</Typography>
<StrategyTooltipLink
name={change.payload.name}
title={change.payload.title}
>
<StrategyDiff change={change} currentStrategy={undefined} />
</StrategyTooltipLink>
<div>
<DisabledEnabledState
disabled
show={change.payload?.disabled === true}
/>
</div>
</ChangeItemInfo>
<div>{actions}</div>
</ChangeItemCreateEditDeleteWrapper>
<StrategyExecution strategy={change.payload} />
{change.payload.variants?.length ? (
<StyledBox>
<StyledTypography>Adding strategy variants:</StyledTypography>
<EnvironmentVariantsTable
variants={change.payload.variants || []}
/>
</StyledBox>
) : null}
</>
);
/**
* Deprecated: use StrategyChange instead. Remove file with flag crDiffView
* @deprecated
*/
export const LegacyStrategyChange: FC<{
actions?: ReactNode;
change:
| IChangeRequestAddStrategy
| IChangeRequestDeleteStrategy
| IChangeRequestUpdateStrategy;
environmentName: string;
featureName: string;
projectId: string;
changeRequestState: ChangeRequestState;
}> = ({
actions,
change,
featureName,
environmentName,
projectId,
changeRequestState,
}) => {
const currentStrategy = useCurrentStrategy(
change,
projectId,
featureName,
environmentName,
);
return (
<>
{change.action === 'addStrategy' && (
<AddStrategy change={change} actions={actions} />
)}
{change.action === 'deleteStrategy' && (
<DeleteStrategy
change={change}
changeRequestState={changeRequestState}
currentStrategy={currentStrategy}
actions={actions}
/>
)}
{change.action === 'updateStrategy' && (
<UpdateStrategy
change={change}
changeRequestState={changeRequestState}
currentStrategy={currentStrategy}
actions={actions}
/>
)}
</>
);
};

View File

@ -10,10 +10,11 @@ import type {
import { useReleasePlanPreview } from 'hooks/useReleasePlanPreview';
import { useReleasePlans } from 'hooks/api/getters/useReleasePlans/useReleasePlans';
import { TooltipLink } from 'component/common/TooltipLink/TooltipLink';
import EventDiff from 'component/events/EventDiff/EventDiff';
import { EventDiff } from 'component/events/EventDiff/EventDiff';
import { ReleasePlan } from 'component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan';
import { ReleasePlanMilestone } from 'component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestone';
import type { IReleasePlan } from 'interfaces/releasePlans';
import { Tab, TabList, TabPanel, Tabs } from './ChangeTabComponents.tsx';
export const ChangeItemWrapper = styled(Box)({
display: 'flex',
@ -111,36 +112,34 @@ const StartMilestone: FC<{
if (!newMilestone) return;
return (
<>
<Tabs>
<ChangeItemCreateEditDeleteWrapper>
<ChangeItemInfo>
<Typography color='success.dark'>
+ Start milestone:
</Typography>
<Typography>{newMilestone.name}</Typography>
<TooltipLink
tooltip={
<StyledCodeSection>
<EventDiff
entry={{
preData: previousMilestone,
data: newMilestone,
}}
/>
</StyledCodeSection>
}
tooltipProps={{
maxWidth: 500,
maxHeight: 600,
}}
>
<ViewDiff>View Diff</ViewDiff>
</TooltipLink>
</ChangeItemInfo>
<div>{actions}</div>
<div>
<TabList>
<Tab>Change</Tab>
<Tab>View diff</Tab>
</TabList>
{actions}
</div>
</ChangeItemCreateEditDeleteWrapper>
<ReleasePlanMilestone readonly milestone={newMilestone} />
</>
<TabPanel>
<ReleasePlanMilestone readonly milestone={newMilestone} />
</TabPanel>
<TabPanel variant='diff'>
<EventDiff
entry={{
preData: previousMilestone,
data: newMilestone,
}}
/>
</TabPanel>
</Tabs>
);
};
@ -183,75 +182,78 @@ const AddReleasePlan: FC<{
releasePlanTemplateId: change.payload.templateId,
};
return (
<>
<ChangeItemCreateEditDeleteWrapper>
<ChangeItemInfo>
{currentReleasePlan ? (
<Typography>
Replacing{' '}
<TooltipLink
tooltip={
<div
onMouseEnter={() =>
openCurrentTooltip()
}
onMouseLeave={() =>
closeCurrentTooltip()
}
>
<ReleasePlan
plan={currentReleasePlan}
readonly
/>
</div>
}
tooltipProps={{
open: currentTooltipOpen,
maxWidth: 500,
maxHeight: 600,
}}
>
<span
onMouseEnter={() => openCurrentTooltip()}
onMouseLeave={() => closeCurrentTooltip()}
>
current
</span>
</TooltipLink>{' '}
release plan with:
</Typography>
) : (
if (!currentReleasePlan) {
return (
<>
<ChangeItemCreateEditDeleteWrapper>
<ChangeItemInfo>
<Typography color='success.dark'>
+ Adding release plan:
</Typography>
)}
<Typography>{planPreview.name}</Typography>
{currentReleasePlan && (
<Typography>{planPreview.name}</Typography>
</ChangeItemInfo>
<div>{actions}</div>
</ChangeItemCreateEditDeleteWrapper>
<ReleasePlan plan={planPreview} readonly />
</>
);
}
return (
<Tabs>
<ChangeItemCreateEditDeleteWrapper>
<ChangeItemInfo>
<Typography>
Replacing{' '}
<TooltipLink
tooltip={
<StyledCodeSection>
<EventDiff
entry={{
preData: currentReleasePlan,
data: planPreviewDiff,
}}
<div
onMouseEnter={() => openCurrentTooltip()}
onMouseLeave={() => closeCurrentTooltip()}
>
<ReleasePlan
plan={currentReleasePlan}
readonly
/>
</StyledCodeSection>
</div>
}
tooltipProps={{
open: currentTooltipOpen,
maxWidth: 500,
maxHeight: 600,
}}
>
<ViewDiff>View Diff</ViewDiff>
</TooltipLink>
)}
<span
onMouseEnter={() => openCurrentTooltip()}
onMouseLeave={() => closeCurrentTooltip()}
>
current
</span>
</TooltipLink>{' '}
release plan with:
</Typography>
<Typography>{planPreview.name}</Typography>
</ChangeItemInfo>
<div>{actions}</div>
<div>
<TabList>
<Tab>Changes</Tab>
<Tab>View diff</Tab>
</TabList>
{actions}
</div>
</ChangeItemCreateEditDeleteWrapper>
<ReleasePlan plan={planPreview} readonly />
</>
<TabPanel>
<ReleasePlan plan={planPreview} readonly />
</TabPanel>
<TabPanel variant='diff'>
<EventDiff
entry={{
preData: currentReleasePlan,
data: planPreviewDiff,
}}
/>
</TabPanel>
</Tabs>
);
};

View File

@ -5,8 +5,11 @@ import type {
ChangeRequestState,
ISegmentChange,
} from '../../../changeRequest.types';
import { LegacySegmentChangeDetails } from './LegacySegmentChangeDetails.tsx';
import { SegmentChangeDetails } from './SegmentChangeDetails.tsx';
import { ConflictWarning } from './ConflictWarning.tsx';
import { useSegment } from 'hooks/api/getters/useSegment/useSegment.ts';
import { useUiFlag } from 'hooks/useUiFlag.ts';
interface ISegmentChangeProps {
segmentChange: ISegmentChange;
@ -20,61 +23,69 @@ export const SegmentChange: FC<ISegmentChangeProps> = ({
onNavigate,
actions,
changeRequestState,
}) => (
<Card
elevation={0}
sx={(theme) => ({
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2),
overflow: 'hidden',
})}
>
<Box
}) => {
const { segment } = useSegment(segmentChange.payload.id);
const ChangeDetails = useUiFlag('crDiffView')
? SegmentChangeDetails
: LegacySegmentChangeDetails;
return (
<Card
elevation={0}
sx={(theme) => ({
backgroundColor: theme.palette.neutral.light,
borderRadius: (theme) =>
`${theme.shape.borderRadiusLarge}px ${theme.shape.borderRadiusLarge}px 0 0`,
border: '1px solid',
borderColor: (theme) =>
segmentChange.conflict
? theme.palette.warning.border
: theme.palette.divider,
borderBottom: 'none',
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2),
overflow: 'hidden',
})}
>
<ConflictWarning conflict={segmentChange.conflict} />
<Box
sx={{
display: 'flex',
pt: 2,
pb: 2,
px: 3,
}}
sx={(theme) => ({
backgroundColor: theme.palette.neutral.light,
borderRadius: `${theme.shape.borderRadiusLarge}px ${theme.shape.borderRadiusLarge}px 0 0`,
border: '1px solid',
borderColor: segmentChange.conflict
? theme.palette.warning.border
: theme.palette.divider,
borderBottom: 'none',
overflow: 'hidden',
})}
>
<Typography>Segment name: </Typography>
<Link
component={RouterLink}
to={`/segments/edit/${segmentChange.payload.id}`}
color='primary'
underline='hover'
<ConflictWarning conflict={segmentChange.conflict} />
<Box
sx={{
marginLeft: 1,
'& :hover': {
textDecoration: 'underline',
},
display: 'flex',
pt: 2,
pb: 2,
px: 3,
}}
onClick={onNavigate}
>
<strong>{segmentChange.payload.name}</strong>
</Link>
<Typography>Segment name:</Typography>
<Link
component={RouterLink}
to={`/segments/edit/${segmentChange.payload.id}`}
color='primary'
underline='hover'
sx={{
marginLeft: 1,
'& :hover': {
textDecoration: 'underline',
},
}}
onClick={onNavigate}
>
<strong>
{segmentChange.payload.name || segment?.name}
</strong>
</Link>
</Box>
</Box>
</Box>
<SegmentChangeDetails
change={segmentChange}
actions={actions}
changeRequestState={changeRequestState}
/>
</Card>
);
<ChangeDetails
change={segmentChange}
actions={actions}
changeRequestState={changeRequestState}
/>
</Card>
);
};

View File

@ -8,14 +8,13 @@ import type {
} from 'component/changeRequest/changeRequest.types';
import { useSegment } from 'hooks/api/getters/useSegment/useSegment';
import { SegmentDiff, SegmentTooltipLink } from '../../SegmentTooltipLink.tsx';
import { ViewableConstraintsList } from 'component/common/NewConstraintAccordion/ConstraintsList/ViewableConstraintsList';
import { ChangeOverwriteWarning } from './ChangeOverwriteWarning/ChangeOverwriteWarning.tsx';
import { Tab, TabList, TabPanel, Tabs } from './ChangeTabComponents.tsx';
const ChangeItemCreateEditWrapper = styled(Box)(({ theme }) => ({
display: 'grid',
gridTemplateColumns: 'auto 40px',
display: 'flex',
gap: theme.spacing(1),
alignItems: 'center',
width: '100%',
@ -68,58 +67,89 @@ export const SegmentChangeDetails: FC<{
const referenceSegment =
changeRequestState === 'Applied' ? snapshotSegment : currentSegment;
const actionsWithTabs = (
<>
<TabList>
<Tab>Change</Tab>
<Tab>View diff</Tab>
</TabList>
{actions}
</>
);
return (
<SegmentContainer conflict={change.conflict}>
{change.action === 'deleteSegment' && (
<ChangeItemWrapper>
<ChangeItemInfo>
<Typography
sx={(theme) => ({
color: theme.palette.error.main,
})}
>
- Deleting segment:
</Typography>
<SegmentTooltipLink
name={change.payload.name}
previousName={previousName}
>
<Tabs>
<SegmentContainer conflict={change.conflict}>
{change.action === 'deleteSegment' && (
<>
<ChangeItemWrapper>
<ChangeItemInfo>
<Typography
sx={(theme) => ({
color: theme.palette.error.main,
})}
>
- Deleting segment:
</Typography>
<SegmentTooltipLink
name={change.payload.name}
previousName={previousName}
>
<SegmentDiff
change={change}
currentSegment={referenceSegment}
/>
</SegmentTooltipLink>
</ChangeItemInfo>
{actionsWithTabs}
</ChangeItemWrapper>
<TabPanel />
<TabPanel sx={{ mt: 1 }} variant='diff'>
<SegmentDiff
change={change}
currentSegment={referenceSegment}
/>
</SegmentTooltipLink>
</ChangeItemInfo>
<div>{actions}</div>
</ChangeItemWrapper>
)}
{change.action === 'updateSegment' && (
<>
<ChangeOverwriteWarning
data={{
current: currentSegment,
change,
changeType: 'segment',
}}
changeRequestState={changeRequestState}
/>
<ChangeItemCreateEditWrapper>
<ChangeItemInfo>
<Typography>Editing segment:</Typography>
<SegmentTooltipLink name={change.payload.name}>
<SegmentDiff
change={change}
currentSegment={referenceSegment}
/>
</SegmentTooltipLink>
</ChangeItemInfo>
<div>{actions}</div>
</ChangeItemCreateEditWrapper>
<ViewableConstraintsList
constraints={change.payload.constraints}
/>
</>
)}
</SegmentContainer>
</TabPanel>
</>
)}
{change.action === 'updateSegment' && (
<>
<ChangeOverwriteWarning
data={{
current: currentSegment,
change,
changeType: 'segment',
}}
changeRequestState={changeRequestState}
/>
<ChangeItemCreateEditWrapper>
<ChangeItemInfo>
<Typography>Editing segment:</Typography>
<SegmentTooltipLink name={change.payload.name}>
<SegmentDiff
change={change}
currentSegment={referenceSegment}
/>
</SegmentTooltipLink>
</ChangeItemInfo>
{actionsWithTabs}
</ChangeItemCreateEditWrapper>
<TabPanel>
<ViewableConstraintsList
constraints={change.payload.constraints}
/>
</TabPanel>
<TabPanel variant='diff'>
<SegmentDiff
change={change}
currentSegment={referenceSegment}
/>
</TabPanel>
</>
)}
</SegmentContainer>
</Tabs>
);
};

View File

@ -21,6 +21,7 @@ import { flexRow } from 'themes/themeStyles';
import { EnvironmentVariantsTable } from 'component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/EnvironmentVariantsTable';
import { ChangeOverwriteWarning } from './ChangeOverwriteWarning/ChangeOverwriteWarning.tsx';
import type { IFeatureStrategy } from 'interfaces/strategy';
import { Tab, TabList, TabPanel, Tabs } from './ChangeTabComponents.tsx';
export const ChangeItemWrapper = styled(Box)({
display: 'flex',
@ -29,12 +30,11 @@ export const ChangeItemWrapper = styled(Box)({
});
const ChangeItemCreateEditDeleteWrapper = styled(Box)(({ theme }) => ({
display: 'grid',
gridTemplateColumns: 'auto auto',
display: 'flex',
justifyContent: 'space-between',
gap: theme.spacing(1),
alignItems: 'center',
marginBottom: theme.spacing(2),
marginBottom: theme.spacing(1),
width: '100%',
}));
@ -141,7 +141,7 @@ const DeleteStrategy: FC<{
return (
<>
<ChangeItemCreateEditDeleteWrapper className='delete-strategy-information-wrapper'>
<ChangeItemCreateEditDeleteWrapper>
<ChangeItemInfo>
<Typography
sx={(theme) => ({
@ -157,11 +157,19 @@ const DeleteStrategy: FC<{
/>
</StrategyTooltipLink>
</ChangeItemInfo>
<div>{actions}</div>
{actions}
</ChangeItemCreateEditDeleteWrapper>
{referenceStrategy && (
<StrategyExecution strategy={referenceStrategy} />
)}
<TabPanel>
{referenceStrategy && (
<StrategyExecution strategy={referenceStrategy} />
)}
</TabPanel>
<TabPanel variant='diff'>
<StrategyDiff
change={change}
currentStrategy={referenceStrategy}
/>
</TabPanel>
</>
);
};
@ -212,7 +220,7 @@ const UpdateStrategy: FC<{
/>
</StrategyTooltipLink>
</ChangeItemInfo>
<div>{actions}</div>
{actions}
</ChangeItemCreateEditDeleteWrapper>
<ConditionallyRender
condition={
@ -234,27 +242,36 @@ const UpdateStrategy: FC<{
</Typography>
}
/>
<StrategyExecution strategy={change.payload} />
{hasVariantDiff ? (
<StyledBox>
{change.payload.variants?.length ? (
<>
<TabPanel>
<StrategyExecution strategy={change.payload} />
{hasVariantDiff ? (
<StyledBox>
{change.payload.variants?.length ? (
<>
<StyledTypography>
{currentStrategy?.variants?.length
? 'Updating strategy variants to:'
: 'Adding strategy variants:'}
</StyledTypography>
<EnvironmentVariantsTable
variants={change.payload.variants || []}
/>
</>
) : (
<StyledTypography>
{currentStrategy?.variants?.length
? 'Updating strategy variants to:'
: 'Adding strategy variants:'}
Removed all strategy variants.
</StyledTypography>
<EnvironmentVariantsTable
variants={change.payload.variants || []}
/>
</>
) : (
<StyledTypography>
Removed all strategy variants.
</StyledTypography>
)}
</StyledBox>
) : null}
)}
</StyledBox>
) : null}
</TabPanel>
<TabPanel variant='diff'>
<StrategyDiff
change={change}
currentStrategy={referenceStrategy}
/>
</TabPanel>
</>
);
};
@ -288,17 +305,24 @@ const AddStrategy: FC<{
/>
</div>
</ChangeItemInfo>
<div>{actions}</div>
{actions}
</ChangeItemCreateEditDeleteWrapper>
<StrategyExecution strategy={change.payload} />
{change.payload.variants?.length ? (
<StyledBox>
<StyledTypography>Adding strategy variants:</StyledTypography>
<EnvironmentVariantsTable
variants={change.payload.variants || []}
/>
</StyledBox>
) : null}
<TabPanel>
<StrategyExecution strategy={change.payload} />
{change.payload.variants?.length ? (
<StyledBox>
<StyledTypography>
Adding strategy variants:
</StyledTypography>
<EnvironmentVariantsTable
variants={change.payload.variants || []}
/>
</StyledBox>
) : null}
</TabPanel>
<TabPanel variant='diff'>
<StrategyDiff change={change} currentStrategy={undefined} />
</TabPanel>
</>
);
@ -327,17 +351,27 @@ export const StrategyChange: FC<{
environmentName,
);
return (
const actionsWithTabs = (
<>
<TabList>
<Tab>Change</Tab>
<Tab>View diff</Tab>
</TabList>
{actions}
</>
);
return (
<Tabs>
{change.action === 'addStrategy' && (
<AddStrategy change={change} actions={actions} />
<AddStrategy change={change} actions={actionsWithTabs} />
)}
{change.action === 'deleteStrategy' && (
<DeleteStrategy
change={change}
changeRequestState={changeRequestState}
currentStrategy={currentStrategy}
actions={actions}
actions={actionsWithTabs}
/>
)}
{change.action === 'updateStrategy' && (
@ -345,9 +379,9 @@ export const StrategyChange: FC<{
change={change}
changeRequestState={changeRequestState}
currentStrategy={currentStrategy}
actions={actions}
actions={actionsWithTabs}
/>
)}
</>
</Tabs>
);
};

View File

@ -3,14 +3,15 @@ import type {
IChangeRequestUpdateSegment,
} from 'component/changeRequest/changeRequest.types';
import type React from 'react';
import type { FC } from 'react';
import EventDiff from 'component/events/EventDiff/EventDiff';
import { Fragment, type FC } from 'react';
import omit from 'lodash.omit';
import { TooltipLink } from 'component/common/TooltipLink/TooltipLink';
import { styled } from '@mui/material';
import { textTruncated } from 'themes/themeStyles';
import type { ISegment } from 'interfaces/segment';
import { NameWithChangeInfo } from './NameWithChangeInfo/NameWithChangeInfo.tsx';
import { EventDiff } from 'component/events/EventDiff/EventDiff.tsx';
import { useUiFlag } from 'hooks/useUiFlag.ts';
const StyledCodeSection = styled('div')(({ theme }) => ({
overflowX: 'auto',
@ -23,22 +24,32 @@ const StyledCodeSection = styled('div')(({ theme }) => ({
},
}));
const omitIfDefined = (obj: any, keys: string[]) =>
obj ? omit(obj, keys) : obj;
export const SegmentDiff: FC<{
change: IChangeRequestUpdateSegment | IChangeRequestDeleteSegment;
currentSegment?: ISegment;
}> = ({ change, currentSegment }) => {
const useNewDiff = useUiFlag('improvedJsonDiff');
const Wrapper = useNewDiff ? Fragment : StyledCodeSection;
const omissionFunction = useNewDiff ? omitIfDefined : omit;
const changeRequestSegment =
change.action === 'deleteSegment' ? undefined : change.payload;
return (
<StyledCodeSection>
<Wrapper>
<EventDiff
entry={{
preData: omit(currentSegment, ['createdAt', 'createdBy']),
data: omit(changeRequestSegment, ['snapshot']),
preData: omissionFunction(currentSegment, [
'createdAt',
'createdBy',
]),
data: omissionFunction(changeRequestSegment, ['snapshot']),
}}
/>
</StyledCodeSection>
</Wrapper>
);
};
interface IStrategyTooltipLinkProps {

View File

@ -4,18 +4,19 @@ import type {
IChangeRequestUpdateStrategy,
} from 'component/changeRequest/changeRequest.types';
import type React from 'react';
import type { FC } from 'react';
import { Fragment, type FC } from 'react';
import {
formatStrategyName,
GetFeatureStrategyIcon,
} from 'utils/strategyNames';
import EventDiff from 'component/events/EventDiff/EventDiff';
import { EventDiff } from 'component/events/EventDiff/EventDiff';
import omit from 'lodash.omit';
import { TooltipLink } from 'component/common/TooltipLink/TooltipLink';
import { Typography, styled } from '@mui/material';
import type { IFeatureStrategy } from 'interfaces/strategy';
import { textTruncated } from 'themes/themeStyles';
import { NameWithChangeInfo } from '../NameWithChangeInfo/NameWithChangeInfo.tsx';
import { useUiFlag } from 'hooks/useUiFlag.ts';
const StyledCodeSection = styled('div')(({ theme }) => ({
overflowX: 'auto',
@ -40,6 +41,9 @@ const sortSegments = <T extends { segments?: number[] }>(
};
};
const omitIfDefined = (obj: any, keys: string[]) =>
obj ? omit(obj, keys) : obj;
export const StrategyDiff: FC<{
change:
| IChangeRequestAddStrategy
@ -47,21 +51,28 @@ export const StrategyDiff: FC<{
| IChangeRequestDeleteStrategy;
currentStrategy?: IFeatureStrategy;
}> = ({ change, currentStrategy }) => {
const useNewDiff = useUiFlag('improvedJsonDiff');
const changeRequestStrategy =
change.action === 'deleteStrategy' ? undefined : change.payload;
const sortedCurrentStrategy = sortSegments(currentStrategy);
const sortedChangeRequestStrategy = sortSegments(changeRequestStrategy);
const Wrapper = useNewDiff ? Fragment : StyledCodeSection;
const omissionFunction = useNewDiff ? omitIfDefined : omit;
return (
<StyledCodeSection>
<Wrapper>
<EventDiff
entry={{
preData: omit(sortedCurrentStrategy, 'sortOrder'),
data: omit(sortedChangeRequestStrategy, 'snapshot'),
preData: omissionFunction(sortedCurrentStrategy, [
'sortOrder',
]),
data: omissionFunction(sortedChangeRequestStrategy, [
'snapshot',
]),
}}
/>
</StyledCodeSection>
</Wrapper>
);
};

View File

@ -1,4 +1,4 @@
import { styled } from '@mui/material';
import { styled, type TypographyProps } from '@mui/material';
import { Box, Card, Paper, Typography } from '@mui/material';
import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
@ -18,12 +18,14 @@ export const StyledInnerContainer = styled(Box)(({ theme }) => ({
alignItems: 'center',
}));
export const StyledHeader = styled(Typography)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
marginRight: theme.spacing(1),
fontSize: theme.fontSizes.mainHeader,
}));
export const StyledHeader = styled(Typography)<TypographyProps>(
({ theme }) => ({
display: 'flex',
alignItems: 'center',
marginRight: theme.spacing(1),
fontSize: theme.fontSizes.mainHeader,
}),
);
export const StyledCard = styled(Card)(({ theme }) => ({
padding: theme.spacing(0.75, 1.5),

View File

@ -26,7 +26,7 @@ export const ChangeRequestHeader: FC<{ changeRequest: ChangeRequestType }> = ({
title={title}
setTitle={setTitle}
>
<StyledHeader variant='h1' sx={{ mr: 1.5 }}>
<StyledHeader variant='h1' component='h2' sx={{ mr: 1.5 }}>
{title}
</StyledHeader>
</ChangeRequestTitle>

View File

@ -34,6 +34,8 @@ import { ScheduleChangeRequestDialog } from './ChangeRequestScheduledDialogs/Sch
import type { PlausibleChangeRequestState } from '../changeRequest.types';
import { useNavigate } from 'react-router-dom';
import { useActionableChangeRequests } from 'hooks/api/getters/useActionableChangeRequests/useActionableChangeRequests';
import { useUiFlag } from 'hooks/useUiFlag.ts';
import { ChangeRequestRequestedApprovers } from './ChangeRequestRequestedApprovers/ChangeRequestRequestedApprovers.tsx';
const StyledAsideBox = styled(Box)(({ theme }) => ({
width: '30%',
@ -106,6 +108,7 @@ export const ChangeRequestOverview: FC = () => {
useChangeRequestsEnabled(projectId);
const [disabled, setDisabled] = useState(false);
const navigate = useNavigate();
const approversEnabled = useUiFlag('changeRequestApproverEmails');
if (!changeRequest) {
return null;
@ -288,7 +291,19 @@ export const ChangeRequestOverview: FC = () => {
<ChangeRequestBody>
<StyledAsideBox>
<ChangeRequestTimeline {...timelineProps} />
<ChangeRequestReviewers changeRequest={changeRequest} />
<ConditionallyRender
condition={approversEnabled}
show={
<ChangeRequestRequestedApprovers
changeRequest={changeRequest}
/>
}
elseShow={
<ChangeRequestReviewers
changeRequest={changeRequest}
/>
}
/>
</StyledAsideBox>
<StyledPaper elevation={0}>
<StyledInnerContainer>

View File

@ -0,0 +1,372 @@
import {
Box,
Paper,
styled,
IconButton,
useTheme,
type AutocompleteChangeReason,
type FilterOptionsState,
Checkbox,
TextField,
Button,
Typography,
} from '@mui/material';
import {
type ReviewerSchema,
useRequestedApprovers,
} from 'hooks/api/getters/useRequestedApprovers/useRequestedApprovers';
import { useState, type FC } from 'react';
import type { ChangeRequestType } from '../../changeRequest.types';
import {
ChangeRequestApprover,
ChangeRequestPending,
ChangeRequestRejector,
} from '../ChangeRequestReviewers/ChangeRequestReviewer.js';
import Add from '@mui/icons-material/Add';
import KeyboardArrowUp from '@mui/icons-material/KeyboardArrowUp';
import AutocompleteVirtual from 'component/common/AutocompleteVirtual/AutcompleteVirtual.js';
import {
type AvailableReviewerSchema,
useAvailableChangeRequestReviewers,
} from 'hooks/api/getters/useAvailableChangeRequestReviewers/useAvailableChangeRequestReviewers.js';
import { caseInsensitiveSearch } from 'utils/search.js';
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
import CheckBoxIcon from '@mui/icons-material/CheckBox';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi.js';
export const StyledSpan = styled('span')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
marginRight: theme.spacing(1),
fontSize: theme.fontSizes.bodySize,
}));
const StyledBox = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'row',
width: '100%',
'& > div': { width: '100%' },
justifyContent: 'space-between',
marginBottom: theme.spacing(2),
marginRight: theme.spacing(-2),
}));
const StyledIconButton = styled(IconButton)(({ theme }) => ({
marginRight: theme.spacing(-1),
}));
const StrechedLi = styled('li')({ width: '100%' });
const StyledOption = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
'& > span:first-of-type': {
color: theme.palette.text.secondary,
},
}));
const StyledTags = styled('div')(({ theme }) => ({
paddingLeft: theme.spacing(1),
}));
const renderOption = (
props: React.HTMLAttributes<HTMLLIElement>,
option: AvailableReviewerSchema,
{ selected }: { selected: boolean },
) => (
<StrechedLi {...props} key={option.id}>
<Checkbox
icon={<CheckBoxOutlineBlankIcon fontSize='small' />}
checkedIcon={<CheckBoxIcon fontSize='small' />}
style={{ marginRight: 8 }}
checked={selected}
/>
<StyledOption>
<span>{option.name || option.username}</span>
<span>
{option.name && option.username
? option.username
: option.email}
</span>
</StyledOption>
</StrechedLi>
);
const renderTags = (value: AvailableReviewerSchema[]) => (
<StyledTags>
{value.length > 1
? `${value.length} reviewers`
: value[0].name || value[0].username || value[0].email}
</StyledTags>
);
export const ChangeRequestReviewersHeader: FC<{
canShowAddReviewers: boolean;
showAddReviewers: boolean;
actualApprovals: number;
minApprovals: number;
setShowAddReviewers: React.Dispatch<React.SetStateAction<boolean>>;
}> = ({
canShowAddReviewers,
showAddReviewers,
actualApprovals,
minApprovals,
setShowAddReviewers,
}) => {
return (
<>
<StyledBox sx={{ mb: 1 }}>
<StyledSpan>
Reviewers
<Typography
component='span'
color='text.secondary'
sx={{ ml: 1 }}
>
({actualApprovals}/{minApprovals} required)
</Typography>
</StyledSpan>
{canShowAddReviewers &&
(showAddReviewers ? (
<StyledIconButton
title='Close'
onClick={() => {
setShowAddReviewers(false);
}}
>
<KeyboardArrowUp />
</StyledIconButton>
) : (
<StyledIconButton
title='Request approvals'
onClick={() => {
setShowAddReviewers(true);
}}
>
<Add />
</StyledIconButton>
))}
</StyledBox>
</>
);
};
export const ChangeRequestAddRequestedApprovers: FC<{
changeRequest: Pick<ChangeRequestType, 'id' | 'project' | 'environment'>;
saveClicked: (reviewers: AvailableReviewerSchema[]) => void;
existingReviewers: Pick<ReviewerSchema, 'id'>[];
}> = ({ changeRequest, saveClicked, existingReviewers }) => {
const theme = useTheme();
const [reviewers, setReviewers] = useState<AvailableReviewerSchema[]>([]);
const { reviewers: fetchedReviewers, loading: isLoading } =
useAvailableChangeRequestReviewers(
changeRequest.project,
changeRequest.environment,
);
const availableReviewers = fetchedReviewers.filter(
(reviewer) =>
!existingReviewers.some((existing) => existing.id === reviewer.id),
);
const autoCompleteChange = (
event: React.SyntheticEvent,
newValue: AvailableReviewerSchema[],
reason: AutocompleteChangeReason,
) => {
if (
event.type === 'keydown' &&
(event as React.KeyboardEvent).key === 'Backspace' &&
reason === 'removeOption'
) {
return;
}
setReviewers(newValue);
};
const filterOptions = (
options: AvailableReviewerSchema[],
{ inputValue }: FilterOptionsState<AvailableReviewerSchema>,
) =>
options.filter(
({ name, username, email }) =>
caseInsensitiveSearch(inputValue, email) ||
caseInsensitiveSearch(inputValue, name) ||
caseInsensitiveSearch(inputValue, username),
);
return (
<StyledBox sx={{ mb: 4 }}>
<AutocompleteVirtual
sx={{ ml: 'auto', width: theme.spacing(40) }}
size='small'
limitTags={3}
openOnFocus
multiple
disableCloseOnSelect
value={reviewers as AvailableReviewerSchema[]}
onChange={autoCompleteChange}
options={availableReviewers}
renderOption={renderOption}
filterOptions={filterOptions}
isOptionEqualToValue={(option, value) => option.id === value.id}
getOptionLabel={(option: AvailableReviewerSchema) =>
option.email || option.name || option.username || ''
}
renderInput={(params) => (
<TextField
{...params}
label={`Reviewers (${reviewers.length})`}
/>
)}
renderTags={(value) => renderTags(value)}
noOptionsText={isLoading ? 'Loading…' : 'No options'}
/>
<Button
sx={{ ml: 2 }}
variant='contained'
color='primary'
disabled={false}
onClick={() => saveClicked(reviewers)}
>
Save
</Button>
</StyledBox>
);
};
export const ChangeRequestRequestedApprovers: FC<{
changeRequest: Pick<
ChangeRequestType,
| 'id'
| 'project'
| 'approvals'
| 'rejections'
| 'state'
| 'minApprovals'
| 'environment'
>;
}> = ({ changeRequest }) => {
const [showAddReviewers, setShowAddReviewers] = useState(false);
const { reviewers: requestedReviewers, refetchReviewers } =
useRequestedApprovers(changeRequest.project, changeRequest.id);
const { updateRequestedApprovers } = useChangeRequestApi();
const canShowAddReviewers =
(changeRequest.state === 'Draft' ||
changeRequest.state === 'In review') &&
changeRequest.minApprovals > 0;
let reviewers = requestedReviewers.map((reviewer) => {
const approver = changeRequest.approvals.find(
(approval) => approval.createdBy.id === reviewer.id,
);
const rejector = changeRequest.rejections.find(
(rejection) => rejection.createdBy.id === reviewer.id,
);
return {
id: reviewer.id,
name: reviewer.username || reviewer.name || 'Unknown user',
imageUrl: reviewer.imageUrl,
status: approver ? 'approved' : rejector ? 'rejected' : 'pending',
};
});
reviewers = [
...reviewers,
...changeRequest.approvals
.filter(
(approval) =>
!reviewers.find(
(r) => r.name === approval.createdBy.username,
),
)
.map((approval) => ({
id: approval.createdBy.id,
name: approval.createdBy.username || 'Unknown user',
imageUrl: approval.createdBy.imageUrl,
status: 'approved',
})),
...changeRequest.rejections
.filter(
(rejection) =>
!reviewers.find(
(r) => r.name === rejection.createdBy.username,
),
)
.map((rejection) => ({
id: rejection.createdBy.id,
name: rejection.createdBy.username || 'Unknown user',
imageUrl: rejection.createdBy.imageUrl,
status: 'rejected',
})),
];
const saveClicked = async (
selectedReviewers: AvailableReviewerSchema[],
) => {
if (selectedReviewers.length > 0) {
const tosend = [
...reviewers.map((reviewer) => reviewer.id),
...selectedReviewers.map((reviewer) => reviewer.id),
];
await updateRequestedApprovers(
changeRequest.project,
changeRequest.id,
tosend,
);
}
refetchReviewers();
setShowAddReviewers(false);
};
return (
<Paper
elevation={0}
sx={(theme) => ({
marginTop: theme.spacing(2),
padding: theme.spacing(4),
paddingTop: theme.spacing(2),
borderRadius: (theme) => `${theme.shape.borderRadiusLarge}px`,
})}
>
<ChangeRequestReviewersHeader
canShowAddReviewers={canShowAddReviewers}
showAddReviewers={showAddReviewers}
minApprovals={changeRequest.minApprovals}
actualApprovals={changeRequest.approvals.length}
setShowAddReviewers={setShowAddReviewers}
/>
{canShowAddReviewers && showAddReviewers && (
<ChangeRequestAddRequestedApprovers
changeRequest={changeRequest}
existingReviewers={reviewers}
saveClicked={saveClicked}
/>
)}
{reviewers.map((reviewer) => (
<>
{reviewer.status === 'approved' && (
<ChangeRequestApprover
key={reviewer.name}
name={reviewer.name || 'Unknown user'}
imageUrl={reviewer.imageUrl}
/>
)}
{reviewer.status === 'rejected' && (
<ChangeRequestRejector
key={reviewer.name}
name={reviewer.name || 'Unknown user'}
imageUrl={reviewer.imageUrl}
/>
)}
{reviewer.status === 'pending' && (
<ChangeRequestPending
key={reviewer.name}
name={reviewer.name || 'Unknown user'}
imageUrl={reviewer.imageUrl}
/>
)}
</>
))}
</Paper>
);
};

View File

@ -3,6 +3,7 @@ import type { FC } from 'react';
import { StyledAvatar } from '../ChangeRequestHeader/ChangeRequestHeader.styles';
import CheckCircle from '@mui/icons-material/CheckCircle';
import Cancel from '@mui/icons-material/Cancel';
import Pending from '@mui/icons-material/Pending';
interface IChangeRequestReviewerProps {
name?: string;
imageUrl?: string;
@ -26,6 +27,11 @@ export const StyledErrorIcon = styled(Cancel)(({ theme }) => ({
marginLeft: 'auto',
}));
export const StyledPendingIcon = styled(Pending)(({ theme }) => ({
color: theme.palette.neutral.main,
marginLeft: 'auto',
}));
export const ReviewerName = styled(Typography)({
maxWidth: '170px',
textOverflow: 'ellipsis',
@ -39,7 +45,7 @@ export const ChangeRequestApprover: FC<IChangeRequestReviewerProps> = ({
imageUrl,
}) => {
return (
<StyledBox>
<StyledBox title='Approved'>
<StyledAvatar user={{ name, imageUrl }} />
<ReviewerName variant='body1'>{name}</ReviewerName>
<StyledSuccessIcon />
@ -52,10 +58,23 @@ export const ChangeRequestRejector: FC<IChangeRequestReviewerProps> = ({
imageUrl,
}) => {
return (
<StyledBox>
<StyledBox title='Rejected'>
<StyledAvatar user={{ name, imageUrl }} />
<ReviewerName variant='body1'>{name}</ReviewerName>
<StyledErrorIcon />
</StyledBox>
);
};
export const ChangeRequestPending: FC<IChangeRequestReviewerProps> = ({
name,
imageUrl,
}) => {
return (
<StyledBox title='Pending'>
<StyledAvatar user={{ name, imageUrl }} />
<ReviewerName variant='body1'>{name}</ReviewerName>
<StyledPendingIcon />
</StyledBox>
);
};

View File

@ -74,7 +74,7 @@ const renderOption = (
const renderTags = (value: AvailableReviewerSchema[]) => (
<StyledTags>
{value.length > 1
? `${value.length} users selected`
? `${value.length} reviewers`
: value[0].name || value[0].username || value[0].email}
</StyledTags>
);

View File

@ -74,7 +74,7 @@ export const EnvironmentChangeRequest: FC<{
const [commentText, setCommentText] = useState('');
const { user } = useAuthUser();
const [title, setTitle] = useState(environmentChangeRequest.title);
const { changeState, updateRequestedReviewers } = useChangeRequestApi();
const { changeState, updateRequestedApprovers } = useChangeRequestApi();
const [reviewers, setReviewers] = useState<AvailableReviewerSchema[]>([]);
const [disabled, setDisabled] = useState(false);
@ -83,7 +83,7 @@ export const EnvironmentChangeRequest: FC<{
setDisabled(true);
try {
if (reviewers && reviewers.length > 0) {
await updateRequestedReviewers(
await updateRequestedApprovers(
project,
environmentChangeRequest.id,
reviewers.map((reviewer) => reviewer.id),

View File

@ -70,7 +70,7 @@ test('Add single archive feature change to change request', async () => {
expect(screen.getByText('Archive feature flag')).toBeInTheDocument();
await screen.findByText(
'Archiving features with dependencies will also remove those dependencies.',
'Archiving flags with dependencies will also remove those dependencies.',
);
const button = await screen.findByText('Add change to draft');
@ -100,7 +100,7 @@ test('Add multiple archive feature changes to change request', async () => {
await screen.findByText('Archive feature flags');
await screen.findByText(
'Archiving features with dependencies will also remove those dependencies.',
'Archiving flags with dependencies will also remove those dependencies.',
);
const button = await screen.findByText('Add to change request');
@ -163,7 +163,7 @@ test('Show error message when multiple parents of orphaned children are archived
);
expect(
screen.queryByText(
'Archiving features with dependencies will also remove those dependencies.',
'Archiving flags with dependencies will also remove those dependencies.',
),
).not.toBeInTheDocument();
});
@ -189,7 +189,7 @@ test('Show error message when 1 parent of orphaned children is archived', async
);
expect(
screen.queryByText(
'Archiving features with dependencies will also remove those dependencies.',
'Archiving flags with dependencies will also remove those dependencies.',
),
).not.toBeInTheDocument();
});

View File

@ -26,7 +26,7 @@ interface IFeatureArchiveDialogProps {
const RemovedDependenciesAlert = () => {
return (
<Alert severity='warning' sx={{ m: (theme) => theme.spacing(2, 0) }}>
Archiving features with dependencies will also remove those
Archiving flags with dependencies will also remove those
dependencies.
</Alert>
);

View File

@ -4,6 +4,7 @@ import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashCon
import { ConstraintsList } from 'component/common/ConstraintsList/ConstraintsList';
import { ConstraintAccordionView } from 'component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView';
import { constraintId } from 'constants/constraintId';
import { objectId } from 'utils/objectId';
export interface IViewableConstraintsListProps {
constraints: IConstraint[];
@ -29,7 +30,7 @@ export const ViewableConstraintsList = ({
<ConstraintsList>
{constraints.map((constraint) => (
<ConstraintAccordionView
key={constraint[constraintId]}
key={constraint[constraintId] || objectId(constraint)}
constraint={constraint}
/>
))}

View File

@ -29,7 +29,7 @@ interface IEventDiffProps {
const DiffStyles = styled('div')(({ theme }) => ({
color: theme.palette.text.secondary,
fontFamily: 'monospace',
whiteSpace: 'pre',
whiteSpace: 'pre-wrap',
fontSize: theme.typography.body2.fontSize,
'.deletion, .addition': {
@ -78,7 +78,7 @@ const ButtonIcon = styled('span')(({ theme }) => ({
marginInlineEnd: theme.spacing(0.5),
}));
const NewEventDiff: FC<IEventDiffProps> = ({ entry, excludeKeys }) => {
export const NewEventDiff: FC<IEventDiffProps> = ({ entry, excludeKeys }) => {
const changeType = entry.preData && entry.data ? 'edit' : 'replacement';
const showExpandButton = changeType === 'edit';
const [full, setFull] = useState(false);
@ -212,7 +212,7 @@ const OldEventDiff: FC<IEventDiffProps> = ({
);
};
const EventDiff: FC<IEventDiffProps> = (props) => {
export const EventDiff: FC<IEventDiffProps> = (props) => {
const useNewJsonDiff = useUiFlag('improvedJsonDiff');
if (useNewJsonDiff) {
return <NewEventDiff {...props} />;
@ -220,4 +220,7 @@ const EventDiff: FC<IEventDiffProps> = (props) => {
return <OldEventDiff {...props} />;
};
/**
* @deprecated remove the default export with flag improvedJsonDiff. Switch imports in files that use this to the named import instead.
*/
export default EventDiff;

View File

@ -1,5 +1,6 @@
import {
Button,
ClickAwayListener,
type InputBaseComponentProps,
Popover,
styled,
@ -15,6 +16,14 @@ const StyledPopover = styled(Popover)(({ theme }) => ({
padding: theme.spacing(2),
width: '250px',
},
'&.MuiPopover-root': {
pointerEvents: 'none',
},
'& .MuiPopover-paper': {
pointerEvents: 'all',
},
}));
const StyledTextField = styled(TextField)(({ theme }) => ({
@ -92,59 +101,61 @@ export const AddValuesPopover: FC<AddValuesProps> = ({
horizontal: 'left',
}}
>
<form
onSubmit={(e) => {
e.stopPropagation();
e.preventDefault();
if (!inputValue?.trim()) {
setError('Value cannot be empty or whitespace');
return;
} else {
onAdd(inputValue, {
setError,
clearInput: () => setInputValue(''),
});
}
}}
>
<InputRow>
<ScreenReaderOnly>
<label htmlFor={inputId}>Constraint Value</label>
</ScreenReaderOnly>
<StyledTextField
id={inputId}
placeholder='Enter value'
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
setError('');
}}
size='small'
variant='standard'
fullWidth
inputRef={inputRef}
autoFocus
error={!!error}
helperText={error}
aria-describedby={helpTextId}
inputProps={{
...inputProps,
}}
data-testid='CONSTRAINT_VALUES_INPUT'
/>
<AddButton
variant='text'
type='submit'
size='small'
color='primary'
disabled={!inputValue?.trim()}
data-testid='CONSTRAINT_VALUES_ADD_BUTTON'
>
Add
</AddButton>
</InputRow>
<HelpText id={helpTextId}>{helpText}</HelpText>
</form>
<ClickAwayListener onClickAway={onClose}>
<form
onSubmit={(e) => {
e.stopPropagation();
e.preventDefault();
if (!inputValue?.trim()) {
setError('Value cannot be empty or whitespace');
return;
} else {
onAdd(inputValue, {
setError,
clearInput: () => setInputValue(''),
});
}
}}
>
<InputRow>
<ScreenReaderOnly>
<label htmlFor={inputId}>Constraint Value</label>
</ScreenReaderOnly>
<StyledTextField
id={inputId}
placeholder='Enter value'
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
setError('');
}}
size='small'
variant='standard'
fullWidth
inputRef={inputRef}
autoFocus
error={!!error}
helperText={error}
aria-describedby={helpTextId}
inputProps={{
...inputProps,
}}
data-testid='CONSTRAINT_VALUES_INPUT'
/>
<AddButton
variant='text'
type='submit'
size='small'
color='primary'
disabled={!inputValue?.trim()}
data-testid='CONSTRAINT_VALUES_ADD_BUTTON'
>
Add
</AddButton>
</InputRow>
<HelpText id={helpTextId}>{helpText}</HelpText>
</form>
</ClickAwayListener>
</StyledPopover>
);
};

View File

@ -12,16 +12,18 @@ const StyledWrapper = styled('div')(({ theme }) => ({
paddingTop: theme.spacing(2),
}));
const NewInsights: FC = () => (
<StyledWrapper>
<InsightsHeader />
<StyledContainer>
<LifecycleInsights />
<PerformanceInsights />
<UserInsights />
</StyledContainer>
</StyledWrapper>
);
const NewInsights: FC = () => {
return (
<StyledWrapper>
<InsightsHeader />
<StyledContainer>
<LifecycleInsights />
<PerformanceInsights />
<UserInsights />
</StyledContainer>
</StyledWrapper>
);
};
export const Insights: FC<{ withCharts?: boolean }> = (props) => {
const useNewInsights = useUiFlag('lifecycleMetrics');

View File

@ -0,0 +1,216 @@
import type { FC } from 'react';
import { useMemo, useState } from 'react';
import { Box, Typography, Alert } from '@mui/material';
import {
LineChart,
NotEnoughData,
} from '../components/LineChart/LineChart.tsx';
import { InsightsSection } from '../sections/InsightsSection.tsx';
import {
StyledChartContainer,
StyledWidget,
StyledWidgetStats,
} from 'component/insights/InsightsCharts.styles';
import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
import { usePlaceholderData } from '../hooks/usePlaceholderData.js';
import { ImpactMetricsControls } from './ImpactMetricsControls.tsx';
import { getDisplayFormat, getTimeUnit, formatLargeNumbers } from './utils.ts';
import { fromUnixTime } from 'date-fns';
import { useChartData } from './hooks/useChartData.ts';
export const ImpactMetrics: FC = () => {
const [selectedSeries, setSelectedSeries] = useState<string>('');
const [selectedRange, setSelectedRange] = useState<
'hour' | 'day' | 'week' | 'month'
>('day');
const [beginAtZero, setBeginAtZero] = useState(false);
const [selectedLabels, setSelectedLabels] = useState<
Record<string, string[]>
>({});
const handleSeriesChange = (series: string) => {
setSelectedSeries(series);
setSelectedLabels({}); // labels are series-specific
};
const {
metadata,
loading: metadataLoading,
error: metadataError,
} = useImpactMetricsMetadata();
const {
data: { start, end, series: timeSeriesData, labels: availableLabels },
loading: dataLoading,
error: dataError,
} = useImpactMetricsData(
selectedSeries
? {
series: selectedSeries,
range: selectedRange,
labels:
Object.keys(selectedLabels).length > 0
? selectedLabels
: undefined,
}
: undefined,
);
const placeholderData = usePlaceholderData({
fill: true,
type: 'constant',
});
const metricSeries = useMemo(() => {
if (!metadata?.series) {
return [];
}
return Object.entries(metadata.series).map(([name, rest]) => ({
name,
...rest,
}));
}, [metadata]);
const data = useChartData(timeSeriesData);
const hasError = metadataError || dataError;
const isLoading = metadataLoading || dataLoading;
const shouldShowPlaceholder = !selectedSeries || isLoading || hasError;
const notEnoughData = useMemo(
() =>
!isLoading &&
(!timeSeriesData ||
timeSeriesData.length === 0 ||
!data.datasets.some((d) => d.data.length > 1)),
[data, isLoading, timeSeriesData],
);
const minTime = start
? fromUnixTime(Number.parseInt(start, 10))
: undefined;
const maxTime = end ? fromUnixTime(Number.parseInt(end, 10)) : undefined;
const placeholder = selectedSeries ? (
<NotEnoughData description='Send impact metrics using Unleash SDK and select data series to view the chart.' />
) : (
<NotEnoughData
title='Select a metric series to view the chart.'
description=''
/>
);
const cover = notEnoughData ? placeholder : isLoading;
return (
<InsightsSection title='Impact metrics'>
<StyledWidget>
<StyledWidgetStats>
<Box
sx={(theme) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
width: '100%',
})}
>
<ImpactMetricsControls
selectedSeries={selectedSeries}
onSeriesChange={handleSeriesChange}
selectedRange={selectedRange}
onRangeChange={setSelectedRange}
beginAtZero={beginAtZero}
onBeginAtZeroChange={setBeginAtZero}
metricSeries={metricSeries}
loading={metadataLoading}
selectedLabels={selectedLabels}
onLabelsChange={setSelectedLabels}
availableLabels={availableLabels}
/>
{!selectedSeries && !isLoading ? (
<Typography variant='body2' color='text.secondary'>
Select a metric series to view the chart
</Typography>
) : null}
</Box>
</StyledWidgetStats>
<StyledChartContainer>
{hasError ? (
<Alert severity='error'>
Failed to load impact metrics. Please check if
Prometheus is configured and the feature flag is
enabled.
</Alert>
) : null}
<LineChart
data={
notEnoughData || isLoading ? placeholderData : data
}
overrideOptions={
shouldShowPlaceholder
? {}
: {
scales: {
x: {
type: 'time',
min: minTime?.getTime(),
max: maxTime?.getTime(),
time: {
unit: getTimeUnit(
selectedRange,
),
displayFormats: {
[getTimeUnit(
selectedRange,
)]:
getDisplayFormat(
selectedRange,
),
},
tooltipFormat: 'PPpp',
},
},
y: {
beginAtZero,
title: {
display: false,
},
ticks: {
precision: 0,
callback: (
value: unknown,
): string | number =>
typeof value === 'number'
? formatLargeNumbers(
value,
)
: (value as number),
},
},
},
plugins: {
legend: {
display:
timeSeriesData &&
timeSeriesData.length > 1,
position: 'bottom' as const,
labels: {
usePointStyle: true,
boxWidth: 8,
padding: 12,
},
},
},
animations: {
x: { duration: 0 },
y: { duration: 0 },
},
}
}
cover={cover}
/>
</StyledChartContainer>
</StyledWidget>
</InsightsSection>
);
};

View File

@ -0,0 +1,203 @@
import type { FC } from 'react';
import {
FormControl,
InputLabel,
Select,
MenuItem,
FormControlLabel,
Checkbox,
Box,
Autocomplete,
TextField,
Typography,
Chip,
} from '@mui/material';
import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
import type { ImpactMetricsLabels } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
import { Highlighter } from 'component/common/Highlighter/Highlighter';
export interface ImpactMetricsControlsProps {
selectedSeries: string;
onSeriesChange: (series: string) => void;
selectedRange: 'hour' | 'day' | 'week' | 'month';
onRangeChange: (range: 'hour' | 'day' | 'week' | 'month') => void;
beginAtZero: boolean;
onBeginAtZeroChange: (beginAtZero: boolean) => void;
metricSeries: (ImpactMetricsSeries & { name: string })[];
loading?: boolean;
selectedLabels: Record<string, string[]>;
onLabelsChange: (labels: Record<string, string[]>) => void;
availableLabels?: ImpactMetricsLabels;
}
export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ({
selectedSeries,
onSeriesChange,
selectedRange,
onRangeChange,
beginAtZero,
onBeginAtZeroChange,
metricSeries,
loading = false,
selectedLabels,
onLabelsChange,
availableLabels,
}) => {
const handleLabelChange = (labelKey: string, values: string[]) => {
const newLabels = { ...selectedLabels };
if (values.length === 0) {
delete newLabels[labelKey];
} else {
newLabels[labelKey] = values;
}
onLabelsChange(newLabels);
};
const clearAllLabels = () => {
onLabelsChange({});
};
return (
<Box
sx={(theme) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(3),
maxWidth: 400,
})}
>
<Typography variant='body2' color='text.secondary'>
Select a custom metric to see its value over time. This can help
you understand the impact of your feature rollout on key
outcomes, such as system performance, usage patterns or error
rates.
</Typography>
<Autocomplete
options={metricSeries}
getOptionLabel={(option) => option.name}
value={
metricSeries.find(
(option) => option.name === selectedSeries,
) || null
}
onChange={(_, newValue) => onSeriesChange(newValue?.name || '')}
disabled={loading}
renderOption={(props, option, { inputValue }) => (
<Box component='li' {...props}>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant='body2'>
<Highlighter search={inputValue}>
{option.name}
</Highlighter>
</Typography>
<Typography
variant='caption'
color='text.secondary'
>
<Highlighter search={inputValue}>
{option.help}
</Highlighter>
</Typography>
</Box>
</Box>
)}
renderInput={(params) => (
<TextField
{...params}
label='Data series'
placeholder='Search for a metric…'
variant='outlined'
size='small'
/>
)}
noOptionsText='No metrics available'
sx={{ minWidth: 300 }}
/>
<FormControl variant='outlined' size='small' sx={{ minWidth: 200 }}>
<InputLabel id='range-select-label'>Time</InputLabel>
<Select
labelId='range-select-label'
value={selectedRange}
onChange={(e) =>
onRangeChange(
e.target.value as 'hour' | 'day' | 'week' | 'month',
)
}
label='Time Range'
>
<MenuItem value='hour'>Last hour</MenuItem>
<MenuItem value='day'>Last 24 hours</MenuItem>
<MenuItem value='week'>Last 7 days</MenuItem>
<MenuItem value='month'>Last 30 days</MenuItem>
</Select>
</FormControl>
<FormControlLabel
control={
<Checkbox
checked={beginAtZero}
onChange={(e) => onBeginAtZeroChange(e.target.checked)}
/>
}
label='Begin at zero'
/>
{availableLabels && Object.keys(availableLabels).length > 0 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant='subtitle2'>
Filter by labels
</Typography>
{Object.keys(selectedLabels).length > 0 && (
<Chip
label='Clear all'
size='small'
variant='outlined'
onClick={clearAllLabels}
/>
)}
</Box>
{Object.entries(availableLabels).map(
([labelKey, values]) => (
<Autocomplete
key={labelKey}
multiple
options={values}
value={selectedLabels[labelKey] || []}
onChange={(_, newValues) =>
handleLabelChange(labelKey, newValues)
}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
const { key, ...chipProps } =
getTagProps({ index });
return (
<Chip
{...chipProps}
key={key}
label={option}
size='small'
/>
);
})
}
renderInput={(params) => (
<TextField
{...params}
label={labelKey}
placeholder='Select values...'
variant='outlined'
size='small'
/>
)}
sx={{ minWidth: 300 }}
/>
),
)}
</Box>
) : null}
</Box>
);
};

View File

@ -0,0 +1,85 @@
import { useMemo } from 'react';
import { useTheme } from '@mui/material';
import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
import { useSeriesColor } from './useSeriesColor.ts';
import { getSeriesLabel } from '../utils.ts';
export const useChartData = (
timeSeriesData: ImpactMetricsSeries[] | undefined,
) => {
const theme = useTheme();
const getSeriesColor = useSeriesColor();
return useMemo(() => {
if (!timeSeriesData || timeSeriesData.length === 0) {
return {
labels: [],
datasets: [
{
data: [],
borderColor: theme.palette.primary.main,
backgroundColor: theme.palette.primary.light,
},
],
};
}
if (timeSeriesData.length === 1) {
const series = timeSeriesData[0];
const timestamps = series.data.map(
([epochTimestamp]) => new Date(epochTimestamp * 1000),
);
const values = series.data.map(([, value]) => value);
return {
labels: timestamps,
datasets: [
{
data: values,
borderColor: theme.palette.primary.main,
backgroundColor: theme.palette.primary.light,
label: getSeriesLabel(series.metric),
},
],
};
} else {
const allTimestamps = new Set<number>();
timeSeriesData.forEach((series) => {
series.data.forEach(([timestamp]) => {
allTimestamps.add(timestamp);
});
});
const sortedTimestamps = Array.from(allTimestamps).sort(
(a, b) => a - b,
);
const labels = sortedTimestamps.map(
(timestamp) => new Date(timestamp * 1000),
);
const datasets = timeSeriesData.map((series) => {
const seriesLabel = getSeriesLabel(series.metric);
const color = getSeriesColor(seriesLabel);
const dataMap = new Map(series.data);
const data = sortedTimestamps.map(
(timestamp) => dataMap.get(timestamp) ?? null,
);
return {
label: seriesLabel,
data,
borderColor: color,
backgroundColor: color,
fill: false,
spanGaps: false,
};
});
return {
labels,
datasets,
};
}
}, [timeSeriesData, theme, getSeriesColor]);
};

View File

@ -0,0 +1,17 @@
import { useTheme } from '@mui/material';
export const useSeriesColor = () => {
const theme = useTheme();
const colors = theme.palette.charts.series;
return (seriesLabel: string): string => {
let hash = 0;
for (let i = 0; i < seriesLabel.length; i++) {
const char = seriesLabel.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
const index = Math.abs(hash) % colors.length;
return colors[index];
};
};

View File

@ -0,0 +1,60 @@
export const getTimeUnit = (selectedRange: string) => {
switch (selectedRange) {
case 'hour':
return 'minute';
case 'day':
return 'hour';
case 'week':
return 'day';
case 'month':
return 'day';
default:
return 'hour';
}
};
export const getDisplayFormat = (selectedRange: string) => {
switch (selectedRange) {
case 'hour':
case 'day':
return 'HH:mm';
case 'week':
case 'month':
return 'MMM dd';
default:
return 'MMM dd HH:mm';
}
};
export const getSeriesLabel = (metric: Record<string, string>): string => {
const { __name__, ...labels } = metric;
const labelParts = Object.entries(labels)
.filter(([key, value]) => key !== '__name__' && value)
.map(([key, value]) => `${key}=${value}`)
.join(', ');
if (!__name__ && !labelParts) {
return 'Series';
}
if (!__name__) {
return labelParts;
}
if (!labelParts) {
return __name__;
}
return `${__name__} (${labelParts})`;
};
export const formatLargeNumbers = (value: number): string => {
if (value >= 1000000) {
return `${(value / 1000000).toFixed(0)}M`;
}
if (value >= 1000) {
return `${(value / 1000).toFixed(0)}k`;
}
return value.toString();
};

View File

@ -190,17 +190,17 @@ export const useChangeRequestApi = () => {
return makeRequest(req.caller, req.id);
};
const updateRequestedReviewers = async (
const updateRequestedApprovers = async (
project: string,
changeRequestId: number,
reviewers: string[],
reviewers: number[],
) => {
trackEvent('change_request', {
props: {
eventType: 'reviewers updated',
eventType: 'approvers updated',
},
});
const path = `api/admin/projects/${project}/change-requests/${changeRequestId}/reviewers`;
const path = `api/admin/projects/${project}/change-requests/${changeRequestId}/approvers`;
const req = createRequest(path, {
method: 'PUT',
body: JSON.stringify({ reviewers }),
@ -217,7 +217,7 @@ export const useChangeRequestApi = () => {
discardDraft,
addComment,
updateTitle,
updateRequestedReviewers,
updateRequestedApprovers,
errors,
loading,
};

View File

@ -5,7 +5,7 @@ import handleErrorResponses from '../httpErrorResponseHandler.js';
// TODO: These will likely be created by Orval next time it is run
export interface AvailableReviewerSchema {
id: string;
id: number;
name?: string;
email: string;
username?: string;

View File

@ -0,0 +1,48 @@
import useSWR from 'swr';
import { useMemo } from 'react';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler.js';
// TODO: These will likely be created by Orval next time it is run
export interface ReviewerSchema {
id: number;
name?: string;
email: string;
username?: string;
imageUrl?: string;
}
export interface IReviewersResponse {
reviewers: ReviewerSchema[];
refetchReviewers: () => void;
loading: boolean;
error?: Error;
}
export const useRequestedApprovers = (
project: string,
changeRequestId: number,
): IReviewersResponse => {
const { data, error, mutate } = useSWR(
formatApiPath(
`api/admin/projects/${project}/change-requests/${changeRequestId}/approvers`,
),
fetcher,
);
return useMemo(
() => ({
reviewers: data?.reviewers || [],
loading: !error && !data,
refetchReviewers: () => mutate(),
error,
}),
[data, error, mutate],
);
};
const fetcher = (path: string) => {
return fetch(path)
.then(handleErrorResponses('Requested Approvers'))
.then((res) => res.json());
};

View File

@ -397,6 +397,8 @@ export interface IEvent extends Omit<IBaseEvent, 'ip'> {
id: number;
createdAt: Date;
ip?: string;
groupType?: string;
groupId?: string;
}
export interface IEnrichedEvent extends IEvent {

View File

@ -41,6 +41,8 @@ const EVENT_COLUMNS = [
'feature_name',
'project',
'environment',
'group_type',
'group_id',
] as const;
export type IQueryOperations =
@ -97,6 +99,8 @@ export interface IEventTable {
environment?: string;
tags: ITag[];
ip?: string;
group_type: string | null;
group_id: string | null;
}
const TABLE = 'events';
@ -157,7 +161,9 @@ export class EventStore implements IEventStore {
async batchStore(events: IBaseEvent[]): Promise<void> {
try {
await this.db(TABLE).insert(events.map(this.eventToDbRow));
await this.db(TABLE).insert(
events.map((event) => this.eventToDbRow(event)),
);
} catch (error: unknown) {
this.logger.warn(
`Failed to store events: ${JSON.stringify(events)}`,
@ -472,10 +478,14 @@ export class EventStore implements IEventStore {
featureName: row.feature_name,
project: row.project,
environment: row.environment,
groupType: row.group_type || undefined,
groupId: row.group_id || undefined,
};
}
eventToDbRow(e: IBaseEvent): Omit<IEventTable, 'id' | 'created_at'> {
const transactionContext = this.db.userParams;
return {
type: e.type,
created_by: e.createdBy ?? 'admin',
@ -490,6 +500,8 @@ export class EventStore implements IEventStore {
project: e.project,
environment: e.environment,
ip: e.ip,
group_type: transactionContext?.type || null,
group_id: transactionContext?.id || null,
};
}

View File

@ -109,6 +109,18 @@ export const eventSchema = {
'The IP address of the user that created the event. Only available in Enterprise.',
example: '192.168.1.1',
},
groupType: {
type: 'string',
description:
'The type of transaction group this event belongs to, if applicable.',
example: 'change-request',
},
groupId: {
type: 'string',
description:
'The unique identifier for the transaction group this event belongs to, if applicable.',
example: '01HQVX5K8P9EXAMPLE123456',
},
},
components: {
schemas: {

View File

@ -45,6 +45,13 @@ export interface IEmailEnvelope {
headers?: Record<string, string>;
}
export interface ICrApprovalParameters {
changeRequestLink: string;
changeRequestTitle: string;
requesterName: string;
requesterEmail: string;
}
const RESET_MAIL_SUBJECT = 'Unleash - Reset your password';
const GETTING_STARTED_SUBJECT = 'Welcome to Unleash';
const PRODUCTIVITY_REPORT = 'Unleash - productivity report';
@ -124,8 +131,7 @@ export class EmailService {
async sendRequestedCRApprovalEmail(
recipient: string,
changeRequestLink: string,
changeRequestTitle: string,
crApprovalParams: ICrApprovalParameters,
): Promise<IEmailEnvelope> {
if (this.configured()) {
const year = new Date().getFullYear();
@ -133,8 +139,7 @@ export class EmailService {
'requested-cr-approval',
TemplateFormat.HTML,
{
changeRequestLink,
changeRequestTitle,
...crApprovalParams,
year,
},
);
@ -142,8 +147,7 @@ export class EmailService {
'requested-cr-approval',
TemplateFormat.PLAIN,
{
changeRequestLink,
changeRequestTitle,
...crApprovalParams,
year,
},
);
@ -173,7 +177,6 @@ export class EmailService {
this.logger.warn(
'No mailer is configured. Please read the docs on how to configure an email service',
);
this.logger.debug('Change request link: ', changeRequestLink);
res({
from: this.sender,
to: recipient,

View File

@ -340,7 +340,7 @@
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateBody">
<tr>
<td valign="top" class="bodyContent" mc:edit="body_content">
<h1>You have been added to review {{{ changeRequestTitle }}}</h1>
<h1>You have been added to review {{{ changeRequestTitle }}} by {{{ requesterName }}} ({{{ requesterEmail }}}) </h1>
<p>Click <a class="changeRequestLink" href="{{{ changeRequestLink }}}" target="_blank" rel="noopener noreferrer">{{{changeRequestLink}}}</a> to review it</p>
</td>
</tr>

View File

@ -1,3 +1,3 @@
You have been added to review {{{ changeRequestTitle }}}
You have been added to review {{{ changeRequestTitle }}} by {{{ requesterName }}} ({{{ requesterEmail }}})
Follow the link: {{{ changeRequestLink }}} to review it.

View File

@ -0,0 +1,15 @@
exports.up = function(db, cb) {
db.runSql(`
ALTER TABLE events
ADD COLUMN IF NOT EXISTS group_type TEXT,
ADD COLUMN IF NOT EXISTS group_id TEXT;
`, cb);
};
exports.down = function(db, cb) {
db.runSql(`
ALTER TABLE events
DROP COLUMN IF EXISTS group_id,
DROP COLUMN IF EXISTS group_type;
`, cb);
};

View File

@ -18,6 +18,11 @@ import dbInit, { type ITestDb } from '../helpers/database-init.js';
import getLogger from '../../fixtures/no-logger.js';
import type { IEventStore } from '../../../lib/types/stores/event-store.js';
import type { IAuditUser, IUnleashStores } from '../../../lib/types/index.js';
import {
withTransactional,
type TransactionUserParams,
} from '../../../lib/db/transaction.js';
import { EventStore } from '../../../lib/features/events/event-store.js';
import { vi } from 'vitest';
@ -472,3 +477,155 @@ test('Should return empty result when filtering by non-existent ID', async () =>
expect(filteredEvents).toHaveLength(0);
});
test('Should store and retrieve transaction context fields', async () => {
const mockTransactionContext: TransactionUserParams = {
type: 'change-request',
id: '01HQVX5K8P9EXAMPLE123456',
};
const eventStoreService = withTransactional(
(db) => new EventStore(db, getLogger),
db.rawDatabase,
);
const event = {
type: FEATURE_CREATED,
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
featureName: 'test-feature-with-context',
project: 'test-project',
ip: '127.0.0.1',
data: {
name: 'test-feature-with-context',
enabled: true,
strategies: [{ name: 'default' }],
},
};
await eventStoreService.transactional(async (transactionalEventStore) => {
await transactionalEventStore.store(event);
}, mockTransactionContext);
const events = await eventStore.getAll();
const storedEvent = events.find(
(e) => e.featureName === 'test-feature-with-context',
);
expect(storedEvent).toBeTruthy();
expect(storedEvent!.groupType).toBe('change-request');
expect(storedEvent!.groupId).toBe('01HQVX5K8P9EXAMPLE123456');
});
test('Should handle missing transaction context gracefully', async () => {
const event = {
type: FEATURE_CREATED,
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
featureName: 'test-feature-no-context',
project: 'test-project',
ip: '127.0.0.1',
data: {
name: 'test-feature-no-context',
enabled: true,
strategies: [{ name: 'default' }],
},
};
await eventStore.store(event);
const events = await eventStore.getAll();
const storedEvent = events.find(
(e) => e.featureName === 'test-feature-no-context',
);
expect(storedEvent).toBeTruthy();
expect(storedEvent!.groupType).toBeUndefined();
expect(storedEvent!.groupId).toBeUndefined();
});
test('Should store transaction context in batch operations', async () => {
const mockTransactionContext: TransactionUserParams = {
type: 'transaction',
id: '01HQVX5K8P9BATCH123456',
};
const eventStoreService = withTransactional(
(db) => new EventStore(db, getLogger),
db.rawDatabase,
);
const events = [
{
type: FEATURE_CREATED,
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
featureName: 'batch-feature-1',
project: 'test-project',
ip: '127.0.0.1',
data: { name: 'batch-feature-1' },
},
{
type: FEATURE_UPDATED,
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
featureName: 'batch-feature-2',
project: 'test-project',
ip: '127.0.0.1',
data: { name: 'batch-feature-2' },
},
];
await eventStoreService.transactional(async (transactionalEventStore) => {
await transactionalEventStore.batchStore(events);
}, mockTransactionContext);
const allEvents = await eventStore.getAll();
const batchEvents = allEvents.filter(
(e) =>
e.featureName === 'batch-feature-1' ||
e.featureName === 'batch-feature-2',
);
expect(batchEvents).toHaveLength(2);
batchEvents.forEach((event) => {
expect(event.groupType).toBe('transaction');
expect(event.groupId).toBe('01HQVX5K8P9BATCH123456');
});
});
test('Should auto-generate transaction context when none provided', async () => {
const eventStoreService = withTransactional(
(db) => new EventStore(db, getLogger),
db.rawDatabase,
);
const event = {
type: FEATURE_CREATED,
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
featureName: 'test-feature-auto-context',
project: 'test-project',
ip: '127.0.0.1',
data: {
name: 'test-feature-auto-context',
enabled: true,
strategies: [{ name: 'default' }],
},
};
await eventStoreService.transactional(async (transactionalEventStore) => {
await transactionalEventStore.store(event);
});
const events = await eventStore.getAll();
const storedEvent = events.find(
(e) => e.featureName === 'test-feature-auto-context',
);
expect(storedEvent).toBeTruthy();
expect(storedEvent!.groupType).toBe('transaction');
expect(storedEvent!.groupId).toBeTruthy();
expect(typeof storedEvent!.groupId).toBe('string');
expect(storedEvent!.groupId).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
});

View File

@ -8412,22 +8412,22 @@ __metadata:
languageName: node
linkType: hard
"git-up@npm:^8.0.0":
version: 8.0.1
resolution: "git-up@npm:8.0.1"
"git-up@npm:^8.1.0":
version: 8.1.1
resolution: "git-up@npm:8.1.1"
dependencies:
is-ssh: "npm:^1.4.0"
parse-url: "npm:^9.2.0"
checksum: 10c0/9aa809907ecfc96093d91e2fc68644ace1ac184ed613a67d74f24627172f62f73cc0149037975bd2edf6540676db99632692dc3b18e0a053273e160cf158973f
checksum: 10c0/2cc4461d8565a3f7a1ecd3d262a58ddb8df0a67f7f7d4915df2913c460b2e88ae570a6ea810700a6d22fb3b9e4bea8dd10a8eb469900ddc12e35c62208608c03
languageName: node
linkType: hard
"git-url-parse@npm:^16.0.0":
version: 16.0.1
resolution: "git-url-parse@npm:16.0.1"
version: 16.1.0
resolution: "git-url-parse@npm:16.1.0"
dependencies:
git-up: "npm:^8.0.0"
checksum: 10c0/bef681b3726c730a3efb599d38ab6affbb13e5e85269fc9c35831ddfe0d195e6a29098c79c8faa63ccd8503ace54c2c4b01a73122af3b66c2ce11f4692b3ef19
git-up: "npm:^8.1.0"
checksum: 10c0/b8f5ebcbd5b2baf9f1bb77a217376f0247c47fe1d42811ccaac3015768eebb0759a59051f758e50e70adf5c67ae059d1975bf6b750164f36bfd39138d11b940b
languageName: node
linkType: hard

View File

@ -1167,8 +1167,8 @@ __metadata:
linkType: hard
"@slack/web-api@npm:^7.9.1":
version: 7.9.1
resolution: "@slack/web-api@npm:7.9.1"
version: 7.9.3
resolution: "@slack/web-api@npm:7.9.3"
dependencies:
"@slack/logger": "npm:^4.0.0"
"@slack/types": "npm:^2.9.0"
@ -1182,7 +1182,7 @@ __metadata:
p-queue: "npm:^6"
p-retry: "npm:^4"
retry: "npm:^0.13.1"
checksum: 10c0/76d5d935518f3c2ab9eea720c0736f5722ac6c3244f4b1ba29aa3f3525803ecc00662209c8a1cce2c2a94ec71d607c05f5f6a24456dd3e60fa65b3fe0c7b9820
checksum: 10c0/a08342156683abe6cd05659c6a51eeb782adcd7f418df43ad212501f0d0da77c6caf57785491a7f024116499e1819da589b803622318454b8abe6b375ba1d8c0
languageName: node
linkType: hard
@ -5984,9 +5984,9 @@ __metadata:
linkType: hard
"pg-connection-string@npm:^2.5.0, pg-connection-string@npm:^2.9.0":
version: 2.9.0
resolution: "pg-connection-string@npm:2.9.0"
checksum: 10c0/7145d00688200685a9d9931a7fc8d61c75f348608626aef88080ece956ceb4ff1cbdee29c3284e41b7a3345bab0e4f50f9edc256e270bfa3a563af4ea78bb490
version: 2.9.1
resolution: "pg-connection-string@npm:2.9.1"
checksum: 10c0/9a646529bbc0843806fc5de98ce93735a4612b571f11867178a85665d11989a827e6fd157388ca0e34ec948098564fce836c178cfd499b9f0e8cd9972b8e2e5c
languageName: node
linkType: hard