1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-23 00:22:19 +01:00

Review your changes - approval flow (#2215)

* Initial changes

* Fix

* continue styling changes review draft

* fix: remove unused import

* update flags snapshot

Co-authored-by: sjaanus <sellinjaanus@gmail.com>
Co-authored-by: Tymoteusz Czech <tymek+gpg@getunleash.ai>
Co-authored-by: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com>
This commit is contained in:
sellinjaanus 2022-10-24 19:15:22 +03:00 committed by GitHub
parent 56e9af3434
commit 8270166286
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 219 additions and 205 deletions

View File

@ -51,7 +51,8 @@ const FeatureOverviewEnvironmentMetrics = ({
data-loading data-loading
> >
The feature has been requested <b>0 times</b> and The feature has been requested <b>0 times</b> and
exposed<b> 0 times</b> in the last hour exposed
<b> 0 times</b> in the last hour
</p> </p>
</div> </div>
<FiberManualRecord <FiberManualRecord

View File

@ -68,15 +68,6 @@ const Project = () => {
path: basePath, path: basePath,
name: 'overview', name: 'overview',
}, },
...(uiConfig?.flags?.suggestChanges
? [
{
title: 'Suggested changes',
path: `${basePath}/changes`,
name: 'changes',
},
]
: []),
{ {
title: 'Health', title: 'Health',
path: `${basePath}/health`, path: `${basePath}/health`,
@ -228,7 +219,6 @@ const Project = () => {
}} }}
/> />
<Routes> <Routes>
<Route path="changes" element={<SuggestedChanges />} />
<Route path="health" element={<ProjectHealth />} /> <Route path="health" element={<ProjectHealth />} />
<Route path="access/*" element={<ProjectAccess />} /> <Route path="access/*" element={<ProjectAccess />} />
<Route path="environments" element={<ProjectEnvironment />} /> <Route path="environments" element={<ProjectEnvironment />} />

View File

@ -1,37 +0,0 @@
import { VFC } from 'react';
import { Box } from '@mui/material';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { formatDateYMDHMS } from 'utils/formatDate';
import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
interface IChangesHeaderProps {
author?: string;
avatar?: string;
createdAt?: string;
}
export const ChangesHeader: VFC<IChangesHeaderProps> = ({
author,
avatar,
createdAt,
}) => {
const { locationSettings } = useLocationSettings();
return (
<Box>
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
data-loading
>
<div>Suggestion by </div>
<div>
<UserAvatar src={avatar} />
</div>
<div>{author}</div>
<div>
Submitted at:{' '}
{formatDateYMDHMS(createdAt || 0, locationSettings.locale)}
</div>
</Box>
</Box>
);
};

View File

@ -0,0 +1,20 @@
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,30 +1,60 @@
import { VFC } from 'react'; import { VFC } from 'react';
import { Box, Paper, Typography, Card } from '@mui/material'; import { Box, Typography, Card, styled } from '@mui/material';
import { PlaygroundResultChip } from 'component/playground/Playground/PlaygroundResultsTable/PlaygroundResultChip/PlaygroundResultChip'; // FIXME: refactor - extract to common
import { ISuggestChange } from 'interfaces/suggestChangeset'; 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 = { type ChangesetDiffProps = {
changeset?: ISuggestChange[]; changes?: ISuggestChange[];
state: string;
}; };
export const ChangesetDiff: VFC<ChangesetDiffProps> = ({ const StyledHeader = styled('div')(({ theme }) => ({
changeset: changeSet, display: 'flex',
}) => ( alignItems: 'center',
<Paper [theme.breakpoints.down(560)]: {
elevation={4} flexDirection: 'column',
textAlign: 'center',
},
paddingBottom: theme.spacing(1),
}));
export const ChangesetDiff: VFC<ChangesetDiffProps> = ({ changes, state }) => (
<Box
sx={{ sx={{
border: '1px solid', p: 3,
p: 2, border: '2px solid',
borderColor: theme => theme.palette.dividerAlternative, borderColor: theme => theme.palette.playgroundBackground,
display: 'flex', display: 'flex',
gap: 2, gap: 2,
flexDirection: 'column', flexDirection: 'column',
borderRadius: theme => `${theme.shape.borderRadius}px`, borderRadius: theme => `${theme.shape.borderRadiusExtraLarge}px`,
}} }}
> >
<Typography variant="h3">Changes</Typography> <StyledHeader>
{/*// @ts-ignore FIXME: types */} <EnvironmentIcon enabled={true} />
{changeSet?.map(item => ( <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 <Card
key={item.feature} key={item.feature}
elevation={0} elevation={0}
@ -45,33 +75,9 @@ export const ChangesetDiff: VFC<ChangesetDiffProps> = ({
<Typography>{item.feature}</Typography> <Typography>{item.feature}</Typography>
</Box> </Box>
<Box sx={{ p: 2 }}> <Box sx={{ p: 2 }}>
{/* <ChangeItem {...item} />
// @ts-ignore FIXME: types */}
{item?.changes?.map(change => {
if (change?.action === 'updateEnabled') {
return (
<Box key={change?.id}>
New status:{' '}
<PlaygroundResultChip
showIcon={false}
label={
change?.payload
? 'Enabled'
: 'Disabled'
}
enabled={change?.payload}
/>
</Box>
);
}
return (
<Box key={change.id}>
Change with ID: {change.id}
</Box>
);
})}
</Box> </Box>
</Card> </Card>
))} ))}
</Paper> </Box>
); );

View File

@ -1,8 +1,10 @@
import { VFC } from 'react'; import { useState, VFC } from 'react';
import { Box, Button, Typography } from '@mui/material'; import { Box, Button, Typography } from '@mui/material';
import { useStyles as useAppStyles } from 'component/App.styles'; import { useStyles as useAppStyles } from 'component/App.styles';
import WarningAmberIcon from '@mui/icons-material/WarningAmber'; import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { EditGroupUsers } from '../../../../admin/groups/Group/EditGroupUsers/EditGroupUsers';
import { SuggestedChanges } from '../SuggestedChanges';
interface IDraftBannerProps { interface IDraftBannerProps {
environment?: string; environment?: string;
@ -10,6 +12,7 @@ interface IDraftBannerProps {
export const DraftBanner: VFC<IDraftBannerProps> = ({ environment }) => { export const DraftBanner: VFC<IDraftBannerProps> = ({ environment }) => {
const { classes } = useAppStyles(); const { classes } = useAppStyles();
const [reviewChangesOpen, setReviewChangesOpen] = useState(false);
return ( return (
<Box <Box
@ -48,7 +51,7 @@ export const DraftBanner: VFC<IDraftBannerProps> = ({ environment }) => {
</Typography> </Typography>
<Button <Button
variant="contained" variant="contained"
onClick={() => {}} onClick={() => setReviewChangesOpen(true)}
sx={{ ml: 'auto' }} sx={{ ml: 'auto' }}
> >
Review changes Review changes
@ -58,6 +61,10 @@ export const DraftBanner: VFC<IDraftBannerProps> = ({ environment }) => {
</Button> </Button>
</Box> </Box>
</Box> </Box>
<SuggestedChanges
open={reviewChangesOpen}
setOpen={setReviewChangesOpen}
/>
</Box> </Box>
); );
}; };

View File

@ -1,4 +1,4 @@
import { useState, VFC } from 'react'; import React, { useState, VFC } from 'react';
import { import {
Box, Box,
Paper, Paper,
@ -9,35 +9,61 @@ import {
FormControl, FormControl,
FormControlLabel, FormControlLabel,
RadioGroup, RadioGroup,
styled,
Tooltip,
} from '@mui/material'; } from '@mui/material';
import { useChangeRequest } from 'hooks/api/getters/useChangeRequest/useChangeRequest'; import { useChangeRequest } from 'hooks/api/getters/useChangeRequest/useChangeRequest';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { ChangesetDiff } from './ChangesetDiff/ChangesetDiff'; import { ChangesetDiff } from './ChangesetDiff/ChangesetDiff';
import { ChangesHeader } from './ChangesHeader/ChangesHeader'; import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import { PageContent } from '../../../common/PageContent/PageContent';
import { PageHeader } from '../../../common/PageHeader/PageHeader';
import { HelpOutline } from '@mui/icons-material';
interface ISuggestedChangesProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
export const SuggestedChanges: VFC = () => { const StyledPageContent = styled(PageContent)(({ theme }) => ({
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null); 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 SuggestedChanges: VFC<ISuggestedChangesProps> = ({
open,
setOpen,
}) => {
const [selectedValue, setSelectedValue] = useState(''); const [selectedValue, setSelectedValue] = useState('');
const { data: changeRequest } = useChangeRequest(); const { data: changeRequest } = useChangeRequest();
const onClick = (event: React.MouseEvent<HTMLButtonElement>) => { const onReview = async () => {
setAnchorEl(event.currentTarget);
};
const onClose = () => setAnchorEl(null);
const onRadioChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSelectedValue((event.target as HTMLInputElement).value);
};
const onSubmit = async (e: any) => {
e.preventDefault();
if (selectedValue === 'approve') {
console.log('approve'); console.log('approve');
} else if (selectedValue === 'requestChanges') { };
console.log('requestChanges');
} const onDiscard = async () => {
// show an error if no action was selected console.log('discard');
}; };
const onApply = async () => { const onApply = async () => {
@ -49,25 +75,50 @@ export const SuggestedChanges: VFC = () => {
}; };
return ( return (
<Paper <SidebarModal
elevation={0} open={open}
sx={{ onClose={() => {
p: 4, setOpen(false);
borderRadius: theme => `${theme.shape.borderRadiusLarge}px`,
}} }}
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>{changeRequest?.state}</Typography> <Typography>{changeRequest?.state}</Typography>
Environment: {changeRequest?.environment}
<br /> <br />
{/* <ChangesHeader <ChangesetDiff
author={changeRequest?.createdBy?.name} changes={changeRequest?.changes}
avatar={changeRequest?.createdBy?.imageUrl} state={changeRequest?.state}
createdAt={changeRequest?.createdAt} />
/> */} <Box sx={{ display: 'flex' }}>
<br />
<ChangesetDiff changeset={changeRequest?.changes} />
<ConditionallyRender <ConditionallyRender
condition={changeRequest?.state === 'APPLIED'} condition={changeRequest?.state === 'APPROVED'}
show={<Typography>Applied</Typography>}
/>
<ConditionallyRender
condition={changeRequest?.state === 'CLOSED'}
show={<Typography>Applied</Typography>} show={<Typography>Applied</Typography>}
/> />
<ConditionallyRender <ConditionallyRender
@ -85,65 +136,28 @@ export const SuggestedChanges: VFC = () => {
} }
/> />
<ConditionallyRender <ConditionallyRender
condition={changeRequest?.state === 'REVIEW'} condition={changeRequest?.state === 'CREATED'}
show={ show={
<> <>
<Button <Button
sx={{ mt: 2 }} sx={{ mt: 2, ml: 'auto' }}
variant="contained" variant="contained"
onClick={onClick} onClick={onReview}
> >
Review changes Request changes
</Button> </Button>
<Popover
id={'review-popover'}
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={onClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
>
<Box
component="form"
onSubmit={onSubmit}
sx={{
padding: '1rem 2rem',
display: 'flex',
flexDirection: 'column',
}}
>
<FormControl>
<RadioGroup
value={selectedValue}
onChange={onRadioChange}
name="review-actions-radio"
>
<FormControlLabel
value="approve"
control={<Radio />}
label="Approve"
/>
<FormControlLabel
value="requestChanges"
control={<Radio />}
label="Request changes"
/>
</RadioGroup>
</FormControl>
<Button <Button
type="submit" sx={{ mt: 2, ml: 2 }}
variant="contained" variant="outlined"
color="primary" onClick={onDiscard}
> >
Submit Discard changes
</Button> </Button>
</Box>
</Popover>
</> </>
} }
/> />
</Paper> </Box>
</StyledPageContent>
</SidebarModal>
); );
}; };

View File

@ -7,7 +7,7 @@ import handleErrorResponses from '../httpErrorResponseHandler';
const data: ISuggestChangeset = { const data: ISuggestChangeset = {
id: 123, id: 123,
environment: 'production', environment: 'production',
state: 'REVIEW', state: 'CREATED',
createdAt: new Date('2021-03-01T12:00:00.000Z'), createdAt: new Date('2021-03-01T12:00:00.000Z'),
project: 'default', project: 'default',
createdBy: '123412341', createdBy: '123412341',

View File

@ -1,6 +1,12 @@
export interface ISuggestChangeset { export interface ISuggestChangeset {
id: number; id: number;
state: string; state:
| 'CREATED'
| 'UPDATED'
| 'SUBMITTED'
| 'APPROVED'
| 'REJECTED'
| 'CLOSED';
project: string; project: string;
environment: string; environment: string;
createdBy?: string; createdBy?: string;

View File

@ -75,6 +75,7 @@ exports[`should create default config 1`] = `
"personalAccessTokens": false, "personalAccessTokens": false,
"publicSignup": false, "publicSignup": false,
"responseTimeWithAppName": false, "responseTimeWithAppName": false,
"suggestChanges": false,
"syncSSOGroups": false, "syncSSOGroups": false,
}, },
}, },
@ -89,6 +90,7 @@ exports[`should create default config 1`] = `
"personalAccessTokens": false, "personalAccessTokens": false,
"publicSignup": false, "publicSignup": false,
"responseTimeWithAppName": false, "responseTimeWithAppName": false,
"suggestChanges": false,
"syncSSOGroups": false, "syncSSOGroups": false,
}, },
"externalResolver": { "externalResolver": {

View File

@ -18,6 +18,10 @@ export const defaultExperimentalOptions = {
process.env.UNLEASH_EXPERIMENTAL_SYNC_SSO_GROUPS, process.env.UNLEASH_EXPERIMENTAL_SYNC_SSO_GROUPS,
false, false,
), ),
suggestChanges: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_SUGGEST_CHANGES,
false,
),
embedProxyFrontend: parseEnvVarBoolean( embedProxyFrontend: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_EMBED_PROXY_FRONTEND, process.env.UNLEASH_EXPERIMENTAL_EMBED_PROXY_FRONTEND,
false, false,
@ -52,6 +56,7 @@ export interface IExperimentalOptions {
anonymiseEventLog?: boolean; anonymiseEventLog?: boolean;
personalAccessTokens?: boolean; personalAccessTokens?: boolean;
syncSSOGroups?: boolean; syncSSOGroups?: boolean;
suggestChanges?: boolean;
cloneEnvironment?: boolean; cloneEnvironment?: boolean;
}; };
externalResolver: IExternalFlagResolver; externalResolver: IExternalFlagResolver;