mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-10 01:16:39 +02: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:
parent
726674ea3e
commit
b8c3833ae4
@ -18,10 +18,12 @@ export const useStyles = makeStyles()(theme => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
backgroundColor: theme.palette.contentWrapper,
|
backgroundColor: theme.palette.contentWrapper,
|
||||||
|
position: 'relative',
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
width: '1250px',
|
width: '1250px',
|
||||||
margin: '16px auto',
|
marginLeft: 'auto',
|
||||||
|
marginRight: 'auto',
|
||||||
[theme.breakpoints.down('lg')]: {
|
[theme.breakpoints.down('lg')]: {
|
||||||
width: '1024px',
|
width: '1024px',
|
||||||
},
|
},
|
||||||
|
@ -40,36 +40,41 @@ export const App = () => {
|
|||||||
elseShow={
|
elseShow={
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<ToastRenderer />
|
<ToastRenderer />
|
||||||
<LayoutPicker>
|
<Routes>
|
||||||
<Routes>
|
{availableRoutes.map(route => (
|
||||||
{availableRoutes.map(route => (
|
<Route
|
||||||
<Route
|
key={route.path}
|
||||||
key={route.path}
|
path={route.path}
|
||||||
path={route.path}
|
element={
|
||||||
element={
|
<LayoutPicker
|
||||||
|
isStandalone={
|
||||||
|
route.isStandalone ===
|
||||||
|
true
|
||||||
|
}
|
||||||
|
>
|
||||||
<ProtectedRoute
|
<ProtectedRoute
|
||||||
route={route}
|
route={route}
|
||||||
/>
|
/>
|
||||||
}
|
</LayoutPicker>
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<Route
|
|
||||||
path="/"
|
|
||||||
element={
|
|
||||||
<Navigate
|
|
||||||
to="/features"
|
|
||||||
replace
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
))}
|
||||||
path="*"
|
<Route
|
||||||
element={<NotFound />}
|
path="/"
|
||||||
/>
|
element={
|
||||||
</Routes>
|
<Navigate
|
||||||
<FeedbackNPS openUrl="http://feedback.unleash.run" />
|
to="/features"
|
||||||
<SplashPageRedirect />
|
replace
|
||||||
</LayoutPicker>
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={<NotFound />}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
<FeedbackNPS openUrl="http://feedback.unleash.run" />
|
||||||
|
<SplashPageRedirect />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
@ -2,7 +2,6 @@ import { Typography } from '@mui/material';
|
|||||||
import { IFeatureStrategyParameters } from 'interfaces/strategy';
|
import { IFeatureStrategyParameters } from 'interfaces/strategy';
|
||||||
import RolloutSlider from '../RolloutSlider/RolloutSlider';
|
import RolloutSlider from '../RolloutSlider/RolloutSlider';
|
||||||
import Select from 'component/common/select';
|
import Select from 'component/common/select';
|
||||||
import React from 'react';
|
|
||||||
import Input from 'component/common/Input/Input';
|
import Input from 'component/common/Input/Input';
|
||||||
import {
|
import {
|
||||||
FLEXIBLE_STRATEGY_GROUP_ID,
|
FLEXIBLE_STRATEGY_GROUP_ID,
|
||||||
|
@ -1,37 +1,19 @@
|
|||||||
|
import { FC, ReactNode } from 'react';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { matchPath } from 'react-router';
|
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
import { MainLayout } from '../MainLayout/MainLayout';
|
import { MainLayout } from '../MainLayout/MainLayout';
|
||||||
import { ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface ILayoutPickerProps {
|
interface ILayoutPickerProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
isStandalone?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LayoutPicker = ({ children }: ILayoutPickerProps) => {
|
export const LayoutPicker: FC<ILayoutPickerProps> = ({
|
||||||
const { pathname } = useLocation();
|
isStandalone,
|
||||||
|
children,
|
||||||
return (
|
}) => (
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={isStandalonePage(pathname)}
|
condition={isStandalone === true}
|
||||||
show={children}
|
show={children}
|
||||||
elseShow={<MainLayout>{children}</MainLayout>}
|
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',
|
|
||||||
];
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import React, { forwardRef, ReactNode } from 'react';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import { makeStyles } from 'tss-react/mui';
|
import { makeStyles } from 'tss-react/mui';
|
||||||
import { Grid } from '@mui/material';
|
import { Grid } from '@mui/material';
|
||||||
@ -30,47 +30,58 @@ const useStyles = makeStyles()(theme => ({
|
|||||||
|
|
||||||
interface IMainLayoutProps {
|
interface IMainLayoutProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
subheader?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MainLayout = ({ children }: IMainLayoutProps) => {
|
export const MainLayout = forwardRef<HTMLDivElement, IMainLayoutProps>(
|
||||||
const { classes } = useStyles();
|
({ children, subheader }, ref) => {
|
||||||
const { classes: styles } = useAppStyles();
|
const { classes } = useStyles();
|
||||||
const { uiConfig } = useUiConfig();
|
const { classes: styles } = useAppStyles();
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SkipNavLink />
|
<SkipNavLink />
|
||||||
<Header />
|
<Header />
|
||||||
<SkipNavTarget />
|
<SkipNavTarget />
|
||||||
<Grid container className={classes.container}>
|
<Grid container className={classes.container}>
|
||||||
<main className={classnames(styles.contentWrapper)}>
|
<main className={classnames(styles.contentWrapper)}>
|
||||||
<Grid item className={styles.content} xs={12} sm={12}>
|
{subheader}
|
||||||
<div
|
<Grid
|
||||||
className={classes.contentContainer}
|
item
|
||||||
style={{ zIndex: 200 }}
|
className={styles.content}
|
||||||
|
xs={12}
|
||||||
|
sm={12}
|
||||||
|
my={2}
|
||||||
>
|
>
|
||||||
<BreadcrumbNav />
|
<div
|
||||||
<Proclamation toast={uiConfig.toast} />
|
className={classes.contentContainer}
|
||||||
{children}
|
style={{ zIndex: 200 }}
|
||||||
</div>
|
ref={ref}
|
||||||
</Grid>
|
>
|
||||||
<img
|
<BreadcrumbNav />
|
||||||
src={formatAssetPath(textureImage)}
|
<Proclamation toast={uiConfig.toast} />
|
||||||
alt=""
|
{children}
|
||||||
style={{
|
</div>
|
||||||
display: 'block',
|
</Grid>
|
||||||
position: 'fixed',
|
<img
|
||||||
zIndex: 0,
|
src={formatAssetPath(textureImage)}
|
||||||
bottom: 0,
|
alt=""
|
||||||
right: 0,
|
style={{
|
||||||
width: 400,
|
display: 'block',
|
||||||
pointerEvents: 'none',
|
position: 'fixed',
|
||||||
userSelect: 'none',
|
zIndex: 0,
|
||||||
}}
|
bottom: 0,
|
||||||
/>
|
right: 0,
|
||||||
</main>
|
width: 400,
|
||||||
<Footer />
|
pointerEvents: 'none',
|
||||||
</Grid>
|
userSelect: 'none',
|
||||||
</>
|
}}
|
||||||
);
|
/>
|
||||||
};
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
@ -4,6 +4,7 @@ exports[`returns all baseRoutes 1`] = `
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"component": [Function],
|
"component": [Function],
|
||||||
|
"isStandalone": true,
|
||||||
"menu": {},
|
"menu": {},
|
||||||
"path": "/splash/:splashId",
|
"path": "/splash/:splashId",
|
||||||
"title": "Unleash",
|
"title": "Unleash",
|
||||||
@ -78,6 +79,7 @@ exports[`returns all baseRoutes 1`] = `
|
|||||||
{
|
{
|
||||||
"component": [Function],
|
"component": [Function],
|
||||||
"flag": "P",
|
"flag": "P",
|
||||||
|
"isStandalone": true,
|
||||||
"menu": {},
|
"menu": {},
|
||||||
"parent": "/projects",
|
"parent": "/projects",
|
||||||
"path": "/projects/:projectId/*",
|
"path": "/projects/:projectId/*",
|
||||||
|
@ -68,6 +68,7 @@ export const routes: IRoute[] = [
|
|||||||
component: SplashPage,
|
component: SplashPage,
|
||||||
type: 'protected',
|
type: 'protected',
|
||||||
menu: {},
|
menu: {},
|
||||||
|
isStandalone: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Project
|
// Project
|
||||||
@ -145,6 +146,7 @@ export const routes: IRoute[] = [
|
|||||||
flag: P,
|
flag: P,
|
||||||
type: 'protected',
|
type: 'protected',
|
||||||
menu: {},
|
menu: {},
|
||||||
|
isStandalone: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/projects',
|
path: '/projects',
|
||||||
@ -546,6 +548,7 @@ export const routes: IRoute[] = [
|
|||||||
type: 'unprotected',
|
type: 'unprotected',
|
||||||
hidden: true,
|
hidden: true,
|
||||||
menu: {},
|
menu: {},
|
||||||
|
isStandalone: true,
|
||||||
},
|
},
|
||||||
/* If you update this route path, make sure you update the path in SWRProvider.tsx */
|
/* 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,
|
component: NewUser,
|
||||||
type: 'unprotected',
|
type: 'unprotected',
|
||||||
menu: {},
|
menu: {},
|
||||||
|
isStandalone: true,
|
||||||
},
|
},
|
||||||
/* If you update this route path, make sure you update the path in SWRProvider.tsx */
|
/* 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,
|
component: ResetPassword,
|
||||||
type: 'unprotected',
|
type: 'unprotected',
|
||||||
menu: {},
|
menu: {},
|
||||||
|
isStandalone: true,
|
||||||
},
|
},
|
||||||
/* If you update this route path, make sure you update the path in SWRProvider.tsx */
|
/* 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,
|
component: ForgottenPassword,
|
||||||
type: 'unprotected',
|
type: 'unprotected',
|
||||||
menu: {},
|
menu: {},
|
||||||
|
isStandalone: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -24,6 +24,9 @@ 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 { DraftBanner } from './SuggestedChanges/DraftBanner/DraftBanner';
|
||||||
|
import { MainLayout } from 'component/layout/MainLayout/MainLayout';
|
||||||
|
|
||||||
const StyledDiv = styled('div')(() => ({
|
const StyledDiv = styled('div')(() => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -53,7 +56,7 @@ const Project = () => {
|
|||||||
const { classes: styles } = useStyles();
|
const { classes: styles } = useStyles();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const { isOss } = useUiConfig();
|
const { isOss, uiConfig } = useUiConfig();
|
||||||
const basePath = `/projects/${projectId}`;
|
const basePath = `/projects/${projectId}`;
|
||||||
const projectName = project?.name || projectId;
|
const projectName = project?.name || projectId;
|
||||||
|
|
||||||
@ -65,6 +68,15 @@ 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`,
|
||||||
@ -112,7 +124,10 @@ const Project = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<MainLayout
|
||||||
|
ref={ref}
|
||||||
|
subheader={uiConfig?.flags?.suggestChanges ? <DraftBanner /> : null}
|
||||||
|
>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<div className={styles.innerContainer}>
|
<div className={styles.innerContainer}>
|
||||||
<h2 className={styles.title}>
|
<h2 className={styles.title}>
|
||||||
@ -213,6 +228,7 @@ 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 />} />
|
||||||
@ -220,7 +236,7 @@ const Project = () => {
|
|||||||
<Route path="logs" element={<ProjectLog />} />
|
<Route path="logs" element={<ProjectLog />} />
|
||||||
<Route path="*" element={<ProjectOverview />} />
|
<Route path="*" element={<ProjectOverview />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</MainLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
);
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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());
|
||||||
|
};
|
@ -13,6 +13,7 @@ export interface IRoute {
|
|||||||
enterprise?: boolean;
|
enterprise?: boolean;
|
||||||
component: VoidFunctionComponent;
|
component: VoidFunctionComponent;
|
||||||
menu: IRouteMenu;
|
menu: IRouteMenu;
|
||||||
|
isStandalone?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IRouteMenu {
|
interface IRouteMenu {
|
||||||
|
52
frontend/src/interfaces/suggestChangeset.ts
Normal file
52
frontend/src/interfaces/suggestChangeset.ts
Normal 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;
|
||||||
|
}
|
@ -43,6 +43,7 @@ export interface IFlags {
|
|||||||
publicSignup?: boolean;
|
publicSignup?: boolean;
|
||||||
personalAccessTokens?: boolean;
|
personalAccessTokens?: boolean;
|
||||||
syncSSOGroups?: boolean;
|
syncSSOGroups?: boolean;
|
||||||
|
suggestChanges?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IVersionInfo {
|
export interface IVersionInfo {
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { formatRelative } from 'date-fns';
|
||||||
|
|
||||||
export const formatDateYMDHMS = (
|
export const formatDateYMDHMS = (
|
||||||
date: number | string | Date,
|
date: number | string | Date,
|
||||||
locale: string
|
locale: string
|
||||||
|
@ -40,6 +40,7 @@ process.nextTick(async () => {
|
|||||||
responseTimeWithAppName: true,
|
responseTimeWithAppName: true,
|
||||||
personalAccessTokens: true,
|
personalAccessTokens: true,
|
||||||
syncSSOGroups: true,
|
syncSSOGroups: true,
|
||||||
|
suggestChanges: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
authentication: {
|
authentication: {
|
||||||
|
Loading…
Reference in New Issue
Block a user