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:
parent
c2b5c563db
commit
a11cb72d99
@ -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', () => {
|
||||
|
@ -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');
|
||||
});
|
||||
|
||||
|
@ -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']");
|
||||
})
|
||||
);
|
||||
|
@ -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>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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"
|
||||
/>
|
||||
}
|
||||
|
@ -8,5 +8,6 @@ export const useStyles = makeStyles()(theme => ({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: theme.spacing(2),
|
||||
},
|
||||
}));
|
||||
|
@ -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';
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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 => {
|
@ -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 },
|
||||
},
|
||||
|
@ -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}
|
||||
>
|
||||
|
@ -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 />
|
||||
|
@ -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 };
|
||||
};
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -14,7 +14,6 @@ export const defaultValue = {
|
||||
CO: false,
|
||||
SE: false,
|
||||
T: false,
|
||||
NEW_PROJECT_OVERVIEW: false,
|
||||
},
|
||||
links: [
|
||||
{
|
||||
|
65
frontend/src/hooks/useLocalStorage.test.ts
Normal file
65
frontend/src/hooks/useLocalStorage.test.ts
Normal 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' })
|
||||
);
|
||||
});
|
||||
});
|
34
frontend/src/hooks/useLocalStorage.ts
Normal file
34
frontend/src/hooks/useLocalStorage.ts
Normal 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;
|
||||
};
|
@ -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>) => {
|
||||
|
@ -29,7 +29,6 @@ export interface IFlags {
|
||||
CO?: boolean;
|
||||
SE?: boolean;
|
||||
T?: boolean;
|
||||
NEW_PROJECT_OVERVIEW: boolean;
|
||||
}
|
||||
|
||||
export interface IVersionInfo {
|
||||
|
Loading…
Reference in New Issue
Block a user