1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01: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 {};
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', () => {

View File

@ -1,16 +1,9 @@
/// <reference types="cypress" />
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');
});

View File

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

View File

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

View File

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

View File

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

View File

@ -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',

View File

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

View File

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

View File

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

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 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<string, any>[];
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<Record<'sort' | 'order' | 'search', string>>;
const columns = [
{
@ -87,21 +95,36 @@ const columns = [
},
];
export const FeatureToggleListTable: VFC<IExperimentProps> = ({
data,
isLoading = false,
}) => {
const defaultSort: SortingRule<string> = { 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<IExperimentProps> = ({
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<IExperimentProps> = ({
}
}, [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 (
<PageContent
isLoading={isLoading}
isLoading={loading}
header={
<PageHeader
title={`Feature toggles (${data.length})`}
@ -173,6 +215,7 @@ export const FeatureToggleListTable: VFC<IExperimentProps> = ({
>
<SearchHighlightProvider value={globalFilter}>
<Table {...getTableProps()}>
{/* @ts-expect-error -- fix in react-table v8 */}
<SortableTableHeader headerGroups={headerGroups} />
<TableBody {...getTableBodyProps()}>
{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 { 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 },
},

View File

@ -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<IColumnsMenuProps> = ({
staticColumns = [],
dividerBefore = [],
dividerAfter = [],
isCustomized = false,
onCustomize = () => {},
setHiddenColumns,
}) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
@ -51,6 +55,10 @@ export const ColumnsMenu: VFC<IColumnsMenuProps> = ({
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<IColumnsMenuProps> = ({
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<IColumnsMenuProps> = ({
show={<Divider className={classes.divider} />}
/>,
<MenuItem
onClick={() => {
column.toggleHidden(column.isVisible);
}}
onClick={() => onItemClick(column)}
disabled={staticColumns.includes(column.id)}
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 { 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<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 (
@ -289,9 +375,11 @@ export const ProjectFeatureToggles = ({
/>
<ColumnsMenu
allColumns={allColumns}
staticColumns={['Actions', 'name']}
staticColumns={staticColumns}
dividerAfter={['createdAt']}
dividerBefore={['Actions']}
isCustomized={Boolean(storedParams.columns)}
onCustomize={onCustomizeColumns}
setHiddenColumns={setHiddenColumns}
/>
<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 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 (
<div>
@ -29,21 +25,10 @@ const ProjectOverview = ({ projectId }: IProjectOverviewProps) => {
featureCount={features?.length}
/>
<div className={styles.projectToggles}>
<ConditionallyRender
condition={uiConfig.flags.NEW_PROJECT_OVERVIEW}
show={() => (
<ProjectFeatureToggles
features={features}
environments={environments}
loading={loading}
/>
)}
elseShow={() => (
<LegacyProjectFeatureToggles
features={features}
loading={loading}
/>
)}
<ProjectFeatureToggles
features={features}
environments={environments}
loading={loading}
/>
</div>
</div>

View File

@ -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 = ({
</TableCell>
<TableCell align="right">
<PermissionIconButton
data-loading
onClick={() => {
navigate(`/segments/edit/${id}`);
}}
permission={UPDATE_SEGMENT}
tooltipProps={{ title: 'Edit segment' }}
>
<Edit />
</PermissionIconButton>
<PermissionIconButton
data-loading
onClick={() => {
setCurrentSegment({
id,
name,
description,
createdAt,
createdBy,
constraints: [],
});
setDelDialog(true);
}}
permission={DELETE_SEGMENT}
tooltipProps={{ title: 'Remove segment' }}
data-testid={`${SEGMENT_DELETE_BTN_ID}_${name}`}
>
<Delete />
</PermissionIconButton>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<PermissionIconButton
data-loading
onClick={() => {
navigate(`/segments/edit/${id}`);
}}
permission={UPDATE_SEGMENT}
tooltipProps={{ title: 'Edit segment' }}
>
<Edit />
</PermissionIconButton>
<PermissionIconButton
data-loading
onClick={() => {
setCurrentSegment({
id,
name,
description,
createdAt,
createdBy,
constraints: [],
});
setDelDialog(true);
}}
permission={DELETE_SEGMENT}
tooltipProps={{ title: 'Remove segment' }}
data-testid={`${SEGMENT_DELETE_BTN_ID}_${name}`}
>
<Delete />
</PermissionIconButton>
</Box>
</TableCell>
</TableRow>
);

View File

@ -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<Record<string, string>>({});
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<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) {
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<Response>,
requestId: string,
loadingOn: boolean = true
): Promise<Response> => {
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,

View File

@ -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,

View File

@ -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 {

View File

@ -14,7 +14,6 @@ export const defaultValue = {
CO: false,
SE: false,
T: false,
NEW_PROJECT_OVERVIEW: false,
},
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 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>(
key: string,
initialValue: T
): UsePersistentGlobalState<T> => {
const container = createGlobalState<{ [key: string]: T }>({
[key]: getLocalStorageItem(key) ?? initialValue,
[key]: getLocalStorageItem<T>(key) ?? initialValue,
});
const setGlobalState = (value: React.SetStateAction<T>) => {

View File

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