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:
parent
56e9af3434
commit
8270166286
@ -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
|
||||
|
@ -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 />} />
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>;
|
||||
};
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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',
|
||||
|
@ -1,6 +1,12 @@
|
||||
export interface ISuggestChangeset {
|
||||
id: number;
|
||||
state: string;
|
||||
state:
|
||||
| 'CREATED'
|
||||
| 'UPDATED'
|
||||
| 'SUBMITTED'
|
||||
| 'APPROVED'
|
||||
| 'REJECTED'
|
||||
| 'CLOSED';
|
||||
project: string;
|
||||
environment: string;
|
||||
createdBy?: string;
|
||||
|
@ -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": {
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user