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:
commit
69f4b73b16
@ -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 |
@ -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) {
|
||||
|
@ -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 />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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'
|
||||
|
@ -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;
|
@ -1,7 +0,0 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
const RedirectAdminInvoices = () => {
|
||||
return <Navigate to="/admin/billing" replace />;
|
||||
};
|
||||
|
||||
export default RedirectAdminInvoices;
|
25
frontend/src/component/admin/invoice/InvoiceAdminPage.tsx
Normal file
25
frontend/src/component/admin/invoice/InvoiceAdminPage.tsx
Normal 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;
|
122
frontend/src/component/admin/invoice/InvoiceList.tsx
Normal file
122
frontend/src/component/admin/invoice/InvoiceList.tsx
Normal 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;
|
@ -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"
|
||||
|
@ -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!}
|
||||
/>
|
||||
)}
|
||||
|
@ -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 “
|
||||
{searchValue}”
|
||||
</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 “
|
||||
{searchValue}”
|
||||
</TablePlaceholder>
|
||||
}
|
||||
elseShow={
|
||||
<TablePlaceholder>
|
||||
None of the feature toggles were archived yet.
|
||||
</TablePlaceholder>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</PageContent>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -23,7 +23,6 @@ const BreadcrumbNav = () => {
|
||||
item !== 'logs' &&
|
||||
item !== 'metrics' &&
|
||||
item !== 'copy' &&
|
||||
item !== 'strategies' &&
|
||||
item !== 'features' &&
|
||||
item !== 'features2' &&
|
||||
item !== 'create-toggle' &&
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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} />
|
||||
|
@ -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));
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
@ -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')}
|
||||
|
@ -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>
|
||||
`;
|
||||
|
@ -26,7 +26,7 @@ const StyledDivider = styled(Divider)(({ theme }) => ({
|
||||
}));
|
||||
|
||||
interface IPageHeaderProps {
|
||||
title: string;
|
||||
title?: string;
|
||||
titleElement?: ReactNode;
|
||||
subtitle?: string;
|
||||
variant?: TypographyProps['variant'];
|
||||
|
@ -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}
|
||||
|
@ -61,7 +61,7 @@ const PermissionIconButton = ({
|
||||
arrow
|
||||
onClick={e => e.preventDefault()}
|
||||
>
|
||||
<div id={id} role="tooltip">
|
||||
<div id={id}>
|
||||
<IconButton
|
||||
{...rest}
|
||||
disabled={!access || disabled}
|
||||
|
@ -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%',
|
||||
|
@ -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,
|
||||
},
|
||||
},
|
||||
}));
|
@ -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>
|
||||
);
|
||||
};
|
@ -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),
|
||||
},
|
||||
|
@ -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}`}
|
||||
/>
|
||||
);
|
||||
|
@ -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
|
||||
|
@ -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';
|
||||
|
@ -28,6 +28,7 @@ export const useStyles = makeStyles()(theme => ({
|
||||
},
|
||||
textContainer: {
|
||||
marginLeft: '1rem',
|
||||
wordBreak: 'break-word',
|
||||
},
|
||||
headerStyles: {
|
||||
fontWeight: 'normal',
|
||||
|
@ -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}
|
||||
|
@ -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}>
|
||||
|
@ -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={
|
||||
|
@ -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}
|
||||
|
@ -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',
|
||||
},
|
||||
|
@ -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\\"
|
||||
}'"
|
||||
`);
|
||||
});
|
@ -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}' \\
|
||||
|
@ -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\\"
|
||||
}'"
|
||||
`);
|
||||
});
|
@ -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}' \\
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -40,7 +40,7 @@ export const FeatureStrategyMenu = ({
|
||||
projectId={projectId}
|
||||
environmentId={environmentId}
|
||||
onClick={onClick}
|
||||
aria-describedby={popoverId}
|
||||
aria-labelledby={popoverId}
|
||||
variant={variant}
|
||||
>
|
||||
{label}
|
||||
|
@ -196,7 +196,7 @@ export const FeatureToggleListItem = memo<IFeatureToggleListItemProps>(
|
||||
!projectExists()
|
||||
}
|
||||
onClick={reviveFeature}
|
||||
tooltipProps={{ title: 'Revive feature' }}
|
||||
tooltipProps={{ title: 'Revive feature toggle' }}
|
||||
>
|
||||
<Undo />
|
||||
</PermissionIconButton>
|
||||
|
@ -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}
|
||||
|
@ -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,
|
||||
},
|
||||
},
|
||||
}));
|
@ -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],
|
||||
},
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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
|
||||
|
@ -144,7 +144,7 @@ export const FeatureView = () => {
|
||||
permission={UPDATE_FEATURE}
|
||||
projectId={projectId}
|
||||
tooltipProps={{
|
||||
title: 'Toggle stale status',
|
||||
title: 'Toggle stale state',
|
||||
}}
|
||||
data-loading
|
||||
>
|
||||
|
@ -27,8 +27,6 @@ export const useStyles = makeStyles()(theme => ({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
padding: '1rem',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
closeIcon: {
|
||||
fontSize: '1.5rem',
|
||||
|
@ -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>
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
},
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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",
|
||||
|
@ -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',
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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} />;
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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 = (
|
@ -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',
|
@ -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';
|
||||
|
@ -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,
|
||||
},
|
||||
];
|
@ -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>
|
||||
|
@ -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 (
|
||||
|
@ -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
|
||||
|
@ -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} />
|
||||
|
@ -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>
|
||||
),
|
||||
},
|
||||
],
|
||||
|
@ -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}
|
||||
|
@ -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';
|
||||
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
@ -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 (
|
||||
<>
|
||||
|
@ -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',
|
||||
|
@ -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:
|
||||
|
@ -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',
|
||||
},
|
||||
}));
|
@ -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 won’t be
|
||||
affected,
|
||||
</b>{' '}
|
||||
but you’ll 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 project’s 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 you’d 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 you’d 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>
|
||||
}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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',
|
||||
},
|
||||
},
|
||||
}));
|
@ -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>
|
||||
);
|
||||
};
|
@ -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];
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
|
@ -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',
|
||||
|
@ -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={
|
||||
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user