From 85a7c55fdf54c264d775d9fa87f66367edbaa98f Mon Sep 17 00:00:00 2001 From: Fredrik Strand Oseberg Date: Wed, 7 Jul 2021 11:04:36 +0200 Subject: [PATCH] Feat/group by projects (#308) This PR adds support for projects as a first class citizen, and toggling features on in different environments. --- frontend/src/app.css | 1 + frontend/src/assets/icons/projectIcon.svg | 9 + frontend/src/common.styles.js | 17 ++ .../Reporting/ReportCard/ReportCard.jsx | 81 ++------ .../ReportToggleList/ReportToggleList.jsx | 2 +- .../ReportToggleListContainer.jsx | 18 +- .../ReportToggleListItem.jsx | 5 +- .../src/component/Reporting/Reporting.jsx | 30 ++- .../Reporting/__tests__/reporting-test.js | 34 ---- frontend/src/component/Reporting/constants.js | 7 - frontend/src/component/Reporting/utils.js | 29 ++- .../application-edit-component-test.js.snap | 2 + .../common/AnimateOnMount/AnimateOnMount.tsx | 3 + .../component/common/ApiError/ApiError.tsx | 32 ++++ .../common/Feedback/Feedback.styles.ts | 17 -- .../component/common/Feedback/Feedback.tsx | 6 +- .../common/HeaderTitle/HeaderTitle.jsx | 21 ++- .../common/PaginateUI/PaginateUI.tsx | 88 +++++++++ .../common/PaginateUI/PaginationUI.styles.ts | 47 +++++ .../ResponsiveButton/ResponsiveButton.tsx | 38 ++++ frontend/src/component/common/Toast/Toast.tsx | 46 +++++ frontend/src/component/common/flags.js | 3 + .../list-component-test.jsx.snap | 2 + .../FeatureToggleListNew.styles.ts | 27 +++ .../FeatureToggleListNew.tsx | 132 +++++++++++++ .../FeatureToggleListNewItem.tsx | 106 +++++++++++ .../loadingFeatures.ts | 103 ++++++++++ .../update-variant-component-test.jsx.snap | 4 +- .../view-component-test.jsx.snap | 12 +- frontend/src/component/menu/routes.js | 22 ++- .../src/component/project/Project/Project.tsx | 48 +++++ .../ProjectFeatureToggles.styles.ts | 25 +++ .../ProjectFeatureToggles.tsx | 70 +++++++ .../Project/ProjectInfo/ProjectInfo.styles.ts | 31 +++ .../Project/ProjectInfo/ProjectInfo.tsx | 82 ++++++++ .../project/ProjectCard/ProjectCard.styles.ts | 41 ++++ .../project/ProjectCard/ProjectCard.tsx | 66 +++++++ .../project/ProjectList/ProjectList.jsx | 29 +-- .../ProjectListNew/ProjectListNew.styles.ts | 12 ++ .../project/ProjectListNew/ProjectListNew.tsx | 137 ++++++++++++++ .../project/ProjectListNew/loadingData.ts | 32 ++++ .../src/component/project/access-component.js | 14 +- .../list-component-test.jsx.snap | 2 + .../strategy-details-component-test.jsx.snap | 1 + .../tag-type-create-component-test.js.snap | 3 + .../tag-type-list-component-test.js.snap | 2 + .../src/component/user/NewUser/NewUser.tsx | 2 +- .../user/ResetPassword/ResetPassword.tsx | 2 +- frontend/src/constants/featureToggleTypes.ts | 5 + .../actions/useAdminUsersApi/errorHandlers.ts | 76 ++++++++ .../useAdminUsersApi/useAdminUsersApi.ts | 113 +++++++++++ .../src/hooks/api/actions/useApi/useApi.ts | 166 ++++++++++++++++ .../useToggleFeatureByEnv.ts | 36 ++++ .../useHealthReport/useHealthReport.ts | 48 +++++ .../api/getters/useProject/fallbackProject.ts | 8 + .../getters/useProject/getProjectFetcher.ts | 17 ++ .../api/getters/useProject/useProject.ts | 38 ++++ .../api/getters/useProjects/useProjects.ts | 36 ++++ .../useResetPassword}/useResetPassword.ts | 4 +- .../api/getters/useUiConfig/defaultValue.ts | 23 +++ .../api/getters/useUiConfig/useUiConfig.ts | 37 ++++ .../{ => api/getters/useUsers}/useUsers.ts | 2 +- frontend/src/hooks/useAdminUsersApi.ts | 178 ------------------ frontend/src/hooks/usePagination.ts | 45 +++++ .../{component/Reporting => hooks}/useSort.js | 11 +- frontend/src/interfaces/featureToggle.ts | 11 ++ frontend/src/interfaces/project.ts | 26 +++ .../src/page/admin/users/AddUser/AddUser.tsx | 2 +- .../users/AddUser/AddUserForm/AddUserForm.jsx | 2 +- .../page/admin/users/UsersList/UsersList.jsx | 4 +- .../page/admin/users/del-user-component.jsx | 2 +- frontend/src/store/project/api.js | 4 +- frontend/src/utils/get-feature-type-icons.ts | 30 +++ frontend/src/utils/paginate.test.ts | 35 ++++ frontend/src/utils/paginate.ts | 22 +++ 75 files changed, 2139 insertions(+), 385 deletions(-) create mode 100644 frontend/src/assets/icons/projectIcon.svg delete mode 100644 frontend/src/component/Reporting/__tests__/reporting-test.js create mode 100644 frontend/src/component/common/ApiError/ApiError.tsx create mode 100644 frontend/src/component/common/PaginateUI/PaginateUI.tsx create mode 100644 frontend/src/component/common/PaginateUI/PaginationUI.styles.ts create mode 100644 frontend/src/component/common/ResponsiveButton/ResponsiveButton.tsx create mode 100644 frontend/src/component/common/Toast/Toast.tsx create mode 100644 frontend/src/component/feature/FeatureToggleListNew/FeatureToggleListNew.styles.ts create mode 100644 frontend/src/component/feature/FeatureToggleListNew/FeatureToggleListNew.tsx create mode 100644 frontend/src/component/feature/FeatureToggleListNew/FeatureToggleListNewItem/FeatureToggleListNewItem.tsx create mode 100644 frontend/src/component/feature/FeatureToggleListNew/FeatureToggleListNewItem/loadingFeatures.ts create mode 100644 frontend/src/component/project/Project/Project.tsx create mode 100644 frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.styles.ts create mode 100644 frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx create mode 100644 frontend/src/component/project/Project/ProjectInfo/ProjectInfo.styles.ts create mode 100644 frontend/src/component/project/Project/ProjectInfo/ProjectInfo.tsx create mode 100644 frontend/src/component/project/ProjectCard/ProjectCard.styles.ts create mode 100644 frontend/src/component/project/ProjectCard/ProjectCard.tsx create mode 100644 frontend/src/component/project/ProjectListNew/ProjectListNew.styles.ts create mode 100644 frontend/src/component/project/ProjectListNew/ProjectListNew.tsx create mode 100644 frontend/src/component/project/ProjectListNew/loadingData.ts create mode 100644 frontend/src/constants/featureToggleTypes.ts create mode 100644 frontend/src/hooks/api/actions/useAdminUsersApi/errorHandlers.ts create mode 100644 frontend/src/hooks/api/actions/useAdminUsersApi/useAdminUsersApi.ts create mode 100644 frontend/src/hooks/api/actions/useApi/useApi.ts create mode 100644 frontend/src/hooks/api/actions/useToggleFeatureByEnv/useToggleFeatureByEnv.ts create mode 100644 frontend/src/hooks/api/getters/useHealthReport/useHealthReport.ts create mode 100644 frontend/src/hooks/api/getters/useProject/fallbackProject.ts create mode 100644 frontend/src/hooks/api/getters/useProject/getProjectFetcher.ts create mode 100644 frontend/src/hooks/api/getters/useProject/useProject.ts create mode 100644 frontend/src/hooks/api/getters/useProjects/useProjects.ts rename frontend/src/hooks/{ => api/getters/useResetPassword}/useResetPassword.ts (90%) create mode 100644 frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts create mode 100644 frontend/src/hooks/api/getters/useUiConfig/useUiConfig.ts rename frontend/src/hooks/{ => api/getters/useUsers}/useUsers.ts (92%) delete mode 100644 frontend/src/hooks/useAdminUsersApi.ts create mode 100644 frontend/src/hooks/usePagination.ts rename frontend/src/{component/Reporting => hooks}/useSort.js (93%) create mode 100644 frontend/src/interfaces/featureToggle.ts create mode 100644 frontend/src/interfaces/project.ts create mode 100644 frontend/src/utils/get-feature-type-icons.ts create mode 100644 frontend/src/utils/paginate.test.ts create mode 100644 frontend/src/utils/paginate.ts diff --git a/frontend/src/app.css b/frontend/src/app.css index 0ef333e86e..ab615b4b49 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -46,6 +46,7 @@ body { background-color: #e2e8f0; z-index: 9999; box-shadow: none; + fill: none; } .skeleton::before { diff --git a/frontend/src/assets/icons/projectIcon.svg b/frontend/src/assets/icons/projectIcon.svg new file mode 100644 index 0000000000..d4604c184d --- /dev/null +++ b/frontend/src/assets/icons/projectIcon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/common.styles.js b/frontend/src/common.styles.js index 61c6f9de38..f962781817 100644 --- a/frontend/src/common.styles.js +++ b/frontend/src/common.styles.js @@ -62,4 +62,21 @@ export const useCommonStyles = makeStyles(theme => ({ fontWeight: 'bold', 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', + }, })); diff --git a/frontend/src/component/Reporting/ReportCard/ReportCard.jsx b/frontend/src/component/Reporting/ReportCard/ReportCard.jsx index 927db4b64b..368f6071d1 100644 --- a/frontend/src/component/Reporting/ReportCard/ReportCard.jsx +++ b/frontend/src/component/Reporting/ReportCard/ReportCard.jsx @@ -7,61 +7,16 @@ import CheckIcon from '@material-ui/icons/Check'; import ReportProblemOutlinedIcon from '@material-ui/icons/ReportProblemOutlined'; import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender'; -import { isFeatureExpired } from '../utils'; - import styles from './ReportCard.module.scss'; -const ReportCard = ({ features }) => { - const getActiveToggles = () => { - const result = features.filter(feature => !feature.stale); - - if (result === 0) return 0; - - return result; - }; - - 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 ReportCard = ({ + health, + activeCount, + staleCount, + potentiallyStaleCount, +}) => { + const healthLessThan50 = health < 50; + const healthLessThan75 = health < 75; const healthClasses = classnames(styles.reportCardHealthRating, { [styles.healthWarning]: healthLessThan75, @@ -71,23 +26,21 @@ const ReportCard = ({ features }) => { const renderActiveToggles = () => ( <> - {activeTogglesCount} active toggles + {activeCount} active toggles ); const renderStaleToggles = () => ( <> - {staleTogglesCount} stale toggles + {staleCount} stale toggles ); const renderPotentiallyStaleToggles = () => ( <> - - {potentiallyStaleTogglesCount} potentially stale toggles - + {potentiallyStaleCount} potentially stale toggles ); @@ -98,10 +51,8 @@ const ReportCard = ({ features }) => {

Health rating

-1} - show={ -

{healthRating}%

- } + condition={health > -1} + show={

{health}%

} />
@@ -110,19 +61,19 @@ const ReportCard = ({ features }) => {
  • diff --git a/frontend/src/component/Reporting/ReportToggleList/ReportToggleList.jsx b/frontend/src/component/Reporting/ReportToggleList/ReportToggleList.jsx index 0d6346bb6a..8a51cccb94 100644 --- a/frontend/src/component/Reporting/ReportToggleList/ReportToggleList.jsx +++ b/frontend/src/component/Reporting/ReportToggleList/ReportToggleList.jsx @@ -13,7 +13,7 @@ import { applyCheckedToFeatures, } from '../utils'; -import useSort from '../useSort'; +import useSort from '../../../hooks/useSort'; import styles from './ReportToggleList.module.scss'; diff --git a/frontend/src/component/Reporting/ReportToggleList/ReportToggleListContainer.jsx b/frontend/src/component/Reporting/ReportToggleList/ReportToggleListContainer.jsx index a3ead1366f..cbf146aa9f 100644 --- a/frontend/src/component/Reporting/ReportToggleList/ReportToggleListContainer.jsx +++ b/frontend/src/component/Reporting/ReportToggleList/ReportToggleListContainer.jsx @@ -1,20 +1,12 @@ import { connect } from 'react-redux'; -import { filterByProject } from '../utils'; - import ReportToggleList from './ReportToggleList'; -const mapStateToProps = (state, ownProps) => { - const features = state.features.toJS(); +const mapStateToProps = (state, ownProps) => {}; - const sameProject = filterByProject(ownProps.selectedProject); - const featuresByProject = features.filter(sameProject); - - return { - features: featuresByProject, - }; -}; - -const ReportToggleListContainer = connect(mapStateToProps, null)(ReportToggleList); +const ReportToggleListContainer = connect( + mapStateToProps, + null +)(ReportToggleList); export default ReportToggleListContainer; diff --git a/frontend/src/component/Reporting/ReportToggleList/ReportToggleListItem/ReportToggleListItem.jsx b/frontend/src/component/Reporting/ReportToggleList/ReportToggleListItem/ReportToggleListItem.jsx index cb5ca47a82..83047f72f9 100644 --- a/frontend/src/component/Reporting/ReportToggleList/ReportToggleListItem/ReportToggleListItem.jsx +++ b/frontend/src/component/Reporting/ReportToggleList/ReportToggleListItem/ReportToggleListItem.jsx @@ -15,7 +15,10 @@ import { toggleExpiryByTypeMap, getDiffInDays, } from '../../utils'; -import { KILLSWITCH, PERMISSION } from '../../constants'; +import { + KILLSWITCH, + PERMISSION, +} from '../../../../constants/featureToggleTypes'; import styles from '../ReportToggleList.module.scss'; diff --git a/frontend/src/component/Reporting/Reporting.jsx b/frontend/src/component/Reporting/Reporting.jsx index 578c0281a4..dda39df3f3 100644 --- a/frontend/src/component/Reporting/Reporting.jsx +++ b/frontend/src/component/Reporting/Reporting.jsx @@ -12,15 +12,17 @@ import { formatProjectOptions } from './utils'; import { REPORTING_SELECT_ID } from '../../testIds'; 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([ { key: 'default', label: 'Default' }, ]); const [selectedProject, setSelectedProject] = useState('default'); + const { project, error, refetch } = useHealthReport(selectedProject); useEffect(() => { - fetchFeatureToggles(); setSelectedProject(projects[0].id); /* eslint-disable-next-line */ }, []); @@ -62,8 +64,28 @@ const Reporting = ({ fetchFeatureToggles, projects }) => { show={renderSelect} /> - - + + } + /> + + ); }; diff --git a/frontend/src/component/Reporting/__tests__/reporting-test.js b/frontend/src/component/Reporting/__tests__/reporting-test.js deleted file mode 100644 index ccdad60ebe..0000000000 --- a/frontend/src/component/Reporting/__tests__/reporting-test.js +++ /dev/null @@ -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( - - - {}} /> - - - ); - - 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); -}); diff --git a/frontend/src/component/Reporting/constants.js b/frontend/src/component/Reporting/constants.js index cccbba6b75..4feb40d730 100644 --- a/frontend/src/component/Reporting/constants.js +++ b/frontend/src/component/Reporting/constants.js @@ -6,13 +6,6 @@ export const EXPIRED = 'expired'; export const STATUS = 'status'; 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 */ export const FOURTYDAYS = 40; export const SEVENDAYS = 7; diff --git a/frontend/src/component/Reporting/utils.js b/frontend/src/component/Reporting/utils.js index db969683d6..6661f54fc5 100644 --- a/frontend/src/component/Reporting/utils.js +++ b/frontend/src/component/Reporting/utils.js @@ -1,7 +1,13 @@ import parseISO from 'date-fns/parseISO'; 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 = { [EXPERIMENT]: FOURTYDAYS, @@ -21,9 +27,11 @@ export const getCheckedState = (name, features) => { 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) => { if (diff >= toggleExpiryByTypeMap[type]) return true; @@ -56,7 +64,8 @@ export const sortFeaturesByNameAscending = features => { return sorted; }; -export const sortFeaturesByNameDescending = features => sortFeaturesByNameAscending([...features]).reverse(); +export const sortFeaturesByNameDescending = features => + sortFeaturesByNameAscending([...features]).reverse(); export const sortFeaturesByLastSeenAscending = features => { const sorted = [...features]; @@ -72,7 +81,8 @@ export const sortFeaturesByLastSeenAscending = features => { return sorted; }; -export const sortFeaturesByLastSeenDescending = features => sortFeaturesByLastSeenAscending([...features]).reverse(); +export const sortFeaturesByLastSeenDescending = features => + sortFeaturesByLastSeenAscending([...features]).reverse(); export const sortFeaturesByCreatedAtAscending = features => { const sorted = [...features]; @@ -85,7 +95,8 @@ export const sortFeaturesByCreatedAtAscending = features => { return sorted; }; -export const sortFeaturesByCreatedAtDescending = features => sortFeaturesByCreatedAtAscending([...features]).reverse(); +export const sortFeaturesByCreatedAtDescending = features => + sortFeaturesByCreatedAtAscending([...features]).reverse(); export const sortFeaturesByExpiredAtAscending = features => { const sorted = [...features]; @@ -149,7 +160,8 @@ export const sortFeaturesByStatusAscending = features => { return sorted; }; -export const sortFeaturesByStatusDescending = features => sortFeaturesByStatusAscending([...features]).reverse(); +export const sortFeaturesByStatusDescending = features => + sortFeaturesByStatusAscending([...features]).reverse(); export const pluralize = (items, word) => { if (items === 1) return `${items} ${word}`; @@ -163,7 +175,8 @@ export const getDates = dateString => { return [date, now]; }; -export const filterByProject = selectedProject => feature => feature.project === selectedProject; +export const filterByProject = selectedProject => feature => + feature.project === selectedProject; export const isFeatureExpired = feature => { const [date, now] = getDates(feature.createdAt); diff --git a/frontend/src/component/application/__tests__/__snapshots__/application-edit-component-test.js.snap b/frontend/src/component/application/__tests__/__snapshots__/application-edit-component-test.js.snap index c305d72f50..46e78fbfd2 100644 --- a/frontend/src/component/application/__tests__/__snapshots__/application-edit-component-test.js.snap +++ b/frontend/src/component/application/__tests__/__snapshots__/application-edit-component-test.js.snap @@ -33,6 +33,7 @@ exports[`renders correctly with permissions 1`] = ` >

    = ({ @@ -16,6 +17,7 @@ const AnimateOnMount: FC = ({ leave, container, children, + style, }) => { const [show, setShow] = useState(mounted); const [styles, setStyles] = useState(''); @@ -49,6 +51,7 @@ const AnimateOnMount: FC = ({ container ? container : '' }`} onTransitionEnd={onTransitionEnd} + style={{ ...style }} > {children}

    diff --git a/frontend/src/component/common/ApiError/ApiError.tsx b/frontend/src/component/common/ApiError/ApiError.tsx new file mode 100644 index 0000000000..4622fc7442 --- /dev/null +++ b/frontend/src/component/common/ApiError/ApiError.tsx @@ -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 = ({ + className, + onClick, + text, + ...rest +}) => { + return ( + + TRY AGAIN + + } + severity="error" + {...rest} + > + {text} + + ); +}; + +export default ApiError; diff --git a/frontend/src/component/common/Feedback/Feedback.styles.ts b/frontend/src/component/common/Feedback/Feedback.styles.ts index 11ec91d033..d080ede874 100644 --- a/frontend/src/component/common/Feedback/Feedback.styles.ts +++ b/frontend/src/component/common/Feedback/Feedback.styles.ts @@ -14,23 +14,6 @@ export const useStyles = makeStyles(theme => ({ animateContainer: { 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: { display: 'flex', flexDirection: 'column', diff --git a/frontend/src/component/common/Feedback/Feedback.tsx b/frontend/src/component/common/Feedback/Feedback.tsx index eb8327dcac..cda7370161 100644 --- a/frontend/src/component/common/Feedback/Feedback.tsx +++ b/frontend/src/component/common/Feedback/Feedback.tsx @@ -82,9 +82,9 @@ const Feedback = ({ return (
    diff --git a/frontend/src/component/common/HeaderTitle/HeaderTitle.jsx b/frontend/src/component/common/HeaderTitle/HeaderTitle.jsx index 9923849daa..0dac67b96c 100644 --- a/frontend/src/component/common/HeaderTitle/HeaderTitle.jsx +++ b/frontend/src/component/common/HeaderTitle/HeaderTitle.jsx @@ -7,20 +7,33 @@ import ConditionallyRender from '../ConditionallyRender/ConditionallyRender'; import { useStyles } from './styles'; -const HeaderTitle = ({ title, actions, subtitle, variant, loading }) => { +const HeaderTitle = ({ + title, + actions, + subtitle, + variant, + loading, + className, +}) => { const styles = useStyles(); const headerClasses = classnames({ skeleton: loading }); return (
    -
    - +
    + {title} {subtitle && {subtitle}}
    - {actions}
    } /> + {actions}
    } + />
    ); }; diff --git a/frontend/src/component/common/PaginateUI/PaginateUI.tsx b/frontend/src/component/common/PaginateUI/PaginateUI.tsx new file mode 100644 index 0000000000..a9f135925a --- /dev/null +++ b/frontend/src/component/common/PaginateUI/PaginateUI.tsx @@ -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 ( + 1} + show={ +
    +
    + 0} + show={ + + } + /> + {pages.map((page, idx) => { + const active = pageIndex === idx; + return ( + + ); + })} + nextPage()} + className={classnames( + styles.idxBtn, + styles.idxBtnRight + )} + > + + + } + /> +
    +
    + } + /> + ); +}; + +export default PaginateUI; diff --git a/frontend/src/component/common/PaginateUI/PaginationUI.styles.ts b/frontend/src/component/common/PaginateUI/PaginationUI.styles.ts new file mode 100644 index 0000000000..70e20b0932 --- /dev/null +++ b/frontend/src/component/common/PaginateUI/PaginationUI.styles.ts @@ -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', + }, +})); diff --git a/frontend/src/component/common/ResponsiveButton/ResponsiveButton.tsx b/frontend/src/component/common/ResponsiveButton/ResponsiveButton.tsx new file mode 100644 index 0000000000..e4155bbd7d --- /dev/null +++ b/frontend/src/component/common/ResponsiveButton/ResponsiveButton.tsx @@ -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 ( + + + + + + } + elseShow={ + + } + /> + ); +}; + +export default ResponsiveButton; diff --git a/frontend/src/component/common/Toast/Toast.tsx b/frontend/src/component/common/Toast/Toast.tsx new file mode 100644 index 0000000000..c00e5c0afe --- /dev/null +++ b/frontend/src/component/common/Toast/Toast.tsx @@ -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 ( + + + + + {text} + + + + + ); +}; + +export default Toast; diff --git a/frontend/src/component/common/flags.js b/frontend/src/component/common/flags.js index 155282a816..8fb12224b4 100644 --- a/frontend/src/component/common/flags.js +++ b/frontend/src/component/common/flags.js @@ -2,3 +2,6 @@ export const P = 'P'; export const C = 'C'; export const RBAC = 'RBAC'; export const OIDC = 'OIDC'; + +export const PROJECTCARDACTIONS = false; +export const PROJECTFILTERING = false; diff --git a/frontend/src/component/feature/FeatureToggleList/__tests__/__snapshots__/list-component-test.jsx.snap b/frontend/src/component/feature/FeatureToggleList/__tests__/__snapshots__/list-component-test.jsx.snap index 10d1325128..a3e78ac35d 100644 --- a/frontend/src/component/feature/FeatureToggleList/__tests__/__snapshots__/list-component-test.jsx.snap +++ b/frontend/src/component/feature/FeatureToggleList/__tests__/__snapshots__/list-component-test.jsx.snap @@ -50,6 +50,7 @@ exports[`renders correctly with one feature 1`] = ` >

    ({ + 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', + }, +})); diff --git a/frontend/src/component/feature/FeatureToggleListNew/FeatureToggleListNew.tsx b/frontend/src/component/feature/FeatureToggleListNew/FeatureToggleListNew.tsx new file mode 100644 index 0000000000..6efb8962c1 --- /dev/null +++ b/frontend/src/component/feature/FeatureToggleListNew/FeatureToggleListNew.tsx @@ -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 ( + + ); + }); + } + + return page.map((feature: IFeatureToggleListItem) => { + return ( + + ); + }); + }; + + return ( + <> + + + + + name + + + type + + {getEnvironments().map((env: any) => { + return ( + + + {env.name === ':global:' + ? 'global' + : env.name} + + + ); + })} + + + {renderFeatures()} +
    + + + ); +}; + +export default FeatureToggleListNew; diff --git a/frontend/src/component/feature/FeatureToggleListNew/FeatureToggleListNewItem/FeatureToggleListNewItem.tsx b/frontend/src/component/feature/FeatureToggleListNew/FeatureToggleListNewItem/FeatureToggleListNewItem.tsx new file mode 100644 index 0000000000..79ff08cfb8 --- /dev/null +++ b/frontend/src/component/feature/FeatureToggleListNew/FeatureToggleListNewItem/FeatureToggleListNewItem.tsx @@ -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 ( + <> + + + {name} + + +
    + {' '} + {type} +
    +
    + {environments.map((env: IEnvironments) => { + return ( + + + handleToggle(env)} + /> + + + ); + })} +
    + + + ); +}; + +export default FeatureToggleListNewItem; diff --git a/frontend/src/component/feature/FeatureToggleListNew/FeatureToggleListNewItem/loadingFeatures.ts b/frontend/src/component/feature/FeatureToggleListNew/FeatureToggleListNewItem/loadingFeatures.ts new file mode 100644 index 0000000000..9515dcbf9d --- /dev/null +++ b/frontend/src/component/feature/FeatureToggleListNew/FeatureToggleListNewItem/loadingFeatures.ts @@ -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; diff --git a/frontend/src/component/feature/variant/__tests__/__snapshots__/update-variant-component-test.jsx.snap b/frontend/src/component/feature/variant/__tests__/__snapshots__/update-variant-component-test.jsx.snap index 261185d422..b2e8ed4823 100644 --- a/frontend/src/component/feature/variant/__tests__/__snapshots__/update-variant-component-test.jsx.snap +++ b/frontend/src/component/feature/variant/__tests__/__snapshots__/update-variant-component-test.jsx.snap @@ -497,10 +497,10 @@ exports[`renders correctly with with variants 1`] = `
    Stickiness diff --git a/frontend/src/component/feature/view/__tests__/__snapshots__/view-component-test.jsx.snap b/frontend/src/component/feature/view/__tests__/__snapshots__/view-component-test.jsx.snap index 740f5d2533..2c1ff5afc5 100644 --- a/frontend/src/component/feature/view/__tests__/__snapshots__/view-component-test.jsx.snap +++ b/frontend/src/component/feature/view/__tests__/__snapshots__/view-component-test.jsx.snap @@ -140,10 +140,10 @@ exports[`renders correctly with one feature 1`] = `
    Project @@ -175,7 +175,7 @@ exports[`renders correctly with one feature 1`] = ` >
    diff --git a/frontend/src/component/menu/routes.js b/frontend/src/component/menu/routes.js index 9d41a61ae9..e7c23af929 100644 --- a/frontend/src/component/menu/routes.js +++ b/frontend/src/component/menu/routes.js @@ -39,6 +39,8 @@ import { P, C } from '../common/flags'; import NewUser from '../user/NewUser'; import ResetPassword from '../user/ResetPassword/ResetPassword'; import ForgottenPassword from '../user/ForgottenPassword/ForgottenPassword'; +import ProjectListNew from '../project/ProjectListNew/ProjectListNew'; +import Project from '../project/Project/Project'; import { List, @@ -231,7 +233,15 @@ export const routes = [ type: 'protected', layout: 'main', }, - + { + path: '/projects/:id', + parent: '/projects', + title: ':id', + component: Project, + flag: P, + type: 'protected', + layout: 'main', + }, { path: '/projects', title: 'Projects', @@ -241,6 +251,16 @@ export const routes = [ type: 'protected', 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', diff --git a/frontend/src/component/project/Project/Project.tsx b/frontend/src/component/project/Project/Project.tsx new file mode 100644 index 0000000000..3aeed3a95f --- /dev/null +++ b/frontend/src/component/project/Project/Project.tsx @@ -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 ( +
    +

    + {project?.name} +

    + + } + /> +
    + + +
    +
    + ); +}; + +export default Project; diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.styles.ts b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.styles.ts new file mode 100644 index 0000000000..37a5bce823 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.styles.ts @@ -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', + }, +})); diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx new file mode 100644 index 0000000000..a9ca984c9f --- /dev/null +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx @@ -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 ( + + + + + } + /> + + + } + /> + } + > + + + ); +}; + +export default ProjectFeatureToggles; diff --git a/frontend/src/component/project/Project/ProjectInfo/ProjectInfo.styles.ts b/frontend/src/component/project/Project/ProjectInfo/ProjectInfo.styles.ts new file mode 100644 index 0000000000..fdfde78e46 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectInfo/ProjectInfo.styles.ts @@ -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', + }, +})); diff --git a/frontend/src/component/project/Project/ProjectInfo/ProjectInfo.tsx b/frontend/src/component/project/Project/ProjectInfo/ProjectInfo.tsx new file mode 100644 index 0000000000..d4924f095d --- /dev/null +++ b/frontend/src/component/project/Project/ProjectInfo/ProjectInfo.tsx @@ -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 ( + + ); +}; + +export default ProjectInfo; diff --git a/frontend/src/component/project/ProjectCard/ProjectCard.styles.ts b/frontend/src/component/project/ProjectCard/ProjectCard.styles.ts new file mode 100644 index 0000000000..076623f75f --- /dev/null +++ b/frontend/src/component/project/ProjectCard/ProjectCard.styles.ts @@ -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', + }, +})); diff --git a/frontend/src/component/project/ProjectCard/ProjectCard.tsx b/frontend/src/component/project/ProjectCard/ProjectCard.tsx new file mode 100644 index 0000000000..ded5ccaae2 --- /dev/null +++ b/frontend/src/component/project/ProjectCard/ProjectCard.tsx @@ -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 ( + +
    +

    {name}

    + + + + } + /> +
    +
    + +
    +
    +
    +

    + {featureCount} +

    +

    toggles

    +
    +
    +

    + {health}% +

    +

    health

    +
    + +
    +

    + {memberCount} +

    +

    members

    +
    +
    +
    + ); +}; + +export default ProjectCard; diff --git a/frontend/src/component/project/ProjectList/ProjectList.jsx b/frontend/src/component/project/ProjectList/ProjectList.jsx index e6a2f1d919..c5025dfb37 100644 --- a/frontend/src/component/project/ProjectList/ProjectList.jsx +++ b/frontend/src/component/project/ProjectList/ProjectList.jsx @@ -14,8 +14,6 @@ import { ListItemAvatar, ListItemText, Tooltip, - Button, - useMediaQuery, } from '@material-ui/core'; import { Add, @@ -29,10 +27,10 @@ import ConfirmDialogue from '../../common/Dialogue'; import PageContent from '../../common/PageContent/PageContent'; import { useStyles } from './styles'; import AccessContext from '../../../contexts/AccessContext'; +import ResponsiveButton from '../../common/ResponsiveButton/ResponsiveButton'; const ProjectList = ({ projects, fetchProjects, removeProject, history }) => { const { hasAccess } = useContext(AccessContext); - const smallScreen = useMediaQuery('(max-width:700px)'); const [showDelDialogue, setShowDelDialogue] = useState(false); const [project, setProject] = useState(undefined); const styles = useStyles(); @@ -44,26 +42,11 @@ const ProjectList = ({ projects, fetchProjects, removeProject, history }) => { - history.push('/projects/create')} - > - - - - } - elseShow={ - - } + history.push('/projects/create')} + maxWidth="700px" + tooltip="Add new project" /> } /> diff --git a/frontend/src/component/project/ProjectListNew/ProjectListNew.styles.ts b/frontend/src/component/project/ProjectListNew/ProjectListNew.styles.ts new file mode 100644 index 0000000000..3cc05b65fd --- /dev/null +++ b/frontend/src/component/project/ProjectListNew/ProjectListNew.styles.ts @@ -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' }, +})); diff --git a/frontend/src/component/project/ProjectListNew/ProjectListNew.tsx b/frontend/src/component/project/ProjectListNew/ProjectListNew.tsx new file mode 100644 index 0000000000..f5e7305160 --- /dev/null +++ b/frontend/src/component/project/ProjectListNew/ProjectListNew.tsx @@ -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({}); + 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 ( + + ); + }; + + const renderProjects = () => { + if (loading) { + return renderLoading(); + } + + return projects.map((project: IProjectCard) => { + return ( + + handleHover(project?.id)} + name={project?.name} + memberCount={project?.memberCount} + health={project?.health} + featureCount={project?.featureCount} + /> + + ); + }); + }; + + const renderLoading = () => { + return loadingData.map((project: IProjectCard) => { + return ( + {}} + key={project.id} + projectName={project.name} + members={2} + health={95} + toggles={4} + /> + ); + }); + }; + + return ( +
    + + history.push('/projects/create') + } + maxWidth="700px" + tooltip="Add new project" + /> + } + /> + } + /> + } + > + +
    + No projects available.
    } + elseShow={renderProjects()} + /> +
    + +
    + ); +}; + +export default ProjectListNew; diff --git a/frontend/src/component/project/ProjectListNew/loadingData.ts b/frontend/src/component/project/ProjectListNew/loadingData.ts new file mode 100644 index 0000000000..2747f907b3 --- /dev/null +++ b/frontend/src/component/project/ProjectListNew/loadingData.ts @@ -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; diff --git a/frontend/src/component/project/access-component.js b/frontend/src/component/project/access-component.js index f626accf46..61f899b136 100644 --- a/frontend/src/component/project/access-component.js +++ b/frontend/src/component/project/access-component.js @@ -35,11 +35,15 @@ function AccessComponent({ projectId, project }) { const history = useHistory(); const fetchAccess = async () => { - const access = await projectApi.fetchAccess(projectId); - setRoles(access.roles); - setUsers( - access.users.map(u => ({ ...u, name: u.name || '(No name)' })) - ); + try { + const access = await projectApi.fetchAccess(projectId); + setRoles(access.roles); + setUsers( + access.users.map(u => ({ ...u, name: u.name || '(No name)' })) + ); + } catch (e) { + console.log(e); + } }; useEffect(() => { diff --git a/frontend/src/component/strategies/__tests__/__snapshots__/list-component-test.jsx.snap b/frontend/src/component/strategies/__tests__/__snapshots__/list-component-test.jsx.snap index a082fa5d75..363bee56c7 100644 --- a/frontend/src/component/strategies/__tests__/__snapshots__/list-component-test.jsx.snap +++ b/frontend/src/component/strategies/__tests__/__snapshots__/list-component-test.jsx.snap @@ -12,6 +12,7 @@ exports[`renders correctly with one strategy 1`] = ` >

    { diff --git a/frontend/src/constants/featureToggleTypes.ts b/frontend/src/constants/featureToggleTypes.ts new file mode 100644 index 0000000000..49e4cc109e --- /dev/null +++ b/frontend/src/constants/featureToggleTypes.ts @@ -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'; diff --git a/frontend/src/hooks/api/actions/useAdminUsersApi/errorHandlers.ts b/frontend/src/hooks/api/actions/useAdminUsersApi/errorHandlers.ts new file mode 100644 index 0000000000..9fd4a38963 --- /dev/null +++ b/frontend/src/hooks/api/actions/useAdminUsersApi/errorHandlers.ts @@ -0,0 +1,76 @@ +import { Dispatch, SetStateAction } from 'react'; + +import { + AuthenticationError, + ForbiddenError, + NotFoundError, +} from '../../../../store/api-helper'; + +export const handleBadRequest = async ( + setErrors?: Dispatch>, + 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>, + 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>, + 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>, + 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); +}; diff --git a/frontend/src/hooks/api/actions/useAdminUsersApi/useAdminUsersApi.ts b/frontend/src/hooks/api/actions/useAdminUsersApi/useAdminUsersApi.ts new file mode 100644 index 0000000000..5fa6f9cde1 --- /dev/null +++ b/frontend/src/hooks/api/actions/useAdminUsersApi/useAdminUsersApi.ts @@ -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; diff --git a/frontend/src/hooks/api/actions/useApi/useApi.ts b/frontend/src/hooks/api/actions/useApi/useApi.ts new file mode 100644 index 0000000000..1fb003fc55 --- /dev/null +++ b/frontend/src/hooks/api/actions/useApi/useApi.ts @@ -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>, + res?: Response, + requestId?: string + ) => void; + handleNotFound?: ( + setErrors?: Dispatch>, + res?: Response, + requestId?: string + ) => void; + handleUnauthorized?: ( + setErrors?: Dispatch>, + res?: Response, + requestId?: string + ) => void; + handleForbidden?: ( + setErrors?: Dispatch>, + 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 => { + 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; diff --git a/frontend/src/hooks/api/actions/useToggleFeatureByEnv/useToggleFeatureByEnv.ts b/frontend/src/hooks/api/actions/useToggleFeatureByEnv/useToggleFeatureByEnv.ts new file mode 100644 index 0000000000..d6a821421c --- /dev/null +++ b/frontend/src/hooks/api/actions/useToggleFeatureByEnv/useToggleFeatureByEnv.ts @@ -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; diff --git a/frontend/src/hooks/api/getters/useHealthReport/useHealthReport.ts b/frontend/src/hooks/api/getters/useHealthReport/useHealthReport.ts new file mode 100644 index 0000000000..9c1a033bbd --- /dev/null +++ b/frontend/src/hooks/api/getters/useHealthReport/useHealthReport.ts @@ -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(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; diff --git a/frontend/src/hooks/api/getters/useProject/fallbackProject.ts b/frontend/src/hooks/api/getters/useProject/fallbackProject.ts new file mode 100644 index 0000000000..844cebad27 --- /dev/null +++ b/frontend/src/hooks/api/getters/useProject/fallbackProject.ts @@ -0,0 +1,8 @@ +export const fallbackProject = { + features: [], + name: '', + health: 0, + members: 0, + version: '1', + description: 'Default', +}; diff --git a/frontend/src/hooks/api/getters/useProject/getProjectFetcher.ts b/frontend/src/hooks/api/getters/useProject/getProjectFetcher.ts new file mode 100644 index 0000000000..1fe3e760ec --- /dev/null +++ b/frontend/src/hooks/api/getters/useProject/getProjectFetcher.ts @@ -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, + }; +}; diff --git a/frontend/src/hooks/api/getters/useProject/useProject.ts b/frontend/src/hooks/api/getters/useProject/useProject.ts new file mode 100644 index 0000000000..391dce99ca --- /dev/null +++ b/frontend/src/hooks/api/getters/useProject/useProject.ts @@ -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(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; diff --git a/frontend/src/hooks/api/getters/useProjects/useProjects.ts b/frontend/src/hooks/api/getters/useProjects/useProjects.ts new file mode 100644 index 0000000000..a45f2b2655 --- /dev/null +++ b/frontend/src/hooks/api/getters/useProjects/useProjects.ts @@ -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; diff --git a/frontend/src/hooks/useResetPassword.ts b/frontend/src/hooks/api/getters/useResetPassword/useResetPassword.ts similarity index 90% rename from frontend/src/hooks/useResetPassword.ts rename to frontend/src/hooks/api/getters/useResetPassword/useResetPassword.ts index 159222da0a..b1b5cab7c6 100644 --- a/frontend/src/hooks/useResetPassword.ts +++ b/frontend/src/hooks/api/getters/useResetPassword/useResetPassword.ts @@ -1,7 +1,7 @@ import useSWR from 'swr'; -import useQueryParams from './useQueryParams'; +import useQueryParams from '../../../useQueryParams'; import { useState, useEffect } from 'react'; -import { formatApiPath } from '../utils/format-path'; +import { formatApiPath } from '../../../../utils/format-path'; const getFetcher = (token: string) => () => { const path = formatApiPath(`auth/reset/validate?token=${token}`); diff --git a/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts b/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts new file mode 100644 index 0000000000..1ec0023f27 --- /dev/null +++ b/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts @@ -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', + }, + ], +}; diff --git a/frontend/src/hooks/api/getters/useUiConfig/useUiConfig.ts b/frontend/src/hooks/api/getters/useUiConfig/useUiConfig.ts new file mode 100644 index 0000000000..06d62cef5d --- /dev/null +++ b/frontend/src/hooks/api/getters/useUiConfig/useUiConfig.ts @@ -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; diff --git a/frontend/src/hooks/useUsers.ts b/frontend/src/hooks/api/getters/useUsers/useUsers.ts similarity index 92% rename from frontend/src/hooks/useUsers.ts rename to frontend/src/hooks/api/getters/useUsers/useUsers.ts index 67530d84eb..87c0c9a4fc 100644 --- a/frontend/src/hooks/useUsers.ts +++ b/frontend/src/hooks/api/getters/useUsers/useUsers.ts @@ -1,6 +1,6 @@ import useSWR, { mutate } from 'swr'; import { useState, useEffect } from 'react'; -import { formatApiPath } from '../utils/format-path'; +import { formatApiPath } from '../../../../utils/format-path'; const useUsers = () => { const fetcher = () => { diff --git a/frontend/src/hooks/useAdminUsersApi.ts b/frontend/src/hooks/useAdminUsersApi.ts deleted file mode 100644 index 898c6aa6f5..0000000000 --- a/frontend/src/hooks/useAdminUsersApi.ts +++ /dev/null @@ -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 => { - 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; diff --git a/frontend/src/hooks/usePagination.ts b/frontend/src/hooks/usePagination.ts new file mode 100644 index 0000000000..1d0b295176 --- /dev/null +++ b/frontend/src/hooks/usePagination.ts @@ -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; diff --git a/frontend/src/component/Reporting/useSort.js b/frontend/src/hooks/useSort.js similarity index 93% rename from frontend/src/component/Reporting/useSort.js rename to frontend/src/hooks/useSort.js index 8838467041..12f35b499f 100644 --- a/frontend/src/component/Reporting/useSort.js +++ b/frontend/src/hooks/useSort.js @@ -10,9 +10,16 @@ import { sortFeaturesByExpiredAtDescending, sortFeaturesByStatusAscending, 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 [sortData, setSortData] = useState({ diff --git a/frontend/src/interfaces/featureToggle.ts b/frontend/src/interfaces/featureToggle.ts new file mode 100644 index 0000000000..aad8546be3 --- /dev/null +++ b/frontend/src/interfaces/featureToggle.ts @@ -0,0 +1,11 @@ +export interface IFeatureToggleListItem { + type: string; + name: string; + environments: IEnvironments[]; +} + +export interface IEnvironments { + name: string; + displayName: string; + enabled: boolean; +} diff --git a/frontend/src/interfaces/project.ts b/frontend/src/interfaces/project.ts new file mode 100644 index 0000000000..619584dda0 --- /dev/null +++ b/frontend/src/interfaces/project.ts @@ -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; +} diff --git a/frontend/src/page/admin/users/AddUser/AddUser.tsx b/frontend/src/page/admin/users/AddUser/AddUser.tsx index 3657cae56b..bd45fffe87 100644 --- a/frontend/src/page/admin/users/AddUser/AddUser.tsx +++ b/frontend/src/page/admin/users/AddUser/AddUser.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; 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 AddUserForm from './AddUserForm/AddUserForm'; diff --git a/frontend/src/page/admin/users/AddUser/AddUserForm/AddUserForm.jsx b/frontend/src/page/admin/users/AddUser/AddUserForm/AddUserForm.jsx index 4fab6d26d9..16b03e7be8 100644 --- a/frontend/src/page/admin/users/AddUser/AddUserForm/AddUserForm.jsx +++ b/frontend/src/page/admin/users/AddUser/AddUserForm/AddUserForm.jsx @@ -18,7 +18,7 @@ import useLoading from '../../../../../hooks/useLoading'; import { ADD_USER_ERROR, UPDATE_USER_ERROR, -} from '../../../../../hooks/useAdminUsersApi'; +} from '../../../../../hooks/api/actions/useAdminUsersApi/useAdminUsersApi'; import { Alert } from '@material-ui/lab'; function AddUserForm({ diff --git a/frontend/src/page/admin/users/UsersList/UsersList.jsx b/frontend/src/page/admin/users/UsersList/UsersList.jsx index 08123c3a5f..d1d103bb14 100644 --- a/frontend/src/page/admin/users/UsersList/UsersList.jsx +++ b/frontend/src/page/admin/users/UsersList/UsersList.jsx @@ -16,8 +16,8 @@ import ConditionallyRender from '../../../../component/common/ConditionallyRende import AccessContext from '../../../../contexts/AccessContext'; import { ADMIN } from '../../../../component/AccessProvider/permissions'; import ConfirmUserAdded from '../ConfirmUserAdded/ConfirmUserAdded'; -import useUsers from '../../../../hooks/useUsers'; -import useAdminUsersApi from '../../../../hooks/useAdminUsersApi'; +import useUsers from '../../../../hooks/api/getters/useUsers/useUsers'; +import useAdminUsersApi from '../../../../hooks/api/actions/useAdminUsersApi/useAdminUsersApi'; import UserListItem from './UserListItem/UserListItem'; import loadingData from './loadingData'; import useLoading from '../../../../hooks/useLoading'; diff --git a/frontend/src/page/admin/users/del-user-component.jsx b/frontend/src/page/admin/users/del-user-component.jsx index bde257c8eb..fddbc7aae4 100644 --- a/frontend/src/page/admin/users/del-user-component.jsx +++ b/frontend/src/page/admin/users/del-user-component.jsx @@ -2,7 +2,7 @@ import React from 'react'; import Dialogue from '../../../component/common/Dialogue/Dialogue'; import ConditionallyRender from '../../../component/common/ConditionallyRender/ConditionallyRender'; 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 useLoading from '../../../hooks/useLoading'; import { Avatar, Typography } from '@material-ui/core'; diff --git a/frontend/src/store/project/api.js b/frontend/src/store/project/api.js index f8178ffcec..4872a4009d 100644 --- a/frontend/src/store/project/api.js +++ b/frontend/src/store/project/api.js @@ -67,7 +67,9 @@ function validate(id) { } 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 { diff --git a/frontend/src/utils/get-feature-type-icons.ts b/frontend/src/utils/get-feature-type-icons.ts new file mode 100644 index 0000000000..d9a1888ba0 --- /dev/null +++ b/frontend/src/utils/get-feature-type-icons.ts @@ -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; + } +}; diff --git a/frontend/src/utils/paginate.test.ts b/frontend/src/utils/paginate.test.ts new file mode 100644 index 0000000000..d84adcf62f --- /dev/null +++ b/frontend/src/utils/paginate.test.ts @@ -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); +}); diff --git a/frontend/src/utils/paginate.ts b/frontend/src/utils/paginate.ts new file mode 100644 index 0000000000..57449c902b --- /dev/null +++ b/frontend/src/utils/paginate.ts @@ -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; +};