1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

Suggest changes - initial frontend (#2213)

* feat: add initial controller

* feat: add fe

* feat: return status codes

* remove backend experiment

* refactor standalone route for project banner

* update suggest changeset type

* refactor changeset mock

* suggest changes banner feature flag

* fix: update routes snapshot

Co-authored-by: Fredrik Oseberg <fredrik.no@gmail.com>
This commit is contained in:
Tymoteusz Czech 2022-10-20 14:00:48 +02:00 committed by GitHub
parent 726674ea3e
commit b8c3833ae4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 557 additions and 101 deletions

View File

@ -18,10 +18,12 @@ export const useStyles = makeStyles()(theme => ({
flex: 1,
width: '100%',
backgroundColor: theme.palette.contentWrapper,
position: 'relative',
},
content: {
width: '1250px',
margin: '16px auto',
marginLeft: 'auto',
marginRight: 'auto',
[theme.breakpoints.down('lg')]: {
width: '1024px',
},

View File

@ -40,36 +40,41 @@ export const App = () => {
elseShow={
<div className={styles.container}>
<ToastRenderer />
<LayoutPicker>
<Routes>
{availableRoutes.map(route => (
<Route
key={route.path}
path={route.path}
element={
<Routes>
{availableRoutes.map(route => (
<Route
key={route.path}
path={route.path}
element={
<LayoutPicker
isStandalone={
route.isStandalone ===
true
}
>
<ProtectedRoute
route={route}
/>
}
/>
))}
<Route
path="/"
element={
<Navigate
to="/features"
replace
/>
</LayoutPicker>
}
/>
<Route
path="*"
element={<NotFound />}
/>
</Routes>
<FeedbackNPS openUrl="http://feedback.unleash.run" />
<SplashPageRedirect />
</LayoutPicker>
))}
<Route
path="/"
element={
<Navigate
to="/features"
replace
/>
}
/>
<Route
path="*"
element={<NotFound />}
/>
</Routes>
<FeedbackNPS openUrl="http://feedback.unleash.run" />
<SplashPageRedirect />
</div>
}
/>

View File

@ -2,7 +2,6 @@ import { Typography } from '@mui/material';
import { IFeatureStrategyParameters } from 'interfaces/strategy';
import RolloutSlider from '../RolloutSlider/RolloutSlider';
import Select from 'component/common/select';
import React from 'react';
import Input from 'component/common/Input/Input';
import {
FLEXIBLE_STRATEGY_GROUP_ID,

View File

@ -1,37 +1,19 @@
import { FC, ReactNode } from 'react';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { matchPath } from 'react-router';
import { useLocation } from 'react-router-dom';
import { MainLayout } from '../MainLayout/MainLayout';
import { ReactNode } from 'react';
interface ILayoutPickerProps {
children: ReactNode;
isStandalone?: boolean;
}
export const LayoutPicker = ({ children }: ILayoutPickerProps) => {
const { pathname } = useLocation();
return (
<ConditionallyRender
condition={isStandalonePage(pathname)}
show={children}
elseShow={<MainLayout>{children}</MainLayout>}
/>
);
};
const isStandalonePage = (pathname: string): boolean => {
return standalonePagePatterns.some(pattern => {
return matchPath(pattern, pathname);
});
};
const standalonePagePatterns = [
'/login',
'/new-user',
'/reset-password',
'/reset-password-success',
'/forgotten-password',
'/splash/:splashId',
'/404',
];
export const LayoutPicker: FC<ILayoutPickerProps> = ({
isStandalone,
children,
}) => (
<ConditionallyRender
condition={isStandalone === true}
show={children}
elseShow={<MainLayout>{children}</MainLayout>}
/>
);

View File

@ -1,4 +1,4 @@
import React, { ReactNode } from 'react';
import React, { forwardRef, ReactNode } from 'react';
import classnames from 'classnames';
import { makeStyles } from 'tss-react/mui';
import { Grid } from '@mui/material';
@ -30,47 +30,58 @@ const useStyles = makeStyles()(theme => ({
interface IMainLayoutProps {
children: ReactNode;
subheader?: ReactNode;
}
export const MainLayout = ({ children }: IMainLayoutProps) => {
const { classes } = useStyles();
const { classes: styles } = useAppStyles();
const { uiConfig } = useUiConfig();
export const MainLayout = forwardRef<HTMLDivElement, IMainLayoutProps>(
({ children, subheader }, ref) => {
const { classes } = useStyles();
const { classes: styles } = useAppStyles();
const { uiConfig } = useUiConfig();
return (
<>
<SkipNavLink />
<Header />
<SkipNavTarget />
<Grid container className={classes.container}>
<main className={classnames(styles.contentWrapper)}>
<Grid item className={styles.content} xs={12} sm={12}>
<div
className={classes.contentContainer}
style={{ zIndex: 200 }}
return (
<>
<SkipNavLink />
<Header />
<SkipNavTarget />
<Grid container className={classes.container}>
<main className={classnames(styles.contentWrapper)}>
{subheader}
<Grid
item
className={styles.content}
xs={12}
sm={12}
my={2}
>
<BreadcrumbNav />
<Proclamation toast={uiConfig.toast} />
{children}
</div>
</Grid>
<img
src={formatAssetPath(textureImage)}
alt=""
style={{
display: 'block',
position: 'fixed',
zIndex: 0,
bottom: 0,
right: 0,
width: 400,
pointerEvents: 'none',
userSelect: 'none',
}}
/>
</main>
<Footer />
</Grid>
</>
);
};
<div
className={classes.contentContainer}
style={{ zIndex: 200 }}
ref={ref}
>
<BreadcrumbNav />
<Proclamation toast={uiConfig.toast} />
{children}
</div>
</Grid>
<img
src={formatAssetPath(textureImage)}
alt=""
style={{
display: 'block',
position: 'fixed',
zIndex: 0,
bottom: 0,
right: 0,
width: 400,
pointerEvents: 'none',
userSelect: 'none',
}}
/>
</main>
<Footer />
</Grid>
</>
);
}
);

View File

@ -4,6 +4,7 @@ exports[`returns all baseRoutes 1`] = `
[
{
"component": [Function],
"isStandalone": true,
"menu": {},
"path": "/splash/:splashId",
"title": "Unleash",
@ -78,6 +79,7 @@ exports[`returns all baseRoutes 1`] = `
{
"component": [Function],
"flag": "P",
"isStandalone": true,
"menu": {},
"parent": "/projects",
"path": "/projects/:projectId/*",

View File

@ -68,6 +68,7 @@ export const routes: IRoute[] = [
component: SplashPage,
type: 'protected',
menu: {},
isStandalone: true,
},
// Project
@ -145,6 +146,7 @@ export const routes: IRoute[] = [
flag: P,
type: 'protected',
menu: {},
isStandalone: true,
},
{
path: '/projects',
@ -546,6 +548,7 @@ export const routes: IRoute[] = [
type: 'unprotected',
hidden: true,
menu: {},
isStandalone: true,
},
/* If you update this route path, make sure you update the path in SWRProvider.tsx */
{
@ -555,6 +558,7 @@ export const routes: IRoute[] = [
component: NewUser,
type: 'unprotected',
menu: {},
isStandalone: true,
},
/* If you update this route path, make sure you update the path in SWRProvider.tsx */
{
@ -564,6 +568,7 @@ export const routes: IRoute[] = [
component: ResetPassword,
type: 'unprotected',
menu: {},
isStandalone: true,
},
/* If you update this route path, make sure you update the path in SWRProvider.tsx */
{
@ -573,6 +578,7 @@ export const routes: IRoute[] = [
component: ForgottenPassword,
type: 'unprotected',
menu: {},
isStandalone: true,
},
];

View File

@ -24,6 +24,9 @@ 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 { SuggestedChanges } from './SuggestedChanges/SuggestedChanges';
import { DraftBanner } from './SuggestedChanges/DraftBanner/DraftBanner';
import { MainLayout } from 'component/layout/MainLayout/MainLayout';
const StyledDiv = styled('div')(() => ({
display: 'flex',
@ -53,7 +56,7 @@ const Project = () => {
const { classes: styles } = useStyles();
const navigate = useNavigate();
const { pathname } = useLocation();
const { isOss } = useUiConfig();
const { isOss, uiConfig } = useUiConfig();
const basePath = `/projects/${projectId}`;
const projectName = project?.name || projectId;
@ -65,6 +68,15 @@ const Project = () => {
path: basePath,
name: 'overview',
},
...(uiConfig?.flags?.suggestChanges
? [
{
title: 'Suggested changes',
path: `${basePath}/changes`,
name: 'changes',
},
]
: []),
{
title: 'Health',
path: `${basePath}/health`,
@ -112,7 +124,10 @@ const Project = () => {
}, []);
return (
<div ref={ref}>
<MainLayout
ref={ref}
subheader={uiConfig?.flags?.suggestChanges ? <DraftBanner /> : null}
>
<div className={styles.header}>
<div className={styles.innerContainer}>
<h2 className={styles.title}>
@ -213,6 +228,7 @@ const Project = () => {
}}
/>
<Routes>
<Route path="changes" element={<SuggestedChanges />} />
<Route path="health" element={<ProjectHealth />} />
<Route path="access/*" element={<ProjectAccess />} />
<Route path="environments" element={<ProjectEnvironment />} />
@ -220,7 +236,7 @@ const Project = () => {
<Route path="logs" element={<ProjectLog />} />
<Route path="*" element={<ProjectOverview />} />
</Routes>
</div>
</MainLayout>
);
};

View File

@ -0,0 +1,37 @@
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,77 @@
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 { ISuggestChange } from 'interfaces/suggestChangeset';
type ChangesetDiffProps = {
changeset?: ISuggestChange[];
};
export const ChangesetDiff: VFC<ChangesetDiffProps> = ({
changeset: changeSet,
}) => (
<Paper
elevation={4}
sx={{
border: '1px solid',
p: 2,
borderColor: theme => theme.palette.dividerAlternative,
display: 'flex',
gap: 2,
flexDirection: 'column',
borderRadius: theme => `${theme.shape.borderRadius}px`,
}}
>
<Typography variant="h3">Changes</Typography>
{/*// @ts-ignore FIXME: types */}
{changeSet?.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 }}>
{/*
// @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>
</Card>
))}
</Paper>
);

View File

@ -0,0 +1,63 @@
import { 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';
interface IDraftBannerProps {
environment?: string;
}
export const DraftBanner: VFC<IDraftBannerProps> = ({ environment }) => {
const { classes } = useAppStyles();
return (
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: theme => theme.zIndex.appBar,
borderTop: theme => `1px solid ${theme.palette.warning.border}`,
borderBottom: theme =>
`1px solid ${theme.palette.warning.border}`,
backgroundColor: theme => theme.palette.warning.light,
}}
>
<Box className={classes.content}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
px: 1,
py: 1.5,
color: theme => theme.palette.warning.main,
}}
>
<WarningAmberIcon />
<Typography variant="body2" sx={{ ml: 1 }}>
<strong>Draft mode!</strong> You have changes{' '}
<ConditionallyRender
condition={Boolean(environment)}
show={
<>
in <strong>{environment} </strong>
</>
}
/>
that need to be reviewed
</Typography>
<Button
variant="contained"
onClick={() => {}}
sx={{ ml: 'auto' }}
>
Review changes
</Button>
<Button variant="text" onClick={() => {}} sx={{ ml: 1 }}>
Discard all
</Button>
</Box>
</Box>
</Box>
);
};

View File

@ -0,0 +1,149 @@
import { useState, VFC } from 'react';
import {
Box,
Paper,
Button,
Typography,
Popover,
Radio,
FormControl,
FormControlLabel,
RadioGroup,
} 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';
export const SuggestedChanges: VFC = () => {
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const [selectedValue, setSelectedValue] = useState('');
const { data: changeRequest } = useChangeRequest();
const onClick = (event: React.MouseEvent<HTMLButtonElement>) => {
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');
} else if (selectedValue === 'requestChanges') {
console.log('requestChanges');
}
// show an error if no action was selected
};
const onApply = async () => {
try {
console.log('apply');
} catch (e) {
console.log(e);
}
};
return (
<Paper
elevation={0}
sx={{
p: 4,
borderRadius: theme => `${theme.shape.borderRadiusLarge}px`,
}}
>
<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"
>
Submit
</Button>
</Box>
</Popover>
</>
}
/>
</Paper>
);
};

View File

@ -0,0 +1,50 @@
// 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: 'REVIEW',
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

@ -13,6 +13,7 @@ export interface IRoute {
enterprise?: boolean;
component: VoidFunctionComponent;
menu: IRouteMenu;
isStandalone?: boolean;
}
interface IRouteMenu {

View File

@ -0,0 +1,52 @@
export interface ISuggestChangeset {
id: number;
state: string;
project: string;
environment: string;
createdBy?: string;
createdAt?: Date;
changes?: ISuggestChange[];
events?: ISuggestChangeEvent[];
}
export interface ISuggestChange {
id: number;
action:
| 'updateEnabled'
| 'strategyAdd'
| 'strategyUpdate'
| 'strategyDelete';
feature: string;
payload?: unknown;
createdBy?: string;
createdAt?: Date;
}
export enum SuggestChangesetEvent {
CREATED = 'CREATED',
UPDATED = 'UPDATED',
SUBMITTED = 'SUBMITTED',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
CLOSED = 'CLOSED',
}
export enum SuggestChangeEvent {
UPDATE_ENABLED = 'updateFeatureEnabledEvent',
ADD_STRATEGY = 'addStrategyEvent',
UPDATE_STRATEGY = 'updateStrategyEvent',
DELETE_STRATEGY = 'deleteStrategyEvent',
}
export interface ISuggestChangeEvent {
id: number;
event: SuggestChangesetEvent;
data: ISuggestChangeEventData;
createdBy?: string;
createdAt?: Date;
}
export interface ISuggestChangeEventData {
feature: string;
data: unknown;
}

View File

@ -43,6 +43,7 @@ export interface IFlags {
publicSignup?: boolean;
personalAccessTokens?: boolean;
syncSSOGroups?: boolean;
suggestChanges?: boolean;
}
export interface IVersionInfo {

View File

@ -1,3 +1,5 @@
import { formatRelative } from 'date-fns';
export const formatDateYMDHMS = (
date: number | string | Date,
locale: string

View File

@ -40,6 +40,7 @@ process.nextTick(async () => {
responseTimeWithAppName: true,
personalAccessTokens: true,
syncSSOGroups: true,
suggestChanges: true,
},
},
authentication: {