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
>
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>
</div>
<FiberManualRecord

View File

@ -68,15 +68,6 @@ const Project = () => {
path: basePath,
name: 'overview',
},
...(uiConfig?.flags?.suggestChanges
? [
{
title: 'Suggested changes',
path: `${basePath}/changes`,
name: 'changes',
},
]
: []),
{
title: 'Health',
path: `${basePath}/health`,
@ -228,7 +219,6 @@ const Project = () => {
}}
/>
<Routes>
<Route path="changes" element={<SuggestedChanges />} />
<Route path="health" element={<ProjectHealth />} />
<Route path="access/*" element={<ProjectAccess />} />
<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 { Box, Paper, Typography, Card } from '@mui/material';
import { PlaygroundResultChip } from 'component/playground/Playground/PlaygroundResultsTable/PlaygroundResultChip/PlaygroundResultChip'; // FIXME: refactor - extract to common
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 = {
changeset?: ISuggestChange[];
changes?: ISuggestChange[];
state: string;
};
export const ChangesetDiff: VFC<ChangesetDiffProps> = ({
changeset: changeSet,
}) => (
<Paper
elevation={4}
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={{
border: '1px solid',
p: 2,
borderColor: theme => theme.palette.dividerAlternative,
p: 3,
border: '2px solid',
borderColor: theme => theme.palette.playgroundBackground,
display: 'flex',
gap: 2,
flexDirection: 'column',
borderRadius: theme => `${theme.shape.borderRadius}px`,
borderRadius: theme => `${theme.shape.borderRadiusExtraLarge}px`,
}}
>
<Typography variant="h3">Changes</Typography>
{/*// @ts-ignore FIXME: types */}
{changeSet?.map(item => (
<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}
@ -45,33 +75,9 @@ export const ChangesetDiff: VFC<ChangesetDiffProps> = ({
<Typography>{item.feature}</Typography>
</Box>
<Box sx={{ p: 2 }}>
{/*
// @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>
);
})}
<ChangeItem {...item} />
</Box>
</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 { useStyles as useAppStyles } from 'component/App.styles';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { EditGroupUsers } from '../../../../admin/groups/Group/EditGroupUsers/EditGroupUsers';
import { SuggestedChanges } from '../SuggestedChanges';
interface IDraftBannerProps {
environment?: string;
@ -10,6 +12,7 @@ interface IDraftBannerProps {
export const DraftBanner: VFC<IDraftBannerProps> = ({ environment }) => {
const { classes } = useAppStyles();
const [reviewChangesOpen, setReviewChangesOpen] = useState(false);
return (
<Box
@ -48,7 +51,7 @@ export const DraftBanner: VFC<IDraftBannerProps> = ({ environment }) => {
</Typography>
<Button
variant="contained"
onClick={() => {}}
onClick={() => setReviewChangesOpen(true)}
sx={{ ml: 'auto' }}
>
Review changes
@ -58,6 +61,10 @@ export const DraftBanner: VFC<IDraftBannerProps> = ({ environment }) => {
</Button>
</Box>
</Box>
<SuggestedChanges
open={reviewChangesOpen}
setOpen={setReviewChangesOpen}
/>
</Box>
);
};

View File

@ -1,4 +1,4 @@
import { useState, VFC } from 'react';
import React, { useState, VFC } from 'react';
import {
Box,
Paper,
@ -9,35 +9,61 @@ import {
FormControl,
FormControlLabel,
RadioGroup,
styled,
Tooltip,
} from '@mui/material';
import { useChangeRequest } from 'hooks/api/getters/useChangeRequest/useChangeRequest';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
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 [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
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 SuggestedChanges: VFC<ISuggestedChangesProps> = ({
open,
setOpen,
}) => {
const [selectedValue, setSelectedValue] = useState('');
const { data: changeRequest } = useChangeRequest();
const onClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
const onReview = async () => {
console.log('approve');
};
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');
} else if (selectedValue === 'requestChanges') {
console.log('requestChanges');
}
// show an error if no action was selected
const onDiscard = async () => {
console.log('discard');
};
const onApply = async () => {
@ -49,101 +75,89 @@ export const SuggestedChanges: VFC = () => {
};
return (
<Paper
elevation={0}
sx={{
p: 4,
borderRadius: theme => `${theme.shape.borderRadiusLarge}px`,
<SidebarModal
open={open}
onClose={() => {
setOpen(false);
}}
label="Review changes"
>
<Typography>{changeRequest?.state}</Typography>
Environment: {changeRequest?.environment}
<br />
{/* <ChangesHeader
author={changeRequest?.createdBy?.name}
avatar={changeRequest?.createdBy?.imageUrl}
createdAt={changeRequest?.createdAt}
/> */}
<br />
<ChangesetDiff changeset={changeRequest?.changes} />
<ConditionallyRender
condition={changeRequest?.state === 'APPLIED'}
show={<Typography>Applied</Typography>}
/>
<ConditionallyRender
condition={changeRequest?.state === 'APPROVED'}
show={
<>
<Button
sx={{ mt: 2 }}
variant="contained"
onClick={onApply}
>
Apply changes
</Button>
</>
}
/>
<ConditionallyRender
condition={changeRequest?.state === 'REVIEW'}
show={
<>
<Button
sx={{ mt: 2 }}
variant="contained"
onClick={onClick}
>
Review changes
</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
type="submit"
variant="contained"
color="primary"
<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
>
Submit
</Button>
</Box>
</Popover>
</>
<StyledHelpOutline />
</Tooltip>
<StyledHeaderHint>
Make sure you are sending the right changes
suggestions to be reviewed
</StyledHeaderHint>
</>
}
></PageHeader>
}
/>
</Paper>
>
{/* TODO: multiple environments (changesets) */}
<Typography>{changeRequest?.state}</Typography>
<br />
<ChangesetDiff
changes={changeRequest?.changes}
state={changeRequest?.state}
/>
<Box sx={{ display: 'flex' }}>
<ConditionallyRender
condition={changeRequest?.state === 'APPROVED'}
show={<Typography>Applied</Typography>}
/>
<ConditionallyRender
condition={changeRequest?.state === 'CLOSED'}
show={<Typography>Applied</Typography>}
/>
<ConditionallyRender
condition={changeRequest?.state === 'APPROVED'}
show={
<>
<Button
sx={{ mt: 2 }}
variant="contained"
onClick={onApply}
>
Apply changes
</Button>
</>
}
/>
<ConditionallyRender
condition={changeRequest?.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

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

View File

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

View File

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

View File

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