mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +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:
		
							parent
							
								
									726674ea3e
								
							
						
					
					
						commit
						b8c3833ae4
					
				| @ -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', | ||||
|         }, | ||||
|  | ||||
| @ -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> | ||||
|                             } | ||||
|                         /> | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
| @ -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>} | ||||
|     /> | ||||
| ); | ||||
|  | ||||
| @ -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> | ||||
|             </> | ||||
|         ); | ||||
|     } | ||||
| ); | ||||
|  | ||||
| @ -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/*", | ||||
|  | ||||
| @ -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, | ||||
|     }, | ||||
| ]; | ||||
| 
 | ||||
|  | ||||
| @ -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> | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
|  | ||||
| @ -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; | ||||
|     component: VoidFunctionComponent; | ||||
|     menu: IRouteMenu; | ||||
|     isStandalone?: boolean; | ||||
| } | ||||
| 
 | ||||
| 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; | ||||
|     personalAccessTokens?: boolean; | ||||
|     syncSSOGroups?: boolean; | ||||
|     suggestChanges?: boolean; | ||||
| } | ||||
| 
 | ||||
| export interface IVersionInfo { | ||||
|  | ||||
| @ -1,3 +1,5 @@ | ||||
| import { formatRelative } from 'date-fns'; | ||||
| 
 | ||||
| export const formatDateYMDHMS = ( | ||||
|     date: number | string | Date, | ||||
|     locale: string | ||||
|  | ||||
| @ -40,6 +40,7 @@ process.nextTick(async () => { | ||||
|                         responseTimeWithAppName: true, | ||||
|                         personalAccessTokens: true, | ||||
|                         syncSSOGroups: true, | ||||
|                         suggestChanges: true, | ||||
|                     }, | ||||
|                 }, | ||||
|                 authentication: { | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user