1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-03-04 00:18:40 +01:00

Suggest changes dialog (#2247)

* refactor: suggested changes folder structure

* feat: add dialogue confirmation
This commit is contained in:
Tymoteusz Czech 2022-10-26 13:57:59 +02:00 committed by GitHub
parent 2304ea1d1e
commit ea2cf144f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 373 additions and 206 deletions

View File

@ -10,6 +10,9 @@ import React from 'react';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useStyles } from './FeatureOverviewEnvSwitch.styles';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useSuggestToggle } from 'hooks/useSuggestToggle';
import { SuggestChangesDialogue } from 'component/suggestChanges/SuggestChangeConfirmDialog/SuggestChangeConfirmDialog';
interface IFeatureOverviewEnvSwitchProps {
env: IFeatureEnvironment;
@ -31,6 +34,12 @@ const FeatureOverviewEnvSwitch = ({
const { refetchFeature } = useFeature(projectId, featureId);
const { setToastData, setToastApiError } = useToast();
const { classes: styles } = useStyles();
const { uiConfig } = useUiConfig();
const {
onSuggestToggle,
onSuggestToggleClose,
suggestChangesDialogDetails,
} = useSuggestToggle();
const handleToggleEnvironmentOn = async () => {
try {
@ -74,6 +83,11 @@ const FeatureOverviewEnvSwitch = ({
};
const toggleEnvironment = async (e: React.ChangeEvent) => {
if (uiConfig?.flags?.suggestChanges && env.name === 'production') {
e.preventDefault();
onSuggestToggle(featureId, env.name, env.enabled);
return;
}
if (env.enabled) {
await handleToggleEnvironmentOff();
return;
@ -104,6 +118,13 @@ const FeatureOverviewEnvSwitch = ({
/>
{content}
</label>
<SuggestChangesDialogue
isOpen={suggestChangesDialogDetails.isOpen}
onClose={onSuggestToggleClose}
featureName={featureId}
environment={suggestChangesDialogDetails?.environment}
onConfirm={() => {}}
/>
</div>
);
};

View File

@ -24,8 +24,8 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { Routes, Route, useLocation } from 'react-router-dom';
import { DeleteProjectDialogue } from './DeleteProject/DeleteProjectDialogue';
import { ProjectLog } from './ProjectLog/ProjectLog';
import { SuggestedChangeOverview } from './SuggestedChanges/SuggestedChangeOverview/SuggestedChangeOverview';
import { DraftBanner } from './SuggestedChanges/DraftBanner/DraftBanner';
import { SuggestedChangeOverview } from 'component/suggestChanges/SuggestedChangeOverview/SuggestedChangeOverview';
import { DraftBanner } from 'component/suggestChanges/DraftBanner/DraftBanner';
import { MainLayout } from 'component/layout/MainLayout/MainLayout';
const StyledDiv = styled('div')(() => ({

View File

@ -36,6 +36,8 @@ import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/Feat
import { useSearch } from 'hooks/useSearch';
import { useMediaQuery } from '@mui/material';
import { Search } from 'component/common/Search/Search';
import { useSuggestToggle } from 'hooks/useSuggestToggle';
import { SuggestChangesDialogue } from 'component/suggestChanges/SuggestChangeConfirmDialog/SuggestChangeConfirmDialog';
interface IProjectFeatureTogglesProps {
features: IProject['features'];
@ -99,6 +101,11 @@ export const ProjectFeatureToggles = ({
const { toggleFeatureEnvironmentOn, toggleFeatureEnvironmentOff } =
useFeatureApi();
const {
onSuggestToggle,
onSuggestToggleClose,
suggestChangesDialogDetails,
} = useSuggestToggle();
const onToggle = useCallback(
async (
@ -107,6 +114,13 @@ export const ProjectFeatureToggles = ({
environment: string,
enabled: boolean
) => {
if (
uiConfig?.flags?.suggestChanges &&
environment === 'production'
) {
onSuggestToggle(featureName, environment, enabled);
throw new Error('Additional approval required');
}
try {
if (enabled) {
await toggleFeatureEnvironmentOn(
@ -501,6 +515,13 @@ export const ProjectFeatureToggles = ({
}}
featureId={featureArchiveState || ''}
projectId={projectId}
/>{' '}
<SuggestChangesDialogue
isOpen={suggestChangesDialogDetails.isOpen}
onClose={onSuggestToggleClose}
featureName={suggestChangesDialogDetails?.featureName}
environment={suggestChangesDialogDetails?.environment}
onConfirm={() => {}}
/>
</PageContent>
);

View File

@ -1,20 +0,0 @@
import { VFC } from 'react';
import { ISuggestChange } from 'interfaces/suggestChangeset';
import { Box } from '@mui/system';
import { PlaygroundResultChip } from 'component/playground/Playground/PlaygroundResultsTable/PlaygroundResultChip/PlaygroundResultChip'; // FIXME: refactor - extract to common
export const ChangeItem: VFC<ISuggestChange> = ({ action, id, payload }) => {
if (action === 'updateEnabled') {
return (
<Box key={id}>
New status:{' '}
<PlaygroundResultChip
showIcon={false}
label={payload ? 'Enabled' : 'Disabled'}
enabled={Boolean(payload)}
/>
</Box>
);
}
return <Box key={id}>Change with ID: {id}</Box>;
};

View File

@ -1,83 +0,0 @@
import { VFC } from 'react';
import { Box, Typography, Card, styled } from '@mui/material';
import { ISuggestChange } from 'interfaces/suggestChangeset';
import EnvironmentIcon from 'component/common/EnvironmentIcon/EnvironmentIcon';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { PlaygroundResultChip } from 'component/playground/Playground/PlaygroundResultsTable/PlaygroundResultChip/PlaygroundResultChip'; // FIXME: refactor - extract to common
import { ChangeItem } from './ChangeItem/ChangeItem';
type ChangesetDiffProps = {
changes?: ISuggestChange[];
state: string;
};
const StyledHeader = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
[theme.breakpoints.down(560)]: {
flexDirection: 'column',
textAlign: 'center',
},
paddingBottom: theme.spacing(1),
}));
export const ChangesetDiff: VFC<ChangesetDiffProps> = ({ changes, state }) => (
<Box
sx={{
p: 3,
border: '2px solid',
borderColor: theme => theme.palette.playgroundBackground,
display: 'flex',
gap: 2,
flexDirection: 'column',
borderRadius: theme => `${theme.shape.borderRadiusExtraLarge}px`,
}}
>
<StyledHeader>
<EnvironmentIcon enabled={true} />
<Box>
<StringTruncator
text={`production`}
maxWidth="100"
maxLength={15}
/>
</Box>
<Box sx={{ ml: 'auto' }}>
<PlaygroundResultChip
showIcon={false}
label={state === 'CREATED' ? 'Draft mode' : '???'}
enabled="unknown"
/>
</Box>
</StyledHeader>
<Typography variant="body2" color="textSecondary">
You request changes for these feature toggles:
</Typography>
{/* TODO: group by feature name */}
{changes?.map(item => (
<Card
key={item.feature}
elevation={0}
sx={{
borderRadius: theme => `${theme.shape.borderRadius}px`,
overflow: 'hidden',
border: '1px solid',
borderColor: theme => theme.palette.dividerAlternative,
}}
>
<Box
sx={{
backgroundColor: theme =>
theme.palette.tableHeaderBackground,
p: 2,
}}
>
<Typography>{item.feature}</Typography>
</Box>
<Box sx={{ p: 2 }}>
<ChangeItem {...item} />
</Box>
</Card>
))}
</Box>
);

View File

@ -1,90 +0,0 @@
import { FC } from 'react';
import { Box, Paper } from '@mui/material';
import { SuggestedFeatureToggleChange } from '../SuggestedFeatureToggleChange/SuggestedFeatureToggleChange';
import { objectId } from '../../../../../../utils/objectId';
import { ConditionallyRender } from '../../../../../common/ConditionallyRender/ConditionallyRender';
import { ToggleStatusChange } from '../SuggestedFeatureToggleChange/ToggleStatusChange';
import {
StrategyAddedChange,
StrategyDeletedChange,
StrategyEditedChange,
} from '../SuggestedFeatureToggleChange/StrategyChange';
import {
formatStrategyName,
GetFeatureStrategyIcon,
} from '../../../../../../utils/strategyNames';
export const SuggestedChangeSet: FC<{ suggestedChange: any }> = ({
suggestedChange,
}) => {
return (
<Paper
elevation={0}
sx={theme => ({
marginTop: theme.spacing(2),
marginLeft: theme.spacing(2),
width: '70%',
padding: 2,
borderRadius: theme => `${theme.shape.borderRadiusLarge}px`,
})}
>
<Box
sx={theme => ({
padding: theme.spacing(2),
})}
>
Changes
{suggestedChange.changes?.map((featureToggleChange: any) => (
<SuggestedFeatureToggleChange
key={featureToggleChange.feature}
featureToggleName={featureToggleChange.feature}
>
{featureToggleChange.changeSet.map((change: any) => (
<Box key={objectId(change)}>
<ConditionallyRender
condition={
change.action === 'updateEnabled'
}
show={
<ToggleStatusChange
enabled={
change?.payload?.data?.data
}
/>
}
/>
<ConditionallyRender
condition={change.action === 'addStrategy'}
show={
<StrategyAddedChange>
<GetFeatureStrategyIcon
strategyName={
change.payload.name
}
/>
{formatStrategyName(
change.payload.name
)}
</StrategyAddedChange>
}
/>
<ConditionallyRender
condition={
change.action === 'deleteStrategy'
}
show={<StrategyDeletedChange />}
/>
<ConditionallyRender
condition={
change.action === 'updateStrategy'
}
show={<StrategyEditedChange />}
/>
</Box>
))}
</SuggestedFeatureToggleChange>
))}
</Box>
</Paper>
);
};

View File

@ -3,6 +3,7 @@ import { Box, Button, Typography } from '@mui/material';
import { useStyles as useAppStyles } from 'component/App.styles';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { SuggestedChangesSidebar } from '../SuggestedChangesSidebar/SuggestedChangesSidebar';
interface IDraftBannerProps {
environment?: string;
@ -10,7 +11,7 @@ interface IDraftBannerProps {
export const DraftBanner: VFC<IDraftBannerProps> = ({ environment }) => {
const { classes } = useAppStyles();
const [reviewChangesOpen, setReviewChangesOpen] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
return (
<Box
@ -49,7 +50,9 @@ export const DraftBanner: VFC<IDraftBannerProps> = ({ environment }) => {
</Typography>
<Button
variant="contained"
onClick={() => setReviewChangesOpen(true)}
onClick={() => {
setIsSidebarOpen(true);
}}
sx={{ ml: 'auto' }}
>
Review changes
@ -59,6 +62,12 @@ export const DraftBanner: VFC<IDraftBannerProps> = ({ environment }) => {
</Button>
</Box>
</Box>
<SuggestedChangesSidebar
open={isSidebarOpen}
onClose={() => {
setIsSidebarOpen(false);
}}
/>
</Box>
);
};

View File

@ -0,0 +1,58 @@
import { FC } from 'react';
import { Alert, Box, Typography } from '@mui/material';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
interface ISuggestChangesDialogueProps {
isOpen: boolean;
onConfirm: () => void;
onClose: () => void;
featureName?: string;
environment?: string;
enabled?: boolean;
}
export const SuggestChangesDialogue: FC<ISuggestChangesDialogueProps> = ({
isOpen,
onConfirm,
onClose,
enabled,
featureName,
environment,
}) => {
const { setToastData, setToastApiError } = useToast();
const onSuggestClick = async () => {
try {
alert('Suggesting changes');
onConfirm();
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
return (
<Dialogue
open={isOpen}
primaryButtonText="Add to draft"
secondaryButtonText="Cancel"
onClick={onSuggestClick}
onClose={onClose}
title="Suggest changes"
>
<Alert severity="info" sx={{ mb: 2 }}>
Suggest changes is enabled for {environment}. Your changes needs
to be approved before they will be live. All the changes you do
now will be added into a draft that you can submit for review.
</Alert>
<Typography variant="body2" color="text.secondary">
Suggested changes:
</Typography>
<Typography>
<strong>{enabled ? 'Disable' : 'Enable'}</strong> feature toggle{' '}
<strong>{featureName}</strong> in <strong>{environment}</strong>
</Typography>
</Dialogue>
);
};

View File

@ -1,7 +1,7 @@
import { FC } from 'react';
import { Avatar, Box, Card, Paper, Typography } from '@mui/material';
import { StyledTrueChip } from '../../../../../playground/Playground/PlaygroundResultsTable/PlaygroundResultChip/PlaygroundResultChip';
import { ReactComponent as ChangesAppliedIcon } from '../../../../../../assets/icons/merge.svg';
import { PlaygroundResultChip } from 'component/playground/Playground/PlaygroundResultsTable/PlaygroundResultChip/PlaygroundResultChip';
import { ReactComponent as ChangesAppliedIcon } from 'assets/icons/merge.svg';
import TimeAgo from 'react-timeago';
export const SuggestedChangeHeader: FC<{ suggestedChange: any }> = ({
@ -35,9 +35,10 @@ export const SuggestedChangeHeader: FC<{ suggestedChange: any }> = ({
#{suggestedChange.id}
</Typography>
</Typography>
<StyledTrueChip
icon={<ChangesAppliedIcon strokeWidth="0.25" />}
<PlaygroundResultChip
// icon={<ChangesAppliedIcon strokeWidth="0.25" />}
label="Changes applied"
enabled="unknown"
/>
</Box>
<Box sx={{ display: 'flex', verticalAlign: 'center', gap: 2 }}>

View File

@ -1,10 +1,10 @@
import { FC } from 'react';
import { Box } from '@mui/material';
import { Box, Paper } from '@mui/material';
import { useSuggestedChange } from 'hooks/api/getters/useSuggestChange/useSuggestedChange';
import { SuggestedChangeHeader } from './SuggestedChangeHeader/SuggestedChangeHeader';
import { SuggestedChangeTimeline } from './SuggestedChangeTimeline/SuggestedChangeTimeline';
import { SuggestedChangeReviewers } from './SuggestedChangeReviewers/SuggestedChangeReviewers';
import { SuggestedChangeSet } from './SuggestedChangeSet/SuggestedChangeSet';
import { SuggestedChangeset } from '../SuggestedChangeset/SuggestedChangeset';
export const SuggestedChangeOverview: FC = () => {
const { data: suggestedChange } = useSuggestedChange();
@ -23,8 +23,25 @@ export const SuggestedChangeOverview: FC = () => {
<SuggestedChangeTimeline />
<SuggestedChangeReviewers />
</Box>
<SuggestedChangeSet suggestedChange={suggestedChange} />
<Paper
elevation={0}
sx={theme => ({
marginTop: theme.spacing(2),
marginLeft: theme.spacing(2),
width: '70%',
padding: 2,
borderRadius: theme =>
`${theme.shape.borderRadiusLarge}px`,
})}
>
<Box
sx={theme => ({
padding: theme.spacing(2),
})}
>
<SuggestedChangeset suggestedChange={suggestedChange} />
</Box>
</Paper>
</Box>
</>
);

View File

@ -1,6 +1,6 @@
import { Box } from '@mui/material';
import { FC } from 'react';
import { PlaygroundResultChip } from '../../../../../playground/Playground/PlaygroundResultsTable/PlaygroundResultChip/PlaygroundResultChip';
import { PlaygroundResultChip } from 'component/playground/Playground/PlaygroundResultsTable/PlaygroundResultChip/PlaygroundResultChip';
export const ToggleStatusChange: FC<{ enabled: boolean }> = ({ enabled }) => {
return (

View File

@ -0,0 +1,135 @@
import React, { useState, VFC } from 'react';
import { Box, Button, Typography, styled, Tooltip } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { HelpOutline } from '@mui/icons-material';
import { useSuggestedChange } from 'hooks/api/getters/useSuggestChange/useSuggestedChange';
import { SuggestedChangeset } from '../SuggestedChangeset/SuggestedChangeset';
interface ISuggestedChangesSidebarProps {
open: boolean;
onClose: () => void;
}
const StyledPageContent = styled(PageContent)(({ theme }) => ({
height: '100vh',
overflow: 'auto',
padding: theme.spacing(7.5, 6),
[theme.breakpoints.down('md')]: {
padding: theme.spacing(4, 2),
},
'& .header': {
padding: theme.spacing(0, 0, 2, 0),
},
'& .body': {
padding: theme.spacing(3, 0, 0, 0),
},
borderRadius: `${theme.spacing(1.5, 0, 0, 1.5)} !important`,
}));
const StyledHelpOutline = styled(HelpOutline)(({ theme }) => ({
fontSize: theme.fontSizes.mainHeader,
marginLeft: '0.3rem',
color: theme.palette.grey[700],
}));
const StyledHeaderHint = styled('div')(({ theme }) => ({
color: theme.palette.text.secondary,
fontSize: theme.fontSizes.smallBody,
}));
export const SuggestedChangesSidebar: VFC<ISuggestedChangesSidebarProps> = ({
open,
onClose,
}) => {
const { data: suggestedChange } = useSuggestedChange();
const onReview = async () => {
console.log('approve');
};
const onDiscard = async () => {
console.log('discard');
};
const onApply = async () => {
try {
console.log('apply');
} catch (e) {
console.log(e);
}
};
return (
<SidebarModal open={open} onClose={onClose} label="Review changes">
<StyledPageContent
header={
<PageHeader
secondary
titleElement={
<>
Review your changes
<Tooltip
title="You can review your changes from this page.
Needs a text to explain the process."
arrow
>
<StyledHelpOutline />
</Tooltip>
<StyledHeaderHint>
Make sure you are sending the right changes
suggestions to be reviewed
</StyledHeaderHint>
</>
}
></PageHeader>
}
>
{/* TODO: multiple environments (changesets) */}
<Typography>{suggestedChange?.state}</Typography>
<br />
<SuggestedChangeset suggestedChange={suggestedChange} />
<Box sx={{ display: 'flex' }}>
<ConditionallyRender
condition={suggestedChange?.state === 'APPROVED'}
show={<Typography>Applied</Typography>}
/>
<ConditionallyRender
condition={suggestedChange?.state === 'CLOSED'}
show={<Typography>Applied</Typography>}
/>
<ConditionallyRender
condition={suggestedChange?.state === 'APPROVED'}
show={
<>
<Button
sx={{ mt: 2 }}
variant="contained"
onClick={onApply}
>
Apply changes
</Button>
</>
}
/>
<ConditionallyRender
condition={suggestedChange?.state === 'CREATED'}
show={
<>
<Button
sx={{ mt: 2, ml: 'auto' }}
variant="contained"
onClick={onReview}
>
Request changes
</Button>
<Button
sx={{ mt: 2, ml: 2 }}
variant="outlined"
onClick={onDiscard}
>
Discard changes
</Button>
</>
}
/>
</Box>
</StyledPageContent>
</SidebarModal>
);
};

View File

@ -0,0 +1,65 @@
import { FC } from 'react';
import { Box } from '@mui/material';
import { SuggestedFeatureToggleChange } from '../SuggestedChangeOverview/SuggestedFeatureToggleChange/SuggestedFeatureToggleChange';
import { objectId } from 'utils/objectId';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { ToggleStatusChange } from '../SuggestedChangeOverview/SuggestedFeatureToggleChange/ToggleStatusChange';
import {
StrategyAddedChange,
StrategyDeletedChange,
StrategyEditedChange,
} from '../SuggestedChangeOverview/SuggestedFeatureToggleChange/StrategyChange';
import {
formatStrategyName,
GetFeatureStrategyIcon,
} from 'utils/strategyNames';
export const SuggestedChangeset: FC<{ suggestedChange: any }> = ({
suggestedChange,
}) => {
return (
<Box>
Changes
{suggestedChange.changes?.map((featureToggleChange: any) => (
<SuggestedFeatureToggleChange
key={featureToggleChange.feature}
featureToggleName={featureToggleChange.feature}
>
{featureToggleChange.changeSet.map((change: any) => (
<Box key={objectId(change)}>
<ConditionallyRender
condition={change.action === 'updateEnabled'}
show={
<ToggleStatusChange
enabled={change?.payload?.data?.data}
/>
}
/>
<ConditionallyRender
condition={change.action === 'addStrategy'}
show={
<StrategyAddedChange>
<GetFeatureStrategyIcon
strategyName={change.payload.name}
/>
{formatStrategyName(
change.payload.name
)}
</StrategyAddedChange>
}
/>
<ConditionallyRender
condition={change.action === 'deleteStrategy'}
show={<StrategyDeletedChange />}
/>
<ConditionallyRender
condition={change.action === 'updateStrategy'}
show={<StrategyEditedChange />}
/>
</Box>
))}
</SuggestedFeatureToggleChange>
))}
</Box>
);
};

View File

@ -0,0 +1,33 @@
import { useCallback, useState } from 'react';
export const useSuggestToggle = () => {
const [suggestChangesDialogDetails, setSuggestChangesDialogDetails] =
useState<{
enabled?: boolean;
featureName?: string;
environment?: string;
isOpen: boolean;
}>({ isOpen: false });
const onSuggestToggle = useCallback(
(featureName: string, environment: string, enabled: boolean) => {
setSuggestChangesDialogDetails({
featureName,
environment,
enabled,
isOpen: true,
});
},
[]
);
const onSuggestToggleClose = useCallback(() => {
setSuggestChangesDialogDetails({ isOpen: false });
}, []);
return {
onSuggestToggle,
onSuggestToggleClose,
suggestChangesDialogDetails,
};
};