1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02:00

Merge branch 'main' into meta/add-stalebot

This commit is contained in:
andreas-unleash 2022-07-07 13:27:41 +03:00 committed by GitHub
commit 69f4b73b16
111 changed files with 1394 additions and 1694 deletions

View File

@ -1,7 +1,7 @@
{
"name": "unleash-frontend",
"description": "unleash your features",
"version": "4.13.0-beta.1",
"version": "4.14.0-beta.0",
"keywords": [
"unleash",
"feature toggle",
@ -29,6 +29,7 @@
"start": "vite",
"start:heroku": "UNLEASH_API=https://unleash.herokuapp.com yarn run start",
"start:enterprise": "UNLEASH_API=https://unleash4.herokuapp.com yarn run start",
"start:demo": "UNLEASH_BASE_PATH=/demo/ yarn start",
"test": "vitest",
"prepare": "yarn run build",
"fmt": "prettier src --write --loglevel warn",
@ -39,27 +40,27 @@
"devDependencies": {
"@emotion/react": "11.9.3",
"@emotion/styled": "11.9.3",
"@mui/icons-material": "5.8.3",
"@mui/lab": "5.0.0-alpha.85",
"@mui/material": "5.8.3",
"@mui/icons-material": "5.8.4",
"@mui/lab": "5.0.0-alpha.88",
"@mui/material": "5.8.6",
"@openapitools/openapi-generator-cli": "2.5.1",
"@testing-library/dom": "8.13.0",
"@testing-library/dom": "8.14.0",
"@testing-library/jest-dom": "5.16.4",
"@testing-library/react": "12.1.5",
"@testing-library/react-hooks": "^7.0.2",
"@testing-library/user-event": "14.2.0",
"@testing-library/user-event": "14.2.1",
"@types/debounce": "1.2.1",
"@types/deep-diff": "1.0.1",
"@types/jest": "27.5.2",
"@types/lodash.clonedeep": "4.5.7",
"@types/node": "17.0.18",
"@types/react": "17.0.45",
"@types/react": "17.0.47",
"@types/react-dom": "17.0.17",
"@types/react-router-dom": "5.3.3",
"@types/react-table": "7.7.12",
"@types/react-test-renderer": "17.0.2",
"@types/react-timeago": "4.1.3",
"@types/semver": "^7.3.9",
"@types/semver": "7.3.10",
"@vitejs/plugin-react": "1.3.2",
"chart.js": "3.8.0",
"chartjs-adapter-date-fns": "2.0.0",
@ -69,17 +70,17 @@
"date-fns": "2.28.0",
"debounce": "1.2.1",
"deep-diff": "1.0.2",
"eslint": "8.17.0",
"eslint": "8.18.0",
"eslint-config-react-app": "^7.0.1",
"fast-json-patch": "3.1.1",
"http-proxy-middleware": "2.0.6",
"immer": "9.0.15",
"jsdom": "^19.0.0",
"jsdom": "20.0.0",
"lodash.clonedeep": "4.5.0",
"msw": "0.42.1",
"msw": "0.42.3",
"pkginfo": "^0.4.1",
"plausible-tracker": "0.3.8",
"prettier": "2.6.2",
"prettier": "2.7.1",
"prop-types": "15.8.1",
"react": "17.0.2",
"react-chartjs-2": "4.2.0",
@ -89,16 +90,16 @@
"react-table": "7.8.0",
"react-test-renderer": "17.0.2",
"react-timeago": "7.1.0",
"sass": "1.52.3",
"sass": "1.53.0",
"semver": "7.3.7",
"swr": "1.3.0",
"tss-react": "3.7.0",
"typescript": "4.7.3",
"vite": "2.9.12",
"typescript": "4.7.4",
"vite": "2.9.13",
"vite-plugin-env-compatible": "^1.1.1",
"vite-plugin-svgr": "2.1.0",
"vite-plugin-svgr": "2.2.0",
"vite-tsconfig-paths": "3.5.0",
"vitest": "0.14.2",
"vitest": "0.16.0",
"whatwg-fetch": "^3.6.2"
},
"jest": {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 454 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 81 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 67 KiB

View File

@ -53,7 +53,7 @@ export const ConfiguredAddons = () => {
type: 'success',
title: 'Success',
text: !addon.enabled
? 'Addon is now active'
? 'Addon is now enabled'
: 'Addon is now disabled',
});
} catch (error: unknown) {

View File

@ -56,24 +56,21 @@ export const ApiTokenTable = () => {
setHiddenColumns(hiddenColumns);
}, [setHiddenColumns, hiddenColumns]);
const headerSearch = (
<Search initialValue={globalFilter} onChange={setGlobalFilter} />
);
const headerActions = (
<>
{headerSearch}
<PageHeader.Divider />
<CreateApiTokenButton />
</>
);
return (
<PageContent
header={
<PageHeader
title={`API access (${rows.length})`}
actions={headerActions}
actions={
<>
<Search
initialValue={globalFilter}
onChange={setGlobalFilter}
/>
<PageHeader.Divider />
<CreateApiTokenButton />
</>
}
/>
}
>

View File

@ -11,6 +11,9 @@ import { ConfirmToken } from '../ConfirmToken/ConfirmToken';
import { useState } from 'react';
import { scrollToTop } from 'component/common/util';
import { formatUnknownError } from 'utils/formatUnknownError';
import { usePageTitle } from 'hooks/usePageTitle';
const pageTitle = 'Create API token';
export const CreateApiToken = () => {
const { setToastApiError } = useToast();
@ -36,6 +39,8 @@ export const CreateApiToken = () => {
const { createToken, loading } = useApiTokensApi();
usePageTitle(pageTitle);
const handleSubmit = async (e: Event) => {
e.preventDefault();
if (!isValid()) {
@ -76,7 +81,7 @@ export const CreateApiToken = () => {
return (
<FormTemplate
loading={loading}
title="Create Api Token"
title={pageTitle}
description="In order to connect to Unleash clients will need an API token to grant access. A client SDK will need to token with 'client privileges', which allows them to fetch feature toggle configuration and post usage metrics back."
documentationLink="https://docs.getunleash.io/reference/api-tokens-and-client-keys"
documentationLinkLabel="API tokens documentation"

View File

@ -1,20 +1,8 @@
import { Button, styled } from '@mui/material';
import { VFC } from 'react';
import { formatApiPath } from 'utils/formatPath';
const href = `mailto:elise@getunleash.ai?subject=Continue with Unleash&body=Hi Unleash,%0D%0A%0D%0A
I would like to continue with Unleash.%0D%0A%0D%0A%0D%0A%0D%0A
Billing information:%0D%0A%0D%0A
1. Company name (legal name): [add your information here]%0D%0A%0D%0A
2. Email address (where we will send the invoice): [add your information here]%0D%0A%0D%0A
3. Address: [add your information here]%0D%0A%0D%0A
4. Country: [add your information here]%0D%0A%0D%0A
5. VAT ID (optional - only European countries): [add your information here]%0D%0A%0D%0A%0D%0A%0D%0A%0D%0A
-- Thank you for signing up. We will upgrade your trial as quick as possible and we will grant you access to the application again. --`;
const PORTAL_URL = formatApiPath('api/admin/invoices');
const StyledButton = styled(Button)(({ theme }) => ({
width: '100%',
@ -27,10 +15,11 @@ interface IBillingInformationButtonProps {
export const BillingInformationButton: VFC<IBillingInformationButtonProps> = ({
update,
}) => {
return (
<StyledButton href={href} variant={update ? 'outlined' : 'contained'}>
{update ? 'Update billing information' : 'Add billing information'}
</StyledButton>
);
};
}) => (
<StyledButton
href={`${PORTAL_URL}/${update ? 'portal' : 'checkout'}`}
variant={update ? 'outlined' : 'contained'}
>
{update ? 'Update billing information' : 'Add billing information'}
</StyledButton>
);

View File

@ -9,7 +9,7 @@ import {
InstanceState,
InstancePlan,
} from 'interfaces/instance';
import { hasTrialExpired } from 'utils/instanceTrial';
import { trialHasExpired, isTrialInstance } from 'utils/instanceTrial';
import { GridRow } from 'component/common/GridRow/GridRow';
import { GridCol } from 'component/common/GridCol/GridCol';
import { GridColLink } from './GridColLink/GridColLink';
@ -81,7 +81,7 @@ interface IBillingPlanProps {
export const BillingPlan: FC<IBillingPlanProps> = ({ instanceStatus }) => {
const { users } = useUsers();
const trialHasExpired = hasTrialExpired(instanceStatus);
const expired = trialHasExpired(instanceStatus);
const price = {
[InstancePlan.PRO]: 80,
@ -124,18 +124,16 @@ export const BillingPlan: FC<IBillingPlanProps> = ({ instanceStatus }) => {
{instanceStatus.plan}
</StyledPlanSpan>
<ConditionallyRender
condition={
instanceStatus.state === InstanceState.TRIAL
}
condition={isTrialInstance(instanceStatus)}
show={
<StyledTrialSpan
sx={theme => ({
color: trialHasExpired
color: expired
? theme.palette.error.dark
: theme.palette.warning.dark,
})}
>
{trialHasExpired
{expired
? 'Trial expired'
: instanceStatus.trialExtended
? 'Extended Trial'

View File

@ -0,0 +1,19 @@
import { Navigate } from 'react-router-dom';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import InvoiceAdminPage from 'component/admin/invoice/InvoiceAdminPage';
const FlaggedBillingRedirect = () => {
const { uiConfig, loading } = useUiConfig();
if (loading) {
return null;
}
if (!uiConfig.flags.UNLEASH_CLOUD) {
return <InvoiceAdminPage />;
}
return <Navigate to="/admin/billing" replace />;
};
export default FlaggedBillingRedirect;

View File

@ -1,7 +0,0 @@
import { Navigate } from 'react-router-dom';
const RedirectAdminInvoices = () => {
return <Navigate to="/admin/billing" replace />;
};
export default RedirectAdminInvoices;

View File

@ -0,0 +1,25 @@
import { useContext } from 'react';
import InvoiceList from './InvoiceList';
import AccessContext from 'contexts/AccessContext';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Alert } from '@mui/material';
const InvoiceAdminPage = () => {
const { hasAccess } = useContext(AccessContext);
return (
<div>
<ConditionallyRender
condition={hasAccess(ADMIN)}
show={<InvoiceList />}
elseShow={
<Alert severity="error">
You need to be instance admin to access this section.
</Alert>
}
/>
</div>
);
};
export default InvoiceAdminPage;

View File

@ -0,0 +1,122 @@
import { useEffect, useState } from 'react';
import {
Table,
TableHead,
TableBody,
TableRow,
TableCell,
Button,
} from '@mui/material';
import OpenInNew from '@mui/icons-material/OpenInNew';
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { formatApiPath } from 'utils/formatPath';
import useInvoices from 'hooks/api/getters/useInvoices/useInvoices';
import { IInvoice } from 'interfaces/invoice';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { formatDateYMD } from 'utils/formatDate';
const PORTAL_URL = formatApiPath('api/admin/invoices/portal');
const InvoiceList = () => {
const { refetchInvoices, invoices } = useInvoices();
const [isLoaded, setLoaded] = useState(false);
const { locationSettings } = useLocationSettings();
useEffect(() => {
refetchInvoices();
setLoaded(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<ConditionallyRender
condition={invoices.length > 0}
show={
<PageContent
header={
<PageHeader
title="Invoices"
actions={
<Button
href={PORTAL_URL}
rel="noreferrer"
target="_blank"
endIcon={<OpenInNew />}
>
Billing portal
</Button>
}
/>
}
>
<div>
<Table>
<TableHead>
<TableRow>
<TableCell>Amount</TableCell>
<TableCell>Status</TableCell>
<TableCell>Due date</TableCell>
<TableCell>PDF</TableCell>
<TableCell>Link</TableCell>
</TableRow>
</TableHead>
<TableBody>
{invoices.map((item: IInvoice) => (
<TableRow
key={item.invoiceURL}
style={{
backgroundColor:
item.status === 'past-due'
? '#ff9194'
: 'inherit',
}}
>
<TableCell
style={{ textAlign: 'left' }}
>
{item.amountFormatted}
</TableCell>
<TableCell
style={{ textAlign: 'left' }}
>
{item.status}
</TableCell>
<TableCell
style={{ textAlign: 'left' }}
>
{item.dueDate &&
formatDateYMD(
item.dueDate,
locationSettings.locale
)}
</TableCell>
<TableCell
style={{ textAlign: 'left' }}
>
<a href={item.invoicePDF}>PDF</a>
</TableCell>
<TableCell
style={{ textAlign: 'left' }}
>
<a
href={item.invoiceURL}
target="_blank"
rel="noreferrer"
>
Payment link
</a>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</PageContent>
}
elseShow={<div>{isLoaded && 'No invoices to show.'}</div>}
/>
);
};
export default InvoiceList;

View File

@ -1,53 +1,49 @@
import React, { useState } from 'react';
import classnames from 'classnames';
import { Avatar, TextField, Typography, Alert } from '@mui/material';
import { Avatar, TextField, Typography } from '@mui/material';
import { trim } from 'component/common/util';
import { modalStyles } from 'component/admin/users/util';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import PasswordChecker from 'component/user/common/ResetPasswordForm/PasswordChecker/PasswordChecker';
import PasswordChecker, {
PASSWORD_FORMAT_MESSAGE,
} from 'component/user/common/ResetPasswordForm/PasswordChecker/PasswordChecker';
import { useThemeStyles } from 'themes/themeStyles';
import PasswordMatcher from 'component/user/common/ResetPasswordForm/PasswordMatcher/PasswordMatcher';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { IUser } from 'interfaces/user';
import useAdminUsersApi from 'hooks/api/actions/useAdminUsersApi/useAdminUsersApi';
interface IChangePasswordProps {
showDialog: boolean;
closeDialog: () => void;
changePassword: (userId: number, password: string) => Promise<Response>;
user: IUser;
}
const ChangePassword = ({
showDialog,
closeDialog,
changePassword,
user,
}: IChangePasswordProps) => {
const [data, setData] = useState<Record<string, string>>({});
const [error, setError] = useState<Record<string, string>>({});
const [error, setError] = useState<string>();
const [validPassword, setValidPassword] = useState(false);
const { classes: themeStyles } = useThemeStyles();
const { changePassword } = useAdminUsersApi();
const updateField: React.ChangeEventHandler<HTMLInputElement> = event => {
setError({});
setError(undefined);
setData({ ...data, [event.target.name]: trim(event.target.value) });
};
const submit = async (event: React.SyntheticEvent) => {
event.preventDefault();
if (data.password !== data.confirm) {
return;
}
if (!validPassword) {
if (!data.password || data.password.length < 8) {
setError({
password:
'You must specify a password with at least 8 chars.',
});
return;
}
if (!(data.password === data.confirm)) {
setError({ confirm: 'Passwords does not match' });
return;
}
setError(PASSWORD_FORMAT_MESSAGE);
return;
}
try {
@ -55,16 +51,15 @@ const ChangePassword = ({
setData({});
closeDialog();
} catch (error: unknown) {
const msg =
(error instanceof Error && error.message) ||
'Could not update password';
setError({ general: msg });
console.warn(error);
setError(PASSWORD_FORMAT_MESSAGE);
}
};
const onCancel = (event: React.SyntheticEvent) => {
event.preventDefault();
setData({});
setError(undefined);
closeDialog();
};
@ -77,6 +72,7 @@ const ChangePassword = ({
primaryButtonText="Save"
title="Update password"
secondaryButtonText="Cancel"
maxWidth="xs"
>
<form
onSubmit={submit}
@ -85,10 +81,6 @@ const ChangePassword = ({
themeStyles.flexColumn
)}
>
<ConditionallyRender
condition={Boolean(error.general)}
show={<Alert severity="error">{error.general}</Alert>}
/>
<Typography variant="subtitle1">
Changing password for user
</Typography>
@ -117,7 +109,8 @@ const ChangePassword = ({
name="password"
type="password"
value={data.password}
helperText={error.password}
error={Boolean(error)}
helperText={error}
onChange={updateField}
variant="outlined"
size="small"
@ -127,8 +120,6 @@ const ChangePassword = ({
name="confirm"
type="password"
value={data.confirm}
error={error.confirm !== undefined}
helperText={error.confirm}
onChange={updateField}
variant="outlined"
size="small"

View File

@ -45,8 +45,7 @@ const UsersList = () => {
const navigate = useNavigate();
const { users, roles, refetch, loading } = useUsers();
const { setToastData, setToastApiError } = useToast();
const { removeUser, changePassword, userLoading, userApiErrors } =
useAdminUsersApi();
const { removeUser, userLoading, userApiErrors } = useAdminUsersApi();
const [pwDialog, setPwDialog] = useState<{ open: boolean; user?: IUser }>({
open: false,
});
@ -320,7 +319,6 @@ const UsersList = () => {
<ChangePassword
showDialog={pwDialog.open}
closeDialog={closePwDialog}
changePassword={changePassword}
user={pwDialog.user!}
/>
)}

View File

@ -1,13 +1,6 @@
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import {
SortableTableHeader,
Table,
TableBody,
TableCell,
TablePlaceholder,
TableRow,
} from 'component/common/Table';
import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { useMediaQuery } from '@mui/material';
@ -17,13 +10,12 @@ import { HighlightCell } from 'component/common/Table/cells/HighlightCell/Highli
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Search } from 'component/common/Search/Search';
import { FeatureTypeCell } from '../../common/Table/cells/FeatureTypeCell/FeatureTypeCell';
import { FeatureSeenCell } from '../../common/Table/cells/FeatureSeenCell/FeatureSeenCell';
import { LinkCell } from '../../common/Table/cells/LinkCell/LinkCell';
import { FeatureStaleCell } from '../../feature/FeatureToggleList/FeatureStaleCell/FeatureStaleCell';
import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell';
import { FeatureSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureSeenCell';
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { FeatureStaleCell } from 'component/feature/FeatureToggleList/FeatureStaleCell/FeatureStaleCell';
import { ReviveArchivedFeatureCell } from 'component/archive/ArchiveTable/ReviveArchivedFeatureCell/ReviveArchivedFeatureCell';
import { useStyles } from '../../feature/FeatureToggleList/styles';
import { featuresPlaceholder } from '../../feature/FeatureToggleList/FeatureToggleListTable';
import { featuresPlaceholder } from 'component/feature/FeatureToggleList/FeatureToggleListTable';
import theme from 'themes/theme';
import { FeatureSchema } from 'openapi';
import { useFeatureArchiveApi } from 'hooks/api/actions/useFeatureArchiveApi/useReviveFeatureApi';
@ -31,7 +23,6 @@ import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useSearch } from 'hooks/useSearch';
import { FeatureArchivedCell } from './FeatureArchivedCell/FeatureArchivedCell';
import { useVirtualizedRange } from 'hooks/useVirtualizedRange';
import { useSearchParams } from 'react-router-dom';
export interface IFeaturesArchiveTableProps {
@ -57,8 +48,6 @@ export const ArchiveTable = ({
title,
projectId,
}: IFeaturesArchiveTableProps) => {
const rowHeight = theme.shape.tableRowHeight;
const { classes } = useStyles();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg'));
const { setToastData, setToastApiError } = useToast();
@ -107,7 +96,7 @@ export const ArchiveTable = ({
align: 'center',
},
{
Header: 'Feature toggle name',
Header: 'Name',
accessor: 'name',
searchable: true,
minWidth: 100,
@ -152,7 +141,7 @@ export const ArchiveTable = ({
]
: []),
{
Header: 'Status',
Header: 'State',
accessor: 'stale',
Cell: FeatureStaleCell,
sortType: 'boolean',
@ -166,7 +155,6 @@ export const ArchiveTable = ({
align: 'center',
maxWidth: 85,
canSort: false,
disableGlobalFilter: true,
Cell: ({ row: { original } }: any) => (
<ReviveArchivedFeatureCell
project={original.project}
@ -210,8 +198,6 @@ export const ArchiveTable = ({
headerGroups,
rows,
state: { sortBy },
getTableBodyProps,
getTableProps,
prepareRow,
setHiddenColumns,
} = useTable(
@ -257,15 +243,12 @@ export const ArchiveTable = ({
setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false });
}, [loading, sortBy, searchValue]); // eslint-disable-line react-hooks/exhaustive-deps
const [firstRenderedIndex, lastRenderedIndex] =
useVirtualizedRange(rowHeight);
return (
<PageContent
isLoading={loading}
header={
<PageHeader
title={`${title} (${
titleElement={`${title} (${
rows.length < data.length
? `${rows.length} of ${data.length}`
: data.length
@ -282,78 +265,30 @@ export const ArchiveTable = ({
}
>
<SearchHighlightProvider value={getSearchText(searchValue)}>
<Table
{...getTableProps()}
rowHeight={rowHeight}
style={{
height:
rowHeight * rows.length +
theme.shape.tableRowHeightCompact,
}}
>
<SortableTableHeader
headerGroups={headerGroups as any}
flex
/>
<TableBody {...getTableBodyProps()}>
{rows.map((row, index) => {
const isVirtual =
index < firstRenderedIndex ||
index > lastRenderedIndex;
if (isVirtual) {
return null;
}
prepareRow(row);
return (
<TableRow
hover
{...row.getRowProps()}
style={{
display: 'flex',
top:
index * rowHeight +
theme.shape.tableRowHeightCompact,
}}
className={classes.row}
>
{row.cells.map(cell => (
<TableCell
{...cell.getCellProps({
style: {
flex: cell.column.minWidth
? '1 0 auto'
: undefined,
},
})}
className={classes.cell}
>
{cell.render('Cell')}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
<VirtualizedTable
rows={rows}
headerGroups={headerGroups}
prepareRow={prepareRow}
/>
</SearchHighlightProvider>
<ConditionallyRender
condition={rows.length === 0 && searchValue?.length > 0}
show={
<TablePlaceholder>
No feature toggles found matching &ldquo;
{searchValue}&rdquo;
</TablePlaceholder>
}
/>
<ConditionallyRender
condition={rows.length === 0 && searchValue?.length === 0}
show={
<TablePlaceholder>
None of the feature toggles where archived yet.
</TablePlaceholder>
}
condition={rows.length === 0}
show={() => (
<ConditionallyRender
condition={searchValue?.length > 0}
show={
<TablePlaceholder>
No feature toggles found matching &ldquo;
{searchValue}&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
None of the feature toggles were archived yet.
</TablePlaceholder>
}
/>
)}
/>
</PageContent>
);

View File

@ -1,6 +1,6 @@
import { VFC } from 'react';
import TimeAgo from 'react-timeago';
import { Tooltip, Typography } from '@mui/material';
import { Tooltip, Typography, useTheme } from '@mui/material';
import { formatDateYMD } from 'utils/formatDate';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { useLocationSettings } from 'hooks/useLocationSettings';
@ -13,24 +13,37 @@ export const FeatureArchivedCell: VFC<IFeatureArchivedCellProps> = ({
value: archivedAt,
}) => {
const { locationSettings } = useLocationSettings();
const theme = useTheme();
if (!archivedAt) return <TextCell />;
if (!archivedAt)
return (
<TextCell>
<Typography
variant="body2"
color={theme.palette.text.secondary}
>
not available
</Typography>
</TextCell>
);
return (
<TextCell>
{archivedAt && (
<Tooltip
title={`Archived on: ${formatDateYMD(
archivedAt,
locationSettings.locale
)}`}
arrow
>
<Typography noWrap variant="body2" data-loading>
<TimeAgo date={new Date(archivedAt)} />
</Typography>
</Tooltip>
)}
<Tooltip
title={`Archived on: ${formatDateYMD(
archivedAt,
locationSettings.locale
)}`}
arrow
>
<Typography noWrap variant="body2" data-loading>
<TimeAgo
date={new Date(archivedAt)}
title=""
live={false}
/>
</Typography>
</Tooltip>
</TextCell>
);
};

View File

@ -19,7 +19,7 @@ export const ReviveArchivedFeatureCell: VFC<IReviveArchivedFeatureCell> = ({
onClick={onRevive}
projectId={project}
permission={UPDATE_FEATURE}
tooltipProps={{ title: 'Revive feature' }}
tooltipProps={{ title: 'Revive feature toggle' }}
>
<Undo />
</PermissionIconButton>

View File

@ -1,17 +1,18 @@
import { useFeaturesArchive } from '../../hooks/api/getters/useFeaturesArchive/useFeaturesArchive';
import { useFeaturesArchive } from 'hooks/api/getters/useFeaturesArchive/useFeaturesArchive';
import { ArchiveTable } from './ArchiveTable/ArchiveTable';
import { SortingRule } from 'react-table';
import { usePageTitle } from 'hooks/usePageTitle';
import { createLocalStorage } from 'utils/createLocalStorage';
const defaultSort: SortingRule<string> = { id: 'createdAt', desc: true };
const defaultSort: SortingRule<string> = { id: 'createdAt' };
const { value, setValue } = createLocalStorage(
'FeaturesArchiveTable:v1',
defaultSort
);
export const FeaturesArchiveTable = () => {
usePageTitle('Archived');
usePageTitle('Archive');
const {
archivedFeatures = [],
loading,
@ -20,7 +21,7 @@ export const FeaturesArchiveTable = () => {
return (
<ArchiveTable
title="Archived"
title="Archive"
archivedFeatures={archivedFeatures}
loading={loading}
storedParams={value}

View File

@ -1,9 +1,9 @@
import { ArchiveTable } from './ArchiveTable/ArchiveTable';
import { SortingRule } from 'react-table';
import { useProjectFeaturesArchive } from '../../hooks/api/getters/useProjectFeaturesArchive/useProjectFeaturesArchive';
import { useProjectFeaturesArchive } from 'hooks/api/getters/useProjectFeaturesArchive/useProjectFeaturesArchive';
import { createLocalStorage } from 'utils/createLocalStorage';
const defaultSort: SortingRule<string> = { id: 'archivedAt', desc: true };
const defaultSort: SortingRule<string> = { id: 'archivedAt' };
interface IProjectFeaturesTable {
projectId: string;
@ -25,7 +25,7 @@ export const ProjectFeaturesArchiveTable = ({
return (
<ArchiveTable
title="Project Features Archive"
title="Project archive"
archivedFeatures={archivedFeatures}
loading={loading}
storedParams={value}

View File

@ -23,7 +23,6 @@ const BreadcrumbNav = () => {
item !== 'logs' &&
item !== 'metrics' &&
item !== 'copy' &&
item !== 'strategies' &&
item !== 'features' &&
item !== 'features2' &&
item !== 'create-toggle' &&

View File

@ -1,5 +1,5 @@
import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
import { DialogContentText } from '@mui/material';
import { Typography } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import React from 'react';
@ -25,14 +25,13 @@ export const FeatureStaleDialog = ({
const { patchFeatureToggle } = useFeatureApi();
const toggleToStaleContent = (
<DialogContentText>
Setting a toggle to stale marks it for cleanup
</DialogContentText>
<Typography>Setting a toggle to stale marks it for cleanup</Typography>
);
const toggleToActiveContent = (
<DialogContentText>
<Typography>
Setting a toggle to active marks it as in active use
</DialogContentText>
</Typography>
);
const toggleActionText = isStale ? 'active' : 'stale';
@ -68,17 +67,15 @@ export const FeatureStaleDialog = ({
open={isOpen}
secondaryButtonText={'Cancel'}
primaryButtonText={`Flip to ${toggleActionText}`}
title={`Set feature status to ${toggleActionText}`}
title={`Set feature state to ${toggleActionText}`}
onClick={onSubmit}
onClose={onClose}
>
<>
<ConditionallyRender
condition={isStale}
show={toggleToActiveContent}
elseShow={toggleToStaleContent}
/>
</>
<ConditionallyRender
condition={isStale}
show={toggleToActiveContent}
elseShow={toggleToStaleContent}
/>
</Dialogue>
);
};

View File

@ -17,7 +17,6 @@ export const HelpIcon = ({ tooltip, style }: IHelpIconProps) => {
className={styles.container}
style={style}
tabIndex={0}
role="tooltip"
aria-label="Help"
>
<Info className={styles.icon} />

View File

@ -5,11 +5,11 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { Typography } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { IInstanceStatus, InstanceState } from 'interfaces/instance';
import { IInstanceStatus } from 'interfaces/instance';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import AccessContext from 'contexts/AccessContext';
import useInstanceStatusApi from 'hooks/api/actions/useInstanceStatusApi/useInstanceStatusApi';
import { hasTrialExpired } from 'utils/instanceTrial';
import { trialHasExpired, canExtendTrial } from 'utils/instanceTrial';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
@ -24,16 +24,25 @@ const TrialDialog: VFC<ITrialDialogProps> = ({
}) => {
const { hasAccess } = useContext(AccessContext);
const navigate = useNavigate();
const trialHasExpired = hasTrialExpired(instanceStatus);
const [dialogOpen, setDialogOpen] = useState(trialHasExpired);
const expired = trialHasExpired(instanceStatus);
const [dialogOpen, setDialogOpen] = useState(expired);
const onClose = (event: React.SyntheticEvent, muiCloseReason?: string) => {
if (!muiCloseReason) {
setDialogOpen(false);
if (canExtendTrial(instanceStatus)) {
onExtendTrial().catch(console.error);
}
}
};
useEffect(() => {
setDialogOpen(trialHasExpired);
setDialogOpen(expired);
const interval = setInterval(() => {
setDialogOpen(trialHasExpired);
setDialogOpen(expired);
}, 60000);
return () => clearInterval(interval);
}, [trialHasExpired]);
}, [expired]);
if (hasAccess(ADMIN)) {
return (
@ -41,23 +50,15 @@ const TrialDialog: VFC<ITrialDialogProps> = ({
open={dialogOpen}
primaryButtonText="Upgrade trial"
secondaryButtonText={
instanceStatus?.trialExtended
? 'Remind me later'
: 'Extend trial (5 days)'
canExtendTrial(instanceStatus)
? 'Extend trial (5 days)'
: 'Remind me later'
}
onClick={() => {
navigate('/admin/billing');
setDialogOpen(false);
}}
onClose={(_: any, reason?: string) => {
if (
reason !== 'backdropClick' &&
reason !== 'escapeKeyDown'
) {
onExtendTrial();
setDialogOpen(false);
}
}}
onClose={onClose}
title={`Your free ${instanceStatus.plan} trial has expired!`}
>
<Typography>
@ -92,16 +93,11 @@ export const InstanceStatus: FC = ({ children }) => {
const { setToastApiError } = useToast();
const onExtendTrial = async () => {
if (
instanceStatus?.state === InstanceState.TRIAL &&
!instanceStatus?.trialExtended
) {
try {
await extendTrial();
await refetchInstanceStatus();
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
try {
await extendTrial();
await refetchInstanceStatus();
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};

View File

@ -2,7 +2,7 @@ import { InstanceStatusBar } from 'component/common/InstanceStatus/InstanceStatu
import { InstancePlan, InstanceState } from 'interfaces/instance';
import { render } from 'utils/testRenderer';
import { screen } from '@testing-library/react';
import { addDays } from 'date-fns';
import { addDays, subDays } from 'date-fns';
import { INSTANCE_STATUS_BAR_ID } from 'utils/testIds';
import { UNKNOWN_INSTANCE_STATUS } from 'hooks/api/getters/useInstanceStatus/useInstanceStatus';
@ -14,7 +14,22 @@ test('InstanceStatusBar should be hidden by default', async () => {
).not.toBeInTheDocument();
});
test('InstanceStatusBar should be hidden when the trial is far from expired', async () => {
test('InstanceStatusBar should be hidden when state is active', async () => {
render(
<InstanceStatusBar
instanceStatus={{
plan: InstancePlan.PRO,
state: InstanceState.ACTIVE,
}}
/>
);
expect(
screen.queryByTestId(INSTANCE_STATUS_BAR_ID)
).not.toBeInTheDocument();
});
test('InstanceStatusBar should warn when the trial is far from expired', async () => {
render(
<InstanceStatusBar
instanceStatus={{
@ -25,9 +40,8 @@ test('InstanceStatusBar should be hidden when the trial is far from expired', as
/>
);
expect(
screen.queryByTestId(INSTANCE_STATUS_BAR_ID)
).not.toBeInTheDocument();
expect(screen.getByTestId(INSTANCE_STATUS_BAR_ID)).toBeInTheDocument();
expect(await screen.findByTestId(INSTANCE_STATUS_BAR_ID)).toMatchSnapshot();
});
test('InstanceStatusBar should warn when the trial is about to expire', async () => {
@ -45,13 +59,41 @@ test('InstanceStatusBar should warn when the trial is about to expire', async ()
expect(await screen.findByTestId(INSTANCE_STATUS_BAR_ID)).toMatchSnapshot();
});
test('InstanceStatusBar should warn when the trial has expired', async () => {
test('InstanceStatusBar should warn when trialExpiry has passed', async () => {
render(
<InstanceStatusBar
instanceStatus={{
plan: InstancePlan.PRO,
state: InstanceState.TRIAL,
trialExpiry: new Date().toISOString(),
trialExpiry: subDays(new Date(), 1).toISOString(),
}}
/>
);
expect(screen.getByTestId(INSTANCE_STATUS_BAR_ID)).toBeInTheDocument();
expect(await screen.findByTestId(INSTANCE_STATUS_BAR_ID)).toMatchSnapshot();
});
test('InstanceStatusBar should warn when the trial has expired', async () => {
render(
<InstanceStatusBar
instanceStatus={{
plan: InstancePlan.PRO,
state: InstanceState.EXPIRED,
}}
/>
);
expect(screen.getByTestId(INSTANCE_STATUS_BAR_ID)).toBeInTheDocument();
expect(await screen.findByTestId(INSTANCE_STATUS_BAR_ID)).toMatchSnapshot();
});
test('InstanceStatusBar should warn when the trial has churned', async () => {
render(
<InstanceStatusBar
instanceStatus={{
plan: InstancePlan.PRO,
state: InstanceState.CHURNED,
}}
/>
);

View File

@ -6,11 +6,12 @@ import { useNavigate } from 'react-router-dom';
import { useContext } from 'react';
import AccessContext from 'contexts/AccessContext';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import {
hasTrialExpired,
formatTrialExpirationWarning,
trialHasExpired,
trialExpiresSoon,
isTrialInstance,
} from 'utils/instanceTrial';
import { formatDistanceToNowStrict, parseISO } from 'date-fns';
const StyledWarningBar = styled('aside')(({ theme }) => ({
position: 'relative',
@ -61,58 +62,75 @@ interface IInstanceStatusBarProps {
export const InstanceStatusBar = ({
instanceStatus,
}: IInstanceStatusBarProps) => {
const { hasAccess } = useContext(AccessContext);
const trialHasExpired = hasTrialExpired(instanceStatus);
const trialExpirationWarning = formatTrialExpirationWarning(instanceStatus);
if (trialHasExpired) {
return (
<StyledWarningBar data-testid={INSTANCE_STATUS_BAR_ID}>
<StyledWarningIcon />
<Typography
sx={theme => ({
fontSize: theme.fontSizes.smallBody,
})}
>
<strong>Warning!</strong> Your free {instanceStatus.plan}{' '}
trial has expired. <strong>Upgrade trial</strong> otherwise
your <strong>account will be deleted.</strong>
</Typography>
<ConditionallyRender
condition={hasAccess(ADMIN)}
show={<UpgradeButton />}
/>
</StyledWarningBar>
);
if (trialHasExpired(instanceStatus)) {
return <StatusBarExpired instanceStatus={instanceStatus} />;
}
if (trialExpirationWarning) {
return (
<StyledInfoBar data-testid={INSTANCE_STATUS_BAR_ID}>
<StyledInfoIcon />
<Typography
sx={theme => ({
fontSize: theme.fontSizes.smallBody,
})}
>
<strong>Heads up!</strong> You have{' '}
<strong>{trialExpirationWarning}</strong> left of your free{' '}
{instanceStatus.plan} trial.
</Typography>
<ConditionallyRender
condition={hasAccess(ADMIN)}
show={<UpgradeButton />}
/>
</StyledInfoBar>
);
if (trialExpiresSoon(instanceStatus)) {
return <StatusBarExpiresSoon instanceStatus={instanceStatus} />;
}
if (isTrialInstance(instanceStatus)) {
return <StatusBarExpiresLater instanceStatus={instanceStatus} />;
}
return null;
};
const UpgradeButton = () => {
const StatusBarExpired = ({ instanceStatus }: IInstanceStatusBarProps) => {
return (
<StyledWarningBar data-testid={INSTANCE_STATUS_BAR_ID}>
<StyledWarningIcon />
<Typography sx={theme => ({ fontSize: theme.fontSizes.smallBody })}>
<strong>Warning!</strong> Your free {instanceStatus.plan} trial
has expired. <strong>Upgrade trial</strong> otherwise your{' '}
<strong>account will be deleted.</strong>
</Typography>
<BillingLink />
</StyledWarningBar>
);
};
const StatusBarExpiresSoon = ({ instanceStatus }: IInstanceStatusBarProps) => {
const timeRemaining = formatDistanceToNowStrict(
parseISO(instanceStatus.trialExpiry!),
{ roundingMethod: 'floor' }
);
return (
<StyledInfoBar data-testid={INSTANCE_STATUS_BAR_ID}>
<StyledInfoIcon />
<Typography sx={theme => ({ fontSize: theme.fontSizes.smallBody })}>
<strong>Heads up!</strong> You have{' '}
<strong>{timeRemaining}</strong> left of your free{' '}
{instanceStatus.plan} trial.
</Typography>
<BillingLink />
</StyledInfoBar>
);
};
const StatusBarExpiresLater = ({ instanceStatus }: IInstanceStatusBarProps) => {
return (
<StyledInfoBar data-testid={INSTANCE_STATUS_BAR_ID}>
<StyledInfoIcon />
<Typography sx={theme => ({ fontSize: theme.fontSizes.smallBody })}>
<strong>Heads up!</strong> You're currently on a free{' '}
{instanceStatus.plan} trial account.
</Typography>
<BillingLink />
</StyledInfoBar>
);
};
const BillingLink = () => {
const { hasAccess } = useContext(AccessContext);
const navigate = useNavigate();
if (!hasAccess(ADMIN)) {
return null;
}
return (
<StyledButton
onClick={() => navigate('/admin/billing')}

View File

@ -1,5 +1,45 @@
// Vitest Snapshot v1
exports[`InstanceStatusBar should warn when the trial has churned 1`] = `
<aside
class="mui-jmsogz"
data-testid="INSTANCE_STATUS_BAR_ID"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-prk1jy-MuiSvgIcon-root"
data-testid="WarningAmberIcon"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M12 5.99 19.53 19H4.47L12 5.99M12 2 1 21h22L12 2z"
/>
<path
d="M13 16h-2v2h2zm0-6h-2v5h2z"
/>
</svg>
<p
class="MuiTypography-root MuiTypography-body1 mui-rviqjc-MuiTypography-root"
>
<strong>
Warning!
</strong>
Your free
Pro
trial has expired.
<strong>
Upgrade trial
</strong>
otherwise your
<strong>
account will be deleted.
</strong>
</p>
</aside>
`;
exports[`InstanceStatusBar should warn when the trial has expired 1`] = `
<aside
class="mui-jmsogz"
@ -27,12 +67,12 @@ exports[`InstanceStatusBar should warn when the trial has expired 1`] = `
</strong>
Your free
Pro
trial has expired.
trial has expired.
<strong>
Upgrade trial
</strong>
otherwise your
otherwise your
<strong>
account will be deleted.
</strong>
@ -74,3 +114,73 @@ exports[`InstanceStatusBar should warn when the trial is about to expire 1`] = `
</p>
</aside>
`;
exports[`InstanceStatusBar should warn when the trial is far from expired 1`] = `
<aside
class="mui-yx2rkt"
data-testid="INSTANCE_STATUS_BAR_ID"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-cle2im-MuiSvgIcon-root"
data-testid="InfoOutlinedIcon"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"
/>
</svg>
<p
class="MuiTypography-root MuiTypography-body1 mui-rviqjc-MuiTypography-root"
>
<strong>
Heads up!
</strong>
You're currently on a free
Pro
trial account.
</p>
</aside>
`;
exports[`InstanceStatusBar should warn when trialExpiry has passed 1`] = `
<aside
class="mui-jmsogz"
data-testid="INSTANCE_STATUS_BAR_ID"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-prk1jy-MuiSvgIcon-root"
data-testid="WarningAmberIcon"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M12 5.99 19.53 19H4.47L12 5.99M12 2 1 21h22L12 2z"
/>
<path
d="M13 16h-2v2h2zm0-6h-2v5h2z"
/>
</svg>
<p
class="MuiTypography-root MuiTypography-body1 mui-rviqjc-MuiTypography-root"
>
<strong>
Warning!
</strong>
Your free
Pro
trial has expired.
<strong>
Upgrade trial
</strong>
otherwise your
<strong>
account will be deleted.
</strong>
</p>
</aside>
`;

View File

@ -26,7 +26,7 @@ const StyledDivider = styled(Divider)(({ theme }) => ({
}));
interface IPageHeaderProps {
title: string;
title?: string;
titleElement?: ReactNode;
subtitle?: string;
variant?: TypographyProps['variant'];

View File

@ -72,7 +72,7 @@ const PermissionButton: React.FC<IPermissionButtonProps> = ({
<Button
onClick={onClick}
disabled={disabled || !access}
aria-describedby={id}
aria-labelledby={id}
variant={variant}
color={color}
{...rest}

View File

@ -61,7 +61,7 @@ const PermissionIconButton = ({
arrow
onClick={e => e.preventDefault()}
>
<div id={id} role="tooltip">
<div id={id}>
<IconButton
{...rest}
disabled={!access || disabled}

View File

@ -16,7 +16,7 @@ export const useStyles = makeStyles()(theme => ({
display: 'flex',
alignItems: 'center',
backgroundColor: theme.palette.background.paper,
border: `1px solid ${theme.palette.grey[300]}`,
border: `1px solid ${theme.palette.grey[500]}`,
borderRadius: theme.shape.borderRadiusExtraLarge,
padding: '3px 5px 3px 12px',
width: '100%',

View File

@ -0,0 +1,16 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(() => ({
row: {
position: 'absolute',
width: '100%',
},
cell: {
alignItems: 'center',
display: 'flex',
flexShrink: 0,
'& > *': {
flexGrow: 1,
},
},
}));

View File

@ -0,0 +1,101 @@
import { useMemo, VFC } from 'react';
import { useTheme } from '@mui/material';
import {
SortableTableHeader,
Table,
TableCell,
TableBody,
TableRow,
} from 'component/common/Table';
import { useVirtualizedRange } from 'hooks/useVirtualizedRange';
import { useStyles } from './VirtualizedTable.styles';
import { HeaderGroup, Row } from 'react-table';
interface IVirtualizedTableProps {
rowHeight?: number;
headerGroups: HeaderGroup<object>[];
rows: Row<object>[];
prepareRow: (row: Row) => void;
}
/**
* READ BEFORE USE
*
* Virtualized tables require some setup.
* With this component all but one columns are fixed width, and one fills remaining space.
* Add `maxWidth` to columns that will be static in width, and `minWidth` to the one that should grow.
*
* Remember to add `useFlexLayout` to `useTable`
* (more at: https://react-table-v7.tanstack.com/docs/api/useFlexLayout)
*/
export const VirtualizedTable: VFC<IVirtualizedTableProps> = ({
rowHeight: rowHeightOverride,
headerGroups,
rows,
prepareRow,
}) => {
const { classes } = useStyles();
const theme = useTheme();
const rowHeight = useMemo(
() => rowHeightOverride || theme.shape.tableRowHeight,
[rowHeightOverride, theme.shape.tableRowHeight]
);
const [firstRenderedIndex, lastRenderedIndex] =
useVirtualizedRange(rowHeight);
const tableHeight = useMemo(
() => rowHeight * rows.length + theme.shape.tableRowHeightCompact,
[rowHeight, rows.length, theme.shape.tableRowHeightCompact]
);
return (
<Table
role="table"
rowHeight={rowHeight}
style={{ height: tableHeight }}
>
<SortableTableHeader headerGroups={headerGroups} flex />
<TableBody role="rowgroup">
{rows.map((row, index) => {
const top =
index * rowHeight + theme.shape.tableRowHeightCompact;
const isVirtual =
index < firstRenderedIndex || index > lastRenderedIndex;
if (isVirtual) {
return null;
}
prepareRow(row);
return (
<TableRow
hover
{...row.getRowProps()}
key={row.id}
className={classes.row}
style={{ display: 'flex', top }}
>
{row.cells.map(cell => (
<TableCell
{...cell.getCellProps({
style: {
flex: cell.column.minWidth
? '1 0 auto'
: undefined,
},
})}
className={classes.cell}
>
{cell.render('Cell')}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
);
};

View File

@ -3,7 +3,7 @@ import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
container: {
display: 'flex',
justifyContent: 'flex-end',
justifyContent: 'center',
alignItems: 'center',
padding: theme.spacing(0, 1.5),
},

View File

@ -11,12 +11,10 @@ interface IFeatureNameCellProps {
};
}
export const FeatureNameCell: VFC<IFeatureNameCellProps> = ({ row }) => {
return (
<LinkCell
title={row.original.name}
subtitle={row.original.description}
to={`/projects/${row.original.project}/features/${row.original.name}`}
/>
);
};
export const FeatureNameCell: VFC<IFeatureNameCellProps> = ({ row }) => (
<LinkCell
title={row.original.name}
subtitle={row.original.description}
to={`/projects/${row.original.project}/features/${row.original.name}`}
/>
);

View File

@ -66,7 +66,6 @@ const Wrapper: FC<{ unit?: string; tooltip: string }> = ({
<div className={styles.container}>
<Tooltip title={tooltip} arrow describeChild>
<div
role="tooltip"
className={styles.box}
style={{ background: getColor(unit) }}
data-loading

View File

@ -3,3 +3,4 @@ export { TableBody, TableRow } from '@mui/material';
export { Table } from './Table/Table';
export { TableCell } from './TableCell/TableCell';
export { TablePlaceholder } from './TablePlaceholder/TablePlaceholder';
export { VirtualizedTable } from './VirtualizedTable/VirtualizedTable';

View File

@ -28,6 +28,7 @@ export const useStyles = makeStyles()(theme => ({
},
textContainer: {
marginLeft: '1rem',
wordBreak: 'break-word',
},
headerStyles: {
fontWeight: 'normal',

View File

@ -4,11 +4,8 @@ import {
UPDATE_ENVIRONMENT,
} from 'component/providers/AccessProvider/permissions';
import { Edit, Delete } from '@mui/icons-material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { IconButton, Tooltip } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import AccessContext from 'contexts/AccessContext';
import { useContext, useState } from 'react';
import { useState } from 'react';
import { IEnvironment } from 'interfaces/environments';
import { formatUnknownError } from 'utils/formatUnknownError';
import EnvironmentToggleConfirm from '../../EnvironmentToggleConfirm/EnvironmentToggleConfirm';
@ -17,8 +14,8 @@ import useEnvironmentApi from 'hooks/api/actions/useEnvironmentApi/useEnvironmen
import useProjectRolePermissions from 'hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions';
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
import useToast from 'hooks/useToast';
import { useId } from 'hooks/useId';
import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
interface IEnvironmentTableActionsProps {
environment: IEnvironment;
@ -28,9 +25,6 @@ export const EnvironmentActionCell = ({
environment,
}: IEnvironmentTableActionsProps) => {
const navigate = useNavigate();
const { hasAccess } = useContext(AccessContext);
const updatePermission = hasAccess(UPDATE_ENVIRONMENT);
const { setToastApiError, setToastData } = useToast();
const { refetchEnvironments } = useEnvironments();
const { refetch: refetchPermissions } = useProjectRolePermissions();
@ -95,84 +89,46 @@ export const EnvironmentActionCell = ({
}
};
const toggleIconTooltip = environment.enabled
? `Disable environment ${environment.name}`
: `Enable environment ${environment.name}`;
const editId = useId();
const deleteId = useId();
return (
<ActionCell>
<ConditionallyRender
condition={updatePermission}
show={
<>
<Tooltip title={toggleIconTooltip} arrow describeChild>
<PermissionSwitch
permission={UPDATE_ENVIRONMENT}
checked={environment.enabled}
onClick={() => setToggleModal(true)}
disabled={environment.protected}
/>
</Tooltip>
<ActionCell.Divider />
</>
}
/>
<ConditionallyRender
condition={updatePermission}
show={
<Tooltip
title={
environment.protected
? 'You cannot edit protected environment'
: 'Edit environment'
}
arrow
>
<span id={editId}>
<IconButton
aria-describedby={editId}
disabled={environment.protected}
onClick={() => {
navigate(
`/environments/${environment.name}`
);
}}
size="large"
>
<Edit />
</IconButton>
</span>
</Tooltip>
}
/>
<ConditionallyRender
condition={hasAccess(DELETE_ENVIRONMENT)}
show={
<Tooltip
title={
environment.protected
? 'You cannot delete protected environment'
: 'Delete environment'
}
describeChild
arrow
>
<span id={deleteId}>
<IconButton
aria-describedby={deleteId}
disabled={environment.protected}
onClick={() => setDeleteModal(true)}
size="large"
>
<Delete />
</IconButton>
</span>
</Tooltip>
<PermissionSwitch
permission={UPDATE_ENVIRONMENT}
checked={environment.enabled}
disabled={environment.protected}
tooltip={
environment.enabled
? `Disable environment ${environment.name}`
: `Enable environment ${environment.name}`
}
onClick={() => setToggleModal(true)}
/>
<ActionCell.Divider />
<PermissionIconButton
permission={UPDATE_ENVIRONMENT}
disabled={environment.protected}
size="large"
tooltipProps={{
title: environment.protected
? 'You cannot edit protected environment'
: 'Edit environment',
}}
onClick={() => navigate(`/environments/${environment.name}`)}
>
<Edit />
</PermissionIconButton>
<PermissionIconButton
permission={DELETE_ENVIRONMENT}
disabled={environment.protected}
size="large"
tooltipProps={{
title: environment.protected
? 'You cannot delete protected environment'
: 'Delete environment',
}}
onClick={() => setDeleteModal(true)}
>
<Delete />
</PermissionIconButton>
<EnvironmentDeleteConfirm
env={environment}
setDeldialogue={setDeleteModal}

View File

@ -3,6 +3,9 @@ import { Row } from 'react-table';
import { TableRow } from '@mui/material';
import { TableCell } from 'component/common/Table';
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { UPDATE_ENVIRONMENT } from 'component/providers/AccessProvider/permissions';
import AccessContext from 'contexts/AccessContext';
import { useContext } from 'react';
interface IEnvironmentRowProps {
row: Row;
@ -10,9 +13,10 @@ interface IEnvironmentRowProps {
}
export const EnvironmentRow = ({ row, moveListItem }: IEnvironmentRowProps) => {
const { hasAccess } = useContext(AccessContext);
const dragItemRef = useDragItem(row.index, moveListItem);
const { searchQuery } = useSearchHighlightContext();
const draggable = !searchQuery;
const draggable = !searchQuery && hasAccess(UPDATE_ENVIRONMENT);
return (
<TableRow hover ref={draggable ? dragItemRef : undefined}>

View File

@ -1,10 +1,4 @@
import {
useState,
useRef,
useEffect,
FormEventHandler,
ChangeEventHandler,
} from 'react';
import { useState, FormEventHandler, ChangeEventHandler } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
Button,
@ -31,16 +25,11 @@ export const CopyFeatureToggle = () => {
const [nameError, setNameError] = useState<string | undefined>();
const [newToggleName, setNewToggleName] = useState<string>();
const { cloneFeatureToggle, validateFeatureToggleName } = useFeatureApi();
const inputRef = useRef<HTMLInputElement>();
const featureId = useRequiredPathParam('featureId');
const projectId = useRequiredPathParam('projectId');
const { feature } = useFeature(projectId, featureId);
const navigate = useNavigate();
useEffect(() => {
inputRef.current?.focus();
}, []);
const setValue: ChangeEventHandler<HTMLInputElement> = event => {
const value = trim(event.target.value);
setNewToggleName(value);
@ -53,17 +42,20 @@ export const CopyFeatureToggle = () => {
const onValidateName = async () => {
try {
await validateFeatureToggleName(newToggleName);
setNameError(undefined);
return true;
} catch (error) {
setNameError(formatUnknownError(error));
}
return false;
};
const onSubmit: FormEventHandler = async event => {
event.preventDefault();
if (nameError) {
const isValidName = await onValidateName();
if (!isValidName) {
return;
}
@ -113,8 +105,8 @@ export const CopyFeatureToggle = () => {
helperText={nameError}
variant="outlined"
size="small"
inputRef={inputRef}
required
aria-required
autoFocus
/>
<FormControlLabel
control={

View File

@ -76,7 +76,7 @@ const CreateFeature = () => {
return (
<FormTemplate
loading={loading}
title="Create Feature toggle"
title="Create feature toggle"
description="Feature toggles support different use cases, each with their own specific needs such as simple static routing or more complex routing.
The feature toggle is disabled when created and you decide when to enable"
documentationLink="https://docs.getunleash.io/advanced/feature_toggle_types"
@ -102,7 +102,7 @@ const CreateFeature = () => {
clearErrors={clearErrors}
>
<CreateButton
name="Feature"
name="feature toggle"
permission={CREATE_FEATURE}
projectId={project}
data-testid={CF_CREATE_BTN_ID}

View File

@ -33,10 +33,11 @@ export const useStyles = makeStyles()(theme => ({
},
inputDescription: {
marginBottom: '0.5rem',
color: theme.palette.text.secondary,
},
typeDescription: {
fontSize: theme.fontSizes.smallBody,
color: theme.palette.grey[600],
color: theme.palette.text.secondary,
top: '-13px',
position: 'relative',
},

View File

@ -0,0 +1,20 @@
import { formatAddStrategyApiCode } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate';
test('formatAddStrategyApiCode', () => {
expect(
formatAddStrategyApiCode(
'projectId',
'featureId',
'environmentId',
{ id: 'strategyId' },
'unleashUrl'
)
).toMatchInlineSnapshot(`
"curl --location --request POST 'unleashUrl/api/admin/projects/projectId/features/featureId/environments/environmentId/strategies' \\\\
--header 'Authorization: INSERT_API_KEY' \\\\
--header 'Content-Type: application/json' \\\\
--data-raw '{
\\"id\\": \\"strategyId\\"
}'"
`);
});

View File

@ -121,7 +121,7 @@ export const formatCreateStrategyPath = (
return `/projects/${projectId}/features/${featureId}/strategies/create?${params}`;
};
const formatAddStrategyApiCode = (
export const formatAddStrategyApiCode = (
projectId: string,
featureId: string,
environmentId: string,
@ -132,7 +132,7 @@ const formatAddStrategyApiCode = (
return '';
}
const url = `${unleashUrl}/api/admin/projects/${projectId}/features/${featureId}/${environmentId}/development/strategies`;
const url = `${unleashUrl}/api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/strategies`;
const payload = JSON.stringify(strategy, undefined, 2);
return `curl --location --request POST '${url}' \\

View File

@ -0,0 +1,20 @@
import { formatUpdateStrategyApiCode } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
test('formatUpdateStrategyApiCode', () => {
expect(
formatUpdateStrategyApiCode(
'projectId',
'featureId',
'environmentId',
{ id: 'strategyId' },
'unleashUrl'
)
).toMatchInlineSnapshot(`
"curl --location --request PUT 'unleashUrl/api/admin/projects/projectId/features/featureId/environments/environmentId/strategies/strategyId' \\\\
--header 'Authorization: INSERT_API_KEY' \\\\
--header 'Content-Type: application/json' \\\\
--data-raw '{
\\"id\\": \\"strategyId\\"
}'"
`);
});

View File

@ -148,7 +148,7 @@ export const formatEditStrategyPath = (
return `/projects/${projectId}/features/${featureId}/strategies/edit?${params}`;
};
const formatUpdateStrategyApiCode = (
export const formatUpdateStrategyApiCode = (
projectId: string,
featureId: string,
environmentId: string,
@ -159,7 +159,7 @@ const formatUpdateStrategyApiCode = (
return '';
}
const url = `${unleashUrl}/api/admin/projects/${projectId}/features/${featureId}/${environmentId}/development/strategies/${strategy.id}`;
const url = `${unleashUrl}/api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/strategies/${strategy.id}`;
const payload = JSON.stringify(strategy, undefined, 2);
return `curl --location --request PUT '${url}' \\

View File

@ -3,7 +3,6 @@ import {
formatStrategyName,
} from 'utils/strategyNames';
import { styled, Tooltip } from '@mui/material';
import { useId } from 'hooks/useId';
interface IFeatureStrategyIconProps {
strategyName: string;
@ -13,14 +12,11 @@ export const FeatureStrategyIcon = ({
strategyName,
}: IFeatureStrategyIconProps) => {
const Icon = getFeatureStrategyIcon(strategyName);
const id = useId();
return (
<StyledIcon>
<Tooltip title={formatStrategyName(strategyName)} arrow>
<div id={id} role="tooltip">
<Icon aria-labelledby={id} />
</div>
<Icon />
</Tooltip>
</StyledIcon>
);

View File

@ -40,7 +40,7 @@ export const FeatureStrategyMenu = ({
projectId={projectId}
environmentId={environmentId}
onClick={onClick}
aria-describedby={popoverId}
aria-labelledby={popoverId}
variant={variant}
>
{label}

View File

@ -196,7 +196,7 @@ export const FeatureToggleListItem = memo<IFeatureToggleListItemProps>(
!projectExists()
}
onClick={reviveFeature}
tooltipProps={{ title: 'Revive feature' }}
tooltipProps={{ title: 'Revive feature toggle' }}
>
<Undo />
</PermissionIconButton>

View File

@ -2,14 +2,7 @@ import { useEffect, useMemo, useState, VFC } from 'react';
import { Link, useMediaQuery, useTheme } from '@mui/material';
import { Link as RouterLink, useSearchParams } from 'react-router-dom';
import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table';
import {
Table,
SortableTableHeader,
TableBody,
TableCell,
TableRow,
TablePlaceholder,
} from 'component/common/Table';
import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
import { useFeatures } from 'hooks/api/getters/useFeatures/useFeatures';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
@ -22,11 +15,9 @@ import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { sortTypes } from 'utils/sortTypes';
import { createLocalStorage } from 'utils/createLocalStorage';
import { useVirtualizedRange } from 'hooks/useVirtualizedRange';
import { FeatureSchema } from 'openapi';
import { CreateFeatureButton } from '../CreateFeatureButton/CreateFeatureButton';
import { FeatureStaleCell } from './FeatureStaleCell/FeatureStaleCell';
import { useStyles } from './styles';
import { useSearch } from 'hooks/useSearch';
import { Search } from 'component/common/Search/Search';
@ -108,8 +99,6 @@ const { value: storedParams, setValue: setStoredParams } = createLocalStorage(
export const FeatureToggleListTable: VFC = () => {
const theme = useTheme();
const rowHeight = theme.shape.tableRowHeight;
const { classes } = useStyles();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg'));
const { features = [], loading } = useFeatures();
@ -143,8 +132,6 @@ export const FeatureToggleListTable: VFC = () => {
);
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
@ -191,12 +178,6 @@ export const FeatureToggleListTable: VFC = () => {
setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false });
}, [sortBy, searchValue, setSearchParams]);
const [firstRenderedIndex, lastRenderedIndex] =
useVirtualizedRange(rowHeight);
const tableHeight =
rowHeight * rows.length + theme.shape.tableRowHeightCompact;
return (
<PageContent
isLoading={loading}
@ -253,54 +234,11 @@ export const FeatureToggleListTable: VFC = () => {
}
>
<SearchHighlightProvider value={getSearchText(searchValue)}>
<Table
{...getTableProps()}
rowHeight={rowHeight}
style={{ height: tableHeight }}
>
<SortableTableHeader headerGroups={headerGroups} flex />
<TableBody {...getTableBodyProps()}>
{rows.map((row, index) => {
const top =
index * rowHeight +
theme.shape.tableRowHeightCompact;
const isVirtual =
index < firstRenderedIndex ||
index > lastRenderedIndex;
if (isVirtual) {
return null;
}
prepareRow(row);
return (
<TableRow
hover
{...row.getRowProps()}
key={row.id}
className={classes.row}
style={{ display: 'flex', top }}
>
{row.cells.map(cell => (
<TableCell
{...cell.getCellProps({
style: {
flex: cell.column.minWidth
? '1 0 auto'
: undefined,
},
})}
className={classes.cell}
>
{cell.render('Cell')}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
<VirtualizedTable
rows={rows}
headerGroups={headerGroups}
prepareRow={prepareRow}
/>
</SearchHighlightProvider>
<ConditionallyRender
condition={rows.length === 0}

View File

@ -1,50 +0,0 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
actionsContainer: {
display: 'flex',
alignItems: 'center',
},
listParagraph: {
textAlign: 'center',
},
searchBarContainer: {
marginBottom: '2rem',
display: 'flex',
gap: '1rem',
justifyContent: 'space-between',
alignItems: 'center',
[theme.breakpoints.down('sm')]: {
display: 'block',
},
'&.dense': {
marginBottom: '1rem',
},
},
searchBar: {
minWidth: '450px',
[theme.breakpoints.down('sm')]: {
minWidth: '100%',
},
},
emptyStateListItem: {
border: `2px dashed ${theme.palette.grey[100]}`,
padding: '0.8rem',
textAlign: 'center',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
},
row: {
position: 'absolute',
width: '100%',
},
cell: {
alignItems: 'center',
display: 'flex',
flexShrink: 0,
'& > *': {
flexGrow: 1,
},
},
}));

View File

@ -4,7 +4,7 @@ export const useStyles = makeStyles()(theme => ({
title: {
margin: 0,
marginBottom: '.5rem',
fontSize: theme.fontSizes.smallerBody,
fontSize: theme.fontSizes.smallBody,
fontWeight: theme.fontWeight.thin,
color: theme.palette.grey[800],
},

View File

@ -2,8 +2,7 @@ import { FeatureMetricsTable } from '../FeatureMetricsTable/FeatureMetricsTable'
import { IFeatureMetricsRaw } from 'interfaces/featureToggle';
import { FeatureMetricsStatsRaw } from '../FeatureMetricsStats/FeatureMetricsStatsRaw';
import { FeatureMetricsChart } from '../FeatureMetricsChart/FeatureMetricsChart';
import { FeatureMetricsEmpty } from '../FeatureMetricsEmpty/FeatureMetricsEmpty';
import { Box } from '@mui/material';
import { Box, Typography } from '@mui/material';
import theme from 'themes/theme';
import { useId } from 'hooks/useId';
@ -22,7 +21,14 @@ export const FeatureMetricsContent = ({
if (metrics.length === 0) {
return (
<Box mt={6}>
<FeatureMetricsEmpty />
<Typography variant="body1" paragraph>
We have yet to receive any metrics for this feature toggle
in the selected time period.
</Typography>
<Typography variant="body1" paragraph>
Please note that, since the SDKs send metrics on an
interval, it might take some time before metrics appear.
</Typography>
</Box>
);
}

View File

@ -1,16 +0,0 @@
import { Typography } from '@mui/material';
export const FeatureMetricsEmpty = () => {
return (
<>
<Typography variant="body1" paragraph>
We have yet to receive any metrics for this feature toggle in
the selected time period.
</Typography>
<Typography variant="body1" paragraph>
Please note that, since the SDKs send metrics on an interval, it
might take some time before metrics appear.
</Typography>
</>
);
};

View File

@ -1,4 +1,4 @@
import { DialogContentText } from '@mui/material';
import { Typography } from '@mui/material';
import React, { useState } from 'react';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import Input from 'component/common/Input/Input';
@ -30,7 +30,7 @@ const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => {
const { addTagToFeature, loading } = useFeatureApi();
const { refetch } = useTags(featureId);
const [errors, setErrors] = useState({ tagError: '' });
const { setToastData, setToastApiError } = useToast();
const { setToastData } = useToast();
const [tag, setTag] = useState(DEFAULT_TAG);
const onCancel = () => {
@ -64,7 +64,6 @@ const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => {
});
} catch (error: unknown) {
const message = formatUnknownError(error);
setToastApiError(message);
setErrors({ tagError: message });
}
};
@ -84,9 +83,9 @@ const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => {
formId={formId}
>
<>
<DialogContentText>
<Typography paragraph>
Tags allow you to group features together
</DialogContentText>
</Typography>
<form id={formId} onSubmit={onSubmit}>
<section className={styles.dialogFormContent}>
<TagSelect

View File

@ -144,7 +144,7 @@ export const FeatureView = () => {
permission={UPDATE_FEATURE}
projectId={projectId}
tooltipProps={{
title: 'Toggle stale status',
title: 'Toggle stale state',
}}
data-loading
>

View File

@ -27,8 +27,6 @@ export const useStyles = makeStyles()(theme => ({
position: 'absolute',
top: 0,
right: 0,
padding: '1rem',
cursor: 'pointer',
},
closeIcon: {
fontSize: '1.5rem',

View File

@ -1,4 +1,4 @@
import { Modal } from '@mui/material';
import { IconButton, Modal } from '@mui/material';
import React, { useContext } from 'react';
import {
feedbackCESContext,
@ -16,12 +16,6 @@ export const FeedbackCES = ({ state }: IFeedbackCESProps) => {
const { hideFeedbackCES } = useContext(feedbackCESContext);
const { classes: styles } = useStyles();
const closeButton = (
<button className={styles.close} onClick={hideFeedbackCES}>
<CloseOutlined titleAccess="Close" className={styles.closeIcon} />
</button>
);
const modalContent = state && (
<FeedbackCESForm state={state} onClose={hideFeedbackCES} />
);
@ -34,7 +28,14 @@ export const FeedbackCES = ({ state }: IFeedbackCESProps) => {
>
<div className={styles.overlay}>
<div className={styles.modal}>
{closeButton}
<div className={styles.close}>
<IconButton onClick={hideFeedbackCES} size="large">
<CloseOutlined
titleAccess="Close"
className={styles.closeIcon}
/>
</IconButton>
</div>
{modalContent}
</div>
</div>

View File

@ -14,7 +14,7 @@ export const useStyles = makeStyles()(theme => ({
all: 'unset',
display: 'block',
textAlign: 'center',
color: theme.palette.grey[600],
color: theme.palette.text.secondary,
},
subtitle: {
all: 'unset',

View File

@ -8,9 +8,9 @@ export const useStyles = makeStyles()(theme => ({
margin: '0 auto',
},
scoreHelp: {
width: '8rem',
width: '6.25rem',
whiteSpace: 'nowrap',
color: theme.palette.grey[600],
color: theme.palette.text.secondary,
'&:first-of-type': {
textAlign: 'right',
},

View File

@ -7,7 +7,7 @@ exports[`FeedbackCESForm 1`] = `
class="tss-fdcp7c-container"
>
<h1
class="tss-1a5bydb-title"
class="tss-iyd7t0-title"
>
Please help us improve
</h1>
@ -24,7 +24,7 @@ exports[`FeedbackCESForm 1`] = `
class="tss-io6e1g-scoreInput"
>
<span
class="tss-b4a690-scoreHelp"
class="tss-16omcck-scoreHelp"
>
Very difficult
</span>
@ -113,7 +113,7 @@ exports[`FeedbackCESForm 1`] = `
</span>
</label>
<span
class="tss-b4a690-scoreHelp"
class="tss-16omcck-scoreHelp"
>
Very easy
</span>

View File

@ -23,7 +23,6 @@ import { useAuthPermissions } from 'hooks/api/getters/useAuth/useAuthPermissions
import { useStyles } from './Header.styles';
import classNames from 'classnames';
import { useId } from 'hooks/useId';
import { useInstanceStatus } from 'hooks/api/getters/useInstanceStatus/useInstanceStatus';
import { IRoute } from 'interfaces/route';
const Header: VFC = () => {
@ -37,6 +36,7 @@ const Header: VFC = () => {
const { permissions } = useAuthPermissions();
const {
uiConfig: { links, name, flags },
isOss,
} = useUiConfig();
const smallScreen = useMediaQuery(theme.breakpoints.down('md'));
const { classes: styles } = useStyles();
@ -57,15 +57,18 @@ const Header: VFC = () => {
}
}, [permissions]);
const { isBilling } = useInstanceStatus();
const routes = getRoutes();
const filterByEnterprise = (route: IRoute): boolean => {
return !route.menu.isEnterprise || !isOss();
};
const filteredMainRoutes = {
mainNavRoutes: routes.mainNavRoutes.filter(filterByFlags(flags)),
mobileRoutes: routes.mobileRoutes.filter(filterByFlags(flags)),
adminRoutes: routes.adminRoutes
.filter(filterByFlags(flags))
.filter(filterByBilling(isBilling)),
.filter(filterByEnterprise),
};
if (smallScreen) {
@ -196,7 +199,4 @@ const Header: VFC = () => {
);
};
export const filterByBilling = (isBilling?: boolean) => (route: IRoute) =>
!route.menu.isBilling || isBilling;
export default Header;

View File

@ -411,10 +411,7 @@ exports[`returns all baseRoutes 1`] = `
},
{
"component": [Function],
"menu": {
"adminSettings": true,
"isBilling": true,
},
"menu": {},
"parent": "/admin",
"path": "/admin/billing",
"title": "Billing",
@ -422,7 +419,10 @@ exports[`returns all baseRoutes 1`] = `
},
{
"component": [Function],
"menu": {},
"menu": {
"adminSettings": true,
"isEnterprise": true,
},
"parent": "/admin",
"path": "/admin-invoices",
"title": "Invoices",

View File

@ -7,7 +7,6 @@ import Admin from 'component/admin';
import AdminApi from 'component/admin/api';
import AdminUsers from 'component/admin/users/UsersAdmin';
import { AuthSettings } from 'component/admin/auth/AuthSettings';
import { Billing } from 'component/admin/billing/Billing';
import Login from 'component/user/Login/Login';
import { C, EEA, P, RE, SE } from 'component/common/flags';
import { NewUser } from 'component/user/NewUser/NewUser';
@ -50,8 +49,9 @@ import { EditSegment } from 'component/segments/EditSegment/EditSegment';
import { IRoute } from 'interfaces/route';
import { EnvironmentTable } from 'component/environments/EnvironmentTable/EnvironmentTable';
import { SegmentTable } from 'component/segments/SegmentTable/SegmentTable';
import RedirectAdminInvoices from 'component/admin/billing/RedirectAdminInvoices/RedirectAdminInvoices';
import FlaggedBillingRedirect from 'component/admin/billing/FlaggedBillingRedirect/FlaggedBillingRedirect';
import { FeaturesArchiveTable } from '../archive/FeaturesArchiveTable';
import { Billing } from 'component/admin/billing/Billing';
export const routes: IRoute[] = [
// Splash
@ -462,15 +462,15 @@ export const routes: IRoute[] = [
title: 'Billing',
component: Billing,
type: 'protected',
menu: { adminSettings: true, isBilling: true },
menu: {},
},
{
path: '/admin-invoices',
parent: '/admin',
title: 'Invoices',
component: RedirectAdminInvoices,
component: FlaggedBillingRedirect,
type: 'protected',
menu: {},
menu: { adminSettings: true, isEnterprise: true },
},
{
path: '/admin',

View File

@ -33,34 +33,55 @@ const Project = () => {
const { isOss } = useUiConfig();
const basePath = `/projects/${projectId}`;
const projectName = project?.name || projectId;
const tabData = [
{
title: 'Overview',
component: <ProjectOverview projectId={projectId} />,
component: (
<ProjectOverview
projectId={projectId}
projectName={projectName}
/>
),
path: basePath,
name: 'overview',
},
{
title: 'Health',
component: <ProjectHealth projectId={projectId} />,
component: (
<ProjectHealth
projectId={projectId}
projectName={projectName}
/>
),
path: `${basePath}/health`,
name: 'health',
},
{
title: 'Access',
component: <ProjectAccess />,
component: <ProjectAccess projectName={projectName} />,
path: `${basePath}/access`,
name: 'access',
},
{
title: 'Environments',
component: <ProjectEnvironment projectId={projectId} />,
component: (
<ProjectEnvironment
projectId={projectId}
projectName={projectName}
/>
),
path: `${basePath}/environments`,
name: 'environments',
},
{
title: 'Archive',
component: <ProjectFeaturesArchive projectId={projectId} />,
component: (
<ProjectFeaturesArchive
projectId={projectId}
projectName={projectName}
/>
),
path: `${basePath}/archive`,
name: 'archive',
},
@ -116,7 +137,7 @@ const Project = () => {
<div className={styles.innerContainer}>
<h2 className={styles.title}>
<div className={styles.titleText} data-loading>
{project?.name || projectId}
{projectName}
</div>
<PermissionIconButton
permission={UPDATE_PROJECT}

View File

@ -18,18 +18,10 @@ import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/Fe
import { sortTypes } from 'utils/sortTypes';
import { formatUnknownError } from 'utils/formatUnknownError';
import { IProject } from 'interfaces/project';
import {
Table,
SortableTableHeader,
TableBody,
TableCell,
TableRow,
TablePlaceholder,
} from 'component/common/Table';
import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import useProject from 'hooks/api/getters/useProject/useProject';
import { createLocalStorage } from 'utils/createLocalStorage';
import { useVirtualizedRange } from 'hooks/useVirtualizedRange';
import useToast from 'hooks/useToast';
import { ENVIRONMENT_STRATEGY_ERROR } from 'constants/apiErrors';
import EnvironmentStrategyDialog from 'component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog';
@ -104,7 +96,6 @@ export const ProjectFeatureToggles = ({
);
const { refetch } = useProject(projectId);
const { setToastData, setToastApiError } = useToast();
const rowHeight = theme.shape.tableRowHeight;
const { toggleFeatureEnvironmentOn, toggleFeatureEnvironmentOff } =
useFeatureApi();
@ -282,7 +273,7 @@ export const ProjectFeatureToggles = ({
getSearchContext,
} = useSearch(columns, searchValue, featuresData);
const data = useMemo<ListItemType[]>(() => {
const data = useMemo<object[]>(() => {
if (loading) {
return Array(6).fill({
type: '-',
@ -291,7 +282,7 @@ export const ProjectFeatureToggles = ({
environments: {
production: { name: 'production', enabled: false },
},
}) as ListItemType[];
}) as object[];
}
return searchedData;
}, [loading, searchedData]);
@ -343,8 +334,6 @@ export const ProjectFeatureToggles = ({
headerGroups,
rows,
state: { sortBy, hiddenColumns },
getTableBodyProps,
getTableProps,
prepareRow,
setHiddenColumns,
} = useTable(
@ -392,12 +381,6 @@ export const ProjectFeatureToggles = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading, sortBy, hiddenColumns, searchValue, setSearchParams]);
const [firstRenderedIndex, lastRenderedIndex] =
useVirtualizedRange(rowHeight);
const tableHeight =
rowHeight * rows.length + theme.shape.tableRowHeightCompact;
return (
<PageContent
isLoading={loading}
@ -406,7 +389,7 @@ export const ProjectFeatureToggles = ({
header={
<PageHeader
className={styles.title}
title={`Feature toggles (${rows.length})`}
titleElement={`Feature toggles (${rows.length})`}
actions={
<>
<ConditionallyRender
@ -464,58 +447,11 @@ export const ProjectFeatureToggles = ({
}
>
<SearchHighlightProvider value={getSearchText(searchValue)}>
<Table
{...getTableProps()}
rowHeight={rowHeight}
style={{ height: tableHeight }}
>
<SortableTableHeader
// @ts-expect-error -- verify after `react-table` v8
headerGroups={headerGroups}
className={styles.headerClass}
flex
/>
<TableBody {...getTableBodyProps()}>
{rows.map((row, index) => {
const top =
index * rowHeight +
theme.shape.tableRowHeightCompact;
const isVirtual =
index < firstRenderedIndex ||
index > lastRenderedIndex;
if (isVirtual) {
return null;
}
prepareRow(row);
return (
<TableRow
hover
{...row.getRowProps()}
className={styles.row}
style={{ display: 'flex', top }}
>
{row.cells.map(cell => (
<TableCell
{...cell.getCellProps({
style: {
flex: cell.column.minWidth
? '1 0 auto'
: undefined,
},
})}
className={styles.cell}
>
{cell.render('Cell')}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
<VirtualizedTable
rows={rows}
headerGroups={headerGroups}
prepareRow={prepareRow}
/>
</SearchHighlightProvider>
<ConditionallyRender
condition={rows.length === 0}

View File

@ -3,12 +3,14 @@ import { usePageTitle } from 'hooks/usePageTitle';
interface IProjectFeaturesArchiveProps {
projectId: string;
projectName: string;
}
export const ProjectFeaturesArchive = ({
projectId,
projectName,
}: IProjectFeaturesArchiveProps) => {
usePageTitle('Project Archived Features');
usePageTitle(`Project archive ${projectName}`);
return <ProjectFeaturesArchiveTable projectId={projectId} />;
};

View File

@ -1,19 +1,22 @@
import { useHealthReport } from 'hooks/api/getters/useHealthReport/useHealthReport';
import ApiError from 'component/common/ApiError/ApiError';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { ReportCard } from 'component/Reporting/ReportCard/ReportCard';
import { usePageTitle } from 'hooks/usePageTitle';
import { ReportTable } from 'component/Reporting/ReportTable/ReportTable';
import { ReportCard } from './ReportTable/ReportCard/ReportCard';
import { ReportTable } from './ReportTable/ReportTable';
interface IProjectHealthProps {
projectId: string;
projectName: string;
}
const ProjectHealth = ({ projectId }: IProjectHealthProps) => {
usePageTitle('Project health');
const ProjectHealth = ({ projectId, projectName }: IProjectHealthProps) => {
usePageTitle(`Project health ${projectName}`);
const { healthReport, refetchHealthReport, error } =
useHealthReport(projectId);
const { healthReport, refetchHealthReport, error } = useHealthReport(
projectId,
{ refreshInterval: 15 * 1000 }
);
if (!healthReport) {
return null;

View File

@ -1,6 +1,7 @@
import { VFC } from 'react';
import { Typography, useTheme } from '@mui/material';
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
import { IReportTableRow } from 'component/Reporting/ReportTable/ReportTable';
import { IReportTableRow } from 'component/project/Project/ProjectHealth/ReportTable/ReportTable';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
interface IReportExpiredCellProps {
@ -10,9 +11,17 @@ interface IReportExpiredCellProps {
}
export const ReportExpiredCell: VFC<IReportExpiredCellProps> = ({ row }) => {
const theme = useTheme();
if (row.original.expiredAt) {
return <DateCell value={row.original.expiredAt} />;
}
return <TextCell>N/A</TextCell>;
return (
<TextCell>
<Typography variant="body2" color={theme.palette.text.secondary}>
N/A
</Typography>
</TextCell>
);
};

View File

@ -1,10 +1,6 @@
import { IFeatureToggleListItem } from 'interfaces/featureToggle';
import { PERMISSION, KILLSWITCH } from 'constants/featureToggleTypes';
import {
getDiffInDays,
expired,
toggleExpiryByTypeMap,
} from 'component/Reporting/utils';
import { getDiffInDays, expired, toggleExpiryByTypeMap } from '../utils';
import { subDays, parseISO } from 'date-fns';
export const formatExpiredAt = (

View File

@ -2,7 +2,7 @@ import { VFC, ReactElement } from 'react';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { ReportProblemOutlined, Check } from '@mui/icons-material';
import { styled } from '@mui/material';
import { IReportTableRow } from 'component/Reporting/ReportTable/ReportTable';
import { IReportTableRow } from 'component/project/Project/ProjectHealth/ReportTable/ReportTable';
const StyledTextPotentiallyStale = styled('span')(({ theme }) => ({
display: 'flex',

View File

@ -1,5 +1,5 @@
import { IFeatureToggleListItem } from 'interfaces/featureToggle';
import { getDiffInDays, expired } from 'component/Reporting/utils';
import { getDiffInDays, expired } from '../utils';
import { PERMISSION, KILLSWITCH } from 'constants/featureToggleTypes';
import { parseISO } from 'date-fns';

View File

@ -1,31 +1,28 @@
import { useMemo, useEffect } from 'react';
import { IFeatureToggleListItem } from 'interfaces/featureToggle';
import {
SortableTableHeader,
TableCell,
TablePlaceholder,
} from 'component/common/Table';
import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
import { PageContent } from 'component/common/PageContent/PageContent';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { sortTypes } from 'utils/sortTypes';
import { useSortBy, useGlobalFilter, useTable } from 'react-table';
import { Table, TableBody, TableRow, useMediaQuery } from '@mui/material';
import {
useSortBy,
useGlobalFilter,
useTable,
useFlexLayout,
} from 'react-table';
import { useMediaQuery, useTheme } from '@mui/material';
import { FeatureSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureSeenCell';
import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell';
import { FeatureNameCell } from 'component/common/Table/cells/FeatureNameCell/FeatureNameCell';
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
import { ReportExpiredCell } from 'component/Reporting/ReportExpiredCell/ReportExpiredCell';
import { ReportStatusCell } from 'component/Reporting/ReportStatusCell/ReportStatusCell';
import { useMemo, useEffect } from 'react';
import {
formatStatus,
ReportingStatus,
} from 'component/Reporting/ReportStatusCell/formatStatus';
import { formatExpiredAt } from 'component/Reporting/ReportExpiredCell/formatExpiredAt';
import { FeatureStaleCell } from 'component/feature/FeatureToggleList/FeatureStaleCell/FeatureStaleCell';
import theme from 'themes/theme';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Search } from 'component/common/Search/Search';
import { ReportExpiredCell } from './ReportExpiredCell/ReportExpiredCell';
import { ReportStatusCell } from './ReportStatusCell/ReportStatusCell';
import { formatStatus, ReportingStatus } from './ReportStatusCell/formatStatus';
import { formatExpiredAt } from './ReportExpiredCell/formatExpiredAt';
interface IReportTableProps {
projectId: string;
@ -44,13 +41,25 @@ export interface IReportTableRow {
}
export const ReportTable = ({ projectId, features }: IReportTableProps) => {
const theme = useTheme();
const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg'));
const data: IReportTableRow[] = useMemo(() => {
return features.map(feature => {
return createReportTableRow(projectId, feature);
});
}, [projectId, features]);
const data: IReportTableRow[] = useMemo<IReportTableRow[]>(
() =>
features.map(report => ({
project: projectId,
name: report.name,
type: report.type,
stale: report.stale,
status: formatStatus(report),
lastSeenAt: report.lastSeenAt,
createdAt: report.createdAt,
expiredAt: formatExpiredAt(report),
})),
[projectId, features]
);
const initialState = useMemo(
() => ({
@ -61,8 +70,6 @@ export const ReportTable = ({ projectId, features }: IReportTableProps) => {
);
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
@ -80,49 +87,44 @@ export const ReportTable = ({ projectId, features }: IReportTableProps) => {
disableSortRemove: true,
},
useGlobalFilter,
useFlexLayout,
useSortBy
);
useEffect(() => {
const hiddenColumns = [];
if (isMediumScreen) {
hiddenColumns.push('createdAt');
}
if (isSmallScreen) {
hiddenColumns.push('createdAt', 'expiredAt');
hiddenColumns.push('expiredAt', 'lastSeenAt');
}
if (isExtraSmallScreen) {
hiddenColumns.push('stale');
}
setHiddenColumns(hiddenColumns);
}, [setHiddenColumns, isSmallScreen]);
const header = (
<PageHeader
title="Overview"
actions={
<Search
initialValue={globalFilter}
onChange={setGlobalFilter}
/>
}
/>
);
}, [setHiddenColumns, isSmallScreen, isMediumScreen, isExtraSmallScreen]);
return (
<PageContent header={header}>
<PageContent
header={
<PageHeader
titleElement="Overview"
actions={
<Search
initialValue={globalFilter}
onChange={setGlobalFilter}
/>
}
/>
}
>
<SearchHighlightProvider value={globalFilter}>
<Table {...getTableProps()}>
<SortableTableHeader headerGroups={headerGroups} />
<TableBody {...getTableBodyProps()}>
{rows.map(row => {
prepareRow(row);
return (
<TableRow hover {...row.getRowProps()}>
{row.cells.map(cell => (
<TableCell {...cell.getCellProps()}>
{cell.render('Cell')}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
<VirtualizedTable
headerGroups={headerGroups}
prepareRow={prepareRow}
rows={rows}
/>
</SearchHighlightProvider>
<ConditionallyRender
condition={rows.length === 0}
@ -149,22 +151,6 @@ export const ReportTable = ({ projectId, features }: IReportTableProps) => {
);
};
const createReportTableRow = (
projectId: string,
report: IFeatureToggleListItem
): IReportTableRow => {
return {
project: projectId,
name: report.name,
type: report.type,
stale: report.stale,
status: formatStatus(report),
lastSeenAt: report.lastSeenAt,
createdAt: report.createdAt,
expiredAt: formatExpiredAt(report),
};
};
const COLUMNS = [
{
Header: 'Seen',
@ -173,6 +159,7 @@ const COLUMNS = [
align: 'center',
Cell: FeatureSeenCell,
disableGlobalFilter: true,
maxWidth: 85,
},
{
Header: 'Type',
@ -180,32 +167,36 @@ const COLUMNS = [
align: 'center',
Cell: FeatureTypeCell,
disableGlobalFilter: true,
maxWidth: 85,
},
{
Header: 'Name',
accessor: 'name',
width: '60%',
sortType: 'alphanumeric',
Cell: FeatureNameCell,
minWidth: 120,
},
{
Header: 'Created on',
Header: 'Created',
accessor: 'createdAt',
sortType: 'date',
Cell: DateCell,
disableGlobalFilter: true,
maxWidth: 150,
},
{
Header: 'Expired',
accessor: 'expiredAt',
Cell: ReportExpiredCell,
disableGlobalFilter: true,
maxWidth: 150,
},
{
Header: 'Status',
accessor: 'status',
id: 'status',
Cell: ReportStatusCell,
disableGlobalFilter: true,
width: 180,
},
{
Header: 'State',
@ -213,5 +204,6 @@ const COLUMNS = [
sortType: 'boolean',
Cell: FeatureStaleCell,
disableGlobalFilter: true,
maxWidth: 120,
},
];

View File

@ -2,17 +2,20 @@ import useProject from 'hooks/api/getters/useProject/useProject';
import { ProjectFeatureToggles } from './ProjectFeatureToggles/ProjectFeatureToggles';
import ProjectInfo from './ProjectInfo/ProjectInfo';
import { useStyles } from './Project.styles';
import { usePageTitle } from 'hooks/usePageTitle';
interface IProjectOverviewProps {
projectName: string;
projectId: string;
}
const ProjectOverview = ({ projectId }: IProjectOverviewProps) => {
const ProjectOverview = ({ projectId, projectName }: IProjectOverviewProps) => {
const { project, loading } = useProject(projectId, {
refreshInterval: 15 * 1000, // ms
});
const { members, features, health, description, environments } = project;
const { classes: styles } = useStyles();
usePageTitle(`Project overview ${projectName}`);
return (
<div>

View File

@ -1,4 +1,4 @@
import React, { useContext } from 'react';
import React, { useContext, VFC } from 'react';
import { ProjectAccessPage } from 'component/project/ProjectAccess/ProjectAccessPage';
import { PageContent } from 'component/common/PageContent/PageContent';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
@ -7,11 +7,17 @@ import { PageHeader } from 'component/common/PageHeader/PageHeader';
import AccessContext from 'contexts/AccessContext';
import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { usePageTitle } from 'hooks/usePageTitle';
export const ProjectAccess = () => {
interface IProjectAccess {
projectName: string;
}
export const ProjectAccess: VFC<IProjectAccess> = ({ projectName }) => {
const projectId = useRequiredPathParam('projectId');
const { hasAccess } = useContext(AccessContext);
const { isOss } = useUiConfig();
usePageTitle(`Project access ${projectName}`);
if (isOss()) {
return (

View File

@ -6,7 +6,6 @@ import {
Button,
InputAdornment,
SelectChangeEvent,
Alert,
} from '@mui/material';
import { Search } from '@mui/icons-material';
import Autocomplete from '@mui/material/Autocomplete';
@ -152,10 +151,6 @@ export const ProjectAccessAddUser = ({ roles }: IProjectAccessAddUserProps) => {
return (
<>
<Alert severity="info" style={{ marginBottom: '20px' }}>
The user must have an Unleash root role before added to the
project.
</Alert>
<Grid container spacing={3} alignItems="flex-end">
<Grid item>
<Autocomplete

View File

@ -57,7 +57,9 @@ export const ProjectAccessPage = () => {
refetchProjectAccess();
setToastData({
type: 'success',
title: 'The user has been removed from project',
title: `${
user.email || user.username || 'The user'
} has been removed from project`,
});
} catch (err: any) {
setToastData({
@ -70,7 +72,7 @@ export const ProjectAccessPage = () => {
return (
<PageContent
header={<PageHeader title="Project roles" />}
header={<PageHeader titleElement="Project roles" />}
className={styles.pageContent}
>
<ProjectAccessAddUser roles={access?.roles} />

View File

@ -7,7 +7,7 @@ import {
TableCell,
SortableTableHeader,
} from 'component/common/Table';
import { Avatar, Box, SelectChangeEvent } from '@mui/material';
import { Avatar, SelectChangeEvent } from '@mui/material';
import { Delete } from '@mui/icons-material';
import { sortTypes } from 'utils/sortTypes';
import {
@ -18,6 +18,7 @@ import { ProjectRoleCell } from './ProjectRoleCell/ProjectRoleCell';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
const initialState = {
sortBy: [{ id: 'name' }],
@ -94,16 +95,10 @@ export const ProjectAccessTable: VFC<IProjectAccessTableProps> = ({
align: 'center',
width: 80,
Cell: ({ row: { original: user } }: any) => (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
}}
>
<ActionCell>
<PermissionIconButton
permission={UPDATE_PROJECT}
projectId={projectId}
edge="end"
onClick={() => handleRemoveAccess(user)}
disabled={access.users.length === 1}
tooltipProps={{
@ -115,7 +110,7 @@ export const ProjectAccessTable: VFC<IProjectAccessTableProps> = ({
>
<Delete />
</PermissionIconButton>
</Box>
</ActionCell>
),
},
],

View File

@ -18,14 +18,18 @@ import { IProjectEnvironment } from 'interfaces/environments';
import { getEnabledEnvs } from './helpers';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { useThemeStyles } from 'themes/themeStyles';
import { usePageTitle } from 'hooks/usePageTitle';
interface IProjectEnvironmentListProps {
projectId: string;
projectName: string;
}
const ProjectEnvironmentList = ({
projectId,
projectName,
}: IProjectEnvironmentListProps) => {
usePageTitle(`Project environments ${projectName}`);
// api state
const [envs, setEnvs] = useState<IProjectEnvironment[]>([]);
const { setToastData, setToastApiError } = useToast();
@ -176,7 +180,7 @@ const ProjectEnvironmentList = ({
<PageContent
header={
<PageHeader
title={`Configure environments for "${project?.name}" project`}
titleElement={`Configure environments for "${project?.name}" project`}
/>
}
isLoading={loading}

View File

@ -1,3 +1,4 @@
import React, { useContext } from 'react';
import { CreateButton } from 'component/common/CreateButton/CreateButton';
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import { CREATE_SEGMENT } from 'component/providers/AccessProvider/permissions';
@ -6,7 +7,6 @@ import { useConstraintsValidation } from 'hooks/api/getters/useConstraintsValida
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import useToast from 'hooks/useToast';
import React, { useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useSegmentForm } from '../hooks/useSegmentForm';

View File

@ -1,18 +0,0 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
paragraph: {
[theme.breakpoints.down('lg')]: {
display: 'inline',
'&:after': {
content: '" "',
},
},
[theme.breakpoints.up('md')]: {
display: 'block',
'& + &': {
marginTop: '0.25rem',
},
},
},
}));

View File

@ -1,26 +1,6 @@
import { Alert } from '@mui/material';
import { useStyles } from 'component/segments/SegmentDocs/SegmentDocs.styles';
import { useSegmentLimits } from 'hooks/api/getters/useSegmentLimits/useSegmentLimits';
export const SegmentDocsWarning = () => {
const { classes: styles } = useStyles();
return (
<Alert severity="warning">
<p className={styles.paragraph}>
Segments is an experimental feature available to select users.
</p>
<p className={styles.paragraph}>
This feature is currently in development. Future versions may
require to update your SDKs.
</p>
<p className={styles.paragraph}>
<SegmentDocsLink />
</p>
</Alert>
);
};
export const SegmentDocsValuesWarning = () => {
const { segmentValuesLimit } = useSegmentLimits();
@ -30,9 +10,8 @@ export const SegmentDocsValuesWarning = () => {
return (
<Alert severity="warning">
Segments is an experimental feature available to select users.
Currently, segments are limited to at most {segmentValuesLimit}{' '}
values. <SegmentLimitsLink />
Segments is an experimental feature, currently limited to at most{' '}
{segmentValuesLimit} values. <SegmentLimitsLink />
</Alert>
);
};
@ -68,22 +47,6 @@ export const SegmentDocsStrategyWarning = () => {
);
};
const SegmentDocsLink = () => {
return (
<>
<a
href={segmentsDocsLink}
target="_blank"
rel="noreferrer"
style={{ color: 'inherit' }}
>
Read more about segments in the documentation
</a>
.
</>
);
};
const SegmentLimitsLink = () => {
return (
<>

View File

@ -11,7 +11,7 @@ import {
import { useTable, useGlobalFilter, useSortBy } from 'react-table';
import { CreateSegmentButton } from 'component/segments/CreateSegmentButton/CreateSegmentButton';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { useMediaQuery, Box } from '@mui/material';
import { useMediaQuery } from '@mui/material';
import { sortTypes } from 'utils/sortTypes';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import { useMemo, useEffect, useState } from 'react';
@ -22,7 +22,6 @@ import { SegmentActionCell } from 'component/segments/SegmentActionCell/SegmentA
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
import theme from 'themes/theme';
import { SegmentDocsWarning } from 'component/segments/SegmentDocs/SegmentDocs';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Search } from 'component/common/Search/Search';
@ -99,9 +98,6 @@ export const SegmentTable = () => {
}
isLoading={loading}
>
<Box sx={{ mb: 4 }}>
<SegmentDocsWarning />
</Box>
<ConditionallyRender
condition={!loading && data.length === 0}
show={
@ -166,7 +162,7 @@ const COLUMNS = [
{
Header: 'Name',
accessor: 'name',
width: '80%',
width: '60%',
Cell: ({ value, row: { original } }: any) => (
<HighlightCell value={value} subtitle={original.description} />
),
@ -181,6 +177,7 @@ const COLUMNS = [
{
Header: 'Created by',
accessor: 'createdBy',
width: '25%',
},
{
Header: 'Actions',

View File

@ -1,5 +1,4 @@
import { useNavigate, Navigate } from 'react-router-dom';
import { SplashPageEnvironments } from '../SplashPageEnvironments/SplashPageEnvironments';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import useSplashApi from 'hooks/api/actions/useSplashApi/useSplashApi';
import { SplashPageOperators } from 'component/splash/SplashPageOperators/SplashPageOperators';
@ -31,8 +30,6 @@ export const SplashPage = () => {
}
switch (splashId) {
case 'environments':
return <SplashPageEnvironments />;
case 'operators':
return <SplashPageOperators />;
default:

View File

@ -1,57 +0,0 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
splashContainer: {
position: 'fixed',
},
title: {
textAlign: 'center',
marginBottom: '20px',
lineHeight: '1.3',
[theme.breakpoints.down('sm')]: {
marginTop: '1rem',
},
},
topDescription: {
padding: '0px 40px',
marginBottom: '15px',
fontSize: '17px',
[theme.breakpoints.down('sm')]: {
padding: '0 20px',
},
},
bottomDescription: {
padding: '0px 20px',
fontSize: '17px',
marginTop: '15px',
[theme.breakpoints.down('sm')]: {
padding: '0 20px',
},
},
icon: {
fontSize: '150px',
display: 'block',
margin: 'auto',
[theme.breakpoints.down('sm')]: {
fontSize: '90px',
},
},
logo: {
width: '70%',
height: '60%',
display: 'block',
margin: 'auto',
marginTop: '2rem',
[theme.breakpoints.down('sm')]: {
width: '80%',
height: '80%',
marginTop: '0rem',
},
},
linkList: {
padding: '30px 25px',
},
link: {
color: '#fff',
},
}));

View File

@ -1,196 +0,0 @@
import { SplashPageEnvironmentsContent } from 'component/splash/SplashPageEnvironments/SplashPageEnvironmentsContent/SplashPageEnvironmentsContent';
import { SplashPageEnvironmentsContainer } from 'component/splash/SplashPageEnvironments/SplashPageEnvironmentsContainer/SplashPageEnvironmentsContainer';
import { VpnKey, CloudCircle } from '@mui/icons-material';
import { useStyles } from 'component/splash/SplashPageEnvironments/SplashPageEnvironments.styles';
import { ReactComponent as Logo1 } from 'assets/img/splashEnv1.svg';
import { ReactComponent as Logo2 } from 'assets/img/splashEnv2.svg';
import { useNavigate } from 'react-router-dom';
export const SplashPageEnvironments = () => {
const { classes: styles } = useStyles();
const navigate = useNavigate();
const onFinish = () => {
navigate('/');
};
return (
<>
<SplashPageEnvironmentsContent
onFinish={onFinish}
components={[
<SplashPageEnvironmentsContainer
key={1}
title={
<h2 className={styles.title}>
Environments are coming to Unleash!
</h2>
}
topDescription={
<p className={styles.topDescription}>
We are bringing native environment support to
Unleash.{' '}
<b>
Your current configurations wont be
affected,
</b>{' '}
but youll have the option of adding strategies
to specific environments going forward.
</p>
}
bottomDescription={
<p className={styles.bottomDescription}>
By default you will get access to three
environments: <b>default</b>, <b>development</b>{' '}
and<b> production</b>. All of your current
configurations will live in the default
environment and{' '}
<b>
nothing will change until you make a
conscious decision to change.
</b>
</p>
}
image={<CloudCircle className={styles.icon} />}
/>,
<SplashPageEnvironmentsContainer
key={2}
title={
<h2 className={styles.title}>
Strategies live in environments
</h2>
}
topDescription={
<p className={styles.topDescription}>
A feature toggle lives as an entity across
multiple environments, but your strategies will
live in a specific environment. This allows you
to have different configuration per environment
for a feature toggle.
</p>
}
image={<Logo1 className={styles.logo} />}
/>,
<SplashPageEnvironmentsContainer
key={3}
title={
<h2 className={styles.title}>
Environments are turned on per project
</h2>
}
topDescription={
<p className={styles.topDescription}>
In order to enable an environment for a feature
toggle you must first enable the environment in
your project. Navigate to your project settings
and enable the environments you want to be
available. The toggles in that project will get
access to all of the projects enabled
environments.
</p>
}
image={<Logo2 className={styles.logo} />}
/>,
<SplashPageEnvironmentsContainer
key={4}
title={
<h2 className={styles.title}>
API Keys control which environment you get the
configuration from
</h2>
}
topDescription={
<p className={styles.topDescription}>
When you have set up environments for your
feature toggles and added strategies to the
specific environments, you must create
environment-specific API keys one for each
environment.
</p>
}
bottomDescription={
<p className={styles.bottomDescription}>
Environment-specific API keys lets the SDK
receive configuration only for the specified
environment.
</p>
}
image={<VpnKey className={styles.icon} />}
/>,
<SplashPageEnvironmentsContainer
key={5}
title={
<h2 className={styles.title}>Want to know more?</h2>
}
topDescription={
<div className={styles.topDescription}>
If youd like some more info on environments,
check out some of the resources below! The
documentation or the video walkthrough is a
great place to start. If youd like to try it
out in a risk-free setting first, how about
heading to the demo instance?
<ul className={styles.linkList}>
<li>
<a
href="https://www.loom.com/share/95239e875bbc4e09a5c5833e1942e4b0?t=0"
target="_blank"
rel="noreferrer"
className={styles.link}
>
Video walkthrough
</a>
</li>
<li>
<a
href="https://app.unleash-hosted.com/demo/"
target="_blank"
rel="noreferrer"
className={styles.link}
>
The Unleash demo instance
</a>
</li>
<li>
<a
href="https://docs.getunleash.io/user_guide/environments"
target="_blank"
rel="noreferrer"
className={styles.link}
>
Environments reference documentation
</a>
</li>
<li>
<a
href="https://www.getunleash.io/blog/simplify-rollout-management-with-the-new-environments-feature"
target="_blank"
rel="noreferrer"
className={styles.link}
>
Blog post introducing environments
</a>
</li>
</ul>
</div>
}
bottomDescription={
<p className={styles.bottomDescription}>
If you have any questions or need help, feel
free to ping us on{' '}
<a
target="_blank"
href="https://slack.unleash.run/"
rel="noreferrer"
className={styles.link}
>
slack!
</a>
</p>
}
/>,
]}
/>
</>
);
};

View File

@ -1,24 +0,0 @@
import React from 'react';
interface ISplashPageEnvironmentsContainerProps {
title: React.ReactNode;
topDescription: React.ReactNode;
image?: React.ReactNode;
bottomDescription?: React.ReactNode;
}
export const SplashPageEnvironmentsContainer = ({
title,
topDescription,
image,
bottomDescription,
}: ISplashPageEnvironmentsContainerProps) => {
return (
<div>
{title}
{topDescription}
{image}
{bottomDescription}
</div>
);
};

View File

@ -1,96 +0,0 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
splashMainContainer: {
backgroundColor: theme.palette.primary.light,
width: '100%',
minHeight: '100vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: '3rem 0',
[theme.breakpoints.down('sm')]: {
padding: '0',
},
},
splashContainer: {
backgroundColor: theme.palette.primary.main,
position: 'relative',
minHeight: '650px',
width: '600px',
padding: '2rem 1.5rem',
borderRadius: '5px',
color: '#fff',
display: 'flex',
overflowX: 'hidden',
flexDirection: 'column',
[theme.breakpoints.down('sm')]: {
top: '0px',
left: '0px',
right: '0px',
bottom: '0px',
padding: '2rem 0',
zIndex: 500,
position: 'fixed',
width: '100%',
height: '100%',
borderRadius: 0,
},
},
closeButtonContainer: {
display: 'inline-flex',
justifyContent: 'flex-end',
color: '#fff',
position: 'absolute',
right: '-10px',
top: '5px',
},
closeButton: {
textDecoration: 'none',
right: '10px',
color: '#fff',
'&:hover': {
backgroundColor: 'inherit',
},
},
controllers: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
height: 'inherit',
marginBottom: 5,
marginTop: 'auto',
},
circlesContainer: {
display: 'flex',
justifyContent: 'center',
},
circles: {
display: 'inline-flex',
justifyContent: 'center',
marginTop: 20,
marginBottom: 15,
position: 'relative',
},
buttonsContainer: {
display: 'flex',
justifyContent: 'center',
},
button: {
textDecoration: 'none',
width: '100px',
color: '#fff',
'&:hover': {
backgroundColor: 'inherit',
},
},
nextButton: {
textDecoration: 'none',
width: '100px',
color: theme.palette.primary.light,
backgroundColor: '#fff',
'&:hover': {
backgroundColor: '#fff',
},
},
}));

View File

@ -1,111 +0,0 @@
import React, { Fragment, useState } from 'react';
import { Button, IconButton } from '@mui/material';
import { useStyles } from 'component/splash/SplashPageEnvironments/SplashPageEnvironmentsContent/SplashPageEnvironmentsContent.styles';
import {
CloseOutlined,
FiberManualRecord,
FiberManualRecordOutlined,
} from '@mui/icons-material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { CLOSE_SPLASH } from 'utils/testIds';
interface ISplashPageEnvironmentsContentProps {
components: React.ReactNode[];
onFinish: (status: boolean) => void;
}
export const SplashPageEnvironmentsContent: React.FC<
ISplashPageEnvironmentsContentProps
> = ({ components, onFinish }: ISplashPageEnvironmentsContentProps) => {
const { classes: styles } = useStyles();
const [counter, setCounter] = useState(0);
const onNext = () => {
if (counter === components.length - 1) {
onFinish(false);
return;
}
setCounter(counter + 1);
};
const onBack = () => {
setCounter(counter - 1);
};
const onClose = () => {
onFinish(false);
};
const calculatePosition = () => {
if (counter === 0) {
return '0';
}
return counter * 24;
};
const renderCircles = () => {
return components.map((_, index) => {
if (index === 0) {
// Use index as key because the amount of pages will never dynamically change.
return (
<Fragment key={index}>
<FiberManualRecordOutlined />
<FiberManualRecord
style={{
position: 'absolute',
transition: 'transform 0.3s ease',
left: '0',
transform: `translateX(${calculatePosition()}px)`,
}}
/>
</Fragment>
);
}
return <FiberManualRecordOutlined key={index} />;
});
};
return (
<div className={styles.splashMainContainer}>
<div className={styles.splashContainer}>
<div className={styles.closeButtonContainer}>
<IconButton
className={styles.closeButton}
onClick={onClose}
data-testid={CLOSE_SPLASH}
size="large"
>
<CloseOutlined titleAccess="Close" />
</IconButton>
</div>
{components[counter]}
<div className={styles.controllers}>
<div className={styles.circlesContainer}>
<div className={styles.circles}>{renderCircles()}</div>
</div>
<div className={styles.buttonsContainer}>
<ConditionallyRender
condition={counter > 0}
show={
<Button
className={styles.button}
disabled={counter === 0}
onClick={onBack}
>
Back
</Button>
}
/>
<Button className={styles.nextButton} onClick={onNext}>
{counter === components.length - 1
? 'Finish'
: 'Next'}
</Button>
</div>
</div>
</div>
</div>
);
};

View File

@ -1,7 +1,7 @@
// All known splash IDs.
export const splashIds = ['environments', 'operators'] as const;
export const splashIds = ['operators'] as const;
// Active splash IDs that may be shown to the user.
export const activeSplashIds: SplashId[] = ['operators'];
export const activeSplashIds: SplashId[] = [];
export type SplashId = typeof splashIds[number];

View File

@ -5,6 +5,7 @@ import { Delete } from '@mui/icons-material';
import { IconButton, Tooltip } from '@mui/material';
import { IStrategy } from 'interfaces/strategy';
import { DELETE_STRATEGY } from 'component/providers/AccessProvider/permissions';
import { useId } from 'hooks/useId';
interface IStrategyDeleteButtonProps {
strategy: IStrategy;
@ -15,6 +16,8 @@ export const StrategyDeleteButton: VFC<IStrategyDeleteButtonProps> = ({
strategy,
onClick,
}) => {
const id = useId();
return (
<ConditionallyRender
condition={strategy?.editable}
@ -29,9 +32,9 @@ export const StrategyDeleteButton: VFC<IStrategyDeleteButtonProps> = ({
}
elseShow={
<Tooltip title="You cannot delete a built-in strategy" arrow>
<div>
<div id={id}>
<IconButton disabled size="large">
<Delete titleAccess="Delete strategy" />
<Delete aria-labelledby={id} />
</IconButton>
</div>
</Tooltip>

View File

@ -5,6 +5,7 @@ import { Edit } from '@mui/icons-material';
import { IconButton, Tooltip } from '@mui/material';
import { UPDATE_STRATEGY } from 'component/providers/AccessProvider/permissions';
import { IStrategy } from 'interfaces/strategy';
import { useId } from 'hooks/useId';
interface IStrategyEditButtonProps {
strategy: IStrategy;
@ -14,26 +15,30 @@ interface IStrategyEditButtonProps {
export const StrategyEditButton: VFC<IStrategyEditButtonProps> = ({
strategy,
onClick,
}) => (
<ConditionallyRender
condition={strategy?.editable}
show={
<PermissionIconButton
onClick={onClick}
permission={UPDATE_STRATEGY}
tooltipProps={{ title: 'Edit strategy' }}
>
<Edit />
</PermissionIconButton>
}
elseShow={
<Tooltip title="You cannot edit a built-in strategy" arrow>
<div>
<IconButton disabled size="large">
<Edit titleAccess="Edit strategy" />
</IconButton>
</div>
</Tooltip>
}
/>
);
}) => {
const id = useId();
return (
<ConditionallyRender
condition={strategy?.editable}
show={
<PermissionIconButton
onClick={onClick}
permission={UPDATE_STRATEGY}
tooltipProps={{ title: 'Edit strategy' }}
>
<Edit />
</PermissionIconButton>
}
elseShow={
<Tooltip title="You cannot edit a built-in strategy" arrow>
<div id={id}>
<IconButton disabled size="large">
<Edit aria-labelledby={id} />
</IconButton>
</div>
</Tooltip>
}
/>
);
};

View File

@ -30,12 +30,13 @@ export const StrategySwitch: VFC<IStrategySwitchProps> = ({
describeChild
arrow
>
<div id={id} role="tooltip">
<div id={id}>
<PermissionSwitch
checked={!deprecated}
permission={UPDATE_STRATEGY}
onClick={onClick}
disabled={disabled}
inputProps={{ 'aria-labelledby': id }}
/>
</div>
</Tooltip>

View File

@ -4,7 +4,11 @@ export const useStyles = makeStyles()(theme => ({
paramsContainer: {
maxWidth: '400px',
},
divider: { borderStyle: 'dashed', marginBottom: '1rem !important' },
divider: {
borderStyle: 'dashed',
marginBottom: '1rem !important',
borderColor: theme.palette.grey[500],
},
nameContainer: {
display: 'flex',
alignItems: 'center',

View File

@ -1,4 +1,10 @@
import { Checkbox, FormControlLabel, IconButton, Tooltip } from '@mui/material';
import {
Checkbox,
Divider,
FormControlLabel,
IconButton,
Tooltip,
} from '@mui/material';
import { Delete } from '@mui/icons-material';
import { useStyles } from './StrategyParameter.styles';
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
@ -69,7 +75,7 @@ export const StrategyParameter = ({
return (
<div className={styles.paramsContainer}>
<hr className={styles.divider} />
<Divider className={styles.divider} />
<ConditionallyRender
condition={index === 0}
show={

View File

@ -5,6 +5,7 @@ import {
ListItemAvatar,
ListItemText,
Tooltip,
useTheme,
} from '@mui/material';
import { Add, RadioButtonChecked } from '@mui/icons-material';
import { AppsLinkList } from 'component/common';
@ -26,6 +27,7 @@ export const StrategyDetails = ({
applications,
toggles,
}: IStrategyDetailsProps) => {
const theme = useTheme();
const { parameters = [] } = strategy;
const renderParameters = (params: IStrategyParameter[]) => {
if (params.length > 0) {
@ -70,7 +72,9 @@ export const StrategyDetails = ({
condition={strategy.deprecated}
show={
<Grid item>
<h5 style={{ color: '#ff0000' }}>Deprecated</h5>
<h5 style={{ color: theme.palette.error.main }}>
Deprecated
</h5>
</Grid>
}
/>

View File

@ -35,7 +35,7 @@ exports[`renders an empty list correctly 1`] = `
className="tss-119iiqp-container"
>
<div
className="tss-1mtd8gr-search search-container"
className="tss-1xjrf9m-search search-container"
>
<svg
aria-hidden={true}
@ -76,7 +76,7 @@ exports[`renders an empty list correctly 1`] = `
id="useId-0"
>
<button
aria-describedby="useId-0"
aria-labelledby="useId-0"
className="MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium MuiButtonBase-root mui-1aw3qf3-MuiButtonBase-root-MuiButton-root"
disabled={false}
onBlur={[Function]}
@ -313,7 +313,6 @@ exports[`renders an empty list correctly 1`] = `
onMouseOver={[Function]}
onTouchEnd={[Function]}
onTouchStart={[Function]}
role="tooltip"
>
<button
aria-labelledby="useId-1"
@ -364,7 +363,6 @@ exports[`renders an empty list correctly 1`] = `
onMouseOver={[Function]}
onTouchEnd={[Function]}
onTouchStart={[Function]}
role="tooltip"
>
<button
aria-labelledby="useId-2"
@ -485,7 +483,6 @@ exports[`renders an empty list correctly 1`] = `
onMouseOver={[Function]}
onTouchEnd={[Function]}
onTouchStart={[Function]}
role="tooltip"
>
<button
aria-labelledby="useId-3"
@ -536,7 +533,6 @@ exports[`renders an empty list correctly 1`] = `
onMouseOver={[Function]}
onTouchEnd={[Function]}
onTouchStart={[Function]}
role="tooltip"
>
<button
aria-labelledby="useId-4"
@ -657,7 +653,6 @@ exports[`renders an empty list correctly 1`] = `
onMouseOver={[Function]}
onTouchEnd={[Function]}
onTouchStart={[Function]}
role="tooltip"
>
<button
aria-labelledby="useId-5"
@ -708,7 +703,6 @@ exports[`renders an empty list correctly 1`] = `
onMouseOver={[Function]}
onTouchEnd={[Function]}
onTouchStart={[Function]}
role="tooltip"
>
<button
aria-labelledby="useId-6"
@ -829,7 +823,6 @@ exports[`renders an empty list correctly 1`] = `
onMouseOver={[Function]}
onTouchEnd={[Function]}
onTouchStart={[Function]}
role="tooltip"
>
<button
aria-labelledby="useId-7"
@ -880,7 +873,6 @@ exports[`renders an empty list correctly 1`] = `
onMouseOver={[Function]}
onTouchEnd={[Function]}
onTouchStart={[Function]}
role="tooltip"
>
<button
aria-labelledby="useId-8"
@ -1001,7 +993,6 @@ exports[`renders an empty list correctly 1`] = `
onMouseOver={[Function]}
onTouchEnd={[Function]}
onTouchStart={[Function]}
role="tooltip"
>
<button
aria-labelledby="useId-9"
@ -1052,7 +1043,6 @@ exports[`renders an empty list correctly 1`] = `
onMouseOver={[Function]}
onTouchEnd={[Function]}
onTouchStart={[Function]}
role="tooltip"
>
<button
aria-labelledby="useId-10"

Some files were not shown because too many files have changed in this diff Show More