1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

UI/bulk select (#3267)

Select multiple toggles on project overview.
This commit is contained in:
Tymoteusz Czech 2023-03-14 09:56:03 +01:00 committed by GitHub
parent cb75a68cc3
commit 30a753b93f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 406 additions and 213 deletions

View File

@ -23,7 +23,13 @@ import {
IPersonalAPIToken,
} from 'interfaces/personalAPIToken';
import { useMemo, useState } from 'react';
import { useTable, SortingRule, useSortBy, useFlexLayout } from 'react-table';
import {
useTable,
SortingRule,
useSortBy,
useFlexLayout,
Column,
} from 'react-table';
import { sortTypes } from 'utils/sortTypes';
import { ServiceAccountCreateTokenDialog } from './ServiceAccountCreateTokenDialog/ServiceAccountCreateTokenDialog';
import { ServiceAccountTokenDialog } from 'component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokenDialog/ServiceAccountTokenDialog';
@ -151,65 +157,69 @@ export const ServiceAccountTokens = ({
};
const columns = useMemo(
() => [
{
Header: 'Description',
accessor: 'description',
Cell: HighlightCell,
minWidth: 100,
searchable: true,
},
{
Header: 'Expires',
accessor: 'expiresAt',
Cell: ({ value }: { value: string }) => {
const date = new Date(value);
if (date.getFullYear() > new Date().getFullYear() + 100) {
return <TextCell>Never</TextCell>;
}
return <DateCell value={value} />;
() =>
[
{
Header: 'Description',
accessor: 'description',
Cell: HighlightCell,
minWidth: 100,
searchable: true,
},
sortType: 'date',
maxWidth: 150,
},
{
Header: 'Created',
accessor: 'createdAt',
Cell: DateCell,
sortType: 'date',
maxWidth: 150,
},
{
Header: 'Last seen',
accessor: 'seenAt',
Cell: TimeAgoCell,
sortType: 'date',
maxWidth: 150,
},
{
Header: 'Actions',
id: 'Actions',
align: 'center',
Cell: ({ row: { original: rowToken } }: any) => (
<ActionCell>
<Tooltip title="Delete token" arrow describeChild>
<span>
<IconButton
onClick={() => {
setSelectedToken(rowToken);
setDeleteOpen(true);
}}
>
<Delete />
</IconButton>
</span>
</Tooltip>
</ActionCell>
),
maxWidth: 100,
disableSortBy: true,
},
],
{
Header: 'Expires',
accessor: 'expiresAt',
Cell: ({ value }: { value: string }) => {
const date = new Date(value);
if (
date.getFullYear() >
new Date().getFullYear() + 100
) {
return <TextCell>Never</TextCell>;
}
return <DateCell value={value} />;
},
sortType: 'date',
maxWidth: 150,
},
{
Header: 'Created',
accessor: 'createdAt',
Cell: DateCell,
sortType: 'date',
maxWidth: 150,
},
{
Header: 'Last seen',
accessor: 'seenAt',
Cell: TimeAgoCell,
sortType: 'date',
maxWidth: 150,
},
{
Header: 'Actions',
id: 'Actions',
align: 'center',
Cell: ({ row: { original: rowToken } }: any) => (
<ActionCell>
<Tooltip title="Delete token" arrow describeChild>
<span>
<IconButton
onClick={() => {
setSelectedToken(rowToken);
setDeleteOpen(true);
}}
>
<Delete />
</IconButton>
</span>
</Tooltip>
</ActionCell>
),
maxWidth: 100,
disableSortBy: true,
},
] as Column<IPersonalAPIToken>[],
[setSelectedToken, setDeleteOpen]
);

View File

@ -1,23 +1,20 @@
import { VFC } from 'react';
import { TableHead, TableRow } from '@mui/material';
import { HeaderGroup } from 'react-table';
import { CellSortable } from './CellSortable/CellSortable';
interface ISortableTableHeaderProps {
headerGroups: HeaderGroup<object>[];
className?: string;
flex?: boolean;
}
export const SortableTableHeader: VFC<ISortableTableHeaderProps> = ({
export const SortableTableHeader = <T extends object>({
headerGroups,
className,
flex,
}: {
headerGroups: HeaderGroup<T>[];
className?: string;
flex?: boolean;
}) => (
<TableHead className={className}>
{headerGroups.map(headerGroup => (
<TableRow {...headerGroup.getHeaderGroupProps()}>
{headerGroup.headers.map((column: HeaderGroup) => {
{headerGroup.headers.map((column: HeaderGroup<T>) => {
const content = column.render('Header');
return (

View File

@ -1,4 +1,4 @@
import { useMemo, VFC } from 'react';
import { useMemo } from 'react';
import { useTheme } from '@mui/material';
import {
SortableTableHeader,
@ -10,13 +10,6 @@ import {
import { useVirtualizedRange } from 'hooks/useVirtualizedRange';
import { HeaderGroup, Row } from 'react-table';
interface IVirtualizedTableProps {
rowHeight?: number;
headerGroups: HeaderGroup<object>[];
rows: Row<object>[];
prepareRow: (row: Row) => void;
}
/**
* READ BEFORE USE
*
@ -27,11 +20,16 @@ interface IVirtualizedTableProps {
* Remember to add `useFlexLayout` to `useTable`
* (more at: https://react-table-v7.tanstack.com/docs/api/useFlexLayout)
*/
export const VirtualizedTable: VFC<IVirtualizedTableProps> = ({
export const VirtualizedTable = <T extends object>({
rowHeight: rowHeightOverride,
headerGroups,
rows,
prepareRow,
}: {
rowHeight?: number;
headerGroups: HeaderGroup<T>[];
rows: Row<T>[];
prepareRow: (row: Row<T>) => void;
}) => {
const theme = useTheme();
const rowHeight = useMemo(

View File

@ -1,16 +1,16 @@
import { createRef, useState } from 'react';
import { styled, Typography, Box } from '@mui/material';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
import { useExportApi } from 'hooks/api/actions/useExportApi/useExportApi';
import useToast from 'hooks/useToast';
import { FeatureSchema } from 'openapi';
import type { FeatureSchema } from 'openapi';
import { createRef, useEffect, useState } from 'react';
import { formatUnknownError } from 'utils/formatUnknownError';
interface IExportDialogProps {
showExportDialog: boolean;
data: FeatureSchema[];
data: Pick<FeatureSchema, 'name'>[];
onClose: () => void;
environments: string[];
}
@ -37,13 +37,6 @@ export const ExportDialog = ({
label: env,
}));
const getPayload = () => {
return {
features: data.map(feature => feature.name),
environment: selected,
};
};
const downloadFile = (json: any) => {
const link = document.createElement('a');
ref.current?.appendChild(link);
@ -65,7 +58,10 @@ export const ExportDialog = ({
const onClick = async () => {
try {
const payload = getPayload();
const payload = {
features: data.map(feature => feature.name),
environment: selected,
};
const res = await createExport(payload);
const body = await res.json();
downloadFile(body);

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Checkbox,
IconButton,
styled,
Tooltip,
@ -8,7 +9,14 @@ import {
} from '@mui/material';
import { Add } from '@mui/icons-material';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table';
import {
SortingRule,
useFlexLayout,
useSortBy,
useRowSelect,
useTable,
} from 'react-table';
import type { FeatureSchema } from 'openapi';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { PageContent } from 'component/common/PageContent/PageContent';
@ -50,12 +58,13 @@ import { usePinnedFavorites } from 'hooks/usePinnedFavorites';
import { useFavoriteFeaturesApi } from 'hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi';
import { FeatureTagCell } from 'component/common/Table/cells/FeatureTagCell/FeatureTagCell';
import { useGlobalLocalStorage } from 'hooks/useGlobalLocalStorage';
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
import { flexRow } from 'themes/themeStyles';
import VariantsWarningTooltip from 'component/feature/FeatureView/FeatureVariants/VariantsTooltipWarning';
import FileDownload from '@mui/icons-material/FileDownload';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { ExportDialog } from 'component/feature/FeatureToggleList/ExportDialog';
import { RowSelectCell } from './RowSelectCell/RowSelectCell';
import { SelectionActionsBar } from './SelectionActionsBar/SelectionActionsBar';
const StyledResponsiveButton = styled(ResponsiveButton)(() => ({
whiteSpace: 'nowrap',
@ -96,7 +105,7 @@ type ListItemType = Pick<
someEnabledEnvironmentHasVariants: boolean;
};
const staticColumns = ['Actions', 'name', 'favorite'];
const staticColumns = ['Select', 'Actions', 'name', 'favorite'];
const defaultSort: SortingRule<string> & {
columns?: string[];
@ -223,8 +232,31 @@ export const ProjectFeatureToggles = ({
[projectId, refetch]
);
const showTagsColumn = useMemo(
() => features.some(feature => feature?.tags?.length),
[features]
);
const columns = useMemo(
() => [
...(uiConfig?.flags?.bulkOperations
? [
{
id: 'Select',
Header: ({ getToggleAllRowsSelectedProps }: any) => (
<Checkbox {...getToggleAllRowsSelectedProps()} />
),
Cell: ({ row }: any) => (
<RowSelectCell
{...row?.getToggleRowSelectedProps?.()}
/>
),
maxWidth: 50,
disableSortBy: true,
hideInMenu: true,
},
]
: []),
{
id: 'favorite',
Header: (
@ -242,6 +274,7 @@ export const ProjectFeatureToggles = ({
),
maxWidth: 50,
disableSortBy: true,
hideInMenu: true,
},
{
Header: 'Seen',
@ -271,18 +304,21 @@ export const ProjectFeatureToggles = ({
sortType: 'alphanumeric',
searchable: true,
},
{
id: 'tags',
Header: 'Tags',
accessor: (row: IFeatureToggleListItem) =>
row.tags
?.map(({ type, value }) => `${type}:${value}`)
.join('\n') || '',
Cell: FeatureTagCell,
width: 80,
hideInMenu: true,
searchable: true,
},
...(showTagsColumn
? [
{
id: 'tags',
Header: 'Tags',
accessor: (row: IFeatureToggleListItem) =>
row.tags
?.map(({ type, value }) => `${type}:${value}`)
.join('\n') || '',
Cell: FeatureTagCell,
width: 80,
searchable: true,
},
]
: []),
{
Header: 'Created',
accessor: 'createdAt',
@ -343,6 +379,7 @@ export const ProjectFeatureToggles = ({
/>
),
disableSortBy: true,
hideInMenu: true,
},
],
[projectId, environments, loading, onToggle]
@ -397,7 +434,7 @@ export const ProjectFeatureToggles = ({
environments: {
production: { name: 'production', enabled: false },
},
}) as object[];
}) as FeatureSchema[];
}
return searchedData;
}, [loading, searchedData]);
@ -438,6 +475,7 @@ export const ProjectFeatureToggles = ({
},
],
hiddenColumns,
selectedRowIds: {},
};
},
[environments] // eslint-disable-line react-hooks/exhaustive-deps
@ -449,7 +487,7 @@ export const ProjectFeatureToggles = ({
allColumns,
headerGroups,
rows,
state: { sortBy, hiddenColumns },
state: { selectedRowIds, sortBy, hiddenColumns },
prepareRow,
setHiddenColumns,
} = useTable(
@ -464,18 +502,8 @@ export const ProjectFeatureToggles = ({
getRowId,
},
useFlexLayout,
useSortBy
);
useConditionallyHiddenColumns(
[
{
condition: !features.some(({ tags }) => tags?.length),
columns: ['tags'],
},
],
setHiddenColumns,
columns
useSortBy,
useRowSelect
);
useEffect(() => {
@ -559,7 +587,7 @@ export const ProjectFeatureToggles = ({
)}
show={
<Tooltip
title="Export current selection"
title="Export toggles visible in the table below"
arrow
>
<IconButton
@ -686,6 +714,10 @@ export const ProjectFeatureToggles = ({
/>
}
/>
<SelectionActionsBar
selectedIds={Object.keys(selectedRowIds)}
data={features}
/>
</PageContent>
);
};

View File

@ -0,0 +1,24 @@
import { Box, Checkbox, styled } from '@mui/material';
import { FC } from 'react';
interface IRowSelectCellProps {
onChange: () => void;
checked: boolean;
title: string;
}
const StyledBoxCell = styled(Box)(({ theme }) => ({
display: 'flex',
justifyContent: 'center',
paddingLeft: theme.spacing(2),
}));
export const RowSelectCell: FC<IRowSelectCellProps> = ({
onChange,
checked,
title,
}) => (
<StyledBoxCell>
<Checkbox onChange={onChange} title={title} checked={checked} />
</StyledBoxCell>
);

View File

@ -0,0 +1,120 @@
import { useMemo, useState, VFC } from 'react';
import { Box, Button, Paper, styled, Typography } from '@mui/material';
import { Archive, FileDownload, Label, WatchLater } from '@mui/icons-material';
import type { FeatureSchema } from 'openapi';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { ExportDialog } from 'component/feature/FeatureToggleList/ExportDialog';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
interface ISelectionActionsBarProps {
selectedIds: string[];
data: FeatureSchema[];
}
const StyledContainer = styled(Box)(() => ({
display: 'flex',
justifyContent: 'center',
width: '100%',
}));
const StyledBar = styled(Paper)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: theme.spacing(2),
marginLeft: 'auto',
marginRight: 'auto',
padding: theme.spacing(2, 3),
backgroundColor: theme.palette.background.paper,
border: `1px solid ${theme.palette.secondary.main}`,
borderRadius: theme.shape.borderRadiusLarge,
columnGap: theme.spacing(1),
}));
const StyledCount = styled('span')(({ theme }) => ({
background: theme.palette.secondary.main,
color: theme.palette.background.paper,
padding: theme.spacing(0.5, 1),
borderRadius: theme.shape.borderRadius,
}));
const StyledText = styled(Typography)(({ theme }) => ({
marginRight: theme.spacing(2),
}));
export const SelectionActionsBar: VFC<ISelectionActionsBarProps> = ({
selectedIds,
data,
}) => {
const { uiConfig } = useUiConfig();
const [showExportDialog, setShowExportDialog] = useState(false);
const selectedData = useMemo(
() => data.filter(d => selectedIds.includes(d.name)),
[data, selectedIds]
);
const environments = useMemo(() => {
const envs = selectedData
.flatMap(d => d.environments)
.map(env => env?.name)
.filter(env => env !== undefined) as string[];
return Array.from(new Set(envs));
}, [selectedData]);
if (selectedIds.length === 0) {
return null;
}
return (
<StyledContainer>
<StyledBar elevation={4}>
<StyledText>
<StyledCount>{selectedIds.length}</StyledCount>
&ensp;selected
</StyledText>
<Button
disabled
startIcon={<Archive />}
variant="outlined"
size="small"
>
Archive
</Button>
<Button
disabled
startIcon={<WatchLater />}
variant="outlined"
size="small"
>
Mark as stale
</Button>
<Button
startIcon={<FileDownload />}
variant="outlined"
size="small"
onClick={() => setShowExportDialog(true)}
>
Export
</Button>
<Button
disabled
startIcon={<Label />}
variant="outlined"
size="small"
>
Tags
</Button>
</StyledBar>
<ConditionallyRender
condition={Boolean(uiConfig?.flags?.featuresExportImport)}
show={
<ExportDialog
showExportDialog={showExportDialog}
data={selectedData}
onClose={() => setShowExportDialog(false)}
environments={environments}
/>
}
/>
</StyledContainer>
);
};

View File

@ -28,7 +28,13 @@ import {
} from 'interfaces/personalAPIToken';
import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useTable, SortingRule, useSortBy, useFlexLayout } from 'react-table';
import {
useTable,
SortingRule,
useSortBy,
useFlexLayout,
Column,
} from 'react-table';
import { createLocalStorage } from 'utils/createLocalStorage';
import { sortTypes } from 'utils/sortTypes';
import { CreatePersonalAPIToken } from './CreatePersonalAPIToken/CreatePersonalAPIToken';
@ -104,65 +110,69 @@ export const PersonalAPITokensTab = () => {
const [selectedToken, setSelectedToken] = useState<IPersonalAPIToken>();
const columns = useMemo(
() => [
{
Header: 'Description',
accessor: 'description',
Cell: HighlightCell,
minWidth: 100,
searchable: true,
},
{
Header: 'Expires',
accessor: 'expiresAt',
Cell: ({ value }: { value: string }) => {
const date = new Date(value);
if (date.getFullYear() > new Date().getFullYear() + 100) {
return <TextCell>Never</TextCell>;
}
return <DateCell value={value} />;
() =>
[
{
Header: 'Description',
accessor: 'description',
Cell: HighlightCell,
minWidth: 100,
searchable: true,
},
sortType: 'date',
maxWidth: 150,
},
{
Header: 'Created',
accessor: 'createdAt',
Cell: DateCell,
sortType: 'date',
maxWidth: 150,
},
{
Header: 'Last seen',
accessor: 'seenAt',
Cell: TimeAgoCell,
sortType: 'date',
maxWidth: 150,
},
{
Header: 'Actions',
id: 'Actions',
align: 'center',
Cell: ({ row: { original: rowToken } }: any) => (
<ActionCell>
<Tooltip title="Delete token" arrow describeChild>
<span>
<IconButton
onClick={() => {
setSelectedToken(rowToken);
setDeleteOpen(true);
}}
>
<Delete />
</IconButton>
</span>
</Tooltip>
</ActionCell>
),
maxWidth: 100,
disableSortBy: true,
},
],
{
Header: 'Expires',
accessor: 'expiresAt',
Cell: ({ value }: { value: string }) => {
const date = new Date(value);
if (
date.getFullYear() >
new Date().getFullYear() + 100
) {
return <TextCell>Never</TextCell>;
}
return <DateCell value={value} />;
},
sortType: 'date',
maxWidth: 150,
},
{
Header: 'Created',
accessor: 'createdAt',
Cell: DateCell,
sortType: 'date',
maxWidth: 150,
},
{
Header: 'Last seen',
accessor: 'seenAt',
Cell: TimeAgoCell,
sortType: 'date',
maxWidth: 150,
},
{
Header: 'Actions',
id: 'Actions',
align: 'center',
Cell: ({ row: { original: rowToken } }: any) => (
<ActionCell>
<Tooltip title="Delete token" arrow describeChild>
<span>
<IconButton
onClick={() => {
setSelectedToken(rowToken);
setDeleteOpen(true);
}}
>
<Delete />
</IconButton>
</span>
</Tooltip>
</ActionCell>
),
maxWidth: 100,
disableSortBy: true,
},
] as Column<IPersonalAPIToken>[],
[setSelectedToken, setDeleteOpen]
);
@ -186,7 +196,7 @@ export const PersonalAPITokensTab = () => {
prepareRow,
state: { sortBy },
setHiddenColumns,
} = useTable(
} = useTable<IPersonalAPIToken>(
{
columns,
data,

View File

@ -1,6 +1,7 @@
import useSWR from 'swr';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
import { PatsSchema } from 'openapi';
import { IPersonalAPIToken } from 'interfaces/personalAPIToken';
export interface IUsePersonalAPITokensOutput {
@ -10,20 +11,15 @@ export interface IUsePersonalAPITokensOutput {
error?: Error;
}
export const usePersonalAPITokens = (
userId?: number
): IUsePersonalAPITokensOutput => {
const { data, error, mutate } = useSWR(
formatApiPath(
userId
? `api/admin/user-admin/${userId}/pat`
: 'api/admin/user/tokens'
),
export const usePersonalAPITokens = (): IUsePersonalAPITokensOutput => {
const { data, error, mutate } = useSWR<PatsSchema>(
formatApiPath('api/admin/user/tokens'),
fetcher
);
return {
tokens: data ? data.pats : undefined,
// FIXME: schema issue
tokens: data ? (data.pats as any) : undefined,
loading: !error && !data,
refetchTokens: () => mutate(),
error,

View File

@ -1,4 +1,5 @@
import { IPersonalAPIToken } from 'interfaces/personalAPIToken';
import { PatsSchema } from 'openapi';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
@ -11,18 +12,21 @@ export interface IUseServiceAccountTokensOutput {
error?: Error;
}
export const useServiceAccountTokens = (id: number) => {
export const useServiceAccountTokens = (
id: number
): IUseServiceAccountTokensOutput => {
const { isEnterprise } = useUiConfig();
const { data, error, mutate } = useConditionalSWR(
const { data, error, mutate } = useConditionalSWR<PatsSchema>(
isEnterprise(),
{ tokens: [] },
{ pats: [] },
formatApiPath(`api/admin/service-account/${id}/token`),
fetcher
);
return {
tokens: data ? data.pats : undefined,
// FIXME: schema issue
tokens: data ? (data.pats as any) : undefined,
loading: !error && !data,
refetchTokens: () => mutate(),
error,

View File

@ -7,7 +7,7 @@ import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
import useUiConfig from '../useUiConfig/useUiConfig';
export const useServiceAccounts = () => {
const { uiConfig, isEnterprise } = useUiConfig();
const { isEnterprise } = useUiConfig();
const { data, error, mutate } = useConditionalSWR(
isEnterprise(),

View File

@ -1,29 +1,34 @@
interface IUseSearchOutput {
getSearchText: (input: string) => string;
data: any[];
getSearchContext: () => IGetSearchContextOutput;
}
import { useCallback, useMemo } from 'react';
export interface IGetSearchContextOutput {
data: any[];
export type IGetSearchContextOutput<T extends any = any> = {
data: T[];
columns: any[];
searchValue: string;
}
};
export const useSearch = (
type IUseSearchOutput<T extends any> = {
getSearchText: (input: string) => string;
data: T[];
getSearchContext: () => IGetSearchContextOutput<T>;
};
export const useSearch = <T extends any>(
columns: any[],
searchValue: string,
data: any[]
): IUseSearchOutput => {
const getSearchText = getSearchTextGenerator(columns);
data: T[]
): IUseSearchOutput<T> => {
const getSearchText = useCallback(
(value: string) => getSearchTextGenerator(columns)(value),
[columns]
);
const getSearchContext = () => {
const getSearchContext = useCallback(() => {
return { data, searchValue, columns };
};
}, [data, searchValue, columns]);
if (!searchValue) return { data, getSearchText, getSearchContext };
const search = useMemo(() => {
if (!searchValue) return data;
const search = () => {
const filteredData = filter(columns, searchValue, data);
const searchedData = searchInFilteredData(
columns,
@ -32,9 +37,9 @@ export const useSearch = (
);
return searchedData;
};
}, [columns, searchValue, data, getSearchText]);
return { data: search(), getSearchText, getSearchContext };
return { data: search, getSearchText, getSearchContext };
};
export const filter = (columns: any[], searchValue: string, data: any[]) => {
@ -57,10 +62,10 @@ export const filter = (columns: any[], searchValue: string, data: any[]) => {
return filteredDataSet;
};
export const searchInFilteredData = (
export const searchInFilteredData = <T extends any>(
columns: any[],
searchValue: string,
filteredData: any[]
filteredData: T[]
) => {
const searchableColumns = columns.filter(
column => column.searchable && column.accessor

View File

@ -40,6 +40,7 @@ process.nextTick(async () => {
responseTimeWithAppNameKillSwitch: false,
featuresExportImport: true,
newProjectOverview: true,
bulkOperations: true,
projectStatusApi: true,
showProjectApiAccess: true,
projectScopedSegments: true,