mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	Feat/group by projects (#308)
This PR adds support for projects as a first class citizen, and toggling features on in different environments.
This commit is contained in:
		
							parent
							
								
									151fccc262
								
							
						
					
					
						commit
						85a7c55fdf
					
				| @ -46,6 +46,7 @@ body { | |||||||
|     background-color: #e2e8f0; |     background-color: #e2e8f0; | ||||||
|     z-index: 9999; |     z-index: 9999; | ||||||
|     box-shadow: none; |     box-shadow: none; | ||||||
|  |     fill: none; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .skeleton::before { | .skeleton::before { | ||||||
|  | |||||||
							
								
								
									
										9
									
								
								frontend/src/assets/icons/projectIcon.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								frontend/src/assets/icons/projectIcon.svg
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,9 @@ | |||||||
|  | <svg width="88" height="40" viewBox="0 0 88 40" fill="none" xmlns="http://www.w3.org/2000/svg"> | ||||||
|  | <rect width="9" height="39" fill="#635DC5"/> | ||||||
|  | <rect x="13" width="10" height="39" fill="#635DC5"/> | ||||||
|  | <rect x="27" y="6" width="9" height="33" fill="#635DC5"/> | ||||||
|  | <rect x="40" y="12" width="9" height="27" fill="#635DC5"/> | ||||||
|  | <rect x="52" y="15" width="9" height="24" fill="#635DC5"/> | ||||||
|  | <rect x="65.0752" y="15.2578" width="9.2957" height="23.9101" fill="#635DC5"/> | ||||||
|  | <rect x="78.355" y="19.0352" width="9.2957" height="20.1348" fill="#635DC5"/> | ||||||
|  | </svg> | ||||||
| After Width: | Height: | Size: 534 B | 
| @ -62,4 +62,21 @@ export const useCommonStyles = makeStyles(theme => ({ | |||||||
|         fontWeight: 'bold', |         fontWeight: 'bold', | ||||||
|         marginBottom: '0.5rem', |         marginBottom: '0.5rem', | ||||||
|     }, |     }, | ||||||
|  |     fadeInBottomStart: { | ||||||
|  |         opacity: '0', | ||||||
|  |         position: 'fixed', | ||||||
|  |         right: '40px', | ||||||
|  |         bottom: '40px', | ||||||
|  |         transform: 'translateY(400px)', | ||||||
|  |     }, | ||||||
|  |     fadeInBottomEnter: { | ||||||
|  |         transform: 'translateY(0)', | ||||||
|  |         opacity: '1', | ||||||
|  |         transition: 'transform 0.6s ease, opacity 1s ease', | ||||||
|  |     }, | ||||||
|  |     fadeInBottomLeave: { | ||||||
|  |         transform: 'translateY(400px)', | ||||||
|  |         opacity: '0', | ||||||
|  |         transition: 'transform 1.25s ease, opacity 1s ease', | ||||||
|  |     }, | ||||||
| })); | })); | ||||||
|  | |||||||
| @ -7,61 +7,16 @@ import CheckIcon from '@material-ui/icons/Check'; | |||||||
| import ReportProblemOutlinedIcon from '@material-ui/icons/ReportProblemOutlined'; | import ReportProblemOutlinedIcon from '@material-ui/icons/ReportProblemOutlined'; | ||||||
| import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender'; | import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender'; | ||||||
| 
 | 
 | ||||||
| import { isFeatureExpired } from '../utils'; |  | ||||||
| 
 |  | ||||||
| import styles from './ReportCard.module.scss'; | import styles from './ReportCard.module.scss'; | ||||||
| 
 | 
 | ||||||
| const ReportCard = ({ features }) => { | const ReportCard = ({ | ||||||
|     const getActiveToggles = () => { |     health, | ||||||
|         const result = features.filter(feature => !feature.stale); |     activeCount, | ||||||
| 
 |     staleCount, | ||||||
|         if (result === 0) return 0; |     potentiallyStaleCount, | ||||||
| 
 | }) => { | ||||||
|         return result; |     const healthLessThan50 = health < 50; | ||||||
|     }; |     const healthLessThan75 = health < 75; | ||||||
| 
 |  | ||||||
|     const getPotentiallyStaleToggles = activeToggles => { |  | ||||||
|         const result = activeToggles.filter( |  | ||||||
|             feature => isFeatureExpired(feature) && !feature.stale |  | ||||||
|         ); |  | ||||||
| 
 |  | ||||||
|         return result; |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     const getHealthRating = ( |  | ||||||
|         total, |  | ||||||
|         staleTogglesCount, |  | ||||||
|         potentiallyStaleTogglesCount |  | ||||||
|     ) => { |  | ||||||
|         const startPercentage = 100; |  | ||||||
| 
 |  | ||||||
|         const stalePercentage = (staleTogglesCount / total) * 100; |  | ||||||
| 
 |  | ||||||
|         const potentiallyStalePercentage = |  | ||||||
|             (potentiallyStaleTogglesCount / total) * 100; |  | ||||||
| 
 |  | ||||||
|         return Math.round( |  | ||||||
|             startPercentage - stalePercentage - potentiallyStalePercentage |  | ||||||
|         ); |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     const total = features.length; |  | ||||||
|     const activeTogglesArray = getActiveToggles(); |  | ||||||
|     const potentiallyStaleToggles = |  | ||||||
|         getPotentiallyStaleToggles(activeTogglesArray); |  | ||||||
| 
 |  | ||||||
|     const activeTogglesCount = activeTogglesArray.length; |  | ||||||
|     const staleTogglesCount = features.length - activeTogglesCount; |  | ||||||
|     const potentiallyStaleTogglesCount = potentiallyStaleToggles.length; |  | ||||||
| 
 |  | ||||||
|     const healthRating = getHealthRating( |  | ||||||
|         total, |  | ||||||
|         staleTogglesCount, |  | ||||||
|         potentiallyStaleTogglesCount |  | ||||||
|     ); |  | ||||||
| 
 |  | ||||||
|     const healthLessThan50 = healthRating < 50; |  | ||||||
|     const healthLessThan75 = healthRating < 75; |  | ||||||
| 
 | 
 | ||||||
|     const healthClasses = classnames(styles.reportCardHealthRating, { |     const healthClasses = classnames(styles.reportCardHealthRating, { | ||||||
|         [styles.healthWarning]: healthLessThan75, |         [styles.healthWarning]: healthLessThan75, | ||||||
| @ -71,23 +26,21 @@ const ReportCard = ({ features }) => { | |||||||
|     const renderActiveToggles = () => ( |     const renderActiveToggles = () => ( | ||||||
|         <> |         <> | ||||||
|             <CheckIcon className={styles.check} /> |             <CheckIcon className={styles.check} /> | ||||||
|             <span>{activeTogglesCount} active toggles</span> |             <span>{activeCount} active toggles</span> | ||||||
|         </> |         </> | ||||||
|     ); |     ); | ||||||
| 
 | 
 | ||||||
|     const renderStaleToggles = () => ( |     const renderStaleToggles = () => ( | ||||||
|         <> |         <> | ||||||
|             <ReportProblemOutlinedIcon className={styles.danger} /> |             <ReportProblemOutlinedIcon className={styles.danger} /> | ||||||
|             <span>{staleTogglesCount} stale toggles</span> |             <span>{staleCount} stale toggles</span> | ||||||
|         </> |         </> | ||||||
|     ); |     ); | ||||||
| 
 | 
 | ||||||
|     const renderPotentiallyStaleToggles = () => ( |     const renderPotentiallyStaleToggles = () => ( | ||||||
|         <> |         <> | ||||||
|             <ReportProblemOutlinedIcon className={styles.danger} /> |             <ReportProblemOutlinedIcon className={styles.danger} /> | ||||||
|             <span> |             <span>{potentiallyStaleCount} potentially stale toggles</span> | ||||||
|                 {potentiallyStaleTogglesCount} potentially stale toggles |  | ||||||
|             </span> |  | ||||||
|         </> |         </> | ||||||
|     ); |     ); | ||||||
| 
 | 
 | ||||||
| @ -98,10 +51,8 @@ const ReportCard = ({ features }) => { | |||||||
|                     <h2 className={styles.header}>Health rating</h2> |                     <h2 className={styles.header}>Health rating</h2> | ||||||
|                     <div className={styles.reportCardHealthInnerContainer}> |                     <div className={styles.reportCardHealthInnerContainer}> | ||||||
|                         <ConditionallyRender |                         <ConditionallyRender | ||||||
|                             condition={healthRating > -1} |                             condition={health > -1} | ||||||
|                             show={ |                             show={<p className={healthClasses}>{health}%</p>} | ||||||
|                                 <p className={healthClasses}>{healthRating}%</p> |  | ||||||
|                             } |  | ||||||
|                         /> |                         /> | ||||||
|                     </div> |                     </div> | ||||||
|                 </div> |                 </div> | ||||||
| @ -110,19 +61,19 @@ const ReportCard = ({ features }) => { | |||||||
|                     <ul className={styles.reportCardList}> |                     <ul className={styles.reportCardList}> | ||||||
|                         <li> |                         <li> | ||||||
|                             <ConditionallyRender |                             <ConditionallyRender | ||||||
|                                 condition={activeTogglesCount} |                                 condition={activeCount} | ||||||
|                                 show={renderActiveToggles} |                                 show={renderActiveToggles} | ||||||
|                             /> |                             /> | ||||||
|                         </li> |                         </li> | ||||||
|                         <li> |                         <li> | ||||||
|                             <ConditionallyRender |                             <ConditionallyRender | ||||||
|                                 condition={staleTogglesCount} |                                 condition={staleCount} | ||||||
|                                 show={renderStaleToggles} |                                 show={renderStaleToggles} | ||||||
|                             /> |                             /> | ||||||
|                         </li> |                         </li> | ||||||
|                         <li> |                         <li> | ||||||
|                             <ConditionallyRender |                             <ConditionallyRender | ||||||
|                                 condition={potentiallyStaleTogglesCount} |                                 condition={potentiallyStaleCount} | ||||||
|                                 show={renderPotentiallyStaleToggles} |                                 show={renderPotentiallyStaleToggles} | ||||||
|                             /> |                             /> | ||||||
|                         </li> |                         </li> | ||||||
|  | |||||||
| @ -13,7 +13,7 @@ import { | |||||||
|     applyCheckedToFeatures, |     applyCheckedToFeatures, | ||||||
| } from '../utils'; | } from '../utils'; | ||||||
| 
 | 
 | ||||||
| import useSort from '../useSort'; | import useSort from '../../../hooks/useSort'; | ||||||
| 
 | 
 | ||||||
| import styles from './ReportToggleList.module.scss'; | import styles from './ReportToggleList.module.scss'; | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1,20 +1,12 @@ | |||||||
| import { connect } from 'react-redux'; | import { connect } from 'react-redux'; | ||||||
| 
 | 
 | ||||||
| import { filterByProject } from '../utils'; |  | ||||||
| 
 |  | ||||||
| import ReportToggleList from './ReportToggleList'; | import ReportToggleList from './ReportToggleList'; | ||||||
| 
 | 
 | ||||||
| const mapStateToProps = (state, ownProps) => { | const mapStateToProps = (state, ownProps) => {}; | ||||||
|     const features = state.features.toJS(); |  | ||||||
| 
 | 
 | ||||||
|     const sameProject = filterByProject(ownProps.selectedProject); | const ReportToggleListContainer = connect( | ||||||
|     const featuresByProject = features.filter(sameProject); |     mapStateToProps, | ||||||
| 
 |     null | ||||||
|     return { | )(ReportToggleList); | ||||||
|         features: featuresByProject, |  | ||||||
|     }; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const ReportToggleListContainer = connect(mapStateToProps, null)(ReportToggleList); |  | ||||||
| 
 | 
 | ||||||
| export default ReportToggleListContainer; | export default ReportToggleListContainer; | ||||||
|  | |||||||
| @ -15,7 +15,10 @@ import { | |||||||
|     toggleExpiryByTypeMap, |     toggleExpiryByTypeMap, | ||||||
|     getDiffInDays, |     getDiffInDays, | ||||||
| } from '../../utils'; | } from '../../utils'; | ||||||
| import { KILLSWITCH, PERMISSION } from '../../constants'; | import { | ||||||
|  |     KILLSWITCH, | ||||||
|  |     PERMISSION, | ||||||
|  | } from '../../../../constants/featureToggleTypes'; | ||||||
| 
 | 
 | ||||||
| import styles from '../ReportToggleList.module.scss'; | import styles from '../ReportToggleList.module.scss'; | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -12,15 +12,17 @@ import { formatProjectOptions } from './utils'; | |||||||
| import { REPORTING_SELECT_ID } from '../../testIds'; | import { REPORTING_SELECT_ID } from '../../testIds'; | ||||||
| 
 | 
 | ||||||
| import styles from './Reporting.module.scss'; | import styles from './Reporting.module.scss'; | ||||||
|  | import useHealthReport from '../../hooks/api/getters/useHealthReport/useHealthReport'; | ||||||
|  | import ApiError from '../common/ApiError/ApiError'; | ||||||
| 
 | 
 | ||||||
| const Reporting = ({ fetchFeatureToggles, projects }) => { | const Reporting = ({ projects }) => { | ||||||
|     const [projectOptions, setProjectOptions] = useState([ |     const [projectOptions, setProjectOptions] = useState([ | ||||||
|         { key: 'default', label: 'Default' }, |         { key: 'default', label: 'Default' }, | ||||||
|     ]); |     ]); | ||||||
|     const [selectedProject, setSelectedProject] = useState('default'); |     const [selectedProject, setSelectedProject] = useState('default'); | ||||||
|  |     const { project, error, refetch } = useHealthReport(selectedProject); | ||||||
| 
 | 
 | ||||||
|     useEffect(() => { |     useEffect(() => { | ||||||
|         fetchFeatureToggles(); |  | ||||||
|         setSelectedProject(projects[0].id); |         setSelectedProject(projects[0].id); | ||||||
|         /* eslint-disable-next-line */ |         /* eslint-disable-next-line */ | ||||||
|     }, []); |     }, []); | ||||||
| @ -62,8 +64,28 @@ const Reporting = ({ fetchFeatureToggles, projects }) => { | |||||||
|                 show={renderSelect} |                 show={renderSelect} | ||||||
|             /> |             /> | ||||||
| 
 | 
 | ||||||
|             <ReportCardContainer selectedProject={selectedProject} /> |             <ConditionallyRender | ||||||
|             <ReportToggleListContainer selectedProject={selectedProject} /> |                 condition={error} | ||||||
|  |                 show={ | ||||||
|  |                     <ApiError | ||||||
|  |                         data-loading | ||||||
|  |                         style={{ maxWidth: '500px', marginTop: '1rem' }} | ||||||
|  |                         onClick={refetch} | ||||||
|  |                         text={`Could not fetch health rating for ${selectedProject}`} | ||||||
|  |                     /> | ||||||
|  |                 } | ||||||
|  |             /> | ||||||
|  |             <ReportCardContainer | ||||||
|  |                 health={project?.health} | ||||||
|  |                 staleCount={project?.staleCount} | ||||||
|  |                 activeCount={project?.activeCount} | ||||||
|  |                 potentiallyStaleCount={project?.potentiallyStaleCount} | ||||||
|  |                 selectedProject={selectedProject} | ||||||
|  |             /> | ||||||
|  |             <ReportToggleListContainer | ||||||
|  |                 features={project.features} | ||||||
|  |                 selectedProject={selectedProject} | ||||||
|  |             /> | ||||||
|         </React.Fragment> |         </React.Fragment> | ||||||
|     ); |     ); | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -1,34 +0,0 @@ | |||||||
| import React from 'react'; |  | ||||||
| import { Provider } from 'react-redux'; |  | ||||||
| import { HashRouter } from 'react-router-dom'; |  | ||||||
| 
 |  | ||||||
| import { createStore } from 'redux'; |  | ||||||
| 
 |  | ||||||
| import { render, screen, fireEvent } from '@testing-library/react'; |  | ||||||
| 
 |  | ||||||
| import Reporting from '../Reporting'; |  | ||||||
| import { REPORTING_SELECT_ID } from '../../../testIds'; |  | ||||||
| 
 |  | ||||||
| import { testProjects, testFeatures } from '../testData'; |  | ||||||
| 
 |  | ||||||
| const mockStore = { |  | ||||||
|     projects: testProjects, |  | ||||||
|     features: { toJS: () => testFeatures }, |  | ||||||
| }; |  | ||||||
| const mockReducer = state => state; |  | ||||||
| 
 |  | ||||||
| test('changing projects renders only toggles from that project', async () => { |  | ||||||
|     render( |  | ||||||
|         <HashRouter> |  | ||||||
|             <Provider store={createStore(mockReducer, mockStore)}> |  | ||||||
|                 <Reporting projects={testProjects} features={testFeatures} fetchFeatureToggles={() => {}} /> |  | ||||||
|             </Provider> |  | ||||||
|         </HashRouter> |  | ||||||
|     ); |  | ||||||
| 
 |  | ||||||
|     const table = await screen.findByRole("table"); |  | ||||||
|     /* Length of projects belonging to project (3) + header row (1) */ |  | ||||||
|     expect(table.rows).toHaveLength(4); |  | ||||||
|     fireEvent.change(await screen.findByTestId(REPORTING_SELECT_ID), { target: { value: 'myProject'}}); |  | ||||||
|     expect(table.rows).toHaveLength(3); |  | ||||||
| }); |  | ||||||
| @ -6,13 +6,6 @@ export const EXPIRED = 'expired'; | |||||||
| export const STATUS = 'status'; | export const STATUS = 'status'; | ||||||
| export const REPORT = 'report'; | export const REPORT = 'report'; | ||||||
| 
 | 
 | ||||||
| /* FEATURE TOGGLE TYPES */ |  | ||||||
| export const EXPERIMENT = 'experiment'; |  | ||||||
| export const RELEASE = 'release'; |  | ||||||
| export const OPERATIONAL = 'operational'; |  | ||||||
| export const KILLSWITCH = 'kill-switch'; |  | ||||||
| export const PERMISSION = 'permission'; |  | ||||||
| 
 |  | ||||||
| /* DAYS */ | /* DAYS */ | ||||||
| export const FOURTYDAYS = 40; | export const FOURTYDAYS = 40; | ||||||
| export const SEVENDAYS = 7; | export const SEVENDAYS = 7; | ||||||
|  | |||||||
| @ -1,7 +1,13 @@ | |||||||
| import parseISO from 'date-fns/parseISO'; | import parseISO from 'date-fns/parseISO'; | ||||||
| import differenceInDays from 'date-fns/differenceInDays'; | import differenceInDays from 'date-fns/differenceInDays'; | ||||||
| 
 | 
 | ||||||
| import { EXPERIMENT, OPERATIONAL, RELEASE, FOURTYDAYS, SEVENDAYS } from './constants'; | import { | ||||||
|  |     EXPERIMENT, | ||||||
|  |     OPERATIONAL, | ||||||
|  |     RELEASE, | ||||||
|  | } from '../../constants/featureToggleTypes'; | ||||||
|  | 
 | ||||||
|  | import { FOURTYDAYS, SEVENDAYS } from './constants'; | ||||||
| 
 | 
 | ||||||
| export const toggleExpiryByTypeMap = { | export const toggleExpiryByTypeMap = { | ||||||
|     [EXPERIMENT]: FOURTYDAYS, |     [EXPERIMENT]: FOURTYDAYS, | ||||||
| @ -21,9 +27,11 @@ export const getCheckedState = (name, features) => { | |||||||
|     return false; |     return false; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const getDiffInDays = (date, now) => Math.abs(differenceInDays(date, now)); | export const getDiffInDays = (date, now) => | ||||||
|  |     Math.abs(differenceInDays(date, now)); | ||||||
| 
 | 
 | ||||||
| export const formatProjectOptions = projects => projects.map(project => ({ key: project.id, label: project.name })); | export const formatProjectOptions = projects => | ||||||
|  |     projects.map(project => ({ key: project.id, label: project.name })); | ||||||
| 
 | 
 | ||||||
| export const expired = (diff, type) => { | export const expired = (diff, type) => { | ||||||
|     if (diff >= toggleExpiryByTypeMap[type]) return true; |     if (diff >= toggleExpiryByTypeMap[type]) return true; | ||||||
| @ -56,7 +64,8 @@ export const sortFeaturesByNameAscending = features => { | |||||||
|     return sorted; |     return sorted; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const sortFeaturesByNameDescending = features => sortFeaturesByNameAscending([...features]).reverse(); | export const sortFeaturesByNameDescending = features => | ||||||
|  |     sortFeaturesByNameAscending([...features]).reverse(); | ||||||
| 
 | 
 | ||||||
| export const sortFeaturesByLastSeenAscending = features => { | export const sortFeaturesByLastSeenAscending = features => { | ||||||
|     const sorted = [...features]; |     const sorted = [...features]; | ||||||
| @ -72,7 +81,8 @@ export const sortFeaturesByLastSeenAscending = features => { | |||||||
|     return sorted; |     return sorted; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const sortFeaturesByLastSeenDescending = features => sortFeaturesByLastSeenAscending([...features]).reverse(); | export const sortFeaturesByLastSeenDescending = features => | ||||||
|  |     sortFeaturesByLastSeenAscending([...features]).reverse(); | ||||||
| 
 | 
 | ||||||
| export const sortFeaturesByCreatedAtAscending = features => { | export const sortFeaturesByCreatedAtAscending = features => { | ||||||
|     const sorted = [...features]; |     const sorted = [...features]; | ||||||
| @ -85,7 +95,8 @@ export const sortFeaturesByCreatedAtAscending = features => { | |||||||
|     return sorted; |     return sorted; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const sortFeaturesByCreatedAtDescending = features => sortFeaturesByCreatedAtAscending([...features]).reverse(); | export const sortFeaturesByCreatedAtDescending = features => | ||||||
|  |     sortFeaturesByCreatedAtAscending([...features]).reverse(); | ||||||
| 
 | 
 | ||||||
| export const sortFeaturesByExpiredAtAscending = features => { | export const sortFeaturesByExpiredAtAscending = features => { | ||||||
|     const sorted = [...features]; |     const sorted = [...features]; | ||||||
| @ -149,7 +160,8 @@ export const sortFeaturesByStatusAscending = features => { | |||||||
|     return sorted; |     return sorted; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const sortFeaturesByStatusDescending = features => sortFeaturesByStatusAscending([...features]).reverse(); | export const sortFeaturesByStatusDescending = features => | ||||||
|  |     sortFeaturesByStatusAscending([...features]).reverse(); | ||||||
| 
 | 
 | ||||||
| export const pluralize = (items, word) => { | export const pluralize = (items, word) => { | ||||||
|     if (items === 1) return `${items} ${word}`; |     if (items === 1) return `${items} ${word}`; | ||||||
| @ -163,7 +175,8 @@ export const getDates = dateString => { | |||||||
|     return [date, now]; |     return [date, now]; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const filterByProject = selectedProject => feature => feature.project === selectedProject; | export const filterByProject = selectedProject => feature => | ||||||
|  |     feature.project === selectedProject; | ||||||
| 
 | 
 | ||||||
| export const isFeatureExpired = feature => { | export const isFeatureExpired = feature => { | ||||||
|     const [date, now] = getDates(feature.createdAt); |     const [date, now] = getDates(feature.createdAt); | ||||||
|  | |||||||
| @ -33,6 +33,7 @@ exports[`renders correctly with permissions 1`] = ` | |||||||
|     > |     > | ||||||
|       <div |       <div | ||||||
|         className="" |         className="" | ||||||
|  |         data-loading={true} | ||||||
|       > |       > | ||||||
|         <h2 |         <h2 | ||||||
|           className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2" |           className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2" | ||||||
| @ -551,6 +552,7 @@ exports[`renders correctly without permission 1`] = ` | |||||||
|     > |     > | ||||||
|       <div |       <div | ||||||
|         className="" |         className="" | ||||||
|  |         data-loading={true} | ||||||
|       > |       > | ||||||
|         <h2 |         <h2 | ||||||
|           className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2" |           className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2" | ||||||
|  | |||||||
| @ -7,6 +7,7 @@ interface IAnimateOnMountProps { | |||||||
|     start: string; |     start: string; | ||||||
|     leave: string; |     leave: string; | ||||||
|     container?: string; |     container?: string; | ||||||
|  |     style?: Object; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const AnimateOnMount: FC<IAnimateOnMountProps> = ({ | const AnimateOnMount: FC<IAnimateOnMountProps> = ({ | ||||||
| @ -16,6 +17,7 @@ const AnimateOnMount: FC<IAnimateOnMountProps> = ({ | |||||||
|     leave, |     leave, | ||||||
|     container, |     container, | ||||||
|     children, |     children, | ||||||
|  |     style, | ||||||
| }) => { | }) => { | ||||||
|     const [show, setShow] = useState(mounted); |     const [show, setShow] = useState(mounted); | ||||||
|     const [styles, setStyles] = useState(''); |     const [styles, setStyles] = useState(''); | ||||||
| @ -49,6 +51,7 @@ const AnimateOnMount: FC<IAnimateOnMountProps> = ({ | |||||||
|                         container ? container : '' |                         container ? container : '' | ||||||
|                     }`}
 |                     }`}
 | ||||||
|                     onTransitionEnd={onTransitionEnd} |                     onTransitionEnd={onTransitionEnd} | ||||||
|  |                     style={{ ...style }} | ||||||
|                 > |                 > | ||||||
|                     {children} |                     {children} | ||||||
|                 </div> |                 </div> | ||||||
|  | |||||||
							
								
								
									
										32
									
								
								frontend/src/component/common/ApiError/ApiError.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								frontend/src/component/common/ApiError/ApiError.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,32 @@ | |||||||
|  | import { Button } from '@material-ui/core'; | ||||||
|  | import { Alert } from '@material-ui/lab'; | ||||||
|  | 
 | ||||||
|  | interface IApiErrorProps { | ||||||
|  |     className?: string; | ||||||
|  |     onClick: () => void; | ||||||
|  |     text: string; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const ApiError: React.FC<IApiErrorProps> = ({ | ||||||
|  |     className, | ||||||
|  |     onClick, | ||||||
|  |     text, | ||||||
|  |     ...rest | ||||||
|  | }) => { | ||||||
|  |     return ( | ||||||
|  |         <Alert | ||||||
|  |             className={className ? className : ''} | ||||||
|  |             action={ | ||||||
|  |                 <Button color="inherit" size="small" onClick={onClick}> | ||||||
|  |                     TRY AGAIN | ||||||
|  |                 </Button> | ||||||
|  |             } | ||||||
|  |             severity="error" | ||||||
|  |             {...rest} | ||||||
|  |         > | ||||||
|  |             {text} | ||||||
|  |         </Alert> | ||||||
|  |     ); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default ApiError; | ||||||
| @ -14,23 +14,6 @@ export const useStyles = makeStyles(theme => ({ | |||||||
|     animateContainer: { |     animateContainer: { | ||||||
|         zIndex: '9999', |         zIndex: '9999', | ||||||
|     }, |     }, | ||||||
|     feedbackStart: { |  | ||||||
|         opacity: '0', |  | ||||||
|         position: 'fixed', |  | ||||||
|         right: '40px', |  | ||||||
|         bottom: '40px', |  | ||||||
|         transform: 'translateY(400px)', |  | ||||||
|     }, |  | ||||||
|     feedbackEnter: { |  | ||||||
|         transform: 'translateY(0)', |  | ||||||
|         opacity: '1', |  | ||||||
|         transition: 'transform 0.6s ease, opacity 1s ease', |  | ||||||
|     }, |  | ||||||
|     feedbackLeave: { |  | ||||||
|         transform: 'translateY(400px)', |  | ||||||
|         opacity: '0', |  | ||||||
|         transition: 'transform 1.25s ease, opacity 1s ease', |  | ||||||
|     }, |  | ||||||
|     container: { |     container: { | ||||||
|         display: 'flex', |         display: 'flex', | ||||||
|         flexDirection: 'column', |         flexDirection: 'column', | ||||||
|  | |||||||
| @ -82,9 +82,9 @@ const Feedback = ({ | |||||||
|     return ( |     return ( | ||||||
|         <AnimateOnMount |         <AnimateOnMount | ||||||
|             mounted={show} |             mounted={show} | ||||||
|             enter={styles.feedbackEnter} |             start={commonStyles.fadeInBottomStart} | ||||||
|             start={styles.feedbackStart} |             enter={commonStyles.fadeInBottomEnter} | ||||||
|             leave={styles.feedbackLeave} |             leave={commonStyles.fadeInBottomLeave} | ||||||
|             container={styles.animateContainer} |             container={styles.animateContainer} | ||||||
|         > |         > | ||||||
|             <div className={styles.feedback}> |             <div className={styles.feedback}> | ||||||
|  | |||||||
| @ -7,20 +7,33 @@ import ConditionallyRender from '../ConditionallyRender/ConditionallyRender'; | |||||||
| 
 | 
 | ||||||
| import { useStyles } from './styles'; | import { useStyles } from './styles'; | ||||||
| 
 | 
 | ||||||
| const HeaderTitle = ({ title, actions, subtitle, variant, loading }) => { | const HeaderTitle = ({ | ||||||
|  |     title, | ||||||
|  |     actions, | ||||||
|  |     subtitle, | ||||||
|  |     variant, | ||||||
|  |     loading, | ||||||
|  |     className, | ||||||
|  | }) => { | ||||||
|     const styles = useStyles(); |     const styles = useStyles(); | ||||||
|     const headerClasses = classnames({ skeleton: loading }); |     const headerClasses = classnames({ skeleton: loading }); | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|         <div className={styles.headerTitleContainer}> |         <div className={styles.headerTitleContainer}> | ||||||
|             <div className={headerClasses}> |             <div className={headerClasses} data-loading> | ||||||
|                 <Typography variant={variant || 'h2'} className={styles.headerTitle}> |                 <Typography | ||||||
|  |                     variant={variant || 'h2'} | ||||||
|  |                     className={classnames(styles.headerTitle, className)} | ||||||
|  |                 > | ||||||
|                     {title} |                     {title} | ||||||
|                 </Typography> |                 </Typography> | ||||||
|                 {subtitle && <small>{subtitle}</small>} |                 {subtitle && <small>{subtitle}</small>} | ||||||
|             </div> |             </div> | ||||||
| 
 | 
 | ||||||
|             <ConditionallyRender condition={actions} show={<div className={styles.headerActions}>{actions}</div>} /> |             <ConditionallyRender | ||||||
|  |                 condition={actions} | ||||||
|  |                 show={<div className={styles.headerActions}>{actions}</div>} | ||||||
|  |             /> | ||||||
|         </div> |         </div> | ||||||
|     ); |     ); | ||||||
| }; | }; | ||||||
|  | |||||||
							
								
								
									
										88
									
								
								frontend/src/component/common/PaginateUI/PaginateUI.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								frontend/src/component/common/PaginateUI/PaginateUI.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,88 @@ | |||||||
|  | import ConditionallyRender from '../ConditionallyRender'; | ||||||
|  | import classnames from 'classnames'; | ||||||
|  | import { useStyles } from './PaginationUI.styles'; | ||||||
|  | 
 | ||||||
|  | import ArrowBackIosIcon from '@material-ui/icons/ArrowBackIos'; | ||||||
|  | import ArrowForwardIosIcon from '@material-ui/icons/ArrowForwardIos'; | ||||||
|  | 
 | ||||||
|  | interface IPaginateUIProps { | ||||||
|  |     pages: any[]; | ||||||
|  |     pageIndex: number; | ||||||
|  |     prevPage: () => void; | ||||||
|  |     setPageIndex: (idx: number) => void; | ||||||
|  |     nextPage: () => void; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const PaginateUI = ({ | ||||||
|  |     pages, | ||||||
|  |     pageIndex, | ||||||
|  |     prevPage, | ||||||
|  |     setPageIndex, | ||||||
|  |     nextPage, | ||||||
|  | }: IPaginateUIProps) => { | ||||||
|  |     const styles = useStyles(); | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         <ConditionallyRender | ||||||
|  |             condition={pages.length > 1} | ||||||
|  |             show={ | ||||||
|  |                 <div className={styles.pagination}> | ||||||
|  |                     <div className={styles.paginationInnerContainer}> | ||||||
|  |                         <ConditionallyRender | ||||||
|  |                             condition={pageIndex > 0} | ||||||
|  |                             show={ | ||||||
|  |                                 <button | ||||||
|  |                                     className={classnames( | ||||||
|  |                                         styles.idxBtn, | ||||||
|  |                                         styles.idxBtnLeft | ||||||
|  |                                     )} | ||||||
|  |                                     onClick={() => prevPage()} | ||||||
|  |                                 > | ||||||
|  |                                     <ArrowBackIosIcon | ||||||
|  |                                         className={styles.idxBtnIcon} | ||||||
|  |                                     /> | ||||||
|  |                                 </button> | ||||||
|  |                             } | ||||||
|  |                         /> | ||||||
|  |                         {pages.map((page, idx) => { | ||||||
|  |                             const active = pageIndex === idx; | ||||||
|  |                             return ( | ||||||
|  |                                 <button | ||||||
|  |                                     key={idx} | ||||||
|  |                                     className={classnames( | ||||||
|  |                                         styles.paginationButton, | ||||||
|  |                                         { | ||||||
|  |                                             [styles.paginationButtonActive]: | ||||||
|  |                                                 active, | ||||||
|  |                                         } | ||||||
|  |                                     )} | ||||||
|  |                                     onClick={() => setPageIndex(idx)} | ||||||
|  |                                 > | ||||||
|  |                                     {idx + 1} | ||||||
|  |                                 </button> | ||||||
|  |                             ); | ||||||
|  |                         })} | ||||||
|  |                         <ConditionallyRender | ||||||
|  |                             condition={pageIndex < pages.length - 1} | ||||||
|  |                             show={ | ||||||
|  |                                 <button | ||||||
|  |                                     onClick={() => nextPage()} | ||||||
|  |                                     className={classnames( | ||||||
|  |                                         styles.idxBtn, | ||||||
|  |                                         styles.idxBtnRight | ||||||
|  |                                     )} | ||||||
|  |                                 > | ||||||
|  |                                     <ArrowForwardIosIcon | ||||||
|  |                                         className={styles.idxBtnIcon} | ||||||
|  |                                     /> | ||||||
|  |                                 </button> | ||||||
|  |                             } | ||||||
|  |                         /> | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  |             } | ||||||
|  |         /> | ||||||
|  |     ); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default PaginateUI; | ||||||
| @ -0,0 +1,47 @@ | |||||||
|  | import { makeStyles } from '@material-ui/core/styles'; | ||||||
|  | 
 | ||||||
|  | export const useStyles = makeStyles(theme => ({ | ||||||
|  |     pagination: { | ||||||
|  |         margin: '1rem 0 0 0', | ||||||
|  |         display: 'flex', | ||||||
|  |         justifyContent: ' center', | ||||||
|  |         position: 'absolute', | ||||||
|  |         bottom: '25px', | ||||||
|  |         right: '0', | ||||||
|  |         left: '0', | ||||||
|  |     }, | ||||||
|  |     paginationInnerContainer: { | ||||||
|  |         position: 'relative', | ||||||
|  |     }, | ||||||
|  |     paginationButton: { | ||||||
|  |         border: 'none', | ||||||
|  |         cursor: 'pointer', | ||||||
|  |         backgroundColor: 'efefef', | ||||||
|  |         margin: '0 0.2rem', | ||||||
|  |         borderRadius: '3px', | ||||||
|  |         padding: '0.25rem 0.5rem', | ||||||
|  |     }, | ||||||
|  |     paginationButtonActive: { | ||||||
|  |         backgroundColor: '#635DC5', | ||||||
|  |         color: '#fff', | ||||||
|  |         transition: 'backgroundColor 0.3s ease', | ||||||
|  |     }, | ||||||
|  |     idxBtn: { | ||||||
|  |         border: 'none', | ||||||
|  |         borderRadius: '3px', | ||||||
|  |         background: 'transparent', | ||||||
|  |         position: 'absolute', | ||||||
|  |         height: '23px', | ||||||
|  |         cursor: 'pointer', | ||||||
|  |     }, | ||||||
|  |     idxBtnIcon: { | ||||||
|  |         height: '15px', | ||||||
|  |         width: '15px', | ||||||
|  |     }, | ||||||
|  |     idxBtnLeft: { | ||||||
|  |         left: '-30px', | ||||||
|  |     }, | ||||||
|  |     idxBtnRight: { | ||||||
|  |         right: '-30px', | ||||||
|  |     }, | ||||||
|  | })); | ||||||
| @ -0,0 +1,38 @@ | |||||||
|  | import { IconButton, Tooltip, Button, useMediaQuery } from '@material-ui/core'; | ||||||
|  | import ConditionallyRender from '../ConditionallyRender'; | ||||||
|  | 
 | ||||||
|  | interface IResponsiveButtonProps { | ||||||
|  |     Icon: React.ElementType; | ||||||
|  |     onClick: () => void; | ||||||
|  |     tooltip?: string; | ||||||
|  |     maxWidth: string; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const ResponsiveButton = ({ | ||||||
|  |     Icon, | ||||||
|  |     onClick, | ||||||
|  |     maxWidth, | ||||||
|  |     tooltip, | ||||||
|  | }: IResponsiveButtonProps) => { | ||||||
|  |     const smallScreen = useMediaQuery(`(max-width:${maxWidth})`); | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         <ConditionallyRender | ||||||
|  |             condition={smallScreen} | ||||||
|  |             show={ | ||||||
|  |                 <Tooltip title={tooltip ? tooltip : ''}> | ||||||
|  |                     <IconButton onClick={onClick}> | ||||||
|  |                         <Icon /> | ||||||
|  |                     </IconButton> | ||||||
|  |                 </Tooltip> | ||||||
|  |             } | ||||||
|  |             elseShow={ | ||||||
|  |                 <Button onClick={onClick} color="primary" variant="contained"> | ||||||
|  |                     Add new project | ||||||
|  |                 </Button> | ||||||
|  |             } | ||||||
|  |         /> | ||||||
|  |     ); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default ResponsiveButton; | ||||||
							
								
								
									
										46
									
								
								frontend/src/component/common/Toast/Toast.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								frontend/src/component/common/Toast/Toast.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,46 @@ | |||||||
|  | import { Portal, Snackbar } from '@material-ui/core'; | ||||||
|  | import { Alert } from '@material-ui/lab'; | ||||||
|  | import { useCommonStyles } from '../../../common.styles'; | ||||||
|  | import AnimateOnMount from '../AnimateOnMount/AnimateOnMount'; | ||||||
|  | 
 | ||||||
|  | interface IToastProps { | ||||||
|  |     show: boolean; | ||||||
|  |     onClose: () => void; | ||||||
|  |     type: string; | ||||||
|  |     text: string; | ||||||
|  |     autoHideDuration?: number; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const Toast = ({ | ||||||
|  |     show, | ||||||
|  |     onClose, | ||||||
|  |     type, | ||||||
|  |     text, | ||||||
|  |     autoHideDuration = 6000, | ||||||
|  | }: IToastProps) => { | ||||||
|  |     const styles = useCommonStyles(); | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         <Portal> | ||||||
|  |             <AnimateOnMount | ||||||
|  |                 mounted={show} | ||||||
|  |                 start={styles.fadeInBottomStart} | ||||||
|  |                 enter={styles.fadeInBottomEnter} | ||||||
|  |                 leave={styles.fadeInBottomLeave} | ||||||
|  |                 container={styles.fullWidth} | ||||||
|  |             > | ||||||
|  |                 <Snackbar | ||||||
|  |                     open={show} | ||||||
|  |                     onClose={onClose} | ||||||
|  |                     autoHideDuration={autoHideDuration} | ||||||
|  |                 > | ||||||
|  |                     <Alert variant="filled" severity={type} onClose={onClose}> | ||||||
|  |                         {text} | ||||||
|  |                     </Alert> | ||||||
|  |                 </Snackbar> | ||||||
|  |             </AnimateOnMount> | ||||||
|  |         </Portal> | ||||||
|  |     ); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default Toast; | ||||||
| @ -2,3 +2,6 @@ export const P = 'P'; | |||||||
| export const C = 'C'; | export const C = 'C'; | ||||||
| export const RBAC = 'RBAC'; | export const RBAC = 'RBAC'; | ||||||
| export const OIDC = 'OIDC'; | export const OIDC = 'OIDC'; | ||||||
|  | 
 | ||||||
|  | export const PROJECTCARDACTIONS = false; | ||||||
|  | export const PROJECTFILTERING = false; | ||||||
|  | |||||||
| @ -50,6 +50,7 @@ exports[`renders correctly with one feature 1`] = ` | |||||||
|       > |       > | ||||||
|         <div |         <div | ||||||
|           className="" |           className="" | ||||||
|  |           data-loading={true} | ||||||
|         > |         > | ||||||
|           <h2 |           <h2 | ||||||
|             className="MuiTypography-root makeStyles-headerTitle-13 MuiTypography-h2" |             className="MuiTypography-root makeStyles-headerTitle-13 MuiTypography-h2" | ||||||
| @ -286,6 +287,7 @@ exports[`renders correctly with one feature without permissions 1`] = ` | |||||||
|       > |       > | ||||||
|         <div |         <div | ||||||
|           className="" |           className="" | ||||||
|  |           data-loading={true} | ||||||
|         > |         > | ||||||
|           <h2 |           <h2 | ||||||
|             className="MuiTypography-root makeStyles-headerTitle-13 MuiTypography-h2" |             className="MuiTypography-root makeStyles-headerTitle-13 MuiTypography-h2" | ||||||
|  | |||||||
| @ -0,0 +1,27 @@ | |||||||
|  | import { makeStyles } from '@material-ui/core/styles'; | ||||||
|  | 
 | ||||||
|  | export const useStyles = makeStyles(theme => ({ | ||||||
|  |     tableRow: { | ||||||
|  |         cursor: 'pointer', | ||||||
|  |     }, | ||||||
|  |     tableCell: { | ||||||
|  |         border: 'none', | ||||||
|  |         padding: '0.25rem 0', | ||||||
|  |     }, | ||||||
|  |     tableCellHeader: { | ||||||
|  |         paddingBottom: '0.5rem', | ||||||
|  |     }, | ||||||
|  |     tableCellName: { | ||||||
|  |         width: '250px', | ||||||
|  |     }, | ||||||
|  |     tableCellEnv: { | ||||||
|  |         width: '20px', | ||||||
|  |     }, | ||||||
|  |     tableCellType: { | ||||||
|  |         display: 'flex', | ||||||
|  |         alignItems: 'center', | ||||||
|  |     }, | ||||||
|  |     icon: { | ||||||
|  |         marginRight: '0.3rem', | ||||||
|  |     }, | ||||||
|  | })); | ||||||
| @ -0,0 +1,132 @@ | |||||||
|  | import { | ||||||
|  |     Table, | ||||||
|  |     TableBody, | ||||||
|  |     TableCell, | ||||||
|  |     TableHead, | ||||||
|  |     TableRow, | ||||||
|  | } from '@material-ui/core'; | ||||||
|  | import classnames from 'classnames'; | ||||||
|  | import { useStyles } from './FeatureToggleListNew.styles'; | ||||||
|  | import FeatureToggleListNewItem from './FeatureToggleListNewItem/FeatureToggleListNewItem'; | ||||||
|  | import usePagination from '../../../hooks/usePagination'; | ||||||
|  | 
 | ||||||
|  | import loadingFeatures from './FeatureToggleListNewItem/loadingFeatures'; | ||||||
|  | import { IFeatureToggleListItem } from '../../../interfaces/featureToggle'; | ||||||
|  | import PaginateUI from '../../common/PaginateUI/PaginateUI'; | ||||||
|  | interface IFeatureToggleListNewProps { | ||||||
|  |     features: IFeatureToggleListItem[]; | ||||||
|  |     loading: boolean; | ||||||
|  |     projectId: string; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const FeatureToggleListNew = ({ | ||||||
|  |     features, | ||||||
|  |     loading, | ||||||
|  |     projectId, | ||||||
|  | }: IFeatureToggleListNewProps) => { | ||||||
|  |     const styles = useStyles(); | ||||||
|  |     const { page, pages, nextPage, prevPage, setPageIndex, pageIndex } = | ||||||
|  |         usePagination(features, 9); | ||||||
|  | 
 | ||||||
|  |     const getEnvironments = () => { | ||||||
|  |         if (features.length > 0) { | ||||||
|  |             const envs = features[0].environments || []; | ||||||
|  |             return envs; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return [ | ||||||
|  |             { | ||||||
|  |                 name: ':global:', | ||||||
|  |                 displayName: 'Across all environments', | ||||||
|  |                 enabled: false, | ||||||
|  |             }, | ||||||
|  |         ]; | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const renderFeatures = () => { | ||||||
|  |         if (loading) { | ||||||
|  |             return loadingFeatures.map((feature: IFeatureToggleListItem) => { | ||||||
|  |                 return ( | ||||||
|  |                     <FeatureToggleListNewItem | ||||||
|  |                         key={feature.name} | ||||||
|  |                         name={feature.name} | ||||||
|  |                         type={feature.type} | ||||||
|  |                         environments={feature.environments} | ||||||
|  |                         projectId={projectId} | ||||||
|  |                     /> | ||||||
|  |                 ); | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return page.map((feature: IFeatureToggleListItem) => { | ||||||
|  |             return ( | ||||||
|  |                 <FeatureToggleListNewItem | ||||||
|  |                     key={feature.name} | ||||||
|  |                     name={feature.name} | ||||||
|  |                     type={feature.type} | ||||||
|  |                     environments={feature.environments} | ||||||
|  |                     projectId={projectId} | ||||||
|  |                 /> | ||||||
|  |             ); | ||||||
|  |         }); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         <> | ||||||
|  |             <Table> | ||||||
|  |                 <TableHead> | ||||||
|  |                     <TableRow> | ||||||
|  |                         <TableCell | ||||||
|  |                             className={classnames( | ||||||
|  |                                 styles.tableCell, | ||||||
|  |                                 styles.tableCellName, | ||||||
|  |                                 styles.tableCellHeader | ||||||
|  |                             )} | ||||||
|  |                             align="left" | ||||||
|  |                         > | ||||||
|  |                             <span data-loading>name</span> | ||||||
|  |                         </TableCell> | ||||||
|  |                         <TableCell | ||||||
|  |                             className={classnames( | ||||||
|  |                                 styles.tableCell, | ||||||
|  |                                 styles.tableCellHeader | ||||||
|  |                             )} | ||||||
|  |                             align="left" | ||||||
|  |                         > | ||||||
|  |                             <span data-loading>type</span> | ||||||
|  |                         </TableCell> | ||||||
|  |                         {getEnvironments().map((env: any) => { | ||||||
|  |                             return ( | ||||||
|  |                                 <TableCell | ||||||
|  |                                     key={env.name} | ||||||
|  |                                     className={classnames( | ||||||
|  |                                         styles.tableCell, | ||||||
|  |                                         styles.tableCellEnv, | ||||||
|  |                                         styles.tableCellHeader | ||||||
|  |                                     )} | ||||||
|  |                                     align="center" | ||||||
|  |                                 > | ||||||
|  |                                     <span data-loading> | ||||||
|  |                                         {env.name === ':global:' | ||||||
|  |                                             ? 'global' | ||||||
|  |                                             : env.name} | ||||||
|  |                                     </span> | ||||||
|  |                                 </TableCell> | ||||||
|  |                             ); | ||||||
|  |                         })} | ||||||
|  |                     </TableRow> | ||||||
|  |                 </TableHead> | ||||||
|  |                 <TableBody>{renderFeatures()}</TableBody> | ||||||
|  |             </Table> | ||||||
|  |             <PaginateUI | ||||||
|  |                 pages={pages} | ||||||
|  |                 pageIndex={pageIndex} | ||||||
|  |                 setPageIndex={setPageIndex} | ||||||
|  |                 nextPage={nextPage} | ||||||
|  |                 prevPage={prevPage} | ||||||
|  |             /> | ||||||
|  |         </> | ||||||
|  |     ); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default FeatureToggleListNew; | ||||||
| @ -0,0 +1,106 @@ | |||||||
|  | import { useRef, useState } from 'react'; | ||||||
|  | import { Switch, TableCell, TableRow } from '@material-ui/core'; | ||||||
|  | import { useHistory } from 'react-router'; | ||||||
|  | import { getFeatureTypeIcons } from '../../../../utils/get-feature-type-icons'; | ||||||
|  | import { useStyles } from '../FeatureToggleListNew.styles'; | ||||||
|  | import useToggleFeatureByEnv from '../../../../hooks/api/actions/useToggleFeatureByEnv/useToggleFeatureByEnv'; | ||||||
|  | import { IEnvironments } from '../../../../interfaces/featureToggle'; | ||||||
|  | import Toast from '../../../common/Toast/Toast'; | ||||||
|  | 
 | ||||||
|  | interface IFeatureToggleListNewItemProps { | ||||||
|  |     name: string; | ||||||
|  |     type: string; | ||||||
|  |     environments: IEnvironments[]; | ||||||
|  |     projectId: string; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const FeatureToggleListNewItem = ({ | ||||||
|  |     name, | ||||||
|  |     type, | ||||||
|  |     environments, | ||||||
|  |     projectId, | ||||||
|  | }: IFeatureToggleListNewItemProps) => { | ||||||
|  |     const { toggleFeatureByEnvironment } = useToggleFeatureByEnv( | ||||||
|  |         projectId, | ||||||
|  |         name | ||||||
|  |     ); | ||||||
|  |     const [snackbarData, setSnackbardata] = useState({ | ||||||
|  |         show: false, | ||||||
|  |         type: 'success', | ||||||
|  |         text: '', | ||||||
|  |     }); | ||||||
|  |     const styles = useStyles(); | ||||||
|  |     const history = useHistory(); | ||||||
|  |     const ref = useRef(null); | ||||||
|  | 
 | ||||||
|  |     const onClick = (e: Event) => { | ||||||
|  |         if (!ref.current?.contains(e.target)) { | ||||||
|  |             history.push(`/features/strategies/${name}`); | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const handleToggle = (env: IEnvironments) => { | ||||||
|  |         toggleFeatureByEnvironment(env.name, env.enabled) | ||||||
|  |             .then(() => { | ||||||
|  |                 setSnackbardata({ | ||||||
|  |                     show: true, | ||||||
|  |                     type: 'success', | ||||||
|  |                     text: 'Successfully updated toggle status.', | ||||||
|  |                 }); | ||||||
|  |             }) | ||||||
|  |             .catch(e => { | ||||||
|  |                 setSnackbardata({ | ||||||
|  |                     show: true, | ||||||
|  |                     type: 'error', | ||||||
|  |                     text: e.toString(), | ||||||
|  |                 }); | ||||||
|  |             }); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const hideSnackbar = () => { | ||||||
|  |         setSnackbardata(prev => ({ ...prev, show: false })); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const IconComponent = getFeatureTypeIcons(type); | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         <> | ||||||
|  |             <TableRow onClick={onClick} className={styles.tableRow}> | ||||||
|  |                 <TableCell className={styles.tableCell} align="left"> | ||||||
|  |                     <span data-loading>{name}</span> | ||||||
|  |                 </TableCell> | ||||||
|  |                 <TableCell className={styles.tableCell} align="left"> | ||||||
|  |                     <div className={styles.tableCellType}> | ||||||
|  |                         <IconComponent data-loading className={styles.icon} />{' '} | ||||||
|  |                         <span data-loading>{type}</span> | ||||||
|  |                     </div> | ||||||
|  |                 </TableCell> | ||||||
|  |                 {environments.map((env: IEnvironments) => { | ||||||
|  |                     return ( | ||||||
|  |                         <TableCell | ||||||
|  |                             className={styles.tableCell} | ||||||
|  |                             align="center" | ||||||
|  |                             key={env.name} | ||||||
|  |                         > | ||||||
|  |                             <span data-loading style={{ display: 'block' }}> | ||||||
|  |                                 <Switch | ||||||
|  |                                     checked={env.enabled} | ||||||
|  |                                     ref={ref} | ||||||
|  |                                     onClick={() => handleToggle(env)} | ||||||
|  |                                 /> | ||||||
|  |                             </span> | ||||||
|  |                         </TableCell> | ||||||
|  |                     ); | ||||||
|  |                 })} | ||||||
|  |             </TableRow> | ||||||
|  |             <Toast | ||||||
|  |                 show={snackbarData.show} | ||||||
|  |                 onClose={hideSnackbar} | ||||||
|  |                 text={snackbarData.text} | ||||||
|  |                 type={snackbarData.type} | ||||||
|  |             /> | ||||||
|  |         </> | ||||||
|  |     ); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default FeatureToggleListNewItem; | ||||||
| @ -0,0 +1,103 @@ | |||||||
|  | const loadingFeatures = [ | ||||||
|  |     { | ||||||
|  |         type: 'release', | ||||||
|  |         name: 'loading1', | ||||||
|  |         environments: [ | ||||||
|  |             { | ||||||
|  |                 name: ':global:', | ||||||
|  |                 displayName: 'Across all environments', | ||||||
|  |                 enabled: true, | ||||||
|  |             }, | ||||||
|  |         ], | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |         type: 'release', | ||||||
|  |         name: 'loadg2', | ||||||
|  |         environments: [ | ||||||
|  |             { | ||||||
|  |                 name: ':global:', | ||||||
|  |                 displayName: 'Across all environments', | ||||||
|  |                 enabled: true, | ||||||
|  |             }, | ||||||
|  |         ], | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |         type: 'release', | ||||||
|  |         name: 'loading3', | ||||||
|  |         environments: [ | ||||||
|  |             { | ||||||
|  |                 name: ':global:', | ||||||
|  |                 displayName: 'Across all environments', | ||||||
|  |                 enabled: true, | ||||||
|  |             }, | ||||||
|  |         ], | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |         type: 'release', | ||||||
|  |         name: 'loadi4', | ||||||
|  |         environments: [ | ||||||
|  |             { | ||||||
|  |                 name: ':global:', | ||||||
|  |                 displayName: 'Across all environments', | ||||||
|  |                 enabled: true, | ||||||
|  |             }, | ||||||
|  |         ], | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |         type: 'release', | ||||||
|  |         name: 'loadi5', | ||||||
|  |         environments: [ | ||||||
|  |             { | ||||||
|  |                 name: ':global:', | ||||||
|  |                 displayName: 'Across all environments', | ||||||
|  |                 enabled: true, | ||||||
|  |             }, | ||||||
|  |         ], | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |         type: 'release', | ||||||
|  |         name: 'loadg6', | ||||||
|  |         environments: [ | ||||||
|  |             { | ||||||
|  |                 name: ':global:', | ||||||
|  |                 displayName: 'Across all environments', | ||||||
|  |                 enabled: true, | ||||||
|  |             }, | ||||||
|  |         ], | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |         type: 'release', | ||||||
|  |         name: 'loading7', | ||||||
|  |         environments: [ | ||||||
|  |             { | ||||||
|  |                 name: ':global:', | ||||||
|  |                 displayName: 'Across all environments', | ||||||
|  |                 enabled: true, | ||||||
|  |             }, | ||||||
|  |         ], | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |         type: 'release', | ||||||
|  |         name: 'ln8', | ||||||
|  |         environments: [ | ||||||
|  |             { | ||||||
|  |                 name: ':global:', | ||||||
|  |                 displayName: 'Across all environments', | ||||||
|  |                 enabled: true, | ||||||
|  |             }, | ||||||
|  |         ], | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |         type: 'release', | ||||||
|  |         name: 'load9', | ||||||
|  |         environments: [ | ||||||
|  |             { | ||||||
|  |                 name: ':global:', | ||||||
|  |                 displayName: 'Across all environments', | ||||||
|  |                 enabled: true, | ||||||
|  |             }, | ||||||
|  |         ], | ||||||
|  |     }, | ||||||
|  | ]; | ||||||
|  | 
 | ||||||
|  | export default loadingFeatures; | ||||||
| @ -497,10 +497,10 @@ exports[`renders correctly with with variants 1`] = ` | |||||||
|           </svg> |           </svg> | ||||||
|           <fieldset |           <fieldset | ||||||
|             aria-hidden={true} |             aria-hidden={true} | ||||||
|             className="PrivateNotchedOutline-root-18 MuiOutlinedInput-notchedOutline" |             className="PrivateNotchedOutline-root-21 MuiOutlinedInput-notchedOutline" | ||||||
|           > |           > | ||||||
|             <legend |             <legend | ||||||
|               className="PrivateNotchedOutline-legendLabelled-20 PrivateNotchedOutline-legendNotched-21" |               className="PrivateNotchedOutline-legendLabelled-23 PrivateNotchedOutline-legendNotched-24" | ||||||
|             > |             > | ||||||
|               <span> |               <span> | ||||||
|                 Stickiness |                 Stickiness | ||||||
|  | |||||||
| @ -140,10 +140,10 @@ exports[`renders correctly with one feature 1`] = ` | |||||||
|             </svg> |             </svg> | ||||||
|             <fieldset |             <fieldset | ||||||
|               aria-hidden={true} |               aria-hidden={true} | ||||||
|               className="PrivateNotchedOutline-root-16 MuiOutlinedInput-notchedOutline" |               className="PrivateNotchedOutline-root-19 MuiOutlinedInput-notchedOutline" | ||||||
|             > |             > | ||||||
|               <legend |               <legend | ||||||
|                 className="PrivateNotchedOutline-legendLabelled-18" |                 className="PrivateNotchedOutline-legendLabelled-21" | ||||||
|               > |               > | ||||||
|                 <span> |                 <span> | ||||||
|                   Project |                   Project | ||||||
| @ -175,7 +175,7 @@ exports[`renders correctly with one feature 1`] = ` | |||||||
|       > |       > | ||||||
|         <span |         <span | ||||||
|           aria-disabled={false} |           aria-disabled={false} | ||||||
|           className="MuiButtonBase-root MuiIconButton-root PrivateSwitchBase-root-20 MuiSwitch-switchBase MuiSwitch-colorSecondary" |           className="MuiButtonBase-root MuiIconButton-root PrivateSwitchBase-root-23 MuiSwitch-switchBase MuiSwitch-colorSecondary" | ||||||
|           onBlur={[Function]} |           onBlur={[Function]} | ||||||
|           onDragLeave={[Function]} |           onDragLeave={[Function]} | ||||||
|           onFocus={[Function]} |           onFocus={[Function]} | ||||||
| @ -194,7 +194,7 @@ exports[`renders correctly with one feature 1`] = ` | |||||||
|           > |           > | ||||||
|             <input |             <input | ||||||
|               checked={false} |               checked={false} | ||||||
|               className="PrivateSwitchBase-input-23 MuiSwitch-input" |               className="PrivateSwitchBase-input-26 MuiSwitch-input" | ||||||
|               disabled={false} |               disabled={false} | ||||||
|               onChange={[Function]} |               onChange={[Function]} | ||||||
|               type="checkbox" |               type="checkbox" | ||||||
| @ -327,7 +327,7 @@ exports[`renders correctly with one feature 1`] = ` | |||||||
|   </div> |   </div> | ||||||
|   <hr /> |   <hr /> | ||||||
|   <div |   <div | ||||||
|     className="MuiPaper-root makeStyles-tabNav-24 MuiPaper-elevation1 MuiPaper-rounded" |     className="MuiPaper-root makeStyles-tabNav-27 MuiPaper-elevation1 MuiPaper-rounded" | ||||||
|   > |   > | ||||||
|     <div |     <div | ||||||
|       className="MuiTabs-root" |       className="MuiTabs-root" | ||||||
| @ -375,7 +375,7 @@ exports[`renders correctly with one feature 1`] = ` | |||||||
|               Activation |               Activation | ||||||
|             </span> |             </span> | ||||||
|             <span |             <span | ||||||
|               className="PrivateTabIndicator-root-25 PrivateTabIndicator-colorPrimary-26 MuiTabs-indicator" |               className="PrivateTabIndicator-root-28 PrivateTabIndicator-colorPrimary-29 MuiTabs-indicator" | ||||||
|               style={Object {}} |               style={Object {}} | ||||||
|             /> |             /> | ||||||
|           </button> |           </button> | ||||||
|  | |||||||
| @ -39,6 +39,8 @@ import { P, C } from '../common/flags'; | |||||||
| import NewUser from '../user/NewUser'; | import NewUser from '../user/NewUser'; | ||||||
| import ResetPassword from '../user/ResetPassword/ResetPassword'; | import ResetPassword from '../user/ResetPassword/ResetPassword'; | ||||||
| import ForgottenPassword from '../user/ForgottenPassword/ForgottenPassword'; | import ForgottenPassword from '../user/ForgottenPassword/ForgottenPassword'; | ||||||
|  | import ProjectListNew from '../project/ProjectListNew/ProjectListNew'; | ||||||
|  | import Project from '../project/Project/Project'; | ||||||
| 
 | 
 | ||||||
| import { | import { | ||||||
|     List, |     List, | ||||||
| @ -231,7 +233,15 @@ export const routes = [ | |||||||
|         type: 'protected', |         type: 'protected', | ||||||
|         layout: 'main', |         layout: 'main', | ||||||
|     }, |     }, | ||||||
| 
 |     { | ||||||
|  |         path: '/projects/:id', | ||||||
|  |         parent: '/projects', | ||||||
|  |         title: ':id', | ||||||
|  |         component: Project, | ||||||
|  |         flag: P, | ||||||
|  |         type: 'protected', | ||||||
|  |         layout: 'main', | ||||||
|  |     }, | ||||||
|     { |     { | ||||||
|         path: '/projects', |         path: '/projects', | ||||||
|         title: 'Projects', |         title: 'Projects', | ||||||
| @ -241,6 +251,16 @@ export const routes = [ | |||||||
|         type: 'protected', |         type: 'protected', | ||||||
|         layout: 'main', |         layout: 'main', | ||||||
|     }, |     }, | ||||||
|  |     { | ||||||
|  |         path: '/projects-new', | ||||||
|  |         title: 'Projects new', | ||||||
|  |         icon: 'folder_open', | ||||||
|  |         component: ProjectListNew, | ||||||
|  |         flag: P, | ||||||
|  |         type: 'protected', | ||||||
|  |         hidden: true, | ||||||
|  |         layout: 'main', | ||||||
|  |     }, | ||||||
| 
 | 
 | ||||||
|     { |     { | ||||||
|         path: '/tag-types/create', |         path: '/tag-types/create', | ||||||
|  | |||||||
							
								
								
									
										48
									
								
								frontend/src/component/project/Project/Project.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								frontend/src/component/project/Project/Project.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,48 @@ | |||||||
|  | import { useParams } from 'react-router'; | ||||||
|  | import { useCommonStyles } from '../../../common.styles'; | ||||||
|  | import useProject from '../../../hooks/api/getters/useProject/useProject'; | ||||||
|  | import useLoading from '../../../hooks/useLoading'; | ||||||
|  | import ApiError from '../../common/ApiError/ApiError'; | ||||||
|  | import ConditionallyRender from '../../common/ConditionallyRender'; | ||||||
|  | import ProjectFeatureToggles from './ProjectFeatureToggles/ProjectFeatureToggles'; | ||||||
|  | import ProjectInfo from './ProjectInfo/ProjectInfo'; | ||||||
|  | 
 | ||||||
|  | const Project = () => { | ||||||
|  |     const { id } = useParams<{ id: string }>(); | ||||||
|  |     const { project, error, loading, refetch } = useProject(id); | ||||||
|  |     const ref = useLoading(loading); | ||||||
|  |     const { members, features, health } = project; | ||||||
|  |     const commonStyles = useCommonStyles(); | ||||||
|  | 
 | ||||||
|  |     const containerStyles = { marginTop: '1.5rem', display: 'flex' }; | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         <div ref={ref}> | ||||||
|  |             <h1 data-loading className={commonStyles.title}> | ||||||
|  |                 {project?.name} | ||||||
|  |             </h1> | ||||||
|  |             <ConditionallyRender | ||||||
|  |                 condition={error} | ||||||
|  |                 show={ | ||||||
|  |                     <ApiError | ||||||
|  |                         data-loading | ||||||
|  |                         style={{ maxWidth: '400px', marginTop: '1rem' }} | ||||||
|  |                         onClick={refetch} | ||||||
|  |                         text="Could not fetch project" | ||||||
|  |                     /> | ||||||
|  |                 } | ||||||
|  |             /> | ||||||
|  |             <div style={containerStyles}> | ||||||
|  |                 <ProjectInfo | ||||||
|  |                     id={id} | ||||||
|  |                     memberCount={members} | ||||||
|  |                     health={health} | ||||||
|  |                     featureCount={features?.length} | ||||||
|  |                 /> | ||||||
|  |                 <ProjectFeatureToggles features={features} loading={loading} /> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     ); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default Project; | ||||||
| @ -0,0 +1,25 @@ | |||||||
|  | import { makeStyles } from '@material-ui/core/styles'; | ||||||
|  | 
 | ||||||
|  | export const useStyles = makeStyles(theme => ({ | ||||||
|  |     container: { | ||||||
|  |         boxShadow: 'none', | ||||||
|  |         marginLeft: '2rem', | ||||||
|  |         width: '100%', | ||||||
|  |         position: 'relative', | ||||||
|  |     }, | ||||||
|  |     header: { | ||||||
|  |         padding: '1rem', | ||||||
|  |     }, | ||||||
|  |     title: { | ||||||
|  |         fontSize: '1rem', | ||||||
|  |         fontWeight: 'normal', | ||||||
|  |     }, | ||||||
|  |     iconButton: { | ||||||
|  |         marginRight: '1rem', | ||||||
|  |     }, | ||||||
|  |     icon: { | ||||||
|  |         color: '#000', | ||||||
|  |         height: '30px', | ||||||
|  |         width: '30px', | ||||||
|  |     }, | ||||||
|  | })); | ||||||
| @ -0,0 +1,70 @@ | |||||||
|  | import { Button, IconButton } from '@material-ui/core'; | ||||||
|  | import FilterListIcon from '@material-ui/icons/FilterList'; | ||||||
|  | import { useParams } from 'react-router'; | ||||||
|  | import { Link } from 'react-router-dom'; | ||||||
|  | import { IFeatureToggleListItem } from '../../../../interfaces/featureToggle'; | ||||||
|  | import ConditionallyRender from '../../../common/ConditionallyRender'; | ||||||
|  | import { PROJECTFILTERING } from '../../../common/flags'; | ||||||
|  | import HeaderTitle from '../../../common/HeaderTitle'; | ||||||
|  | import PageContent from '../../../common/PageContent'; | ||||||
|  | import FeatureToggleListNew from '../../../feature/FeatureToggleListNew/FeatureToggleListNew'; | ||||||
|  | import { useStyles } from './ProjectFeatureToggles.styles'; | ||||||
|  | 
 | ||||||
|  | interface IProjectFeatureToggles { | ||||||
|  |     features: IFeatureToggleListItem[]; | ||||||
|  |     loading: boolean; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const ProjectFeatureToggles = ({ | ||||||
|  |     features, | ||||||
|  |     loading, | ||||||
|  | }: IProjectFeatureToggles) => { | ||||||
|  |     const styles = useStyles(); | ||||||
|  |     const { id } = useParams(); | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         <PageContent | ||||||
|  |             className={styles.container} | ||||||
|  |             headerContent={ | ||||||
|  |                 <HeaderTitle | ||||||
|  |                     className={styles.title} | ||||||
|  |                     title="Feature toggles" | ||||||
|  |                     actions={ | ||||||
|  |                         <> | ||||||
|  |                             <ConditionallyRender | ||||||
|  |                                 condition={PROJECTFILTERING} | ||||||
|  |                                 show={ | ||||||
|  |                                     <IconButton | ||||||
|  |                                         className={styles.iconButton} | ||||||
|  |                                         data-loading | ||||||
|  |                                     > | ||||||
|  |                                         <FilterListIcon | ||||||
|  |                                             className={styles.icon} | ||||||
|  |                                         /> | ||||||
|  |                                     </IconButton> | ||||||
|  |                                 } | ||||||
|  |                             /> | ||||||
|  |                             <Button | ||||||
|  |                                 variant="contained" | ||||||
|  |                                 color="primary" | ||||||
|  |                                 component={Link} | ||||||
|  |                                 to="/features/create" | ||||||
|  |                                 data-loading | ||||||
|  |                             > | ||||||
|  |                                 New feature toggle | ||||||
|  |                             </Button> | ||||||
|  |                         </> | ||||||
|  |                     } | ||||||
|  |                 /> | ||||||
|  |             } | ||||||
|  |         > | ||||||
|  |             <FeatureToggleListNew | ||||||
|  |                 features={features} | ||||||
|  |                 loading={loading} | ||||||
|  |                 projectId={id} | ||||||
|  |             /> | ||||||
|  |         </PageContent> | ||||||
|  |     ); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default ProjectFeatureToggles; | ||||||
| @ -0,0 +1,31 @@ | |||||||
|  | import { makeStyles } from '@material-ui/core/styles'; | ||||||
|  | 
 | ||||||
|  | export const useStyles = makeStyles(theme => ({ | ||||||
|  |     projectInfo: { | ||||||
|  |         width: '275px', | ||||||
|  |         padding: '1rem', | ||||||
|  |         display: 'flex', | ||||||
|  |         flexDirection: 'column', | ||||||
|  |         alignItems: 'center', | ||||||
|  |         boxShadow: 'none', | ||||||
|  |     }, | ||||||
|  |     subtitle: { | ||||||
|  |         marginBottom: '1.25rem', | ||||||
|  |     }, | ||||||
|  |     emphazisedText: { | ||||||
|  |         fontSize: '1.5rem', | ||||||
|  |         marginBottom: '1rem', | ||||||
|  |     }, | ||||||
|  |     infoSection: { | ||||||
|  |         margin: '1.8rem 0', | ||||||
|  |         textAlign: 'center', | ||||||
|  |     }, | ||||||
|  |     arrowIcon: { | ||||||
|  |         color: '#635dc5', | ||||||
|  |         marginLeft: '0.5rem', | ||||||
|  |     }, | ||||||
|  |     infoLink: { | ||||||
|  |         textDecoration: 'none', | ||||||
|  |         color: '#635dc5', | ||||||
|  |     }, | ||||||
|  | })); | ||||||
| @ -0,0 +1,82 @@ | |||||||
|  | import { Paper } from '@material-ui/core'; | ||||||
|  | import { useStyles } from './ProjectInfo.styles'; | ||||||
|  | import { Link } from 'react-router-dom'; | ||||||
|  | import ArrowForwardIcon from '@material-ui/icons/ArrowForward'; | ||||||
|  | import classnames from 'classnames'; | ||||||
|  | 
 | ||||||
|  | import { ReactComponent as ProjectIcon } from '../../../../assets/icons/projectIcon.svg'; | ||||||
|  | import { useCommonStyles } from '../../../../common.styles'; | ||||||
|  | import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig'; | ||||||
|  | 
 | ||||||
|  | interface IProjectInfoProps { | ||||||
|  |     id: string; | ||||||
|  |     memberCount: number; | ||||||
|  |     featureCount: number; | ||||||
|  |     health: number; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const ProjectInfo = ({ | ||||||
|  |     id, | ||||||
|  |     memberCount, | ||||||
|  |     featureCount, | ||||||
|  |     health, | ||||||
|  | }: IProjectInfoProps) => { | ||||||
|  |     const commonStyles = useCommonStyles(); | ||||||
|  |     const styles = useStyles(); | ||||||
|  |     const { uiConfig } = useUiConfig(); | ||||||
|  | 
 | ||||||
|  |     let link = `/admin/users`; | ||||||
|  | 
 | ||||||
|  |     if (uiConfig?.versionInfo?.current?.enterprise) { | ||||||
|  |         link = `/projects/${id}/access`; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         <aside> | ||||||
|  |             <Paper className={styles.projectInfo}> | ||||||
|  |                 <div className={styles.infoSection} data-loading> | ||||||
|  |                     <ProjectIcon /> | ||||||
|  |                 </div> | ||||||
|  | 
 | ||||||
|  |                 <div className={styles.infoSection} data-loading> | ||||||
|  |                     <p className={styles.subtitle}>Overall health rating</p> | ||||||
|  |                     <p className={styles.emphazisedText}>{health}%</p> | ||||||
|  |                     <Link | ||||||
|  |                         className={classnames( | ||||||
|  |                             commonStyles.flexRow, | ||||||
|  |                             commonStyles.justifyCenter, | ||||||
|  |                             styles.infoLink | ||||||
|  |                         )} | ||||||
|  |                         to="/reporting" | ||||||
|  |                     > | ||||||
|  |                         view more{' '} | ||||||
|  |                         <ArrowForwardIcon className={styles.arrowIcon} /> | ||||||
|  |                     </Link> | ||||||
|  |                 </div> | ||||||
|  | 
 | ||||||
|  |                 <div className={styles.infoSection} data-loading> | ||||||
|  |                     <p className={styles.subtitle}>Project members</p> | ||||||
|  |                     <p className={styles.emphazisedText}>{memberCount}</p> | ||||||
|  |                     <Link | ||||||
|  |                         className={classnames( | ||||||
|  |                             commonStyles.flexRow, | ||||||
|  |                             commonStyles.justifyCenter, | ||||||
|  |                             styles.infoLink | ||||||
|  |                         )} | ||||||
|  |                         to={link} | ||||||
|  |                     > | ||||||
|  |                         view more{' '} | ||||||
|  |                         <ArrowForwardIcon className={styles.arrowIcon} /> | ||||||
|  |                     </Link> | ||||||
|  |                 </div> | ||||||
|  | 
 | ||||||
|  |                 <div className={styles.infoSection} data-loading> | ||||||
|  |                     <p className={styles.subtitle}>Feature toggles</p> | ||||||
|  |                     <p className={styles.emphazisedText}>{featureCount}</p> | ||||||
|  |                 </div> | ||||||
|  |             </Paper> | ||||||
|  |         </aside> | ||||||
|  |     ); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default ProjectInfo; | ||||||
| @ -0,0 +1,41 @@ | |||||||
|  | import { makeStyles } from '@material-ui/core/styles'; | ||||||
|  | 
 | ||||||
|  | export const useStyles = makeStyles(theme => ({ | ||||||
|  |     projectCard: { | ||||||
|  |         padding: '1rem', | ||||||
|  |         width: '220px', | ||||||
|  |         height: '204px', | ||||||
|  |         display: 'flex', | ||||||
|  |         flexDirection: 'column', | ||||||
|  |         justifyContent: 'space-between', | ||||||
|  |         margin: '0.5rem', | ||||||
|  |         boxShadow: 'none', | ||||||
|  |         border: '1px solid #efefef', | ||||||
|  |     }, | ||||||
|  |     header: { | ||||||
|  |         display: 'flex', | ||||||
|  |         alignItems: 'center', | ||||||
|  |         justifyContent: 'space-between', | ||||||
|  |     }, | ||||||
|  |     title: { | ||||||
|  |         fontWeight: 'normal', | ||||||
|  |         fontSize: '1rem', | ||||||
|  |     }, | ||||||
|  |     projectIcon: { | ||||||
|  |         margin: '1rem auto', | ||||||
|  |         width: '80px', | ||||||
|  |         display: 'block', | ||||||
|  |     }, | ||||||
|  |     info: { | ||||||
|  |         display: 'flex', | ||||||
|  |         justifyContent: 'space-between', | ||||||
|  |         fontSize: '0.8rem', | ||||||
|  |     }, | ||||||
|  |     infoBox: { | ||||||
|  |         textAlign: 'center', | ||||||
|  |     }, | ||||||
|  |     infoStats: { | ||||||
|  |         color: '#4A4599', | ||||||
|  |         fontWeight: 'bold', | ||||||
|  |     }, | ||||||
|  | })); | ||||||
							
								
								
									
										66
									
								
								frontend/src/component/project/ProjectCard/ProjectCard.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								frontend/src/component/project/ProjectCard/ProjectCard.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,66 @@ | |||||||
|  | import { Card, IconButton } from '@material-ui/core'; | ||||||
|  | import { useStyles } from './ProjectCard.styles'; | ||||||
|  | import MoreVertIcon from '@material-ui/icons/MoreVert'; | ||||||
|  | 
 | ||||||
|  | import { ReactComponent as ProjectIcon } from '../../../assets/icons/projectIcon.svg'; | ||||||
|  | import ConditionallyRender from '../../common/ConditionallyRender'; | ||||||
|  | import { PROJECTCARDACTIONS } from '../../common/flags'; | ||||||
|  | 
 | ||||||
|  | interface IProjectCardProps { | ||||||
|  |     name: string; | ||||||
|  |     featureCount: number; | ||||||
|  |     health: number; | ||||||
|  |     memberCount: number; | ||||||
|  |     onHover: () => void; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const ProjectCard = ({ | ||||||
|  |     name, | ||||||
|  |     featureCount, | ||||||
|  |     health, | ||||||
|  |     memberCount, | ||||||
|  |     onHover, | ||||||
|  | }: IProjectCardProps) => { | ||||||
|  |     const styles = useStyles(); | ||||||
|  |     return ( | ||||||
|  |         <Card className={styles.projectCard} onMouseEnter={onHover}> | ||||||
|  |             <div className={styles.header} data-loading> | ||||||
|  |                 <h2 className={styles.title}>{name}</h2> | ||||||
|  |                 <ConditionallyRender | ||||||
|  |                     condition={PROJECTCARDACTIONS} | ||||||
|  |                     show={ | ||||||
|  |                         <IconButton data-loading> | ||||||
|  |                             <MoreVertIcon /> | ||||||
|  |                         </IconButton> | ||||||
|  |                     } | ||||||
|  |                 /> | ||||||
|  |             </div> | ||||||
|  |             <div data-loading> | ||||||
|  |                 <ProjectIcon className={styles.projectIcon} /> | ||||||
|  |             </div> | ||||||
|  |             <div className={styles.info}> | ||||||
|  |                 <div className={styles.infoBox}> | ||||||
|  |                     <p className={styles.infoStats} data-loading> | ||||||
|  |                         {featureCount} | ||||||
|  |                     </p> | ||||||
|  |                     <p data-loading>toggles</p> | ||||||
|  |                 </div> | ||||||
|  |                 <div className={styles.infoBox}> | ||||||
|  |                     <p className={styles.infoStats} data-loading> | ||||||
|  |                         {health}% | ||||||
|  |                     </p> | ||||||
|  |                     <p data-loading>health</p> | ||||||
|  |                 </div> | ||||||
|  | 
 | ||||||
|  |                 <div className={styles.infoBox}> | ||||||
|  |                     <p className={styles.infoStats} data-loading> | ||||||
|  |                         {memberCount} | ||||||
|  |                     </p> | ||||||
|  |                     <p data-loading>members</p> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |         </Card> | ||||||
|  |     ); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default ProjectCard; | ||||||
| @ -14,8 +14,6 @@ import { | |||||||
|     ListItemAvatar, |     ListItemAvatar, | ||||||
|     ListItemText, |     ListItemText, | ||||||
|     Tooltip, |     Tooltip, | ||||||
|     Button, |  | ||||||
|     useMediaQuery, |  | ||||||
| } from '@material-ui/core'; | } from '@material-ui/core'; | ||||||
| import { | import { | ||||||
|     Add, |     Add, | ||||||
| @ -29,10 +27,10 @@ import ConfirmDialogue from '../../common/Dialogue'; | |||||||
| import PageContent from '../../common/PageContent/PageContent'; | import PageContent from '../../common/PageContent/PageContent'; | ||||||
| import { useStyles } from './styles'; | import { useStyles } from './styles'; | ||||||
| import AccessContext from '../../../contexts/AccessContext'; | import AccessContext from '../../../contexts/AccessContext'; | ||||||
|  | import ResponsiveButton from '../../common/ResponsiveButton/ResponsiveButton'; | ||||||
| 
 | 
 | ||||||
| const ProjectList = ({ projects, fetchProjects, removeProject, history }) => { | const ProjectList = ({ projects, fetchProjects, removeProject, history }) => { | ||||||
|     const { hasAccess } = useContext(AccessContext); |     const { hasAccess } = useContext(AccessContext); | ||||||
|     const smallScreen = useMediaQuery('(max-width:700px)'); |  | ||||||
|     const [showDelDialogue, setShowDelDialogue] = useState(false); |     const [showDelDialogue, setShowDelDialogue] = useState(false); | ||||||
|     const [project, setProject] = useState(undefined); |     const [project, setProject] = useState(undefined); | ||||||
|     const styles = useStyles(); |     const styles = useStyles(); | ||||||
| @ -44,26 +42,11 @@ const ProjectList = ({ projects, fetchProjects, removeProject, history }) => { | |||||||
|         <ConditionallyRender |         <ConditionallyRender | ||||||
|             condition={hasAccess(CREATE_PROJECT)} |             condition={hasAccess(CREATE_PROJECT)} | ||||||
|             show={ |             show={ | ||||||
|                 <ConditionallyRender |                 <ResponsiveButton | ||||||
|                     condition={smallScreen} |                     Icon={Add} | ||||||
|                     show={ |                     onClick={() => history.push('/projects/create')} | ||||||
|                         <Tooltip title="Add new project"> |                     maxWidth="700px" | ||||||
|                             <IconButton |                     tooltip="Add new project" | ||||||
|                                 onClick={() => history.push('/projects/create')} |  | ||||||
|                             > |  | ||||||
|                                 <Add /> |  | ||||||
|                             </IconButton> |  | ||||||
|                         </Tooltip> |  | ||||||
|                     } |  | ||||||
|                     elseShow={ |  | ||||||
|                         <Button |  | ||||||
|                             onClick={() => history.push('/projects/create')} |  | ||||||
|                             color="primary" |  | ||||||
|                             variant="contained" |  | ||||||
|                         > |  | ||||||
|                             Add new project |  | ||||||
|                         </Button> |  | ||||||
|                     } |  | ||||||
|                 /> |                 /> | ||||||
|             } |             } | ||||||
|         /> |         /> | ||||||
|  | |||||||
| @ -0,0 +1,12 @@ | |||||||
|  | import { makeStyles } from '@material-ui/core/styles'; | ||||||
|  | 
 | ||||||
|  | export const useStyles = makeStyles(theme => ({ | ||||||
|  |     container: { | ||||||
|  |         display: 'flex', | ||||||
|  |     }, | ||||||
|  |     apiError: { | ||||||
|  |         maxWidth: '400px', | ||||||
|  |         marginBottom: '1rem', | ||||||
|  |     }, | ||||||
|  |     cardLink: { color: 'inherit', textDecoration: 'none' }, | ||||||
|  | })); | ||||||
							
								
								
									
										137
									
								
								frontend/src/component/project/ProjectListNew/ProjectListNew.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								frontend/src/component/project/ProjectListNew/ProjectListNew.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,137 @@ | |||||||
|  | import { useContext, useState } from 'react'; | ||||||
|  | import { Link, useHistory } from 'react-router-dom'; | ||||||
|  | import { mutate } from 'swr'; | ||||||
|  | import { getProjectFetcher } from '../../../hooks/api/getters/useProject/getProjectFetcher'; | ||||||
|  | import useProjects from '../../../hooks/api/getters/useProjects/useProjects'; | ||||||
|  | import ConditionallyRender from '../../common/ConditionallyRender'; | ||||||
|  | import ProjectCard from '../ProjectCard/ProjectCard'; | ||||||
|  | import { useStyles } from './ProjectListNew.styles'; | ||||||
|  | import { IProjectCard } from '../../../interfaces/project'; | ||||||
|  | 
 | ||||||
|  | import loadingData from './loadingData'; | ||||||
|  | import useLoading from '../../../hooks/useLoading'; | ||||||
|  | import PageContent from '../../common/PageContent'; | ||||||
|  | import AccessContext from '../../../contexts/AccessContext'; | ||||||
|  | import HeaderTitle from '../../common/HeaderTitle'; | ||||||
|  | import ResponsiveButton from '../../common/ResponsiveButton/ResponsiveButton'; | ||||||
|  | import { CREATE_PROJECT } from '../../AccessProvider/permissions'; | ||||||
|  | 
 | ||||||
|  | import { Add } from '@material-ui/icons'; | ||||||
|  | import ApiError from '../../common/ApiError/ApiError'; | ||||||
|  | 
 | ||||||
|  | type projectMap = { | ||||||
|  |     [index: string]: boolean; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const ProjectListNew = () => { | ||||||
|  |     const { hasAccess } = useContext(AccessContext); | ||||||
|  |     const history = useHistory(); | ||||||
|  | 
 | ||||||
|  |     const styles = useStyles(); | ||||||
|  |     const { projects, loading, error, refetch } = useProjects(); | ||||||
|  |     const [fetchedProjects, setFetchedProjects] = useState<projectMap>({}); | ||||||
|  |     const ref = useLoading(loading); | ||||||
|  | 
 | ||||||
|  |     const handleHover = (projectId: string) => { | ||||||
|  |         if (fetchedProjects[projectId]) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         const { KEY, fetcher } = getProjectFetcher(projectId); | ||||||
|  |         mutate(KEY, fetcher); | ||||||
|  |         setFetchedProjects(prev => ({ ...prev, [projectId]: true })); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const renderError = () => { | ||||||
|  |         return ( | ||||||
|  |             <ApiError | ||||||
|  |                 onClick={refetch} | ||||||
|  |                 className={styles.apiError} | ||||||
|  |                 text="Error fetching projects" | ||||||
|  |             /> | ||||||
|  |         ); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const renderProjects = () => { | ||||||
|  |         if (loading) { | ||||||
|  |             return renderLoading(); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return projects.map((project: IProjectCard) => { | ||||||
|  |             return ( | ||||||
|  |                 <Link | ||||||
|  |                     key={project.id} | ||||||
|  |                     to={{ | ||||||
|  |                         pathname: `/projects/${project.id}`, | ||||||
|  |                         state: { | ||||||
|  |                             projectName: project.name, | ||||||
|  |                         }, | ||||||
|  |                     }} | ||||||
|  |                     className={styles.cardLink} | ||||||
|  |                 > | ||||||
|  |                     <ProjectCard | ||||||
|  |                         onHover={() => handleHover(project?.id)} | ||||||
|  |                         name={project?.name} | ||||||
|  |                         memberCount={project?.memberCount} | ||||||
|  |                         health={project?.health} | ||||||
|  |                         featureCount={project?.featureCount} | ||||||
|  |                     /> | ||||||
|  |                 </Link> | ||||||
|  |             ); | ||||||
|  |         }); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const renderLoading = () => { | ||||||
|  |         return loadingData.map((project: IProjectCard) => { | ||||||
|  |             return ( | ||||||
|  |                 <ProjectCard | ||||||
|  |                     data-loading | ||||||
|  |                     onHover={() => {}} | ||||||
|  |                     key={project.id} | ||||||
|  |                     projectName={project.name} | ||||||
|  |                     members={2} | ||||||
|  |                     health={95} | ||||||
|  |                     toggles={4} | ||||||
|  |                 /> | ||||||
|  |             ); | ||||||
|  |         }); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         <div ref={ref}> | ||||||
|  |             <PageContent | ||||||
|  |                 headerContent={ | ||||||
|  |                     <HeaderTitle | ||||||
|  |                         title="Projects" | ||||||
|  |                         actions={ | ||||||
|  |                             <ConditionallyRender | ||||||
|  |                                 condition={hasAccess(CREATE_PROJECT)} | ||||||
|  |                                 show={ | ||||||
|  |                                     <ResponsiveButton | ||||||
|  |                                         Icon={Add} | ||||||
|  |                                         onClick={() => | ||||||
|  |                                             history.push('/projects/create') | ||||||
|  |                                         } | ||||||
|  |                                         maxWidth="700px" | ||||||
|  |                                         tooltip="Add new project" | ||||||
|  |                                     /> | ||||||
|  |                                 } | ||||||
|  |                             /> | ||||||
|  |                         } | ||||||
|  |                     /> | ||||||
|  |                 } | ||||||
|  |             > | ||||||
|  |                 <ConditionallyRender condition={error} show={renderError()} /> | ||||||
|  |                 <div className={styles.container}> | ||||||
|  |                     <ConditionallyRender | ||||||
|  |                         condition={projects.length < 1 && !loading} | ||||||
|  |                         show={<div>No projects available.</div>} | ||||||
|  |                         elseShow={renderProjects()} | ||||||
|  |                     /> | ||||||
|  |                 </div> | ||||||
|  |             </PageContent> | ||||||
|  |         </div> | ||||||
|  |     ); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default ProjectListNew; | ||||||
							
								
								
									
										32
									
								
								frontend/src/component/project/ProjectListNew/loadingData.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								frontend/src/component/project/ProjectListNew/loadingData.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,32 @@ | |||||||
|  | const loadingData = [ | ||||||
|  |     { | ||||||
|  |         id: 'loading1', | ||||||
|  |         name: 'loading1', | ||||||
|  |         members: 1, | ||||||
|  |         health: 95, | ||||||
|  |         toggles: 4, | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |         id: 'loading2', | ||||||
|  |         name: 'loading2', | ||||||
|  |         members: 1, | ||||||
|  |         health: 95, | ||||||
|  |         toggles: 4, | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |         id: 'loading3', | ||||||
|  |         name: 'loading3', | ||||||
|  |         members: 1, | ||||||
|  |         health: 95, | ||||||
|  |         toggles: 4, | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |         id: 'loading4', | ||||||
|  |         name: 'loading4', | ||||||
|  |         members: 1, | ||||||
|  |         health: 95, | ||||||
|  |         toggles: 4, | ||||||
|  |     }, | ||||||
|  | ]; | ||||||
|  | 
 | ||||||
|  | export default loadingData; | ||||||
| @ -35,11 +35,15 @@ function AccessComponent({ projectId, project }) { | |||||||
|     const history = useHistory(); |     const history = useHistory(); | ||||||
| 
 | 
 | ||||||
|     const fetchAccess = async () => { |     const fetchAccess = async () => { | ||||||
|         const access = await projectApi.fetchAccess(projectId); |         try { | ||||||
|         setRoles(access.roles); |             const access = await projectApi.fetchAccess(projectId); | ||||||
|         setUsers( |             setRoles(access.roles); | ||||||
|             access.users.map(u => ({ ...u, name: u.name || '(No name)' })) |             setUsers( | ||||||
|         ); |                 access.users.map(u => ({ ...u, name: u.name || '(No name)' })) | ||||||
|  |             ); | ||||||
|  |         } catch (e) { | ||||||
|  |             console.log(e); | ||||||
|  |         } | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     useEffect(() => { |     useEffect(() => { | ||||||
|  | |||||||
| @ -12,6 +12,7 @@ exports[`renders correctly with one strategy 1`] = ` | |||||||
|     > |     > | ||||||
|       <div |       <div | ||||||
|         className="" |         className="" | ||||||
|  |         data-loading={true} | ||||||
|       > |       > | ||||||
|         <h2 |         <h2 | ||||||
|           className="MuiTypography-root makeStyles-headerTitle-8 MuiTypography-h2" |           className="MuiTypography-root makeStyles-headerTitle-8 MuiTypography-h2" | ||||||
| @ -140,6 +141,7 @@ exports[`renders correctly with one strategy without permissions 1`] = ` | |||||||
|     > |     > | ||||||
|       <div |       <div | ||||||
|         className="" |         className="" | ||||||
|  |         data-loading={true} | ||||||
|       > |       > | ||||||
|         <h2 |         <h2 | ||||||
|           className="MuiTypography-root makeStyles-headerTitle-8 MuiTypography-h2" |           className="MuiTypography-root makeStyles-headerTitle-8 MuiTypography-h2" | ||||||
|  | |||||||
| @ -12,6 +12,7 @@ exports[`renders correctly with one strategy 1`] = ` | |||||||
|     > |     > | ||||||
|       <div |       <div | ||||||
|         className="" |         className="" | ||||||
|  |         data-loading={true} | ||||||
|       > |       > | ||||||
|         <h2 |         <h2 | ||||||
|           className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2" |           className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2" | ||||||
|  | |||||||
| @ -12,6 +12,7 @@ exports[`it supports editMode 1`] = ` | |||||||
|     > |     > | ||||||
|       <div |       <div | ||||||
|         className="" |         className="" | ||||||
|  |         data-loading={true} | ||||||
|       > |       > | ||||||
|         <h2 |         <h2 | ||||||
|           className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2" |           className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2" | ||||||
| @ -108,6 +109,7 @@ exports[`renders correctly for creating 1`] = ` | |||||||
|     > |     > | ||||||
|       <div |       <div | ||||||
|         className="" |         className="" | ||||||
|  |         data-loading={true} | ||||||
|       > |       > | ||||||
|         <h2 |         <h2 | ||||||
|           className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2" |           className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2" | ||||||
| @ -204,6 +206,7 @@ exports[`renders correctly for creating without permissions 1`] = ` | |||||||
|     > |     > | ||||||
|       <div |       <div | ||||||
|         className="" |         className="" | ||||||
|  |         data-loading={true} | ||||||
|       > |       > | ||||||
|         <h2 |         <h2 | ||||||
|           className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2" |           className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2" | ||||||
|  | |||||||
| @ -12,6 +12,7 @@ exports[`renders a list with elements correctly 1`] = ` | |||||||
|     > |     > | ||||||
|       <div |       <div | ||||||
|         className="" |         className="" | ||||||
|  |         data-loading={true} | ||||||
|       > |       > | ||||||
|         <h2 |         <h2 | ||||||
|           className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2" |           className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2" | ||||||
| @ -154,6 +155,7 @@ exports[`renders an empty list correctly 1`] = ` | |||||||
|     > |     > | ||||||
|       <div |       <div | ||||||
|         className="" |         className="" | ||||||
|  |         data-loading={true} | ||||||
|       > |       > | ||||||
|         <h2 |         <h2 | ||||||
|           className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2" |           className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2" | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ import StandaloneBanner from '../StandaloneBanner/StandaloneBanner'; | |||||||
| import ResetPasswordDetails from '../common/ResetPasswordDetails/ResetPasswordDetails'; | import ResetPasswordDetails from '../common/ResetPasswordDetails/ResetPasswordDetails'; | ||||||
| 
 | 
 | ||||||
| import { useStyles } from './NewUser.styles'; | import { useStyles } from './NewUser.styles'; | ||||||
| import useResetPassword from '../../../hooks/useResetPassword'; | import useResetPassword from '../../../hooks/api/getters/useResetPassword/useResetPassword'; | ||||||
| import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout'; | import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout'; | ||||||
| import ConditionallyRender from '../../common/ConditionallyRender'; | import ConditionallyRender from '../../common/ConditionallyRender'; | ||||||
| import InvalidToken from '../common/InvalidToken/InvalidToken'; | import InvalidToken from '../common/InvalidToken/InvalidToken'; | ||||||
|  | |||||||
| @ -6,7 +6,7 @@ import { useStyles } from './ResetPassword.styles'; | |||||||
| import { Typography } from '@material-ui/core'; | import { Typography } from '@material-ui/core'; | ||||||
| import ConditionallyRender from '../../common/ConditionallyRender'; | import ConditionallyRender from '../../common/ConditionallyRender'; | ||||||
| import InvalidToken from '../common/InvalidToken/InvalidToken'; | import InvalidToken from '../common/InvalidToken/InvalidToken'; | ||||||
| import useResetPassword from '../../../hooks/useResetPassword'; | import useResetPassword from '../../../hooks/api/getters/useResetPassword/useResetPassword'; | ||||||
| import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout'; | import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout'; | ||||||
| 
 | 
 | ||||||
| const ResetPassword = () => { | const ResetPassword = () => { | ||||||
|  | |||||||
							
								
								
									
										5
									
								
								frontend/src/constants/featureToggleTypes.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								frontend/src/constants/featureToggleTypes.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | |||||||
|  | export const EXPERIMENT = 'experiment'; | ||||||
|  | export const RELEASE = 'release'; | ||||||
|  | export const OPERATIONAL = 'operational'; | ||||||
|  | export const KILLSWITCH = 'kill-switch'; | ||||||
|  | export const PERMISSION = 'permission'; | ||||||
| @ -0,0 +1,76 @@ | |||||||
|  | import { Dispatch, SetStateAction } from 'react'; | ||||||
|  | 
 | ||||||
|  | import { | ||||||
|  |     AuthenticationError, | ||||||
|  |     ForbiddenError, | ||||||
|  |     NotFoundError, | ||||||
|  | } from '../../../../store/api-helper'; | ||||||
|  | 
 | ||||||
|  | export const handleBadRequest = async ( | ||||||
|  |     setErrors?: Dispatch<SetStateAction<{}>>, | ||||||
|  |     res?: Response, | ||||||
|  |     requestId?: string | ||||||
|  | ) => { | ||||||
|  |     if (!setErrors || !requestId) return; | ||||||
|  |     if (res) { | ||||||
|  |         const data = await res.json(); | ||||||
|  | 
 | ||||||
|  |         setErrors(prev => ({ | ||||||
|  |             ...prev, | ||||||
|  |             [requestId]: data[0].msg, | ||||||
|  |         })); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     throw new Error(); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const handleNotFound = ( | ||||||
|  |     setErrors?: Dispatch<SetStateAction<{}>>, | ||||||
|  |     res?: Response, | ||||||
|  |     requestId?: string | ||||||
|  | ) => { | ||||||
|  |     if (!setErrors || !requestId) return; | ||||||
|  | 
 | ||||||
|  |     setErrors(prev => ({ | ||||||
|  |         ...prev, | ||||||
|  |         [requestId]: 'Could not find the requested resource.', | ||||||
|  |     })); | ||||||
|  | 
 | ||||||
|  |     throw new NotFoundError(res?.status); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const handleUnauthorized = async ( | ||||||
|  |     setErrors?: Dispatch<SetStateAction<{}>>, | ||||||
|  |     res?: Response, | ||||||
|  |     requestId?: string | ||||||
|  | ) => { | ||||||
|  |     if (!setErrors || !requestId) return; | ||||||
|  |     if (res) { | ||||||
|  |         const data = await res.json(); | ||||||
|  | 
 | ||||||
|  |         setErrors(prev => ({ | ||||||
|  |             ...prev, | ||||||
|  |             [requestId]: data[0].msg, | ||||||
|  |         })); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     throw new AuthenticationError(res?.status); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const handleForbidden = async ( | ||||||
|  |     setErrors?: Dispatch<SetStateAction<{}>>, | ||||||
|  |     res?: Response, | ||||||
|  |     requestId?: string | ||||||
|  | ) => { | ||||||
|  |     if (!setErrors || !requestId) return; | ||||||
|  |     if (res) { | ||||||
|  |         const data = await res.json(); | ||||||
|  | 
 | ||||||
|  |         setErrors(prev => ({ | ||||||
|  |             ...prev, | ||||||
|  |             [requestId]: data[0].msg, | ||||||
|  |         })); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     throw new ForbiddenError(res?.status); | ||||||
|  | }; | ||||||
| @ -0,0 +1,113 @@ | |||||||
|  | import { IUserPayload } from '../../../../interfaces/user'; | ||||||
|  | 
 | ||||||
|  | import useAPI from '../useApi/useApi'; | ||||||
|  | import { | ||||||
|  |     handleBadRequest, | ||||||
|  |     handleForbidden, | ||||||
|  |     handleNotFound, | ||||||
|  |     handleUnauthorized, | ||||||
|  | } from './errorHandlers'; | ||||||
|  | 
 | ||||||
|  | export interface IUserApiErrors { | ||||||
|  |     addUser?: string; | ||||||
|  |     removeUser?: string; | ||||||
|  |     updateUser?: string; | ||||||
|  |     changePassword?: string; | ||||||
|  |     validatePassword?: string; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const ADD_USER_ERROR = 'addUser'; | ||||||
|  | export const UPDATE_USER_ERROR = 'updateUser'; | ||||||
|  | export const REMOVE_USER_ERROR = 'removeUser'; | ||||||
|  | export const CHANGE_PASSWORD_ERROR = 'changePassword'; | ||||||
|  | export const VALIDATE_PASSWORD_ERROR = 'validatePassword'; | ||||||
|  | 
 | ||||||
|  | const useAdminUsersApi = () => { | ||||||
|  |     const { loading, makeRequest, createRequest, errors } = useAPI({ | ||||||
|  |         handleBadRequest, | ||||||
|  |         handleNotFound, | ||||||
|  |         handleUnauthorized, | ||||||
|  |         handleForbidden, | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     const addUser = async (user: IUserPayload) => { | ||||||
|  |         const requestId = 'addUser'; | ||||||
|  |         const req = createRequest( | ||||||
|  |             'api/admin/user-admin', | ||||||
|  |             { | ||||||
|  |                 method: 'POST', | ||||||
|  |                 body: JSON.stringify(user), | ||||||
|  |             }, | ||||||
|  |             requestId | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         return makeRequest(req.caller, req.id); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const removeUser = async (user: IUserPayload) => { | ||||||
|  |         const requestId = 'removeUser'; | ||||||
|  |         const req = createRequest( | ||||||
|  |             `api/admin/user-admin/${user.id}`, | ||||||
|  |             { | ||||||
|  |                 method: 'DELETE', | ||||||
|  |             }, | ||||||
|  |             requestId | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         return makeRequest(req.caller, req.id); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const updateUser = async (user: IUserPayload) => { | ||||||
|  |         const requestId = 'updateUser'; | ||||||
|  |         const req = createRequest( | ||||||
|  |             `api/admin/user-admin/${user.id}`, | ||||||
|  |             { | ||||||
|  |                 method: 'PUT', | ||||||
|  |                 body: JSON.stringify(user), | ||||||
|  |             }, | ||||||
|  |             requestId | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         return makeRequest(req.caller, req.id); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const changePassword = async (user: IUserPayload, password: string) => { | ||||||
|  |         const requestId = 'changePassword'; | ||||||
|  |         const req = createRequest( | ||||||
|  |             `api/admin/user-admin/${user.id}/change-password`, | ||||||
|  |             { | ||||||
|  |                 method: 'POST', | ||||||
|  |                 body: JSON.stringify({ password }), | ||||||
|  |             }, | ||||||
|  |             requestId | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         return makeRequest(req.caller, req.id); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const validatePassword = async (password: string) => { | ||||||
|  |         const requestId = 'validatePassword'; | ||||||
|  |         const req = createRequest( | ||||||
|  |             `api/admin/user-admin/validate-password`, | ||||||
|  |             { | ||||||
|  |                 method: 'POST', | ||||||
|  |                 body: JSON.stringify({ password }), | ||||||
|  |             }, | ||||||
|  |             requestId | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         return makeRequest(req.caller, req.id); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     return { | ||||||
|  |         addUser, | ||||||
|  |         updateUser, | ||||||
|  |         removeUser, | ||||||
|  |         changePassword, | ||||||
|  |         validatePassword, | ||||||
|  |         userApiErrors: errors, | ||||||
|  |         userLoading: loading, | ||||||
|  |     }; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default useAdminUsersApi; | ||||||
							
								
								
									
										166
									
								
								frontend/src/hooks/api/actions/useApi/useApi.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										166
									
								
								frontend/src/hooks/api/actions/useApi/useApi.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,166 @@ | |||||||
|  | import { useState, Dispatch, SetStateAction } from 'react'; | ||||||
|  | import { | ||||||
|  |     BAD_REQUEST, | ||||||
|  |     FORBIDDEN, | ||||||
|  |     NOT_FOUND, | ||||||
|  |     OK, | ||||||
|  |     UNAUTHORIZED, | ||||||
|  | } from '../../../../constants/statusCodes'; | ||||||
|  | import { | ||||||
|  |     AuthenticationError, | ||||||
|  |     ForbiddenError, | ||||||
|  |     headers, | ||||||
|  |     NotFoundError, | ||||||
|  | } from '../../../../store/api-helper'; | ||||||
|  | import { formatApiPath } from '../../../../utils/format-path'; | ||||||
|  | 
 | ||||||
|  | interface IUseAPI { | ||||||
|  |     handleBadRequest?: ( | ||||||
|  |         setErrors?: Dispatch<SetStateAction<{}>>, | ||||||
|  |         res?: Response, | ||||||
|  |         requestId?: string | ||||||
|  |     ) => void; | ||||||
|  |     handleNotFound?: ( | ||||||
|  |         setErrors?: Dispatch<SetStateAction<{}>>, | ||||||
|  |         res?: Response, | ||||||
|  |         requestId?: string | ||||||
|  |     ) => void; | ||||||
|  |     handleUnauthorized?: ( | ||||||
|  |         setErrors?: Dispatch<SetStateAction<{}>>, | ||||||
|  |         res?: Response, | ||||||
|  |         requestId?: string | ||||||
|  |     ) => void; | ||||||
|  |     handleForbidden?: ( | ||||||
|  |         setErrors?: Dispatch<SetStateAction<{}>>, | ||||||
|  |         res?: Response, | ||||||
|  |         requestId?: string | ||||||
|  |     ) => void; | ||||||
|  |     propagateErrors?: boolean; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const useAPI = ({ | ||||||
|  |     handleBadRequest, | ||||||
|  |     handleNotFound, | ||||||
|  |     handleForbidden, | ||||||
|  |     handleUnauthorized, | ||||||
|  |     propagateErrors = false, | ||||||
|  | }: IUseAPI) => { | ||||||
|  |     const [errors, setErrors] = useState({}); | ||||||
|  |     const [loading, setLoading] = useState(false); | ||||||
|  | 
 | ||||||
|  |     const defaultOptions: RequestInit = { | ||||||
|  |         headers, | ||||||
|  |         credentials: 'include', | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const makeRequest = async ( | ||||||
|  |         apiCaller: any, | ||||||
|  |         requestId?: string | ||||||
|  |     ): Promise<Response> => { | ||||||
|  |         setLoading(true); | ||||||
|  |         try { | ||||||
|  |             const res = await apiCaller(); | ||||||
|  |             setLoading(false); | ||||||
|  |             if (res.status > 299) { | ||||||
|  |                 await handleResponses(res, requestId); | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             if (res.status === OK) { | ||||||
|  |                 setErrors({}); | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             return res; | ||||||
|  |         } catch (e) { | ||||||
|  |             setLoading(false); | ||||||
|  |             throw e; | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const createRequest = ( | ||||||
|  |         path: string, | ||||||
|  |         options: any, | ||||||
|  |         requestId: string = '' | ||||||
|  |     ) => { | ||||||
|  |         return { | ||||||
|  |             caller: () => { | ||||||
|  |                 return fetch(formatApiPath(path), { | ||||||
|  |                     ...defaultOptions, | ||||||
|  |                     ...options, | ||||||
|  |                 }); | ||||||
|  |             }, | ||||||
|  |             id: requestId, | ||||||
|  |         }; | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const handleResponses = async (res: Response, requestId?: string) => { | ||||||
|  |         if (res.status === BAD_REQUEST) { | ||||||
|  |             if (handleBadRequest) { | ||||||
|  |                 return handleBadRequest(setErrors, res, requestId); | ||||||
|  |             } else { | ||||||
|  |                 setErrors(prev => ({ | ||||||
|  |                     ...prev, | ||||||
|  |                     badRequest: 'Bad request format', | ||||||
|  |                 })); | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             if (propagateErrors) { | ||||||
|  |                 throw new Error(); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         if (res.status === NOT_FOUND) { | ||||||
|  |             if (handleNotFound) { | ||||||
|  |                 return handleNotFound(setErrors, res, requestId); | ||||||
|  |             } else { | ||||||
|  |                 setErrors(prev => ({ | ||||||
|  |                     ...prev, | ||||||
|  |                     notFound: 'Could not find the requested resource', | ||||||
|  |                 })); | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             if (propagateErrors) { | ||||||
|  |                 throw new NotFoundError(res.status); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         if (res.status === UNAUTHORIZED) { | ||||||
|  |             if (handleUnauthorized) { | ||||||
|  |                 return handleUnauthorized(setErrors, res, requestId); | ||||||
|  |             } else { | ||||||
|  |                 setErrors(prev => ({ | ||||||
|  |                     ...prev, | ||||||
|  |                     unauthorized: | ||||||
|  |                         'You are not authorized to perform this operation', | ||||||
|  |                 })); | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             if (propagateErrors) { | ||||||
|  |                 throw new AuthenticationError(res.status); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         if (res.status === FORBIDDEN) { | ||||||
|  |             if (handleForbidden) { | ||||||
|  |                 return handleForbidden(setErrors); | ||||||
|  |             } else { | ||||||
|  |                 setErrors(prev => ({ | ||||||
|  |                     ...prev, | ||||||
|  |                     forbidden: 'This operation is forbidden', | ||||||
|  |                 })); | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             if (propagateErrors) { | ||||||
|  |                 throw new ForbiddenError(res.status); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     return { | ||||||
|  |         loading, | ||||||
|  |         makeRequest, | ||||||
|  |         createRequest, | ||||||
|  |         errors, | ||||||
|  |     }; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default useAPI; | ||||||
| @ -0,0 +1,36 @@ | |||||||
|  | import useProject from '../../getters/useProject/useProject'; | ||||||
|  | import useAPI from '../useApi/useApi'; | ||||||
|  | 
 | ||||||
|  | const useToggleFeatureByEnv = (projectId: string, name: string) => { | ||||||
|  |     const { refetch } = useProject(projectId); | ||||||
|  |     const { makeRequest, createRequest, errors } = useAPI({ | ||||||
|  |         propagateErrors: true, | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     const toggleFeatureByEnvironment = async ( | ||||||
|  |         env: string, | ||||||
|  |         enabled: boolean | ||||||
|  |     ) => { | ||||||
|  |         const path = getToggleAPIPath(env, enabled); | ||||||
|  |         const req = createRequest(path, { method: 'POST' }); | ||||||
|  | 
 | ||||||
|  |         try { | ||||||
|  |             const res = await makeRequest(req.caller, req.id); | ||||||
|  |             refetch(); | ||||||
|  |             return res; | ||||||
|  |         } catch (e) { | ||||||
|  |             throw e; | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const getToggleAPIPath = (env: string, enabled: boolean) => { | ||||||
|  |         if (enabled) { | ||||||
|  |             return `api/admin/projects/${projectId}/features/${name}/environments/${env}/off`; | ||||||
|  |         } | ||||||
|  |         return `api/admin/projects/${projectId}/features/${name}/environments/${env}/on`; | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     return { toggleFeatureByEnvironment, errors }; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default useToggleFeatureByEnv; | ||||||
| @ -0,0 +1,48 @@ | |||||||
|  | import useSWR, { mutate } from 'swr'; | ||||||
|  | import { useState, useEffect } from 'react'; | ||||||
|  | import { IProjectHealthReport } from '../../../../interfaces/project'; | ||||||
|  | import { fallbackProject } from '../useProject/fallbackProject'; | ||||||
|  | import useSort from '../../../useSort'; | ||||||
|  | import { formatApiPath } from '../../../../utils/format-path'; | ||||||
|  | 
 | ||||||
|  | const useHealthReport = (id: string) => { | ||||||
|  |     const KEY = `api/admin/projects/${id}/health-report`; | ||||||
|  | 
 | ||||||
|  |     const fetcher = () => { | ||||||
|  |         const path = formatApiPath(`api/admin/projects/${id}/health-report`); | ||||||
|  |         return fetch(path, { | ||||||
|  |             method: 'GET', | ||||||
|  |         }).then(res => res.json()); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const [sort] = useSort(); | ||||||
|  | 
 | ||||||
|  |     const { data, error } = useSWR<IProjectHealthReport>(KEY, fetcher); | ||||||
|  |     const [loading, setLoading] = useState(!error && !data); | ||||||
|  | 
 | ||||||
|  |     const refetch = () => { | ||||||
|  |         mutate(KEY); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     useEffect(() => { | ||||||
|  |         setLoading(!error && !data); | ||||||
|  |     }, [data, error]); | ||||||
|  | 
 | ||||||
|  |     const sortedData = ( | ||||||
|  |         data: IProjectHealthReport | undefined | ||||||
|  |     ): IProjectHealthReport => { | ||||||
|  |         if (data) { | ||||||
|  |             return { ...data, features: sort(data.features || []) }; | ||||||
|  |         } | ||||||
|  |         return fallbackProject; | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     return { | ||||||
|  |         project: sortedData(data), | ||||||
|  |         error, | ||||||
|  |         loading, | ||||||
|  |         refetch, | ||||||
|  |     }; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default useHealthReport; | ||||||
| @ -0,0 +1,8 @@ | |||||||
|  | export const fallbackProject = { | ||||||
|  |     features: [], | ||||||
|  |     name: '', | ||||||
|  |     health: 0, | ||||||
|  |     members: 0, | ||||||
|  |     version: '1', | ||||||
|  |     description: 'Default', | ||||||
|  | }; | ||||||
| @ -0,0 +1,17 @@ | |||||||
|  | import { formatApiPath } from '../../../../utils/format-path'; | ||||||
|  | 
 | ||||||
|  | export const getProjectFetcher = (id: string) => { | ||||||
|  |     const fetcher = () => { | ||||||
|  |         const path = formatApiPath(`api/admin/projects/${id}`); | ||||||
|  |         return fetch(path, { | ||||||
|  |             method: 'GET', | ||||||
|  |         }).then(res => res.json()); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const KEY = `api/admin/projects/${id}`; | ||||||
|  | 
 | ||||||
|  |     return { | ||||||
|  |         fetcher, | ||||||
|  |         KEY, | ||||||
|  |     }; | ||||||
|  | }; | ||||||
							
								
								
									
										38
									
								
								frontend/src/hooks/api/getters/useProject/useProject.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								frontend/src/hooks/api/getters/useProject/useProject.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,38 @@ | |||||||
|  | import useSWR, { mutate } from 'swr'; | ||||||
|  | import { useState, useEffect } from 'react'; | ||||||
|  | import { getProjectFetcher } from './getProjectFetcher'; | ||||||
|  | import { IProject } from '../../../../interfaces/project'; | ||||||
|  | import { fallbackProject } from './fallbackProject'; | ||||||
|  | import useSort from '../../../useSort'; | ||||||
|  | 
 | ||||||
|  | const useProject = (id: string) => { | ||||||
|  |     const { KEY, fetcher } = getProjectFetcher(id); | ||||||
|  |     const [sort] = useSort(); | ||||||
|  | 
 | ||||||
|  |     const { data, error } = useSWR<IProject>(KEY, fetcher); | ||||||
|  |     const [loading, setLoading] = useState(!error && !data); | ||||||
|  | 
 | ||||||
|  |     const refetch = () => { | ||||||
|  |         mutate(KEY); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     useEffect(() => { | ||||||
|  |         setLoading(!error && !data); | ||||||
|  |     }, [data, error]); | ||||||
|  | 
 | ||||||
|  |     const sortedData = (data: IProject | undefined): IProject => { | ||||||
|  |         if (data) { | ||||||
|  |             return { ...data, features: sort(data.features || []) }; | ||||||
|  |         } | ||||||
|  |         return fallbackProject; | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     return { | ||||||
|  |         project: sortedData(data), | ||||||
|  |         error, | ||||||
|  |         loading, | ||||||
|  |         refetch, | ||||||
|  |     }; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default useProject; | ||||||
							
								
								
									
										36
									
								
								frontend/src/hooks/api/getters/useProjects/useProjects.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								frontend/src/hooks/api/getters/useProjects/useProjects.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,36 @@ | |||||||
|  | import useSWR, { mutate } from 'swr'; | ||||||
|  | import { useState, useEffect } from 'react'; | ||||||
|  | import { formatApiPath } from '../../../../utils/format-path'; | ||||||
|  | 
 | ||||||
|  | import { IProjectCard } from '../../../../interfaces/project'; | ||||||
|  | 
 | ||||||
|  | const useProjects = () => { | ||||||
|  |     const fetcher = () => { | ||||||
|  |         const path = formatApiPath(`api/admin/projects`); | ||||||
|  |         return fetch(path, { | ||||||
|  |             method: 'GET', | ||||||
|  |         }).then(res => res.json()); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const KEY = `api/admin/projects`; | ||||||
|  | 
 | ||||||
|  |     const { data, error } = useSWR<{ projects: IProjectCard[] }>(KEY, fetcher); | ||||||
|  |     const [loading, setLoading] = useState(!error && !data); | ||||||
|  | 
 | ||||||
|  |     const refetch = () => { | ||||||
|  |         mutate(KEY); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     useEffect(() => { | ||||||
|  |         setLoading(!error && !data); | ||||||
|  |     }, [data, error]); | ||||||
|  | 
 | ||||||
|  |     return { | ||||||
|  |         projects: data?.projects || [], | ||||||
|  |         error, | ||||||
|  |         loading, | ||||||
|  |         refetch, | ||||||
|  |     }; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default useProjects; | ||||||
| @ -1,7 +1,7 @@ | |||||||
| import useSWR from 'swr'; | import useSWR from 'swr'; | ||||||
| import useQueryParams from './useQueryParams'; | import useQueryParams from '../../../useQueryParams'; | ||||||
| import { useState, useEffect } from 'react'; | import { useState, useEffect } from 'react'; | ||||||
| import { formatApiPath } from '../utils/format-path'; | import { formatApiPath } from '../../../../utils/format-path'; | ||||||
| 
 | 
 | ||||||
| const getFetcher = (token: string) => () => { | const getFetcher = (token: string) => () => { | ||||||
|     const path = formatApiPath(`auth/reset/validate?token=${token}`); |     const path = formatApiPath(`auth/reset/validate?token=${token}`); | ||||||
							
								
								
									
										23
									
								
								frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | |||||||
|  | import { LibraryBooks } from '@material-ui/icons'; | ||||||
|  | 
 | ||||||
|  | export const defaultValue = { | ||||||
|  |     name: 'Unleash', | ||||||
|  |     version: '3.x', | ||||||
|  |     environment: '', | ||||||
|  |     slogan: 'The enterprise ready feature toggle service.', | ||||||
|  |     flags: {}, | ||||||
|  |     links: [ | ||||||
|  |         { | ||||||
|  |             value: 'Documentation', | ||||||
|  |             icon: LibraryBooks, | ||||||
|  |             href: 'https://docs.getunleash.io/docs?source=oss', | ||||||
|  |             title: 'User documentation', | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             value: 'GitHub', | ||||||
|  |             icon: 'c_github', | ||||||
|  |             href: 'https://github.com/Unleash', | ||||||
|  |             title: 'Source code on GitHub', | ||||||
|  |         }, | ||||||
|  |     ], | ||||||
|  | }; | ||||||
							
								
								
									
										37
									
								
								frontend/src/hooks/api/getters/useUiConfig/useUiConfig.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								frontend/src/hooks/api/getters/useUiConfig/useUiConfig.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,37 @@ | |||||||
|  | import useSWR, { mutate } from 'swr'; | ||||||
|  | import { useState, useEffect } from 'react'; | ||||||
|  | import { formatApiPath } from '../../../../utils/format-path'; | ||||||
|  | import { defaultValue } from './defaultValue'; | ||||||
|  | 
 | ||||||
|  | const REQUEST_KEY = 'api/admin/ui-config'; | ||||||
|  | 
 | ||||||
|  | const useUiConfig = () => { | ||||||
|  |     const fetcher = () => { | ||||||
|  |         const path = formatApiPath(`api/admin/ui-config`); | ||||||
|  | 
 | ||||||
|  |         return fetch(path, { | ||||||
|  |             method: 'GET', | ||||||
|  |             credentials: 'include', | ||||||
|  |         }).then(res => res.json()); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const { data, error } = useSWR(REQUEST_KEY, fetcher); | ||||||
|  |     const [loading, setLoading] = useState(!error && !data); | ||||||
|  | 
 | ||||||
|  |     const refetch = () => { | ||||||
|  |         mutate(REQUEST_KEY); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     useEffect(() => { | ||||||
|  |         setLoading(!error && !data); | ||||||
|  |     }, [data, error]); | ||||||
|  | 
 | ||||||
|  |     return { | ||||||
|  |         uiConfig: data || defaultValue, | ||||||
|  |         error, | ||||||
|  |         loading, | ||||||
|  |         refetch, | ||||||
|  |     }; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default useUiConfig; | ||||||
| @ -1,6 +1,6 @@ | |||||||
| import useSWR, { mutate } from 'swr'; | import useSWR, { mutate } from 'swr'; | ||||||
| import { useState, useEffect } from 'react'; | import { useState, useEffect } from 'react'; | ||||||
| import { formatApiPath } from '../utils/format-path'; | import { formatApiPath } from '../../../../utils/format-path'; | ||||||
| 
 | 
 | ||||||
| const useUsers = () => { | const useUsers = () => { | ||||||
|     const fetcher = () => { |     const fetcher = () => { | ||||||
| @ -1,178 +0,0 @@ | |||||||
| import { useState } from 'react'; |  | ||||||
| import { |  | ||||||
|     BAD_REQUEST, |  | ||||||
|     FORBIDDEN, |  | ||||||
|     NOT_FOUND, |  | ||||||
|     OK, |  | ||||||
|     UNAUTHORIZED, |  | ||||||
| } from '../constants/statusCodes'; |  | ||||||
| import { IUserPayload } from '../interfaces/user'; |  | ||||||
| import { |  | ||||||
|     AuthenticationError, |  | ||||||
|     ForbiddenError, |  | ||||||
|     headers, |  | ||||||
|     NotFoundError, |  | ||||||
| } from '../store/api-helper'; |  | ||||||
| import { formatApiPath } from '../utils/format-path'; |  | ||||||
| 
 |  | ||||||
| export interface IUserApiErrors { |  | ||||||
|     addUser?: string; |  | ||||||
|     removeUser?: string; |  | ||||||
|     updateUser?: string; |  | ||||||
|     changePassword?: string; |  | ||||||
|     validatePassword?: string; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export const ADD_USER_ERROR = 'addUser'; |  | ||||||
| export const UPDATE_USER_ERROR = 'updateUser'; |  | ||||||
| export const REMOVE_USER_ERROR = 'removeUser'; |  | ||||||
| export const CHANGE_PASSWORD_ERROR = 'changePassword'; |  | ||||||
| export const VALIDATE_PASSWORD_ERROR = 'validatePassword'; |  | ||||||
| 
 |  | ||||||
| const useAdminUsersApi = () => { |  | ||||||
|     const [userApiErrors, setUserApiErrors] = useState({}); |  | ||||||
|     const [userLoading, setUserLoading] = useState(false); |  | ||||||
| 
 |  | ||||||
|     const defaultOptions: RequestInit = { |  | ||||||
|         headers, |  | ||||||
|         credentials: 'include', |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     const makeRequest = async ( |  | ||||||
|         apiCaller: any, |  | ||||||
|         type: string |  | ||||||
|     ): Promise<Response> => { |  | ||||||
|         setUserLoading(true); |  | ||||||
|         try { |  | ||||||
|             const res = await apiCaller(); |  | ||||||
|             setUserLoading(false); |  | ||||||
|             if (res.status > 299) { |  | ||||||
|                 await handleResponses(res, type); |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             if (res.status === OK) { |  | ||||||
|                 setUserApiErrors({}); |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             return res; |  | ||||||
|         } catch (e) { |  | ||||||
|             setUserLoading(false); |  | ||||||
|             throw e; |  | ||||||
|         } |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     const addUser = async (user: IUserPayload) => { |  | ||||||
|         return makeRequest(() => { |  | ||||||
|             const path = formatApiPath('api/admin/user-admin'); |  | ||||||
|             return fetch(path, { |  | ||||||
|                 ...defaultOptions, |  | ||||||
|                 method: 'POST', |  | ||||||
|                 body: JSON.stringify(user), |  | ||||||
|             }); |  | ||||||
|         }, 'addUser'); |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     const removeUser = async (user: IUserPayload) => { |  | ||||||
|         return makeRequest(() => { |  | ||||||
|             const path = formatApiPath(`api/admin/user-admin/${user.id}`); |  | ||||||
|             return fetch(path, { |  | ||||||
|                 ...defaultOptions, |  | ||||||
|                 method: 'DELETE', |  | ||||||
|             }); |  | ||||||
|         }, 'removeUser'); |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     const updateUser = async (user: IUserPayload) => { |  | ||||||
|         return makeRequest(() => { |  | ||||||
|             const path = formatApiPath(`api/admin/user-admin/${user.id}`); |  | ||||||
| 
 |  | ||||||
|             return fetch(path, { |  | ||||||
|                 ...defaultOptions, |  | ||||||
|                 method: 'PUT', |  | ||||||
|                 body: JSON.stringify(user), |  | ||||||
|             }); |  | ||||||
|         }, 'updateUser'); |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     const changePassword = async (user: IUserPayload, password: string) => { |  | ||||||
|         return makeRequest(() => { |  | ||||||
|             const path = formatApiPath( |  | ||||||
|                 `api/admin/user-admin/${user.id}/change-password` |  | ||||||
|             ); |  | ||||||
|             return fetch(path, { |  | ||||||
|                 ...defaultOptions, |  | ||||||
|                 method: 'POST', |  | ||||||
|                 body: JSON.stringify({ password }), |  | ||||||
|             }); |  | ||||||
|         }, 'changePassword'); |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     const validatePassword = async (password: string) => { |  | ||||||
|         return makeRequest(() => { |  | ||||||
|             const path = formatApiPath( |  | ||||||
|                 `api/admin/user-admin/validate-password` |  | ||||||
|             ); |  | ||||||
|             return fetch(path, { |  | ||||||
|                 ...defaultOptions, |  | ||||||
|                 method: 'POST', |  | ||||||
|                 body: JSON.stringify({ password }), |  | ||||||
|             }); |  | ||||||
|         }, 'validatePassword'); |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     const handleResponses = async (res: Response, type: string) => { |  | ||||||
|         if (res.status === BAD_REQUEST) { |  | ||||||
|             const data = await res.json(); |  | ||||||
| 
 |  | ||||||
|             setUserApiErrors(prev => ({ |  | ||||||
|                 ...prev, |  | ||||||
|                 [type]: data[0].msg, |  | ||||||
|             })); |  | ||||||
| 
 |  | ||||||
|             throw new Error(); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         if (res.status === NOT_FOUND) { |  | ||||||
|             setUserApiErrors(prev => ({ |  | ||||||
|                 ...prev, |  | ||||||
|                 [type]: 'Could not find the requested resource.', |  | ||||||
|             })); |  | ||||||
| 
 |  | ||||||
|             throw new NotFoundError(res.status); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         if (res.status === UNAUTHORIZED) { |  | ||||||
|             const data = await res.json(); |  | ||||||
| 
 |  | ||||||
|             setUserApiErrors(prev => ({ |  | ||||||
|                 ...prev, |  | ||||||
|                 [type]: data[0].msg, |  | ||||||
|             })); |  | ||||||
| 
 |  | ||||||
|             throw new AuthenticationError(res.status); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         if (res.status === FORBIDDEN) { |  | ||||||
|             const data = await res.json(); |  | ||||||
| 
 |  | ||||||
|             setUserApiErrors(prev => ({ |  | ||||||
|                 ...prev, |  | ||||||
|                 [type]: data[0].msg, |  | ||||||
|             })); |  | ||||||
| 
 |  | ||||||
|             throw new ForbiddenError(res.status); |  | ||||||
|         } |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     return { |  | ||||||
|         addUser, |  | ||||||
|         updateUser, |  | ||||||
|         removeUser, |  | ||||||
|         changePassword, |  | ||||||
|         validatePassword, |  | ||||||
|         userApiErrors, |  | ||||||
|         userLoading, |  | ||||||
|     }; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export default useAdminUsersApi; |  | ||||||
							
								
								
									
										45
									
								
								frontend/src/hooks/usePagination.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								frontend/src/hooks/usePagination.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,45 @@ | |||||||
|  | import { useEffect, useState } from 'react'; | ||||||
|  | import { paginate } from '../utils/paginate'; | ||||||
|  | 
 | ||||||
|  | const usePagination = (data: any[], limit: number) => { | ||||||
|  |     const [paginatedData, setPaginatedData] = useState([[]]); | ||||||
|  |     const [pageIndex, setPageIndex] = useState(0); | ||||||
|  | 
 | ||||||
|  |     useEffect(() => { | ||||||
|  |         const result = paginate(data, limit); | ||||||
|  |         setPaginatedData(result); | ||||||
|  |     }, [data, limit]); | ||||||
|  | 
 | ||||||
|  |     const nextPage = () => { | ||||||
|  |         if (pageIndex < paginatedData.length - 1) { | ||||||
|  |             setPageIndex(prev => prev + 1); | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const prevPage = () => { | ||||||
|  |         if (pageIndex > 0) { | ||||||
|  |             setPageIndex(prev => prev - 1); | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const lastPage = () => { | ||||||
|  |         setPageIndex(paginatedData.length - 1); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const firstPage = () => { | ||||||
|  |         setPageIndex(0); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     return { | ||||||
|  |         page: paginatedData[pageIndex] || [], | ||||||
|  |         pages: paginatedData, | ||||||
|  |         nextPage, | ||||||
|  |         prevPage, | ||||||
|  |         lastPage, | ||||||
|  |         firstPage, | ||||||
|  |         setPageIndex, | ||||||
|  |         pageIndex, | ||||||
|  |     }; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default usePagination; | ||||||
| @ -10,9 +10,16 @@ import { | |||||||
|     sortFeaturesByExpiredAtDescending, |     sortFeaturesByExpiredAtDescending, | ||||||
|     sortFeaturesByStatusAscending, |     sortFeaturesByStatusAscending, | ||||||
|     sortFeaturesByStatusDescending, |     sortFeaturesByStatusDescending, | ||||||
| } from './utils'; | } from '../component/Reporting/utils'; | ||||||
| 
 | 
 | ||||||
| import { LAST_SEEN, NAME, CREATED, EXPIRED, STATUS, REPORT } from './constants'; | import { | ||||||
|  |     LAST_SEEN, | ||||||
|  |     NAME, | ||||||
|  |     CREATED, | ||||||
|  |     EXPIRED, | ||||||
|  |     STATUS, | ||||||
|  |     REPORT, | ||||||
|  | } from '../component/Reporting/constants'; | ||||||
| 
 | 
 | ||||||
| const useSort = () => { | const useSort = () => { | ||||||
|     const [sortData, setSortData] = useState({ |     const [sortData, setSortData] = useState({ | ||||||
							
								
								
									
										11
									
								
								frontend/src/interfaces/featureToggle.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								frontend/src/interfaces/featureToggle.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,11 @@ | |||||||
|  | export interface IFeatureToggleListItem { | ||||||
|  |     type: string; | ||||||
|  |     name: string; | ||||||
|  |     environments: IEnvironments[]; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface IEnvironments { | ||||||
|  |     name: string; | ||||||
|  |     displayName: string; | ||||||
|  |     enabled: boolean; | ||||||
|  | } | ||||||
							
								
								
									
										26
									
								
								frontend/src/interfaces/project.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								frontend/src/interfaces/project.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,26 @@ | |||||||
|  | import { IFeatureToggleListItem } from './featureToggle'; | ||||||
|  | 
 | ||||||
|  | export interface IProjectCard { | ||||||
|  |     name: string; | ||||||
|  |     id: string; | ||||||
|  |     createdAt: string; | ||||||
|  |     health: number; | ||||||
|  |     description: string; | ||||||
|  |     featureCount: number; | ||||||
|  |     memberCount: number; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface IProject { | ||||||
|  |     members: number; | ||||||
|  |     version: string; | ||||||
|  |     name: string; | ||||||
|  |     description: string; | ||||||
|  |     health: number; | ||||||
|  |     features: IFeatureToggleListItem[]; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface IProjectHealthReport extends IProject { | ||||||
|  |     staleCount: number; | ||||||
|  |     potentiallyStaleCount: number; | ||||||
|  |     activeCount: number; | ||||||
|  | } | ||||||
| @ -1,7 +1,7 @@ | |||||||
| import { useState } from 'react'; | import { useState } from 'react'; | ||||||
| import Dialogue from '../../../../component/common/Dialogue'; | import Dialogue from '../../../../component/common/Dialogue'; | ||||||
| 
 | 
 | ||||||
| import { IUserApiErrors } from '../../../../hooks/useAdminUsersApi'; | import { IUserApiErrors } from '../../../../hooks/api/actions/useAdminUsersApi/useAdminUsersApi'; | ||||||
| import IRole from '../../../../interfaces/role'; | import IRole from '../../../../interfaces/role'; | ||||||
| import AddUserForm from './AddUserForm/AddUserForm'; | import AddUserForm from './AddUserForm/AddUserForm'; | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -18,7 +18,7 @@ import useLoading from '../../../../../hooks/useLoading'; | |||||||
| import { | import { | ||||||
|     ADD_USER_ERROR, |     ADD_USER_ERROR, | ||||||
|     UPDATE_USER_ERROR, |     UPDATE_USER_ERROR, | ||||||
| } from '../../../../../hooks/useAdminUsersApi'; | } from '../../../../../hooks/api/actions/useAdminUsersApi/useAdminUsersApi'; | ||||||
| import { Alert } from '@material-ui/lab'; | import { Alert } from '@material-ui/lab'; | ||||||
| 
 | 
 | ||||||
| function AddUserForm({ | function AddUserForm({ | ||||||
|  | |||||||
| @ -16,8 +16,8 @@ import ConditionallyRender from '../../../../component/common/ConditionallyRende | |||||||
| import AccessContext from '../../../../contexts/AccessContext'; | import AccessContext from '../../../../contexts/AccessContext'; | ||||||
| import { ADMIN } from '../../../../component/AccessProvider/permissions'; | import { ADMIN } from '../../../../component/AccessProvider/permissions'; | ||||||
| import ConfirmUserAdded from '../ConfirmUserAdded/ConfirmUserAdded'; | import ConfirmUserAdded from '../ConfirmUserAdded/ConfirmUserAdded'; | ||||||
| import useUsers from '../../../../hooks/useUsers'; | import useUsers from '../../../../hooks/api/getters/useUsers/useUsers'; | ||||||
| import useAdminUsersApi from '../../../../hooks/useAdminUsersApi'; | import useAdminUsersApi from '../../../../hooks/api/actions/useAdminUsersApi/useAdminUsersApi'; | ||||||
| import UserListItem from './UserListItem/UserListItem'; | import UserListItem from './UserListItem/UserListItem'; | ||||||
| import loadingData from './loadingData'; | import loadingData from './loadingData'; | ||||||
| import useLoading from '../../../../hooks/useLoading'; | import useLoading from '../../../../hooks/useLoading'; | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ import React from 'react'; | |||||||
| import Dialogue from '../../../component/common/Dialogue/Dialogue'; | import Dialogue from '../../../component/common/Dialogue/Dialogue'; | ||||||
| import ConditionallyRender from '../../../component/common/ConditionallyRender/ConditionallyRender'; | import ConditionallyRender from '../../../component/common/ConditionallyRender/ConditionallyRender'; | ||||||
| import propTypes from 'prop-types'; | import propTypes from 'prop-types'; | ||||||
| import { REMOVE_USER_ERROR } from '../../../hooks/useAdminUsersApi'; | import { REMOVE_USER_ERROR } from '../../../hooks/api/actions/useAdminUsersApi/useAdminUsersApi'; | ||||||
| import { Alert } from '@material-ui/lab'; | import { Alert } from '@material-ui/lab'; | ||||||
| import useLoading from '../../../hooks/useLoading'; | import useLoading from '../../../hooks/useLoading'; | ||||||
| import { Avatar, Typography } from '@material-ui/core'; | import { Avatar, Typography } from '@material-ui/core'; | ||||||
|  | |||||||
| @ -67,7 +67,9 @@ function validate(id) { | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| function searchProjectUser(query) { | function searchProjectUser(query) { | ||||||
|     return fetch(`${formatApiPath('api/admin/user-admin/search')}?q=${query}`).then(res => res.json()) |     return fetch( | ||||||
|  |         `${formatApiPath('api/admin/user-admin/search')}?q=${query}` | ||||||
|  |     ).then(res => res.json()); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export default { | export default { | ||||||
|  | |||||||
							
								
								
									
										30
									
								
								frontend/src/utils/get-feature-type-icons.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								frontend/src/utils/get-feature-type-icons.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,30 @@ | |||||||
|  | import { | ||||||
|  |     EXPERIMENT, | ||||||
|  |     RELEASE, | ||||||
|  |     KILLSWITCH, | ||||||
|  |     OPERATIONAL, | ||||||
|  |     PERMISSION, | ||||||
|  | } from '../constants/featureToggleTypes'; | ||||||
|  | 
 | ||||||
|  | import LoopIcon from '@material-ui/icons/Loop'; | ||||||
|  | import TimelineIcon from '@material-ui/icons/Timeline'; | ||||||
|  | import PowerSettingsNewIcon from '@material-ui/icons/PowerSettingsNew'; | ||||||
|  | import PanToolIcon from '@material-ui/icons/PanTool'; | ||||||
|  | import BuildIcon from '@material-ui/icons/Build'; | ||||||
|  | 
 | ||||||
|  | export const getFeatureTypeIcons = (type: string) => { | ||||||
|  |     switch (type) { | ||||||
|  |         case RELEASE: | ||||||
|  |             return LoopIcon; | ||||||
|  |         case EXPERIMENT: | ||||||
|  |             return TimelineIcon; | ||||||
|  |         case KILLSWITCH: | ||||||
|  |             return PowerSettingsNewIcon; | ||||||
|  |         case OPERATIONAL: | ||||||
|  |             return BuildIcon; | ||||||
|  |         case PERMISSION: | ||||||
|  |             return PanToolIcon; | ||||||
|  |         default: | ||||||
|  |             return LoopIcon; | ||||||
|  |     } | ||||||
|  | }; | ||||||
							
								
								
									
										35
									
								
								frontend/src/utils/paginate.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								frontend/src/utils/paginate.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,35 @@ | |||||||
|  | import { paginate } from './paginate'; | ||||||
|  | 
 | ||||||
|  | const createInput = (count: number) => { | ||||||
|  |     const result = []; | ||||||
|  | 
 | ||||||
|  |     for (let i = 0; i < count; i++) { | ||||||
|  |         result.push(i); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return result; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | test('it creates the correct amount of pages when count is even', () => { | ||||||
|  |     const input = createInput(20); | ||||||
|  |     expect(input.length).toBe(20); | ||||||
|  | 
 | ||||||
|  |     const paginationResult = paginate(input, 5); | ||||||
|  |     expect(paginationResult.length).toBe(4); | ||||||
|  |     expect(Array.isArray(paginationResult[0])).toBe(true); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('it creates the correct amount of pages when count is uneven', () => { | ||||||
|  |     const input = createInput(33); | ||||||
|  |     expect(input.length).toBe(33); | ||||||
|  | 
 | ||||||
|  |     const paginationResult = paginate(input, 9); | ||||||
|  |     expect(paginationResult.length).toBe(4); | ||||||
|  | 
 | ||||||
|  |     const paginationCount = paginationResult.reduce((acc, cur) => { | ||||||
|  |         acc += cur.length; | ||||||
|  |         return acc; | ||||||
|  |     }, 0); | ||||||
|  | 
 | ||||||
|  |     expect(paginationCount).toBe(33); | ||||||
|  | }); | ||||||
							
								
								
									
										22
									
								
								frontend/src/utils/paginate.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								frontend/src/utils/paginate.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,22 @@ | |||||||
|  | export const paginate = (data: any[], limit: number) => { | ||||||
|  |     let result = []; | ||||||
|  |     let currentIdx = 0; | ||||||
|  | 
 | ||||||
|  |     if (data.length <= currentIdx) { | ||||||
|  |         return data; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     while (currentIdx < data.length) { | ||||||
|  |         if (currentIdx === 0) { | ||||||
|  |             currentIdx += limit; | ||||||
|  |             const page = data.slice(0, currentIdx); | ||||||
|  |             result.push(page); | ||||||
|  |         } else { | ||||||
|  |             const page = data.slice(currentIdx, currentIdx + limit); | ||||||
|  |             currentIdx += limit; | ||||||
|  |             result.push(page); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return result; | ||||||
|  | }; | ||||||
		Loading…
	
		Reference in New Issue
	
	Block a user