diff --git a/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureImpactMetrics.tsx b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureImpactMetrics.tsx index 54798557a7..bcb93572c1 100644 --- a/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureImpactMetrics.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureImpactMetrics.tsx @@ -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; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/MilestoneProgressionForm/MilestoneProgressionForm.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/MilestoneProgressionForm/MilestoneProgressionForm.tsx index 6b2a3189de..604a3532e1 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/MilestoneProgressionForm/MilestoneProgressionForm.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/MilestoneProgressionForm/MilestoneProgressionForm.tsx @@ -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; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx index 7b07c7e587..6da03cf1ec 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx @@ -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(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 ( @@ -406,13 +423,20 @@ export const ReleasePlan = ({ {safeguardsEnabled ? ( - {}} - color='primary' - startIcon={} - > - Add safeguard - + {safeguardFormOpen ? ( + setSafeguardFormOpen(false)} + /> + ) : ( + setSafeguardFormOpen(true)} + color='primary' + startIcon={} + > + Add safeguard + + )} ) : null} diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/SafeguardForm/SafeguardForm.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/SafeguardForm/SafeguardForm.tsx new file mode 100644 index 0000000000..24543343b4 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/SafeguardForm/SafeguardForm.tsx @@ -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 ( + + + + + setSelectedMetric(e.target.value as string) + } + variant='outlined' + size='small' + > + {metricSeries.map((metric) => ( + + {metric.displayName} + + ))} + + + filtered by + setApplication(String(e.target.value))} + variant='outlined' + size='small' + > + {applicationNames.map((app) => ( + + {app} + + ))} + + + aggregated by + setAggregationMode(String(e.target.value))} + variant='outlined' + size='small' + > + {aggregationModes.map((mode) => ( + + {mode} + + ))} + + + is + setOperator(String(e.target.value))} + variant='outlined' + size='small' + > + More than + Less than + + + setThreshold(Number(e.target.value))} + placeholder='Value' + variant='outlined' + size='small' + required + /> + + + + + + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/shared/SharedFormComponents.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/shared/SharedFormComponents.tsx new file mode 100644 index 0000000000..34691eb1cd --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/shared/SharedFormComponents.tsx @@ -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) => + 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', +})); diff --git a/frontend/src/component/impact-metrics/ImpactMetrics.tsx b/frontend/src/component/impact-metrics/ImpactMetrics.tsx index a65aaa9728..879988c7c7 100644 --- a/frontend/src/component/impact-metrics/ImpactMetrics.tsx +++ b/frontend/src/component/impact-metrics/ImpactMetrics.tsx @@ -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); diff --git a/frontend/src/hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata.ts b/frontend/src/hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata.ts index 57f3a852cb..be2b9d7081 100644 --- a/frontend/src/hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata.ts +++ b/frontend/src/hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata.ts @@ -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, + }; +};