1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-11-24 20:06:55 +01:00

feat: safeguard form draft (#10954)

This commit is contained in:
Mateusz Kwasniewski 2025-11-10 13:56:54 +01:00 committed by GitHub
parent 142b5a5d95
commit 529726decf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 301 additions and 91 deletions

View File

@ -2,7 +2,7 @@ import { PageContent } from 'component/common/PageContent/PageContent.tsx';
import { PageHeader } from '../../../common/PageHeader/PageHeader.tsx';
import { Box, styled, Typography } from '@mui/material';
import Add from '@mui/icons-material/Add';
import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata.ts';
import { useImpactMetricsNames } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata.ts';
import { type FC, useMemo, useState } from 'react';
import { ChartConfigModal } from '../../../impact-metrics/ChartConfigModal/ChartConfigModal.tsx';
import { useImpactMetricsApi } from 'hooks/api/actions/useImpactMetricsApi/useImpactMetricsApi.ts';
@ -49,10 +49,10 @@ export const FeatureImpactMetrics: FC = () => {
const { setToastApiError } = useToast();
const {
metadata,
metricSeries,
loading: metadataLoading,
error: metadataError,
} = useImpactMetricsMetadata();
} = useImpactMetricsNames();
const handleAddChart = () => {
setModalState({ type: 'creating' });
@ -94,16 +94,6 @@ export const FeatureImpactMetrics: FC = () => {
}
};
const metricSeries = useMemo(() => {
if (!metadata?.series) {
return [];
}
return Object.entries(metadata.series).map(([name, rest]) => ({
name,
...rest,
}));
}, [metadata]);
const isModalOpen = modalState.type !== 'closed';
const editingChart =
modalState.type === 'editing' ? modalState.config : undefined;

View File

@ -1,66 +1,21 @@
import { Button, styled } from '@mui/material';
import { Button } from '@mui/material';
import BoltIcon from '@mui/icons-material/Bolt';
import { useMilestoneProgressionForm } from '../hooks/useMilestoneProgressionForm.js';
import { MilestoneProgressionTimeInput } from './MilestoneProgressionTimeInput.tsx';
import type { ChangeMilestoneProgressionSchema } from 'openapi';
import type { MilestoneStatus } from '../ReleasePlanMilestone/ReleasePlanMilestoneStatus.tsx';
import { useMilestoneProgressionInfo } from '../hooks/useMilestoneProgressionInfo.ts';
import {
StyledFormContainer,
StyledTopRow,
StyledLabel,
StyledButtonGroup,
StyledErrorMessage,
StyledInfoLine,
createStyledIcon,
} from '../shared/SharedFormComponents.tsx';
const StyledFormContainer = styled('form')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1.5),
padding: theme.spacing(1.5, 2),
backgroundColor: theme.palette.background.elevation1,
border: `1px solid ${theme.palette.divider}`,
width: '100%',
borderRadius: `${theme.shape.borderRadiusLarge}px`,
position: 'relative',
}));
const StyledTopRow = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}));
const StyledIcon = styled(BoltIcon)(({ theme }) => ({
color: theme.palette.common.white,
fontSize: 18,
flexShrink: 0,
backgroundColor: theme.palette.primary.main,
borderRadius: '50%',
padding: theme.spacing(0.25),
}));
const StyledLabel = styled('span')(({ theme }) => ({
color: theme.palette.text.primary,
fontSize: theme.typography.body2.fontSize,
flexShrink: 0,
}));
const StyledButtonGroup = styled('div')(({ theme }) => ({
display: 'flex',
gap: theme.spacing(1),
justifyContent: 'flex-end',
alignItems: 'center',
paddingTop: theme.spacing(1),
marginTop: theme.spacing(0.5),
borderTop: `1px solid ${theme.palette.divider}`,
}));
const StyledErrorMessage = styled('span')(({ theme }) => ({
color: theme.palette.error.main,
fontSize: theme.typography.body2.fontSize,
paddingLeft: theme.spacing(3.25),
}));
const StyledInfoLine = styled('span')(({ theme }) => ({
color: theme.palette.text.secondary,
fontSize: theme.typography.caption.fontSize,
paddingLeft: theme.spacing(3.25),
fontStyle: 'italic',
}));
const StyledIcon = createStyledIcon(BoltIcon);
interface IMilestoneProgressionFormProps {
sourceMilestoneId: string;

View File

@ -31,6 +31,7 @@ import { ReleasePlanMilestoneItem } from './ReleasePlanMilestoneItem/ReleasePlan
import Add from '@mui/icons-material/Add';
import { StyledActionButton } from './ReleasePlanMilestoneItem/StyledActionButton.tsx';
import { SafeguardForm } from './SafeguardForm/SafeguardForm.tsx';
const StyledContainer = styled('div')(({ theme }) => ({
padding: theme.spacing(2),
@ -194,6 +195,7 @@ export const ReleasePlan = ({
const [milestoneToDeleteProgression, setMilestoneToDeleteProgression] =
useState<IReleasePlanMilestone | null>(null);
const [isDeletingProgression, setIsDeletingProgression] = useState(false);
const [safeguardFormOpen, setSafeguardFormOpen] = useState(false);
const onChangeRequestConfirm = async () => {
if (!changeRequestAction) return;
@ -375,6 +377,21 @@ export const ReleasePlan = ({
(milestone) => milestone.id === activeMilestoneId,
);
const handleSafeguardSubmit = (data: {
metric: string;
application: string;
aggregation: string;
comparison: string;
threshold: number;
}) => {
console.log('Safeguard data:', data);
setSafeguardFormOpen(false);
setToastData({
type: 'success',
text: 'Safeguard added successfully',
});
};
return (
<StyledContainer>
<StyledHeader>
@ -406,13 +423,20 @@ export const ReleasePlan = ({
<StyledBody safeguards={safeguardsEnabled}>
{safeguardsEnabled ? (
<StyledAddSafeguard>
<StyledActionButton
onClick={() => {}}
color='primary'
startIcon={<Add />}
>
Add safeguard
</StyledActionButton>
{safeguardFormOpen ? (
<SafeguardForm
onSubmit={handleSafeguardSubmit}
onCancel={() => setSafeguardFormOpen(false)}
/>
) : (
<StyledActionButton
onClick={() => setSafeguardFormOpen(true)}
color='primary'
startIcon={<Add />}
>
Add safeguard
</StyledActionButton>
)}
</StyledAddSafeguard>
) : null}
<StyledMilestones safeguards={safeguardsEnabled}>

View File

@ -0,0 +1,148 @@
import { Button } from '@mui/material';
import ShieldIcon from '@mui/icons-material/Shield';
import { useState, useEffect } from 'react';
import type { FormEvent } from 'react';
import { useImpactMetricsNames } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
import {
StyledFormContainer,
StyledTopRow,
StyledLabel,
StyledButtonGroup,
StyledSelect,
StyledMenuItem,
StyledTextField,
createStyledIcon,
} from '../shared/SharedFormComponents.tsx';
const StyledIcon = createStyledIcon(ShieldIcon);
interface ISafeguardFormProps {
onSubmit: (data: {
metric: string;
application: string;
aggregation: string;
comparison: string;
threshold: number;
}) => void;
onCancel: () => void;
}
export const SafeguardForm = ({ onSubmit, onCancel }: ISafeguardFormProps) => {
const { metricSeries, loading } = useImpactMetricsNames();
// Hardcoded values for now
const aggregationModes = ['rps', 'count'];
const applicationNames = ['web', 'mobile', 'api', 'backend'];
const [selectedMetric, setSelectedMetric] = useState('');
const [application, setApplication] = useState('web');
const [aggregationMode, setAggregationMode] = useState('rps');
const [operator, setOperator] = useState('>');
const [threshold, setThreshold] = useState(0);
// Set initial metric when data loads
useEffect(() => {
if (metricSeries.length > 0 && !selectedMetric) {
setSelectedMetric(metricSeries[0].name);
}
}, [metricSeries, selectedMetric]);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (threshold && !Number.isNaN(Number(threshold))) {
const metric = metricSeries.find((m) => m.name === selectedMetric);
onSubmit({
metric: metric?.displayName || selectedMetric,
application,
aggregation: aggregationMode,
comparison: operator,
threshold: Number(threshold),
});
}
};
return (
<StyledFormContainer onSubmit={handleSubmit}>
<StyledTopRow>
<StyledIcon />
<StyledSelect
value={selectedMetric}
onChange={(e) =>
setSelectedMetric(e.target.value as string)
}
variant='outlined'
size='small'
>
{metricSeries.map((metric) => (
<StyledMenuItem key={metric.name} value={metric.name}>
{metric.displayName}
</StyledMenuItem>
))}
</StyledSelect>
<StyledLabel>filtered by</StyledLabel>
<StyledSelect
value={application}
onChange={(e) => setApplication(String(e.target.value))}
variant='outlined'
size='small'
>
{applicationNames.map((app) => (
<StyledMenuItem key={app} value={app}>
{app}
</StyledMenuItem>
))}
</StyledSelect>
<StyledLabel>aggregated by</StyledLabel>
<StyledSelect
value={aggregationMode}
onChange={(e) => setAggregationMode(String(e.target.value))}
variant='outlined'
size='small'
>
{aggregationModes.map((mode) => (
<StyledMenuItem key={mode} value={mode}>
{mode}
</StyledMenuItem>
))}
</StyledSelect>
<StyledLabel>is</StyledLabel>
<StyledSelect
value={operator}
onChange={(e) => setOperator(String(e.target.value))}
variant='outlined'
size='small'
>
<StyledMenuItem value='>'>More than</StyledMenuItem>
<StyledMenuItem value='<'>Less than</StyledMenuItem>
</StyledSelect>
<StyledTextField
type='number'
value={threshold}
onChange={(e) => setThreshold(Number(e.target.value))}
placeholder='Value'
variant='outlined'
size='small'
required
/>
</StyledTopRow>
<StyledButtonGroup>
<Button variant='outlined' onClick={onCancel} size='small'>
Cancel
</Button>
<Button
variant='contained'
color='primary'
size='small'
type='submit'
disabled={!threshold || Number.isNaN(Number(threshold))}
>
Save
</Button>
</StyledButtonGroup>
</StyledFormContainer>
);
};

View File

@ -0,0 +1,82 @@
import { styled, Select, MenuItem, TextField } from '@mui/material';
export const StyledFormContainer = styled('form')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1.5),
padding: theme.spacing(1.5, 2),
backgroundColor: theme.palette.background.elevation1,
border: `1px solid ${theme.palette.divider}`,
width: '100%',
borderRadius: `${theme.shape.borderRadiusLarge}px`,
position: 'relative',
}));
export const StyledTopRow = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}));
export const StyledLabel = styled('span')(({ theme }) => ({
color: theme.palette.text.primary,
fontSize: theme.typography.body2.fontSize,
flexShrink: 0,
}));
export const StyledButtonGroup = styled('div')(({ theme }) => ({
display: 'flex',
gap: theme.spacing(1),
justifyContent: 'flex-end',
alignItems: 'center',
paddingTop: theme.spacing(1),
marginTop: theme.spacing(0.5),
borderTop: `1px solid ${theme.palette.divider}`,
}));
export const StyledSelect = styled(Select)(({ theme }) => ({
minWidth: 120,
maxWidth: 120,
'& .MuiSelect-select': {
fontSize: theme.typography.body2.fontSize,
padding: theme.spacing(0.5, 1),
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
}));
export const StyledMenuItem = styled(MenuItem)(({ theme }) => ({
fontSize: theme.typography.body2.fontSize,
}));
export const StyledTextField = styled(TextField)(({ theme }) => ({
width: 80,
'& .MuiInputBase-input': {
fontSize: theme.typography.body2.fontSize,
padding: theme.spacing(0.5, 1),
},
}));
export const createStyledIcon = (IconComponent: React.ComponentType<any>) =>
styled(IconComponent)(({ theme }) => ({
color: theme.palette.common.white,
fontSize: 18,
flexShrink: 0,
backgroundColor: theme.palette.primary.main,
borderRadius: '50%',
padding: theme.spacing(0.25),
}));
export const StyledErrorMessage = styled('span')(({ theme }) => ({
color: theme.palette.error.main,
fontSize: theme.typography.body2.fontSize,
paddingLeft: theme.spacing(3.25),
}));
export const StyledInfoLine = styled('span')(({ theme }) => ({
color: theme.palette.text.secondary,
fontSize: theme.typography.caption.fontSize,
paddingLeft: theme.spacing(3.25),
fontStyle: 'italic',
}));

View File

@ -1,9 +1,9 @@
import type { FC } from 'react';
import { useMemo, useState, useCallback } from 'react';
import { type FC, useMemo } from 'react';
import { useState, useCallback } from 'react';
import { Typography, Button, Paper, styled, Box } from '@mui/material';
import Add from '@mui/icons-material/Add';
import { PageHeader } from 'component/common/PageHeader/PageHeader.tsx';
import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
import { useImpactMetricsNames } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
import { ChartConfigModal } from './ChartConfigModal/ChartConfigModal.tsx';
import { ChartItem } from './ChartItem.tsx';
import { PlausibleChartItem } from './PlausibleChartItem.tsx';
@ -54,20 +54,10 @@ export const ImpactMetrics: FC = () => {
} = useImpactMetricsState();
const {
metadata,
metricSeries,
loading: metadataLoading,
error: metadataError,
} = useImpactMetricsMetadata();
const metricSeries = useMemo(() => {
if (!metadata?.series) {
return [];
}
return Object.entries(metadata.series).map(([name, rest]) => ({
name,
...rest,
}));
}, [metadata]);
} = useImpactMetricsNames();
const handleAddChart = () => {
setEditingChart(undefined);

View File

@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { fetcher, useApiGetter } from '../useApiGetter/useApiGetter.js';
import { formatApiPath } from 'utils/formatPath';
@ -25,3 +26,23 @@ export const useImpactMetricsMetadata = () => {
error,
};
};
export const useImpactMetricsNames = () => {
const { metadata, loading, error } = useImpactMetricsMetadata();
const metricSeries = useMemo(() => {
if (!metadata?.series) {
return [];
}
return Object.entries(metadata.series).map(([name, rest]) => ({
name,
...rest,
}));
}, [metadata]);
return {
metricSeries,
loading,
error,
};
};