1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-06 01:15:28 +02:00

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
This commit is contained in:
Tymoteusz Czech 2022-05-25 10:14:22 +02:00 committed by GitHub
parent c2b5c563db
commit a11cb72d99
28 changed files with 621 additions and 380 deletions

View File

@ -2,8 +2,6 @@
export {}; export {};
const AUTH_USER = Cypress.env('AUTH_USER');
const AUTH_PASSWORD = Cypress.env('AUTH_PASSWORD');
const ENTERPRISE = Boolean(Cypress.env('ENTERPRISE')); const ENTERPRISE = Boolean(Cypress.env('ENTERPRISE'));
const randomId = String(Math.random()).split('.')[1]; const randomId = String(Math.random()).split('.')[1];
const featureToggleName = `unleash-e2e-${randomId}`; const featureToggleName = `unleash-e2e-${randomId}`;
@ -41,16 +39,8 @@ describe('feature', () => {
}); });
beforeEach(() => { beforeEach(() => {
cy.login();
cy.visit('/'); 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', () => { it('can create a feature toggle', () => {

View File

@ -1,16 +1,9 @@
/// <reference types="cypress" /> /// <reference types="cypress" />
export {}; export {};
const AUTH_USER = Cypress.env('AUTH_USER');
const AUTH_PASSWORD = Cypress.env('AUTH_PASSWORD');
const randomId = String(Math.random()).split('.')[1]; const randomId = String(Math.random()).split('.')[1];
const segmentName = `unleash-e2e-${randomId}`; const segmentName = `unleash-e2e-${randomId}`;
Cypress.config({
experimentalSessionSupport: true,
});
// Disable all active splash pages by visiting them. // Disable all active splash pages by visiting them.
const disableActiveSplashScreens = () => { const disableActiveSplashScreens = () => {
cy.visit(`/splash/operators`); cy.visit(`/splash/operators`);
@ -22,20 +15,7 @@ describe('segments', () => {
}); });
beforeEach(() => { beforeEach(() => {
cy.session(AUTH_USER, () => { cy.login();
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.visit('/segments'); cy.visit('/segments');
}); });

View File

@ -23,3 +23,27 @@
// //
// -- This will overwrite an existing command -- // -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) // 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']");
})
);

View File

@ -14,7 +14,15 @@
// *********************************************************** // ***********************************************************
// Import commands.js using ES2015 syntax: // Import commands.js using ES2015 syntax:
import './commands' import './commands';
// Alternatively you can use CommonJS syntax: // Alternatively you can use CommonJS syntax:
// require('./commands') // require('./commands')
declare global {
namespace Cypress {
interface Chainable {
login(user?: string, password?: string): Chainable<null>;
}
}
}

View File

@ -16,23 +16,16 @@ export const useStyles = makeStyles()(theme => ({
}, },
sortButton: { sortButton: {
all: 'unset', all: 'unset',
padding: theme.spacing(2),
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
width: '100%', width: '100%',
'& .hover-only': { position: 'relative',
visibility: 'hidden',
},
':hover, :focus, &:focus-visible, &:active': { ':hover, :focus, &:focus-visible, &:active': {
outline: 'revert', outline: 'revert',
'& svg': { '.hover-only': {
color: 'inherit', display: 'inline-block',
},
'& .hover-only': {
visibility: 'visible',
}, },
}, },
display: 'flex', display: 'flex',
alignItems: 'center',
boxSizing: 'inherit', boxSizing: 'inherit',
cursor: 'pointer', cursor: 'pointer',
}, },
@ -42,10 +35,8 @@ export const useStyles = makeStyles()(theme => ({
label: { label: {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
whiteSpace: 'nowrap', flexShrink: 1,
textOverflow: 'ellipsis', minWidth: 0,
overflowX: 'hidden',
overflowY: 'visible',
'::after': { '::after': {
fontWeight: 'bold', fontWeight: 'bold',
display: 'inline-block', display: 'inline-block',
@ -67,4 +58,29 @@ export const useStyles = makeStyles()(theme => ({
justifyContent: 'center', justifyContent: 'center',
textAlign: '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',
},
},
})); }));

View File

@ -102,23 +102,44 @@ export const CellSortable: FC<ICellSortableProps> = ({
<button <button
className={classnames( className={classnames(
isSorted && styles.sortedButton, isSorted && styles.sortedButton,
styles.sortButton, styles.sortButton
alignClass
)} )}
onClick={onSortClick} onClick={onSortClick}
> >
<span <span
className={styles.label} className={classnames(
ref={ref} styles.hiddenMeasurementLayer,
tabIndex={-1} alignClass
data-text={children} )}
aria-hidden
> >
{children} <span
className={styles.label}
tabIndex={-1}
data-text={children}
>
{children}
</span>
<SortArrow
isSorted={isSorted}
isDesc={isDescending}
/>
</span>
<span
className={classnames(
styles.visibleAbsoluteLayer,
alignClass
)}
>
<span ref={ref} tabIndex={-1}>
<span>{children}</span>
</span>
<SortArrow
isSorted={isSorted}
isDesc={isDescending}
className="sort-arrow"
/>
</span> </span>
<SortArrow
isSorted={isSorted}
isDesc={isDescending}
/>
</button> </button>
</Tooltip> </Tooltip>
} }

View File

@ -3,7 +3,7 @@ import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({ export const useStyles = makeStyles()(theme => ({
icon: { icon: {
marginLeft: theme.spacing(0.25), marginLeft: theme.spacing(0.25),
marginRight: -theme.spacing(0.5), marginRight: theme.spacing(-0.5),
color: theme.palette.grey[700], color: theme.palette.grey[700],
fontSize: theme.fontSizes.mainHeader, fontSize: theme.fontSizes.mainHeader,
verticalAlign: 'middle', verticalAlign: 'middle',

View File

@ -11,11 +11,13 @@ import classnames from 'classnames';
interface ISortArrowProps { interface ISortArrowProps {
isSorted?: boolean; isSorted?: boolean;
isDesc?: boolean; isDesc?: boolean;
className?: string;
} }
export const SortArrow: VFC<ISortArrowProps> = ({ export const SortArrow: VFC<ISortArrowProps> = ({
isSorted: sorted, isSorted: sorted,
isDesc: desc = false, isDesc: desc = false,
className,
}) => { }) => {
const { classes: styles } = useStyles(); const { classes: styles } = useStyles();
@ -27,13 +29,21 @@ export const SortArrow: VFC<ISortArrowProps> = ({
condition={Boolean(desc)} condition={Boolean(desc)}
show={ show={
<KeyboardArrowDown <KeyboardArrowDown
className={classnames(styles.icon, styles.sorted)} className={classnames(
styles.icon,
styles.sorted,
className
)}
fontSize="inherit" fontSize="inherit"
/> />
} }
elseShow={ elseShow={
<KeyboardArrowUp <KeyboardArrowUp
className={classnames(styles.icon, styles.sorted)} className={classnames(
styles.icon,
styles.sorted,
className
)}
fontSize="inherit" fontSize="inherit"
/> />
} }
@ -41,7 +51,7 @@ export const SortArrow: VFC<ISortArrowProps> = ({
} }
elseShow={ elseShow={
<UnfoldMoreOutlined <UnfoldMoreOutlined
className={classnames(styles.icon, 'hover-only')} className={classnames(styles.icon, className, 'hover-only')}
fontSize="inherit" fontSize="inherit"
/> />
} }

View File

@ -8,5 +8,6 @@ export const useStyles = makeStyles()(theme => ({
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
marginTop: theme.spacing(2),
}, },
})); }));

View File

@ -5,4 +5,3 @@ export const RBAC = 'RBAC';
export const EEA = 'EEA'; export const EEA = 'EEA';
export const RE = 'RE'; export const RE = 'RE';
export const SE = 'SE'; export const SE = 'SE';
export const NEW_PROJECT_OVERVIEW = 'NEW_PROJECT_OVERVIEW';

View File

@ -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 (
<FeatureToggleListTable
data={loading ? featuresPlaceholder : features}
isLoading={loading}
/>
);
};

View File

@ -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, useMediaQuery, useTheme } from '@mui/material';
import { Link as RouterLink } from 'react-router-dom'; import { Link as RouterLink, useSearchParams } from 'react-router-dom';
import { useGlobalFilter, useSortBy, useTable } from 'react-table'; import { SortingRule, useGlobalFilter, useSortBy, useTable } from 'react-table';
import { import {
Table, Table,
SortableTableHeader, SortableTableHeader,
@ -11,22 +11,30 @@ import {
TablePlaceholder, TablePlaceholder,
TableSearch, TableSearch,
} from 'component/common/Table'; } from 'component/common/Table';
import { useFeatures } from 'hooks/api/getters/useFeatures/useFeatures';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; 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 { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { FeatureSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureSeenCell'; import { FeatureSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureSeenCell';
import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell'; 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 { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { PageContent } from 'component/common/PageContent/PageContent'; import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader'; import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { sortTypes } from 'utils/sortTypes'; 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 { const featuresPlaceholder: FeatureSchema[] = Array(15).fill({
data: Record<string, any>[]; name: 'Name of the feature',
isLoading?: boolean; description: 'Short description of the feature',
} type: '-',
createdAt: new Date(2022, 1, 1),
project: 'projectID',
});
type PageQueryType = Partial<Record<'sort' | 'order' | 'search', string>>;
const columns = [ const columns = [
{ {
@ -87,21 +95,36 @@ const columns = [
}, },
]; ];
export const FeatureToggleListTable: VFC<IExperimentProps> = ({ const defaultSort: SortingRule<string> = { id: 'createdAt', desc: false };
data,
isLoading = false, export const FeatureToggleListTable: VFC = () => {
}) => {
const theme = useTheme(); const theme = useTheme();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg')); const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg'));
const [searchParams, setSearchParams] = useSearchParams();
const initialState = useMemo( const [storedParams, setStoredParams] = useLocalStorage(
() => ({ 'FeatureToggleListTable:v1',
sortBy: [{ id: 'createdAt', desc: false }], defaultSort
hiddenColumns: ['description'],
}),
[]
); );
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 { const {
getTableProps, getTableProps,
@ -109,17 +132,20 @@ export const FeatureToggleListTable: VFC<IExperimentProps> = ({
headerGroups, headerGroups,
rows, rows,
prepareRow, prepareRow,
state: { globalFilter }, state: { globalFilter, sortBy },
setGlobalFilter, setGlobalFilter,
setHiddenColumns, setHiddenColumns,
} = useTable( } = useTable(
{ {
// @ts-expect-error -- fix in react-table v8
columns, columns,
data, data,
initialState, initialState,
sortTypes, sortTypes,
autoResetGlobalFilter: false, autoResetGlobalFilter: false,
autoResetSortBy: false,
disableSortRemove: true, disableSortRemove: true,
disableMultiSort: true,
}, },
useGlobalFilter, useGlobalFilter,
useSortBy useSortBy
@ -141,9 +167,25 @@ export const FeatureToggleListTable: VFC<IExperimentProps> = ({
} }
}, [setHiddenColumns, isSmallScreen, isMediumScreen]); }, [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 ( return (
<PageContent <PageContent
isLoading={isLoading} isLoading={loading}
header={ header={
<PageHeader <PageHeader
title={`Feature toggles (${data.length})`} title={`Feature toggles (${data.length})`}
@ -173,6 +215,7 @@ export const FeatureToggleListTable: VFC<IExperimentProps> = ({
> >
<SearchHighlightProvider value={globalFilter}> <SearchHighlightProvider value={globalFilter}>
<Table {...getTableProps()}> <Table {...getTableProps()}>
{/* @ts-expect-error -- fix in react-table v8 */}
<SortableTableHeader headerGroups={headerGroups} /> <SortableTableHeader headerGroups={headerGroups} />
<TableBody {...getTableBodyProps()}> <TableBody {...getTableBodyProps()}>
{rows.map(row => { {rows.map(row => {

View File

@ -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 { StrategyView } from 'component/strategies/StrategyView/StrategyView';
import { StrategiesList } from 'component/strategies/StrategiesList/StrategiesList'; import { StrategiesList } from 'component/strategies/StrategiesList/StrategiesList';
@ -166,7 +166,7 @@ export const routes: IRoute[] = [
{ {
path: '/features', path: '/features',
title: 'Feature toggles', title: 'Feature toggles',
component: FeatureToggleListContainer, component: FeatureToggleListTable,
type: 'protected', type: 'protected',
menu: { mobile: true }, menu: { mobile: true },
}, },

View File

@ -29,6 +29,8 @@ interface IColumnsMenuProps {
staticColumns?: string[]; staticColumns?: string[];
dividerBefore?: string[]; dividerBefore?: string[];
dividerAfter?: string[]; dividerAfter?: string[];
isCustomized?: boolean;
onCustomize?: (columns: string[]) => void;
setHiddenColumns: ( setHiddenColumns: (
hiddenColumns: hiddenColumns:
| string[] | string[]
@ -41,6 +43,8 @@ export const ColumnsMenu: VFC<IColumnsMenuProps> = ({
staticColumns = [], staticColumns = [],
dividerBefore = [], dividerBefore = [],
dividerAfter = [], dividerAfter = [],
isCustomized = false,
onCustomize = () => {},
setHiddenColumns, setHiddenColumns,
}) => { }) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
@ -51,6 +55,10 @@ export const ColumnsMenu: VFC<IColumnsMenuProps> = ({
const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg')); const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg'));
useEffect(() => { useEffect(() => {
if (isCustomized) {
return;
}
const setVisibleColumns = ( const setVisibleColumns = (
columns: string[], columns: string[],
environmentsToShow: number = 0 environmentsToShow: number = 0
@ -88,6 +96,17 @@ export const ColumnsMenu: VFC<IColumnsMenuProps> = ({
setAnchorEl(null); 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 isOpen = Boolean(anchorEl);
const id = `columns-menu`; const id = `columns-menu`;
const menuId = `columns-menu-list-${id}`; const menuId = `columns-menu-list-${id}`;
@ -142,9 +161,7 @@ export const ColumnsMenu: VFC<IColumnsMenuProps> = ({
show={<Divider className={classes.divider} />} show={<Divider className={classes.divider} />}
/>, />,
<MenuItem <MenuItem
onClick={() => { onClick={() => onItemClick(column)}
column.toggleHidden(column.isVisible);
}}
disabled={staticColumns.includes(column.id)} disabled={staticColumns.includes(column.id)}
className={classes.menuItem} className={classes.menuItem}
> >

View File

@ -1,6 +1,6 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Add } from '@mui/icons-material'; 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 { useFilters, useSortBy, useTable } from 'react-table';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { PageHeader } from 'component/common/PageHeader/PageHeader'; import { PageHeader } from 'component/common/PageHeader/PageHeader';
@ -28,11 +28,12 @@ import {
} from 'component/common/Table'; } from 'component/common/Table';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import useProject from 'hooks/api/getters/useProject/useProject'; import useProject from 'hooks/api/getters/useProject/useProject';
import { useLocalStorage } from 'hooks/useLocalStorage';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
import { ENVIRONMENT_STRATEGY_ERROR } from 'constants/apiErrors'; import { ENVIRONMENT_STRATEGY_ERROR } from 'constants/apiErrors';
import EnvironmentStrategyDialog from 'component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog'; import EnvironmentStrategyDialog from 'component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog';
import { useEnvironmentsRef } from './hooks/useEnvironmentsRef'; import { useEnvironmentsRef } from './hooks/useEnvironmentsRef';
import { useSetFeatureState } from './hooks/useSetFeatureState'; import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
import { FeatureToggleSwitch } from './FeatureToggleSwitch/FeatureToggleSwitch'; import { FeatureToggleSwitch } from './FeatureToggleSwitch/FeatureToggleSwitch';
import { ActionsCell } from './ActionsCell/ActionsCell'; import { ActionsCell } from './ActionsCell/ActionsCell';
import { ColumnsMenu } from './ColumnsMenu/ColumnsMenu'; import { ColumnsMenu } from './ColumnsMenu/ColumnsMenu';
@ -56,6 +57,8 @@ type ListItemType = Pick<
}; };
}; };
const staticColumns = ['Actions', 'name'];
export const ProjectFeatureToggles = ({ export const ProjectFeatureToggles = ({
features, features,
loading, loading,
@ -118,7 +121,8 @@ export const ProjectFeatureToggles = ({
); );
}, [features, loading]); // eslint-disable-line react-hooks/exhaustive-deps }, [features, loading]); // eslint-disable-line react-hooks/exhaustive-deps
const { setFeatureState } = useSetFeatureState(); const { toggleFeatureEnvironmentOn, toggleFeatureEnvironmentOff } =
useFeatureApi();
const onToggle = useCallback( const onToggle = useCallback(
async ( async (
projectId: string, projectId: string,
@ -127,12 +131,20 @@ export const ProjectFeatureToggles = ({
enabled: boolean enabled: boolean
) => { ) => {
try { try {
await setFeatureState( if (enabled) {
projectId, await toggleFeatureEnvironmentOn(
featureName, projectId,
environment, featureName,
enabled environment
); );
} else {
await toggleFeatureEnvironmentOff(
projectId,
featureName,
environment
);
}
refetch();
} catch (error) { } catch (error) {
const message = formatUnknownError(error); const message = formatUnknownError(error);
if (message === ENVIRONMENT_STRATEGY_ERROR) { if (message === ENVIRONMENT_STRATEGY_ERROR) {
@ -154,7 +166,7 @@ export const ProjectFeatureToggles = ({
}); });
refetch(); refetch();
}, },
[setFeatureState] // eslint-disable-line react-hooks/exhaustive-deps [toggleFeatureEnvironmentOff, toggleFeatureEnvironmentOn] // eslint-disable-line react-hooks/exhaustive-deps
); );
const columns = useMemo( const columns = useMemo(
@ -232,22 +244,60 @@ export const ProjectFeatureToggles = ({
], ],
[projectId, environments, onToggle, loading] [projectId, environments, onToggle, loading]
); );
const [searchParams, setSearchParams] = useSearchParams();
const [storedParams, setStoredParams] = useLocalStorage<{
columns?: string[];
}>(`${projectId}:ProjectFeatureToggles`, {});
const initialState = useMemo( const initialState = useMemo(
() => ({ () => {
sortBy: [{ id: 'createdAt', desc: false }], const allColumnIds = columns.map(
hiddenColumns: environments (column: any) => column?.accessor || column?.id
);
let hiddenColumns = environments
.filter((_, index) => index >= 3) .filter((_, index) => index >= 3)
.map(environment => `environments.${environment}`), .map(environment => `environments.${environment}`);
}),
[environments] 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 { const {
allColumns, allColumns,
headerGroups, headerGroups,
rows, rows,
state: { filters }, state: { filters, sortBy, hiddenColumns },
getTableBodyProps, getTableBodyProps,
getTableProps, getTableProps,
prepareRow, prepareRow,
@ -268,8 +318,44 @@ export const ProjectFeatureToggles = ({
); );
const filter = useMemo( 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<string, string> = {};
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 ( return (
@ -289,9 +375,11 @@ export const ProjectFeatureToggles = ({
/> />
<ColumnsMenu <ColumnsMenu
allColumns={allColumns} allColumns={allColumns}
staticColumns={['Actions', 'name']} staticColumns={staticColumns}
dividerAfter={['createdAt']} dividerAfter={['createdAt']}
dividerBefore={['Actions']} dividerBefore={['Actions']}
isCustomized={Boolean(storedParams.columns)}
onCustomize={onCustomizeColumns}
setHiddenColumns={setHiddenColumns} setHiddenColumns={setHiddenColumns}
/> />
<PageHeader.Divider /> <PageHeader.Divider />

View File

@ -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 };
};

View File

@ -1,8 +1,5 @@
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import useProject from 'hooks/api/getters/useProject/useProject'; import useProject from 'hooks/api/getters/useProject/useProject';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { ProjectFeatureToggles } from './ProjectFeatureToggles/ProjectFeatureToggles'; import { ProjectFeatureToggles } from './ProjectFeatureToggles/ProjectFeatureToggles';
import { ProjectFeatureToggles as LegacyProjectFeatureToggles } from './ProjectFeatureToggles/LegacyProjectFeatureToggles';
import ProjectInfo from './ProjectInfo/ProjectInfo'; import ProjectInfo from './ProjectInfo/ProjectInfo';
import { useStyles } from './Project.styles'; import { useStyles } from './Project.styles';
@ -12,11 +9,10 @@ interface IProjectOverviewProps {
const ProjectOverview = ({ projectId }: IProjectOverviewProps) => { const ProjectOverview = ({ projectId }: IProjectOverviewProps) => {
const { project, loading } = useProject(projectId, { const { project, loading } = useProject(projectId, {
refreshInterval: 10000, refreshInterval: 15 * 1000, // ms
}); });
const { members, features, health, description, environments } = project; const { members, features, health, description, environments } = project;
const { classes: styles } = useStyles(); const { classes: styles } = useStyles();
const { uiConfig } = useUiConfig();
return ( return (
<div> <div>
@ -29,21 +25,10 @@ const ProjectOverview = ({ projectId }: IProjectOverviewProps) => {
featureCount={features?.length} featureCount={features?.length}
/> />
<div className={styles.projectToggles}> <div className={styles.projectToggles}>
<ConditionallyRender <ProjectFeatureToggles
condition={uiConfig.flags.NEW_PROJECT_OVERVIEW} features={features}
show={() => ( environments={environments}
<ProjectFeatureToggles loading={loading}
features={features}
environments={environments}
loading={loading}
/>
)}
elseShow={() => (
<LegacyProjectFeatureToggles
features={features}
loading={loading}
/>
)}
/> />
</div> </div>
</div> </div>

View File

@ -1,5 +1,5 @@
import { useStyles } from './SegmentListItem.styles'; 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 { Delete, Edit } from '@mui/icons-material';
import { import {
UPDATE_SEGMENT, UPDATE_SEGMENT,
@ -60,35 +60,37 @@ export const SegmentListItem = ({
</TableCell> </TableCell>
<TableCell align="right"> <TableCell align="right">
<PermissionIconButton <Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
data-loading <PermissionIconButton
onClick={() => { data-loading
navigate(`/segments/edit/${id}`); onClick={() => {
}} navigate(`/segments/edit/${id}`);
permission={UPDATE_SEGMENT} }}
tooltipProps={{ title: 'Edit segment' }} permission={UPDATE_SEGMENT}
> tooltipProps={{ title: 'Edit segment' }}
<Edit /> >
</PermissionIconButton> <Edit />
<PermissionIconButton </PermissionIconButton>
data-loading <PermissionIconButton
onClick={() => { data-loading
setCurrentSegment({ onClick={() => {
id, setCurrentSegment({
name, id,
description, name,
createdAt, description,
createdBy, createdAt,
constraints: [], createdBy,
}); constraints: [],
setDelDialog(true); });
}} setDelDialog(true);
permission={DELETE_SEGMENT} }}
tooltipProps={{ title: 'Remove segment' }} permission={DELETE_SEGMENT}
data-testid={`${SEGMENT_DELETE_BTN_ID}_${name}`} tooltipProps={{ title: 'Remove segment' }}
> data-testid={`${SEGMENT_DELETE_BTN_ID}_${name}`}
<Delete /> >
</PermissionIconButton> <Delete />
</PermissionIconButton>
</Box>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );

View File

@ -1,4 +1,4 @@
import { Dispatch, SetStateAction, useState } from 'react'; import { Dispatch, SetStateAction, useCallback, useState } from 'react';
import { import {
BAD_REQUEST, BAD_REQUEST,
FORBIDDEN, FORBIDDEN,
@ -40,137 +40,148 @@ const useAPI = ({
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const defaultOptions: RequestInit = { const handleResponses = useCallback(
headers, async (res: Response, requestId: string) => {
credentials: 'include', 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<Response>,
requestId: string,
loadingOn: boolean = true
): Promise<Response> => {
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) { 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); 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<Response>,
requestId: string,
loadingOn: boolean = true
): Promise<Response> => {
if (loadingOn) {
setLoading(true);
} }
if (response?.length > 0 && propagateErrors) { try {
const error = response[0]; const res = await apiCaller();
throw new Error(error.message || error.msg); setLoading(false);
} if (res.status > 299) {
await handleResponses(res, requestId);
}
if (propagateErrors) { if (res.status === OK) {
throw new Error('Action could not be performed'); 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 { return {
loading, loading,

View File

@ -4,6 +4,7 @@ import { Operation } from 'fast-json-patch';
import { CreateFeatureSchema } from 'openapi'; import { CreateFeatureSchema } from 'openapi';
import { openApiAdmin } from 'utils/openapiClient'; import { openApiAdmin } from 'utils/openapiClient';
import { IConstraint } from 'interfaces/strategy'; import { IConstraint } from 'interfaces/strategy';
import { useCallback } from 'react';
const useFeatureApi = () => { const useFeatureApi = () => {
const { makeRequest, createRequest, errors, loading } = useAPI({ const { makeRequest, createRequest, errors, loading } = useAPI({
@ -47,47 +48,45 @@ const useFeatureApi = () => {
}); });
}; };
const toggleFeatureEnvironmentOn = async ( const toggleFeatureEnvironmentOn = useCallback(
projectId: string, async (projectId: string, featureId: string, environmentId: string) => {
featureId: string, const path = `api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/on`;
environmentId: string const req = createRequest(
) => { path,
const path = `api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/on`; { method: 'POST' },
const req = createRequest( 'toggleFeatureEnvironmentOn'
path, );
{ method: 'POST' },
'toggleFeatureEnvironmentOn'
);
try { try {
const res = await makeRequest(req.caller, req.id); const res = await makeRequest(req.caller, req.id);
return res; return res;
} catch (e) { } catch (e) {
throw e; throw e;
} }
}; },
[createRequest, makeRequest]
);
const toggleFeatureEnvironmentOff = async ( const toggleFeatureEnvironmentOff = useCallback(
projectId: string, async (projectId: string, featureId: string, environmentId: string) => {
featureId: string, const path = `api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/off`;
environmentId: string const req = createRequest(
) => { path,
const path = `api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/off`; { method: 'POST' },
const req = createRequest( 'toggleFeatureEnvironmentOff'
path, );
{ method: 'POST' },
'toggleFeatureEnvironmentOff'
);
try { try {
const res = await makeRequest(req.caller, req.id); const res = await makeRequest(req.caller, req.id);
return res; return res;
} catch (e) { } catch (e) {
throw e; throw e;
} }
}; },
[createRequest, makeRequest]
);
const changeFeatureProject = async ( const changeFeatureProject = async (
projectId: string, projectId: string,

View File

@ -12,7 +12,10 @@ export interface IUseFeaturesOutput {
export const useFeatures = (): IUseFeaturesOutput => { export const useFeatures = (): IUseFeaturesOutput => {
const { data, refetch, loading, error } = useApiGetter( const { data, refetch, loading, error } = useApiGetter(
'apiAdminFeaturesGet', 'apiAdminFeaturesGet',
() => openApiAdmin.getAllToggles() () => openApiAdmin.getAllToggles(),
{
refreshInterval: 15 * 1000, // ms
}
); );
return { return {

View File

@ -14,7 +14,6 @@ export const defaultValue = {
CO: false, CO: false,
SE: false, SE: false,
T: false, T: false,
NEW_PROJECT_OVERVIEW: false,
}, },
links: [ links: [
{ {

View File

@ -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' })
);
});
});

View File

@ -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 = <T extends object>(
key: string,
initialValue: T
) => {
const internalKey = `${basePath}:${key}:useLocalStorage:v1`;
const [value, setValue] = useState<T>(() => {
const state = getLocalStorageItem<T>(internalKey);
if (state === undefined) {
return initialValue;
}
return state;
});
const onUpdate = useCallback<Dispatch<SetStateAction<T>>>(
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;
};

View File

@ -12,14 +12,14 @@ type UsePersistentGlobalState<T> = () => [
* The state is also persisted to localStorage and restored on page load. * The state is also persisted to localStorage and restored on page load.
* The localStorage state is not synced between tabs. * The localStorage state is not synced between tabs.
* *
* @deprecated * @deprecated `hooks/useLocalStorage` -- we don't need `react-hooks-global-state`
*/ */
export const createPersistentGlobalStateHook = <T extends object>( export const createPersistentGlobalStateHook = <T extends object>(
key: string, key: string,
initialValue: T initialValue: T
): UsePersistentGlobalState<T> => { ): UsePersistentGlobalState<T> => {
const container = createGlobalState<{ [key: string]: T }>({ const container = createGlobalState<{ [key: string]: T }>({
[key]: getLocalStorageItem(key) ?? initialValue, [key]: getLocalStorageItem<T>(key) ?? initialValue,
}); });
const setGlobalState = (value: React.SetStateAction<T>) => { const setGlobalState = (value: React.SetStateAction<T>) => {

View File

@ -29,7 +29,6 @@ export interface IFlags {
CO?: boolean; CO?: boolean;
SE?: boolean; SE?: boolean;
T?: boolean; T?: boolean;
NEW_PROJECT_OVERVIEW: boolean;
} }
export interface IVersionInfo { export interface IVersionInfo {