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:
commit
6b097846e9
22
.github/workflows/ai-flag-cleanup.yml
vendored
Normal file
22
.github/workflows/ai-flag-cleanup.yml
vendored
Normal 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 }}
|
@ -16,7 +16,7 @@ export const ArchiveFeatureChange: FC<IArchiveFeatureChange> = ({
|
||||
actions,
|
||||
}) => (
|
||||
<ChangeItemWrapper>
|
||||
<ArchiveBox>Archiving feature</ArchiveBox>
|
||||
<ArchiveBox>Archiving flag</ArchiveBox>
|
||||
{actions}
|
||||
</ChangeItemWrapper>
|
||||
);
|
||||
|
@ -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}`,
|
||||
}
|
||||
: {},
|
||||
);
|
@ -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' \\
|
||||
|
@ -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}`}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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),
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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),
|
||||
|
@ -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();
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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');
|
||||
|
216
frontend/src/component/insights/impact-metrics/ImpactMetrics.tsx
Normal file
216
frontend/src/component/insights/impact-metrics/ImpactMetrics.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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]);
|
||||
};
|
@ -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];
|
||||
};
|
||||
};
|
60
frontend/src/component/insights/impact-metrics/utils.ts
Normal file
60
frontend/src/component/insights/impact-metrics/utils.ts
Normal 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();
|
||||
};
|
@ -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,
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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());
|
||||
};
|
@ -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 {
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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: {
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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.
|
||||
|
@ -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);
|
||||
};
|
@ -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}$/);
|
||||
});
|
||||
|
@ -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
|
||||
|
||||
|
12
yarn.lock
12
yarn.lock
@ -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
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user