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

Feat/apply changes (#2258)

* feat: add suggested change component

* fix: build

* feat: suggestion header

* ui sketching different toggle changes

* feat: strategy change sets UI tweaks

* refactor: extract nested components

Co-authored-by: Fredrik Oseberg <fredrik.no@gmail.com>
This commit is contained in:
Mateusz Kwasniewski 2022-10-26 09:45:24 +02:00 committed by GitHub
parent 4aa1a34fef
commit 0dba973881
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 460 additions and 220 deletions

View File

@ -0,0 +1,3 @@
<svg width="18" height="14" viewBox="0 0 18 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.59 2.00016L2 0.590157L5.41 4.00016L4 5.41016L0.59 2.00016ZM13 11.5002L13 8.00016L7.41 8.00016L2 13.4102L0.59 12.0002L6.59 6.00016L13 6.00016L13 2.50016L17.5 7.00016L13 11.5002Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 319 B

View File

@ -43,6 +43,7 @@ export const StyledTrueChip = styled(StyledChip)(({ theme }) => ({
}, },
['& .MuiChip-icon']: { ['& .MuiChip-icon']: {
color: theme.palette.success.main, color: theme.palette.success.main,
marginRight: 0,
}, },
})); }));

View File

@ -24,7 +24,7 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { Routes, Route, useLocation } from 'react-router-dom'; import { Routes, Route, useLocation } from 'react-router-dom';
import { DeleteProjectDialogue } from './DeleteProject/DeleteProjectDialogue'; import { DeleteProjectDialogue } from './DeleteProject/DeleteProjectDialogue';
import { ProjectLog } from './ProjectLog/ProjectLog'; import { ProjectLog } from './ProjectLog/ProjectLog';
import { SuggestedChanges } from './SuggestedChanges/SuggestedChanges'; import { SuggestedChangeOverview } from './SuggestedChanges/SuggestedChangeOverview/SuggestedChangeOverview';
import { DraftBanner } from './SuggestedChanges/DraftBanner/DraftBanner'; import { DraftBanner } from './SuggestedChanges/DraftBanner/DraftBanner';
import { MainLayout } from 'component/layout/MainLayout/MainLayout'; import { MainLayout } from 'component/layout/MainLayout/MainLayout';
@ -224,6 +224,16 @@ const Project = () => {
<Route path="environments" element={<ProjectEnvironment />} /> <Route path="environments" element={<ProjectEnvironment />} />
<Route path="archive" element={<ProjectFeaturesArchive />} /> <Route path="archive" element={<ProjectFeaturesArchive />} />
<Route path="logs" element={<ProjectLog />} /> <Route path="logs" element={<ProjectLog />} />
<Route
path="suggest-changes/:id"
element={
<ConditionallyRender
condition={Boolean(uiConfig?.flags?.suggestChanges)}
show={<SuggestedChangeOverview />}
/>
}
/>
<Route path="*" element={<ProjectOverview />} /> <Route path="*" element={<ProjectOverview />} />
</Routes> </Routes>
</MainLayout> </MainLayout>

View File

@ -3,8 +3,6 @@ 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;
@ -61,10 +59,6 @@ export const DraftBanner: VFC<IDraftBannerProps> = ({ environment }) => {
</Button> </Button>
</Box> </Box>
</Box> </Box>
<SuggestedChanges
open={reviewChangesOpen}
setOpen={setReviewChangesOpen}
/>
</Box> </Box>
); );
}; };

View File

@ -0,0 +1,68 @@
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 TimeAgo from 'react-timeago';
export const SuggestedChangeHeader: FC<{ suggestedChange: any }> = ({
suggestedChange,
}) => {
return (
<Paper
elevation={0}
sx={theme => ({
p: theme.spacing(2, 4),
borderRadius: theme => `${theme.shape.borderRadiusLarge}px`,
})}
>
<Box
sx={theme => ({
display: 'flex',
alignItems: 'center',
gap: 2,
marginBottom: theme.spacing(2),
})}
>
<Typography
sx={{
display: 'flex',
alignItems: 'center',
}}
variant="h1"
>
Suggestion
<Typography variant="h1" component="p">
#{suggestedChange.id}
</Typography>
</Typography>
<StyledTrueChip
icon={<ChangesAppliedIcon strokeWidth="0.25" />}
label="Changes applied"
/>
</Box>
<Box sx={{ display: 'flex', verticalAlign: 'center', gap: 2 }}>
<Typography sx={{ margin: 'auto 0' }}>
Created{' '}
<TimeAgo date={new Date(suggestedChange.createdAt)} /> by
</Typography>
<Avatar src={suggestedChange?.createdBy?.avatar} />
<Card
variant="outlined"
sx={theme => ({
padding: 1,
backgroundColor: theme.palette.tertiary.light,
})}
>
Environment:{' '}
<Typography display="inline" fontWeight="bold">
{suggestedChange?.environment}
</Typography>{' '}
| Updates:{' '}
<Typography display="inline" fontWeight="bold">
{suggestedChange?.changes.length} feature toggles
</Typography>
</Card>
</Box>
</Paper>
);
};

View File

@ -0,0 +1,31 @@
import { FC } from 'react';
import { Box } 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';
export const SuggestedChangeOverview: FC = () => {
const { data: suggestedChange } = useSuggestedChange();
return (
<>
<SuggestedChangeHeader suggestedChange={suggestedChange} />
<Box sx={{ display: 'flex' }}>
<Box
sx={{
width: '30%',
display: 'flex',
flexDirection: 'column',
}}
>
<SuggestedChangeTimeline />
<SuggestedChangeReviewers />
</Box>
<SuggestedChangeSet suggestedChange={suggestedChange} />
</Box>
</>
);
};

View File

@ -0,0 +1,16 @@
import { Box, Paper } from '@mui/material';
export const SuggestedChangeReviewers = () => {
return (
<Paper
elevation={0}
sx={theme => ({
marginTop: theme.spacing(2),
padding: 2,
borderRadius: theme => `${theme.shape.borderRadiusLarge}px`,
})}
>
<Box sx={theme => ({ padding: theme.spacing(2) })}>Reviewers</Box>
</Paper>
);
};

View File

@ -0,0 +1,90 @@
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

@ -0,0 +1,52 @@
import { FC } from 'react';
import { Box, Paper } from '@mui/material';
import Timeline from '@mui/lab/Timeline';
import TimelineItem, { timelineItemClasses } from '@mui/lab/TimelineItem';
import TimelineSeparator from '@mui/lab/TimelineSeparator';
import TimelineDot from '@mui/lab/TimelineDot';
import TimelineConnector from '@mui/lab/TimelineConnector';
import TimelineContent from '@mui/lab/TimelineContent';
export const SuggestedChangeTimeline: FC = () => {
return (
<Paper
elevation={0}
sx={theme => ({
marginTop: theme.spacing(2),
borderRadius: theme => `${theme.shape.borderRadiusLarge}px`,
})}
>
<Box sx={theme => ({ padding: theme.spacing(2) })}>
<Timeline
sx={{
[`& .${timelineItemClasses.root}:before`]: {
flex: 0,
padding: 0,
},
}}
>
<TimelineItem>
<TimelineSeparator>
<TimelineDot color="success" />
<TimelineConnector color="success" />
</TimelineSeparator>
<TimelineContent>Draft</TimelineContent>
</TimelineItem>
<TimelineItem>
<TimelineSeparator>
<TimelineDot color="success" />
<TimelineConnector />
</TimelineSeparator>
<TimelineContent>Approved</TimelineContent>
</TimelineItem>
<TimelineItem>
<TimelineSeparator>
<TimelineDot />
</TimelineSeparator>
<TimelineContent>Applied</TimelineContent>
</TimelineItem>
</Timeline>
</Box>
</Paper>
);
};

View File

@ -0,0 +1,27 @@
import { Box, Typography } from '@mui/material';
import { FC } from 'react';
export const StrategyAddedChange: FC = ({ children }) => {
return (
<Box sx={{ p: 1, display: 'flex', gap: 1 }}>
<Typography sx={theme => ({ color: theme.palette.success.main })}>
+ Strategy Added:
</Typography>
{children}
</Box>
);
};
export const StrategyEditedChange: FC = () => {
return <Box sx={{ p: 1 }}>Strategy Edited</Box>;
};
export const StrategyDeletedChange: FC = () => {
return (
<Box sx={{ p: 1 }}>
<Typography sx={theme => ({ color: theme.palette.error.main })}>
- Strategy Deleted
</Typography>
</Box>
);
};

View File

@ -0,0 +1,37 @@
import { FC } from 'react';
import { Box, Card, Typography } from '@mui/material';
import ToggleOnIcon from '@mui/icons-material/ToggleOn';
interface ISuggestedFeatureToggleChange {
featureToggleName: string;
}
export const SuggestedFeatureToggleChange: FC<
ISuggestedFeatureToggleChange
> = ({ featureToggleName, children }) => {
return (
<Card
elevation={0}
sx={theme => ({
marginTop: theme.spacing(2),
borderRadius: theme => `${theme.shape.borderRadiusLarge}px`,
overflow: 'hidden',
border: '1px solid',
borderColor: theme => theme.palette.dividerAlternative,
})}
>
<Box
sx={theme => ({
backgroundColor: theme.palette.tableHeaderBackground,
p: 2,
})}
>
<Box sx={{ display: 'flex', gap: 1 }}>
<ToggleOnIcon color="disabled" />
<Typography color="primary">{featureToggleName}</Typography>
</Box>
</Box>
<Box sx={{ p: 2 }}>{children}</Box>
</Card>
);
};

View File

@ -0,0 +1,16 @@
import { Box } from '@mui/material';
import { FC } from 'react';
import { PlaygroundResultChip } from '../../../../../playground/Playground/PlaygroundResultsTable/PlaygroundResultChip/PlaygroundResultChip';
export const ToggleStatusChange: FC<{ enabled: boolean }> = ({ enabled }) => {
return (
<Box sx={{ p: 1 }}>
New status:{' '}
<PlaygroundResultChip
showIcon={false}
label={enabled ? ' Enabled' : 'Disabled'}
enabled={enabled}
/>
</Box>
);
};

View File

@ -1,163 +0,0 @@
import React, { useState, VFC } from 'react';
import {
Box,
Paper,
Button,
Typography,
Popover,
Radio,
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 { 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>>;
}
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 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={() => {
setOpen(false);
}}
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>
<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

@ -1,50 +0,0 @@
// import useSWR from 'swr';
// import { formatApiPath } from 'utils/formatPath';
import { ISuggestChangeset } from 'interfaces/suggestChangeset';
import handleErrorResponses from '../httpErrorResponseHandler';
// FIXME: mock
const data: ISuggestChangeset = {
id: 123,
environment: 'production',
state: 'CREATED',
createdAt: new Date('2021-03-01T12:00:00.000Z'),
project: 'default',
createdBy: '123412341',
changes: [
{
id: 1,
feature: 'feature1',
action: 'updateEnabled',
payload: true,
createdAt: new Date('2021-03-01T12:00:00.000Z'),
},
{
id: 2,
feature: 'feature2',
action: 'updateEnabled',
payload: false,
createdAt: new Date('2022-09-30T16:34:00.000Z'),
},
],
};
export const useChangeRequest = () => {
// const { data, error, mutate } = useSWR(
// formatApiPath(`api/admin/suggest-changes/${id}`),
// fetcher
// );
return {
data,
// loading: !error && !data,
// refetchChangeRequest: () => mutate(),
// error,
};
};
const fetcher = (path: string) => {
return fetch(path)
.then(handleErrorResponses('Request changes'))
.then(res => res.json());
};

View File

@ -0,0 +1,101 @@
// import useSWR from 'swr';
// import { formatApiPath } from 'utils/formatPath';
import { ISuggestChangeset } from 'interfaces/suggestChangeset';
import handleErrorResponses from '../httpErrorResponseHandler';
// FIXME: mock
const data: any = {
id: '12',
environment: 'production',
state: 'DRAFT',
project: 'default',
createdBy: {
email: 'mateusz@getunleash.ai',
avatar: 'https://gravatar-uri.com/1321',
},
createdAt: '2020-10-20T12:00:00.000Z',
changes: [
{
feature: 'my-feature-toggle',
changeSet: [
{
id: 'f79d399f-cb38-4982-b9b6-4141sdsdaad',
action: 'updateEnabled',
payload: { data: { data: true } },
},
{
id: 'f79d399f-cb38-4982-b9b6-4141sdsdaad',
action: 'addStrategy',
payload: {
name: 'flexibleRollout',
constraints: [],
parameters: {
rollout: '50',
stickiness: 'default',
groupId: 'suggest-changes',
},
},
},
{
id: 'f79d399f-cb38-4982-b9b6-4141sdsdaad',
action: 'updateStrategy',
payload: {
data: {},
},
},
{
id: 'f79d399f-cb38-4982-b9b6-4141sdsdaad',
action: 'deleteStrategy',
payload: {
data: {},
},
},
],
},
{
feature: 'new-feature-toggle',
changeSet: [
{
id: 'f79d399f-cb38-4982-b9b6-4141sdsdaad',
action: 'updateEnabled',
payload: {
data: { data: false },
strategyId: '123-14',
},
},
],
},
{
feature: 'add-strategy-feature-toggle',
changeSet: [
{
id: 'f79d399f-cb38-4982-b9b6-4141sdsdaad',
action: 'addStrategy',
payload: {
data: {},
},
},
],
},
],
};
export const useSuggestedChange = () => {
// const { data, error, mutate } = useSWR(
// formatApiPath(`api/admin/suggest-changes/${id}`),
// fetcher
// );
return {
data,
// loading: !error && !data,
// refetchChangeRequest: () => mutate(),
// error,
};
};
const fetcher = (path: string) => {
return fetch(path)
.then(handleErrorResponses('Request changes'))
.then(res => res.json());
};

View File

@ -36,6 +36,13 @@ export const getFeatureStrategyIcon = (strategyName: string): ElementType => {
} }
}; };
export const GetFeatureStrategyIcon: FC<{ strategyName: string }> = ({
strategyName,
}) => {
const Icon = getFeatureStrategyIcon(strategyName);
return <Icon />;
};
export const formattedStrategyNames: Record<string, string> = { export const formattedStrategyNames: Record<string, string> = {
applicationHostname: 'Hosts', applicationHostname: 'Hosts',
default: 'Standard', default: 'Standard',