From a11cb72d99547210b51a3367cc1d7403cf39fbb3 Mon Sep 17 00:00:00 2001 From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Date: Wed, 25 May 2022 10:14:22 +0200 Subject: [PATCH] Persistent table query (#999) * feat: persistent table query * project overview sort query * refactor: api methods as hook callbacks * persitent columns in project overview * enable new project overview * fix: refactor feature state change in overview * add type to sort * update e2e tests now takes 10% less time with use of cypress session * prevent sort reset on features list * fix feature toggle list loading * fix: update column state saving * update local storage hook test --- .../integration/feature/feature.spec.ts | 12 +- .../integration/segments/segments.spec.ts | 22 +- frontend/cypress/support/commands.ts | 24 ++ frontend/cypress/support/index.ts | 10 +- .../CellSortable/CellSortable.styles.ts | 44 ++- .../CellSortable/CellSortable.tsx | 43 ++- .../SortArrow/SortArrow.styles.ts | 2 +- .../CellSortable/SortArrow/SortArrow.tsx | 16 +- .../TablePlaceholder.styles.ts | 1 + frontend/src/component/common/flags.ts | 1 - .../FeatureStaleCell.styles.ts | 0 .../FeatureStaleCell/FeatureStaleCell.tsx | 0 .../FeatureToggleListContainer.tsx | 22 -- .../FeatureToggleListTable.tsx | 89 ++++-- frontend/src/component/menu/routes.ts | 4 +- .../ColumnsMenu/ColumnsMenu.tsx | 23 +- .../ProjectFeatureToggles.tsx | 130 +++++++-- .../hooks/useSetFeatureState.ts | 31 --- .../project/Project/ProjectOverview.tsx | 25 +- .../SegmentListItem/SegmentListItem.tsx | 62 +++-- .../src/hooks/api/actions/useApi/useApi.ts | 259 +++++++++--------- .../actions/useFeatureApi/useFeatureApi.ts | 71 +++-- .../api/getters/useFeatures/useFeatures.ts | 5 +- .../api/getters/useUiConfig/defaultValue.ts | 1 - frontend/src/hooks/useLocalStorage.test.ts | 65 +++++ frontend/src/hooks/useLocalStorage.ts | 34 +++ .../src/hooks/usePersistentGlobalState.ts | 4 +- frontend/src/interfaces/uiConfig.ts | 1 - 28 files changed, 621 insertions(+), 380 deletions(-) rename frontend/src/component/feature/FeatureToggleList/{FeatureToggleListTable => }/FeatureStaleCell/FeatureStaleCell.styles.ts (100%) rename frontend/src/component/feature/FeatureToggleList/{FeatureToggleListTable => }/FeatureStaleCell/FeatureStaleCell.tsx (100%) delete mode 100644 frontend/src/component/feature/FeatureToggleList/FeatureToggleListContainer.tsx rename frontend/src/component/feature/FeatureToggleList/{FeatureToggleListTable => }/FeatureToggleListTable.tsx (71%) delete mode 100644 frontend/src/component/project/Project/ProjectFeatureToggles/hooks/useSetFeatureState.ts create mode 100644 frontend/src/hooks/useLocalStorage.test.ts create mode 100644 frontend/src/hooks/useLocalStorage.ts diff --git a/frontend/cypress/integration/feature/feature.spec.ts b/frontend/cypress/integration/feature/feature.spec.ts index b413f42d9a..c2bc9d6b86 100644 --- a/frontend/cypress/integration/feature/feature.spec.ts +++ b/frontend/cypress/integration/feature/feature.spec.ts @@ -2,8 +2,6 @@ export {}; -const AUTH_USER = Cypress.env('AUTH_USER'); -const AUTH_PASSWORD = Cypress.env('AUTH_PASSWORD'); const ENTERPRISE = Boolean(Cypress.env('ENTERPRISE')); const randomId = String(Math.random()).split('.')[1]; const featureToggleName = `unleash-e2e-${randomId}`; @@ -41,16 +39,8 @@ describe('feature', () => { }); beforeEach(() => { + cy.login(); cy.visit('/'); - cy.get('[data-testid="LOGIN_EMAIL_ID"]').type(AUTH_USER); - - if (AUTH_PASSWORD) { - cy.get('[data-testid="LOGIN_PASSWORD_ID"]').type(AUTH_PASSWORD); - } - - cy.get("[data-testid='LOGIN_BUTTON']").click(); - // Wait for the login redirect to complete. - cy.get('[data-testid=HEADER_USER_AVATAR'); }); it('can create a feature toggle', () => { diff --git a/frontend/cypress/integration/segments/segments.spec.ts b/frontend/cypress/integration/segments/segments.spec.ts index 001387985e..7dc3a54d3f 100644 --- a/frontend/cypress/integration/segments/segments.spec.ts +++ b/frontend/cypress/integration/segments/segments.spec.ts @@ -1,16 +1,9 @@ /// export {}; - -const AUTH_USER = Cypress.env('AUTH_USER'); -const AUTH_PASSWORD = Cypress.env('AUTH_PASSWORD'); const randomId = String(Math.random()).split('.')[1]; const segmentName = `unleash-e2e-${randomId}`; -Cypress.config({ - experimentalSessionSupport: true, -}); - // Disable all active splash pages by visiting them. const disableActiveSplashScreens = () => { cy.visit(`/splash/operators`); @@ -22,20 +15,7 @@ describe('segments', () => { }); beforeEach(() => { - cy.session(AUTH_USER, () => { - cy.visit('/'); - cy.wait(1000); - cy.get("[data-testid='LOGIN_EMAIL_ID']").type(AUTH_USER); - - if (AUTH_PASSWORD) { - cy.get("[data-testid='LOGIN_PASSWORD_ID']").type(AUTH_PASSWORD); - } - - cy.get("[data-testid='LOGIN_BUTTON']").click(); - // Wait for the login redirect to complete. - cy.get("[data-testid='HEADER_USER_AVATAR']"); - }); - + cy.login(); cy.visit('/segments'); }); diff --git a/frontend/cypress/support/commands.ts b/frontend/cypress/support/commands.ts index 119ab03f7c..9e95963650 100644 --- a/frontend/cypress/support/commands.ts +++ b/frontend/cypress/support/commands.ts @@ -23,3 +23,27 @@ // // -- This will overwrite an existing command -- // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) + +const AUTH_USER = Cypress.env('AUTH_USER'); +const AUTH_PASSWORD = Cypress.env('AUTH_PASSWORD'); + +Cypress.config({ + experimentalSessionSupport: true, +}); + +Cypress.Commands.add('login', (user = AUTH_USER, password = AUTH_PASSWORD) => + cy.session(user, () => { + cy.visit('/'); + cy.wait(1000); + cy.get("[data-testid='LOGIN_EMAIL_ID']").type(user); + + if (AUTH_PASSWORD) { + cy.get("[data-testid='LOGIN_PASSWORD_ID']").type(password); + } + + cy.get("[data-testid='LOGIN_BUTTON']").click(); + + // Wait for the login redirect to complete. + cy.get("[data-testid='HEADER_USER_AVATAR']"); + }) +); diff --git a/frontend/cypress/support/index.ts b/frontend/cypress/support/index.ts index d68db96df2..1f43f103c0 100644 --- a/frontend/cypress/support/index.ts +++ b/frontend/cypress/support/index.ts @@ -14,7 +14,15 @@ // *********************************************************** // Import commands.js using ES2015 syntax: -import './commands' +import './commands'; // Alternatively you can use CommonJS syntax: // require('./commands') + +declare global { + namespace Cypress { + interface Chainable { + login(user?: string, password?: string): Chainable; + } + } +} diff --git a/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.styles.ts b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.styles.ts index 20a108e8d7..e428a07c02 100644 --- a/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.styles.ts +++ b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.styles.ts @@ -16,23 +16,16 @@ export const useStyles = makeStyles()(theme => ({ }, sortButton: { all: 'unset', - padding: theme.spacing(2), whiteSpace: 'nowrap', width: '100%', - '& .hover-only': { - visibility: 'hidden', - }, + position: 'relative', ':hover, :focus, &:focus-visible, &:active': { outline: 'revert', - '& svg': { - color: 'inherit', - }, - '& .hover-only': { - visibility: 'visible', + '.hover-only': { + display: 'inline-block', }, }, display: 'flex', - alignItems: 'center', boxSizing: 'inherit', cursor: 'pointer', }, @@ -42,10 +35,8 @@ export const useStyles = makeStyles()(theme => ({ label: { display: 'flex', flexDirection: 'column', - whiteSpace: 'nowrap', - textOverflow: 'ellipsis', - overflowX: 'hidden', - overflowY: 'visible', + flexShrink: 1, + minWidth: 0, '::after': { fontWeight: 'bold', display: 'inline-block', @@ -67,4 +58,29 @@ export const useStyles = makeStyles()(theme => ({ justifyContent: 'center', textAlign: 'center', }, + hiddenMeasurementLayer: { + padding: theme.spacing(2), + visibility: 'hidden', + display: 'flex', + alignItems: 'center', + width: '100%', + }, + visibleAbsoluteLayer: { + padding: theme.spacing(2), + position: 'absolute', + display: 'flex', + alignItems: 'center', + width: '100%', + height: '100%', + '.hover-only': { + display: 'none', + }, + '& > span': { + minWidth: 0, + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + overflowX: 'hidden', + overflowY: 'visible', + }, + }, })); diff --git a/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.tsx b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.tsx index 33191d03ba..59b7c423e5 100644 --- a/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.tsx +++ b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.tsx @@ -102,23 +102,44 @@ export const CellSortable: FC = ({ } diff --git a/frontend/src/component/common/Table/SortableTableHeader/CellSortable/SortArrow/SortArrow.styles.ts b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/SortArrow/SortArrow.styles.ts index d1d8aa1654..2cfd400e7b 100644 --- a/frontend/src/component/common/Table/SortableTableHeader/CellSortable/SortArrow/SortArrow.styles.ts +++ b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/SortArrow/SortArrow.styles.ts @@ -3,7 +3,7 @@ import { makeStyles } from 'tss-react/mui'; export const useStyles = makeStyles()(theme => ({ icon: { marginLeft: theme.spacing(0.25), - marginRight: -theme.spacing(0.5), + marginRight: theme.spacing(-0.5), color: theme.palette.grey[700], fontSize: theme.fontSizes.mainHeader, verticalAlign: 'middle', diff --git a/frontend/src/component/common/Table/SortableTableHeader/CellSortable/SortArrow/SortArrow.tsx b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/SortArrow/SortArrow.tsx index e3943d9822..5ddf22dd74 100644 --- a/frontend/src/component/common/Table/SortableTableHeader/CellSortable/SortArrow/SortArrow.tsx +++ b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/SortArrow/SortArrow.tsx @@ -11,11 +11,13 @@ import classnames from 'classnames'; interface ISortArrowProps { isSorted?: boolean; isDesc?: boolean; + className?: string; } export const SortArrow: VFC = ({ isSorted: sorted, isDesc: desc = false, + className, }) => { const { classes: styles } = useStyles(); @@ -27,13 +29,21 @@ export const SortArrow: VFC = ({ condition={Boolean(desc)} show={ } elseShow={ } @@ -41,7 +51,7 @@ export const SortArrow: VFC = ({ } elseShow={ } diff --git a/frontend/src/component/common/Table/TablePlaceholder/TablePlaceholder.styles.ts b/frontend/src/component/common/Table/TablePlaceholder/TablePlaceholder.styles.ts index ab3feae978..e85267f6ff 100644 --- a/frontend/src/component/common/Table/TablePlaceholder/TablePlaceholder.styles.ts +++ b/frontend/src/component/common/Table/TablePlaceholder/TablePlaceholder.styles.ts @@ -8,5 +8,6 @@ export const useStyles = makeStyles()(theme => ({ display: 'flex', justifyContent: 'space-between', alignItems: 'center', + marginTop: theme.spacing(2), }, })); diff --git a/frontend/src/component/common/flags.ts b/frontend/src/component/common/flags.ts index d4db0bac25..a01017916e 100644 --- a/frontend/src/component/common/flags.ts +++ b/frontend/src/component/common/flags.ts @@ -5,4 +5,3 @@ export const RBAC = 'RBAC'; export const EEA = 'EEA'; export const RE = 'RE'; export const SE = 'SE'; -export const NEW_PROJECT_OVERVIEW = 'NEW_PROJECT_OVERVIEW'; diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable/FeatureStaleCell/FeatureStaleCell.styles.ts b/frontend/src/component/feature/FeatureToggleList/FeatureStaleCell/FeatureStaleCell.styles.ts similarity index 100% rename from frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable/FeatureStaleCell/FeatureStaleCell.styles.ts rename to frontend/src/component/feature/FeatureToggleList/FeatureStaleCell/FeatureStaleCell.styles.ts diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable/FeatureStaleCell/FeatureStaleCell.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureStaleCell/FeatureStaleCell.tsx similarity index 100% rename from frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable/FeatureStaleCell/FeatureStaleCell.tsx rename to frontend/src/component/feature/FeatureToggleList/FeatureStaleCell/FeatureStaleCell.tsx diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListContainer.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListContainer.tsx deleted file mode 100644 index 6df1735b12..0000000000 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListContainer.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useFeatures } from 'hooks/api/getters/useFeatures/useFeatures'; -import { FeatureSchema } from 'openapi'; -import { FeatureToggleListTable } from './FeatureToggleListTable/FeatureToggleListTable'; - -const featuresPlaceholder: FeatureSchema[] = Array(15).fill({ - name: 'Name of the feature', - description: 'Short description of the feature', - type: '-', - createdAt: new Date(2022, 1, 1), - project: 'projectID', -}); - -export const FeatureToggleListContainer = () => { - const { features = [], loading } = useFeatures(); - - return ( - - ); -}; diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable/FeatureToggleListTable.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx similarity index 71% rename from frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable/FeatureToggleListTable.tsx rename to frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx index f468829649..2edf4d93c3 100644 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable/FeatureToggleListTable.tsx +++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx @@ -1,7 +1,7 @@ -import { useEffect, useMemo, VFC } from 'react'; +import { useEffect, useMemo, useState, VFC } from 'react'; import { Link, useMediaQuery, useTheme } from '@mui/material'; -import { Link as RouterLink } from 'react-router-dom'; -import { useGlobalFilter, useSortBy, useTable } from 'react-table'; +import { Link as RouterLink, useSearchParams } from 'react-router-dom'; +import { SortingRule, useGlobalFilter, useSortBy, useTable } from 'react-table'; import { Table, SortableTableHeader, @@ -11,22 +11,30 @@ import { TablePlaceholder, TableSearch, } from 'component/common/Table'; +import { useFeatures } from 'hooks/api/getters/useFeatures/useFeatures'; import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; -import { DateCell } from '../../../common/Table/cells/DateCell/DateCell'; +import { DateCell } from 'component/common/Table/cells/DateCell/DateCell'; import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell'; import { FeatureSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureSeenCell'; import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell'; -import { FeatureStaleCell } from './FeatureStaleCell/FeatureStaleCell'; -import { CreateFeatureButton } from '../../CreateFeatureButton/CreateFeatureButton'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { PageContent } from 'component/common/PageContent/PageContent'; import { PageHeader } from 'component/common/PageHeader/PageHeader'; import { sortTypes } from 'utils/sortTypes'; +import { useLocalStorage } from 'hooks/useLocalStorage'; +import { FeatureSchema } from 'openapi'; +import { CreateFeatureButton } from '../CreateFeatureButton/CreateFeatureButton'; +import { FeatureStaleCell } from './FeatureStaleCell/FeatureStaleCell'; -interface IExperimentProps { - data: Record[]; - isLoading?: boolean; -} +const featuresPlaceholder: FeatureSchema[] = Array(15).fill({ + name: 'Name of the feature', + description: 'Short description of the feature', + type: '-', + createdAt: new Date(2022, 1, 1), + project: 'projectID', +}); + +type PageQueryType = Partial>; const columns = [ { @@ -87,21 +95,36 @@ const columns = [ }, ]; -export const FeatureToggleListTable: VFC = ({ - data, - isLoading = false, -}) => { +const defaultSort: SortingRule = { id: 'createdAt', desc: false }; + +export const FeatureToggleListTable: VFC = () => { const theme = useTheme(); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg')); - - const initialState = useMemo( - () => ({ - sortBy: [{ id: 'createdAt', desc: false }], - hiddenColumns: ['description'], - }), - [] + const [searchParams, setSearchParams] = useSearchParams(); + const [storedParams, setStoredParams] = useLocalStorage( + 'FeatureToggleListTable:v1', + defaultSort ); + const { features = [], loading } = useFeatures(); + const data = useMemo( + () => + features?.length === 0 && loading ? featuresPlaceholder : features, + [features, loading] + ); + + const [initialState] = useState(() => ({ + sortBy: [ + { + id: searchParams.get('sort') || storedParams.id, + desc: searchParams.has('order') + ? searchParams.get('order') === 'desc' + : storedParams.desc, + }, + ], + hiddenColumns: ['description'], + globalFilter: searchParams.get('search') || '', + })); const { getTableProps, @@ -109,17 +132,20 @@ export const FeatureToggleListTable: VFC = ({ headerGroups, rows, prepareRow, - state: { globalFilter }, + state: { globalFilter, sortBy }, setGlobalFilter, setHiddenColumns, } = useTable( { + // @ts-expect-error -- fix in react-table v8 columns, data, initialState, sortTypes, autoResetGlobalFilter: false, + autoResetSortBy: false, disableSortRemove: true, + disableMultiSort: true, }, useGlobalFilter, useSortBy @@ -141,9 +167,25 @@ export const FeatureToggleListTable: VFC = ({ } }, [setHiddenColumns, isSmallScreen, isMediumScreen]); + useEffect(() => { + const tableState: PageQueryType = {}; + tableState.sort = sortBy[0].id; + if (sortBy[0].desc) { + tableState.order = 'desc'; + } + if (globalFilter) { + tableState.search = globalFilter; + } + + setSearchParams(tableState, { + replace: true, + }); + setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false }); + }, [sortBy, globalFilter, setSearchParams, setStoredParams]); + return ( = ({ > + {/* @ts-expect-error -- fix in react-table v8 */} {rows.map(row => { diff --git a/frontend/src/component/menu/routes.ts b/frontend/src/component/menu/routes.ts index e9057382b8..c453cd9657 100644 --- a/frontend/src/component/menu/routes.ts +++ b/frontend/src/component/menu/routes.ts @@ -1,4 +1,4 @@ -import { FeatureToggleListContainer } from 'component/feature/FeatureToggleList/FeatureToggleListContainer'; +import { FeatureToggleListTable } from 'component/feature/FeatureToggleList/FeatureToggleListTable'; import { StrategyView } from 'component/strategies/StrategyView/StrategyView'; import { StrategiesList } from 'component/strategies/StrategiesList/StrategiesList'; @@ -166,7 +166,7 @@ export const routes: IRoute[] = [ { path: '/features', title: 'Feature toggles', - component: FeatureToggleListContainer, + component: FeatureToggleListTable, type: 'protected', menu: { mobile: true }, }, diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ColumnsMenu/ColumnsMenu.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/ColumnsMenu/ColumnsMenu.tsx index c329725c65..2d32b6b97d 100644 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/ColumnsMenu/ColumnsMenu.tsx +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ColumnsMenu/ColumnsMenu.tsx @@ -29,6 +29,8 @@ interface IColumnsMenuProps { staticColumns?: string[]; dividerBefore?: string[]; dividerAfter?: string[]; + isCustomized?: boolean; + onCustomize?: (columns: string[]) => void; setHiddenColumns: ( hiddenColumns: | string[] @@ -41,6 +43,8 @@ export const ColumnsMenu: VFC = ({ staticColumns = [], dividerBefore = [], dividerAfter = [], + isCustomized = false, + onCustomize = () => {}, setHiddenColumns, }) => { const [anchorEl, setAnchorEl] = useState(null); @@ -51,6 +55,10 @@ export const ColumnsMenu: VFC = ({ const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg')); useEffect(() => { + if (isCustomized) { + return; + } + const setVisibleColumns = ( columns: string[], environmentsToShow: number = 0 @@ -88,6 +96,17 @@ export const ColumnsMenu: VFC = ({ setAnchorEl(null); }; + const onItemClick = (column: typeof allColumns[number]) => { + onCustomize([ + ...allColumns + .filter(({ isVisible }) => isVisible) + .map(({ id }) => id) + .filter(id => !staticColumns.includes(id) && id !== column.id), + ...(!column.isVisible ? [column.id] : []), + ]); + column.toggleHidden(column.isVisible); + }; + const isOpen = Boolean(anchorEl); const id = `columns-menu`; const menuId = `columns-menu-list-${id}`; @@ -142,9 +161,7 @@ export const ColumnsMenu: VFC = ({ show={} />, { - column.toggleHidden(column.isVisible); - }} + onClick={() => onItemClick(column)} disabled={staticColumns.includes(column.id)} className={classes.menuItem} > diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx index 22a8bde75d..70abc92201 100644 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx @@ -1,6 +1,6 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Add } from '@mui/icons-material'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { useFilters, useSortBy, useTable } from 'react-table'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { PageHeader } from 'component/common/PageHeader/PageHeader'; @@ -28,11 +28,12 @@ import { } from 'component/common/Table'; import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; import useProject from 'hooks/api/getters/useProject/useProject'; +import { useLocalStorage } from 'hooks/useLocalStorage'; import useToast from 'hooks/useToast'; import { ENVIRONMENT_STRATEGY_ERROR } from 'constants/apiErrors'; import EnvironmentStrategyDialog from 'component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog'; import { useEnvironmentsRef } from './hooks/useEnvironmentsRef'; -import { useSetFeatureState } from './hooks/useSetFeatureState'; +import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; import { FeatureToggleSwitch } from './FeatureToggleSwitch/FeatureToggleSwitch'; import { ActionsCell } from './ActionsCell/ActionsCell'; import { ColumnsMenu } from './ColumnsMenu/ColumnsMenu'; @@ -56,6 +57,8 @@ type ListItemType = Pick< }; }; +const staticColumns = ['Actions', 'name']; + export const ProjectFeatureToggles = ({ features, loading, @@ -118,7 +121,8 @@ export const ProjectFeatureToggles = ({ ); }, [features, loading]); // eslint-disable-line react-hooks/exhaustive-deps - const { setFeatureState } = useSetFeatureState(); + const { toggleFeatureEnvironmentOn, toggleFeatureEnvironmentOff } = + useFeatureApi(); const onToggle = useCallback( async ( projectId: string, @@ -127,12 +131,20 @@ export const ProjectFeatureToggles = ({ enabled: boolean ) => { try { - await setFeatureState( - projectId, - featureName, - environment, - enabled - ); + if (enabled) { + await toggleFeatureEnvironmentOn( + projectId, + featureName, + environment + ); + } else { + await toggleFeatureEnvironmentOff( + projectId, + featureName, + environment + ); + } + refetch(); } catch (error) { const message = formatUnknownError(error); if (message === ENVIRONMENT_STRATEGY_ERROR) { @@ -154,7 +166,7 @@ export const ProjectFeatureToggles = ({ }); refetch(); }, - [setFeatureState] // eslint-disable-line react-hooks/exhaustive-deps + [toggleFeatureEnvironmentOff, toggleFeatureEnvironmentOn] // eslint-disable-line react-hooks/exhaustive-deps ); const columns = useMemo( @@ -232,22 +244,60 @@ export const ProjectFeatureToggles = ({ ], [projectId, environments, onToggle, loading] ); + const [searchParams, setSearchParams] = useSearchParams(); + const [storedParams, setStoredParams] = useLocalStorage<{ + columns?: string[]; + }>(`${projectId}:ProjectFeatureToggles`, {}); const initialState = useMemo( - () => ({ - sortBy: [{ id: 'createdAt', desc: false }], - hiddenColumns: environments + () => { + const allColumnIds = columns.map( + (column: any) => column?.accessor || column?.id + ); + let hiddenColumns = environments .filter((_, index) => index >= 3) - .map(environment => `environments.${environment}`), - }), - [environments] + .map(environment => `environments.${environment}`); + + if (searchParams.has('columns')) { + const columnsInParams = + searchParams.get('columns')?.split(',') || []; + const visibleColumns = [...staticColumns, ...columnsInParams]; + hiddenColumns = allColumnIds.filter( + columnId => !visibleColumns.includes(columnId) + ); + } else if (storedParams.columns) { + const visibleColumns = [ + ...staticColumns, + ...storedParams.columns, + ]; + hiddenColumns = allColumnIds.filter( + columnId => !visibleColumns.includes(columnId) + ); + } + + return { + sortBy: [ + { + id: searchParams.get('sort') || 'createdAt', + desc: searchParams.has('order') + ? searchParams.get('order') === 'desc' + : false, + }, + ], + hiddenColumns, + filters: [ + { id: 'name', value: searchParams.get('search') || '' }, + ], + }; + }, + [environments] // eslint-disable-line react-hooks/exhaustive-deps ); const { allColumns, headerGroups, rows, - state: { filters }, + state: { filters, sortBy, hiddenColumns }, getTableBodyProps, getTableProps, prepareRow, @@ -268,8 +318,44 @@ export const ProjectFeatureToggles = ({ ); const filter = useMemo( - () => filters?.find(filterRow => filterRow?.id === 'name')?.value || '', - [filters] + () => + filters?.find(filterRow => filterRow?.id === 'name')?.value || + initialState.filters[0].value, + [filters, initialState] + ); + + useEffect(() => { + if (loading) { + return; + } + const tableState: Record = {}; + tableState.sort = sortBy[0].id; + if (sortBy[0].desc) { + tableState.order = 'desc'; + } + if (filter) { + tableState.search = filter; + } + tableState.columns = allColumns + .map(({ id }) => id) + .filter( + id => + !staticColumns.includes(id) && !hiddenColumns?.includes(id) + ) + .join(','); + + setSearchParams(tableState, { + replace: true, + }); + }, [loading, sortBy, hiddenColumns, filter, setSearchParams, allColumns]); + + const onCustomizeColumns = useCallback( + visibleColumns => { + setStoredParams({ + columns: visibleColumns, + }); + }, + [setStoredParams] ); return ( @@ -289,9 +375,11 @@ export const ProjectFeatureToggles = ({ /> diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/hooks/useSetFeatureState.ts b/frontend/src/component/project/Project/ProjectFeatureToggles/hooks/useSetFeatureState.ts deleted file mode 100644 index fec5521ec9..0000000000 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/hooks/useSetFeatureState.ts +++ /dev/null @@ -1,31 +0,0 @@ -import useAPI from 'hooks/api/actions/useApi/useApi'; -import { useCallback } from 'react'; - -export const useSetFeatureState = () => { - const { makeRequest, createRequest, errors } = useAPI({ - propagateErrors: true, - }); - - const setFeatureState = useCallback( - async ( - projectId: string, - featureName: string, - environment: string, - enabled: boolean - ) => { - const path = `api/admin/projects/${projectId}/features/${featureName}/environments/${environment}/${ - enabled ? 'on' : 'off' - }`; - const req = createRequest(path, { method: 'POST' }); - - try { - return makeRequest(req.caller, req.id); - } catch (e) { - throw e; - } - }, - [] // eslint-disable-line react-hooks/exhaustive-deps - ); - - return { setFeatureState, errors }; -}; diff --git a/frontend/src/component/project/Project/ProjectOverview.tsx b/frontend/src/component/project/Project/ProjectOverview.tsx index bb5dd4bdcf..83d2801a0a 100644 --- a/frontend/src/component/project/Project/ProjectOverview.tsx +++ b/frontend/src/component/project/Project/ProjectOverview.tsx @@ -1,8 +1,5 @@ -import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import useProject from 'hooks/api/getters/useProject/useProject'; -import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { ProjectFeatureToggles } from './ProjectFeatureToggles/ProjectFeatureToggles'; -import { ProjectFeatureToggles as LegacyProjectFeatureToggles } from './ProjectFeatureToggles/LegacyProjectFeatureToggles'; import ProjectInfo from './ProjectInfo/ProjectInfo'; import { useStyles } from './Project.styles'; @@ -12,11 +9,10 @@ interface IProjectOverviewProps { const ProjectOverview = ({ projectId }: IProjectOverviewProps) => { const { project, loading } = useProject(projectId, { - refreshInterval: 10000, + refreshInterval: 15 * 1000, // ms }); const { members, features, health, description, environments } = project; const { classes: styles } = useStyles(); - const { uiConfig } = useUiConfig(); return (
@@ -29,21 +25,10 @@ const ProjectOverview = ({ projectId }: IProjectOverviewProps) => { featureCount={features?.length} />
- ( - - )} - elseShow={() => ( - - )} +
diff --git a/frontend/src/component/segments/SegmentList/SegmentListItem/SegmentListItem.tsx b/frontend/src/component/segments/SegmentList/SegmentListItem/SegmentListItem.tsx index e032321517..62d89bcd5a 100644 --- a/frontend/src/component/segments/SegmentList/SegmentListItem/SegmentListItem.tsx +++ b/frontend/src/component/segments/SegmentList/SegmentListItem/SegmentListItem.tsx @@ -1,5 +1,5 @@ import { useStyles } from './SegmentListItem.styles'; -import { TableCell, TableRow, Typography } from '@mui/material'; +import { Box, TableCell, TableRow, Typography } from '@mui/material'; import { Delete, Edit } from '@mui/icons-material'; import { UPDATE_SEGMENT, @@ -60,35 +60,37 @@ export const SegmentListItem = ({ - { - navigate(`/segments/edit/${id}`); - }} - permission={UPDATE_SEGMENT} - tooltipProps={{ title: 'Edit segment' }} - > - - - { - setCurrentSegment({ - id, - name, - description, - createdAt, - createdBy, - constraints: [], - }); - setDelDialog(true); - }} - permission={DELETE_SEGMENT} - tooltipProps={{ title: 'Remove segment' }} - data-testid={`${SEGMENT_DELETE_BTN_ID}_${name}`} - > - - + + { + navigate(`/segments/edit/${id}`); + }} + permission={UPDATE_SEGMENT} + tooltipProps={{ title: 'Edit segment' }} + > + + + { + setCurrentSegment({ + id, + name, + description, + createdAt, + createdBy, + constraints: [], + }); + setDelDialog(true); + }} + permission={DELETE_SEGMENT} + tooltipProps={{ title: 'Remove segment' }} + data-testid={`${SEGMENT_DELETE_BTN_ID}_${name}`} + > + + + ); diff --git a/frontend/src/hooks/api/actions/useApi/useApi.ts b/frontend/src/hooks/api/actions/useApi/useApi.ts index 48e0274a50..256aee594d 100644 --- a/frontend/src/hooks/api/actions/useApi/useApi.ts +++ b/frontend/src/hooks/api/actions/useApi/useApi.ts @@ -1,4 +1,4 @@ -import { Dispatch, SetStateAction, useState } from 'react'; +import { Dispatch, SetStateAction, useCallback, useState } from 'react'; import { BAD_REQUEST, FORBIDDEN, @@ -40,137 +40,148 @@ const useAPI = ({ const [errors, setErrors] = useState>({}); const [loading, setLoading] = useState(false); - const defaultOptions: RequestInit = { - headers, - credentials: 'include', - }; + const handleResponses = useCallback( + 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', + })); + } - const makeRequest = async ( - apiCaller: () => Promise, - requestId: string, - loadingOn: boolean = true - ): Promise => { - if (loadingOn) { - 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) { - const response = await res.json(); - throw new BadRequestError(res.status, response); - } - } - - 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: ACCESS_DENIED_TEXT, - })); - } - - if (propagateErrors) { - throw new AuthenticationError(res.status); - } - } - - if (res.status === FORBIDDEN) { - if (handleForbidden) { - return handleForbidden(setErrors, res, requestId); - } else { - setErrors(prev => ({ - ...prev, - forbidden: 'This operation is forbidden', - })); - } - - if (propagateErrors) { - const response = await res.json(); - throw new ForbiddenError(res.status, response); - } - } - - if (res.status > 399) { - const response = await res.json(); - if (response?.details?.length > 0 && propagateErrors) { - const error = response.details[0]; if (propagateErrors) { + const response = await res.json(); + throw new BadRequestError(res.status, response); + } + } + + 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: ACCESS_DENIED_TEXT, + })); + } + + if (propagateErrors) { + throw new AuthenticationError(res.status); + } + } + + if (res.status === FORBIDDEN) { + if (handleForbidden) { + return handleForbidden(setErrors, res, requestId); + } else { + setErrors(prev => ({ + ...prev, + forbidden: 'This operation is forbidden', + })); + } + + if (propagateErrors) { + const response = await res.json(); + throw new ForbiddenError(res.status, response); + } + } + + if (res.status > 399) { + const response = await res.json(); + if (response?.details?.length > 0 && propagateErrors) { + const error = response.details[0]; + if (propagateErrors) { + throw new Error(error.message || error.msg); + } + return error; + } + + if (response?.length > 0 && propagateErrors) { + const error = response[0]; throw new Error(error.message || error.msg); } - return error; + + if (propagateErrors) { + throw new Error('Action could not be performed'); + } + } + }, + [ + handleBadRequest, + handleForbidden, + handleNotFound, + handleUnauthorized, + propagateErrors, + ] + ); + + const makeRequest = useCallback( + async ( + apiCaller: () => Promise, + requestId: string, + loadingOn: boolean = true + ): Promise => { + if (loadingOn) { + setLoading(true); } - if (response?.length > 0 && propagateErrors) { - const error = response[0]; - throw new Error(error.message || error.msg); - } + try { + const res = await apiCaller(); + setLoading(false); + if (res.status > 299) { + await handleResponses(res, requestId); + } - if (propagateErrors) { - throw new Error('Action could not be performed'); + if (res.status === OK) { + setErrors({}); + } + + return res; + } catch (e) { + setLoading(false); + throw e; } - } - }; + }, + [handleResponses] + ); + + const createRequest = useCallback( + (path: string, options: any, requestId: string = '') => { + const defaultOptions: RequestInit = { + headers, + credentials: 'include', + }; + + return { + caller: () => { + return fetch(formatApiPath(path), { + ...defaultOptions, + ...options, + }); + }, + id: requestId, + }; + }, + [] + ); return { loading, diff --git a/frontend/src/hooks/api/actions/useFeatureApi/useFeatureApi.ts b/frontend/src/hooks/api/actions/useFeatureApi/useFeatureApi.ts index f3445dc3c2..e0e39e73cc 100644 --- a/frontend/src/hooks/api/actions/useFeatureApi/useFeatureApi.ts +++ b/frontend/src/hooks/api/actions/useFeatureApi/useFeatureApi.ts @@ -4,6 +4,7 @@ import { Operation } from 'fast-json-patch'; import { CreateFeatureSchema } from 'openapi'; import { openApiAdmin } from 'utils/openapiClient'; import { IConstraint } from 'interfaces/strategy'; +import { useCallback } from 'react'; const useFeatureApi = () => { const { makeRequest, createRequest, errors, loading } = useAPI({ @@ -47,47 +48,45 @@ const useFeatureApi = () => { }); }; - const toggleFeatureEnvironmentOn = async ( - projectId: string, - featureId: string, - environmentId: string - ) => { - const path = `api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/on`; - const req = createRequest( - path, - { method: 'POST' }, - 'toggleFeatureEnvironmentOn' - ); + const toggleFeatureEnvironmentOn = useCallback( + async (projectId: string, featureId: string, environmentId: string) => { + const path = `api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/on`; + const req = createRequest( + path, + { method: 'POST' }, + 'toggleFeatureEnvironmentOn' + ); - try { - const res = await makeRequest(req.caller, req.id); + try { + const res = await makeRequest(req.caller, req.id); - return res; - } catch (e) { - throw e; - } - }; + return res; + } catch (e) { + throw e; + } + }, + [createRequest, makeRequest] + ); - const toggleFeatureEnvironmentOff = async ( - projectId: string, - featureId: string, - environmentId: string - ) => { - const path = `api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/off`; - const req = createRequest( - path, - { method: 'POST' }, - 'toggleFeatureEnvironmentOff' - ); + const toggleFeatureEnvironmentOff = useCallback( + async (projectId: string, featureId: string, environmentId: string) => { + const path = `api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/off`; + const req = createRequest( + path, + { method: 'POST' }, + 'toggleFeatureEnvironmentOff' + ); - try { - const res = await makeRequest(req.caller, req.id); + try { + const res = await makeRequest(req.caller, req.id); - return res; - } catch (e) { - throw e; - } - }; + return res; + } catch (e) { + throw e; + } + }, + [createRequest, makeRequest] + ); const changeFeatureProject = async ( projectId: string, diff --git a/frontend/src/hooks/api/getters/useFeatures/useFeatures.ts b/frontend/src/hooks/api/getters/useFeatures/useFeatures.ts index 9b0cbee967..2b9269bdb6 100644 --- a/frontend/src/hooks/api/getters/useFeatures/useFeatures.ts +++ b/frontend/src/hooks/api/getters/useFeatures/useFeatures.ts @@ -12,7 +12,10 @@ export interface IUseFeaturesOutput { export const useFeatures = (): IUseFeaturesOutput => { const { data, refetch, loading, error } = useApiGetter( 'apiAdminFeaturesGet', - () => openApiAdmin.getAllToggles() + () => openApiAdmin.getAllToggles(), + { + refreshInterval: 15 * 1000, // ms + } ); return { diff --git a/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts b/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts index cccca101f2..d0cb77ed38 100644 --- a/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts +++ b/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts @@ -14,7 +14,6 @@ export const defaultValue = { CO: false, SE: false, T: false, - NEW_PROJECT_OVERVIEW: false, }, links: [ { diff --git a/frontend/src/hooks/useLocalStorage.test.ts b/frontend/src/hooks/useLocalStorage.test.ts new file mode 100644 index 0000000000..c9a237eb82 --- /dev/null +++ b/frontend/src/hooks/useLocalStorage.test.ts @@ -0,0 +1,65 @@ +import { vi } from 'vitest'; +import { useLocalStorage } from './useLocalStorage'; +import { act, renderHook } from '@testing-library/react-hooks'; + +describe('useLocalStorage', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return an object with data and mutate properties', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementationOnce(() => + JSON.stringify(undefined) + ); + const { result } = renderHook(() => useLocalStorage('key', {})); + + expect(result.current).toEqual([{}, expect.any(Function)]); + }); + + it('returns default value', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementationOnce(() => + JSON.stringify(undefined) + ); + const { result } = renderHook(() => + useLocalStorage('key', { key: 'value' }) + ); + + expect(result.current).toEqual([ + { key: 'value' }, + expect.any(Function), + ]); + }); + + it('returns a value from local storage', async () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementationOnce(() => + JSON.stringify({ key: 'value' }) + ); + + const { result, waitFor } = renderHook(() => + useLocalStorage('test-key', {}) + ); + + await waitFor(() => + expect(result.current).toEqual([ + { key: 'value' }, + expect.any(Function), + ]) + ); + }); + + it('sets new value to local storage', async () => { + const setItem = vi.spyOn(Storage.prototype, 'setItem'); + const { result } = renderHook(() => + useLocalStorage('test-key', { key: 'initial-value' }) + ); + + await act(async () => { + result.current[1]({ key: 'new-value' }); + }); + + expect(setItem).toHaveBeenCalledWith( + ':test-key:useLocalStorage:v1', + JSON.stringify({ key: 'new-value' }) + ); + }); +}); diff --git a/frontend/src/hooks/useLocalStorage.ts b/frontend/src/hooks/useLocalStorage.ts new file mode 100644 index 0000000000..409279e337 --- /dev/null +++ b/frontend/src/hooks/useLocalStorage.ts @@ -0,0 +1,34 @@ +import { Dispatch, SetStateAction, useCallback, useState } from 'react'; +import { basePath } from 'utils/formatPath'; +import { getLocalStorageItem, setLocalStorageItem } from '../utils/storage'; + +export const useLocalStorage = ( + key: string, + initialValue: T +) => { + const internalKey = `${basePath}:${key}:useLocalStorage:v1`; + const [value, setValue] = useState(() => { + const state = getLocalStorageItem(internalKey); + if (state === undefined) { + return initialValue; + } + return state; + }); + + const onUpdate = useCallback>>( + value => { + if (value instanceof Function) { + setValue(prev => { + const output = value(prev); + setLocalStorageItem(internalKey, output); + return output; + }); + } + setLocalStorageItem(internalKey, value); + setValue(value); + }, + [internalKey] + ); + + return [value, onUpdate] as const; +}; diff --git a/frontend/src/hooks/usePersistentGlobalState.ts b/frontend/src/hooks/usePersistentGlobalState.ts index dc37c24387..624c61af6c 100644 --- a/frontend/src/hooks/usePersistentGlobalState.ts +++ b/frontend/src/hooks/usePersistentGlobalState.ts @@ -12,14 +12,14 @@ type UsePersistentGlobalState = () => [ * The state is also persisted to localStorage and restored on page load. * The localStorage state is not synced between tabs. * - * @deprecated + * @deprecated `hooks/useLocalStorage` -- we don't need `react-hooks-global-state` */ export const createPersistentGlobalStateHook = ( key: string, initialValue: T ): UsePersistentGlobalState => { const container = createGlobalState<{ [key: string]: T }>({ - [key]: getLocalStorageItem(key) ?? initialValue, + [key]: getLocalStorageItem(key) ?? initialValue, }); const setGlobalState = (value: React.SetStateAction) => { diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 6c7eb8ac85..4cd9a3a613 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -29,7 +29,6 @@ export interface IFlags { CO?: boolean; SE?: boolean; T?: boolean; - NEW_PROJECT_OVERVIEW: boolean; } export interface IVersionInfo {