1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-11-10 01:19:53 +01:00

Merge branch 'main' into meta/add-stalebot

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 454 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 81 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 67 KiB

View File

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

View File

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

View File

@ -11,6 +11,9 @@ import { ConfirmToken } from '../ConfirmToken/ConfirmToken';
import { useState } from 'react'; import { useState } from 'react';
import { scrollToTop } from 'component/common/util'; import { scrollToTop } from 'component/common/util';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import { usePageTitle } from 'hooks/usePageTitle';
const pageTitle = 'Create API token';
export const CreateApiToken = () => { export const CreateApiToken = () => {
const { setToastApiError } = useToast(); const { setToastApiError } = useToast();
@ -36,6 +39,8 @@ export const CreateApiToken = () => {
const { createToken, loading } = useApiTokensApi(); const { createToken, loading } = useApiTokensApi();
usePageTitle(pageTitle);
const handleSubmit = async (e: Event) => { const handleSubmit = async (e: Event) => {
e.preventDefault(); e.preventDefault();
if (!isValid()) { if (!isValid()) {
@ -76,7 +81,7 @@ export const CreateApiToken = () => {
return ( return (
<FormTemplate <FormTemplate
loading={loading} 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." 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" documentationLink="https://docs.getunleash.io/reference/api-tokens-and-client-keys"
documentationLinkLabel="API tokens documentation" documentationLinkLabel="API tokens documentation"

View File

@ -1,20 +1,8 @@
import { Button, styled } from '@mui/material'; import { Button, styled } from '@mui/material';
import { VFC } from 'react'; 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 const PORTAL_URL = formatApiPath('api/admin/invoices');
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 StyledButton = styled(Button)(({ theme }) => ({ const StyledButton = styled(Button)(({ theme }) => ({
width: '100%', width: '100%',
@ -27,10 +15,11 @@ interface IBillingInformationButtonProps {
export const BillingInformationButton: VFC<IBillingInformationButtonProps> = ({ export const BillingInformationButton: VFC<IBillingInformationButtonProps> = ({
update, update,
}) => { }) => (
return ( <StyledButton
<StyledButton href={href} variant={update ? 'outlined' : 'contained'}> href={`${PORTAL_URL}/${update ? 'portal' : 'checkout'}`}
{update ? 'Update billing information' : 'Add billing information'} variant={update ? 'outlined' : 'contained'}
</StyledButton> >
); {update ? 'Update billing information' : 'Add billing information'}
}; </StyledButton>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,11 +6,12 @@ import { useNavigate } from 'react-router-dom';
import { useContext } from 'react'; import { useContext } from 'react';
import AccessContext from 'contexts/AccessContext'; import AccessContext from 'contexts/AccessContext';
import { ADMIN } from 'component/providers/AccessProvider/permissions'; import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { import {
hasTrialExpired, trialHasExpired,
formatTrialExpirationWarning, trialExpiresSoon,
isTrialInstance,
} from 'utils/instanceTrial'; } from 'utils/instanceTrial';
import { formatDistanceToNowStrict, parseISO } from 'date-fns';
const StyledWarningBar = styled('aside')(({ theme }) => ({ const StyledWarningBar = styled('aside')(({ theme }) => ({
position: 'relative', position: 'relative',
@ -61,58 +62,75 @@ interface IInstanceStatusBarProps {
export const InstanceStatusBar = ({ export const InstanceStatusBar = ({
instanceStatus, instanceStatus,
}: IInstanceStatusBarProps) => { }: IInstanceStatusBarProps) => {
const { hasAccess } = useContext(AccessContext); if (trialHasExpired(instanceStatus)) {
const trialHasExpired = hasTrialExpired(instanceStatus); return <StatusBarExpired instanceStatus={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 (trialExpirationWarning) { if (trialExpiresSoon(instanceStatus)) {
return ( return <StatusBarExpiresSoon instanceStatus={instanceStatus} />;
<StyledInfoBar data-testid={INSTANCE_STATUS_BAR_ID}> }
<StyledInfoIcon />
<Typography if (isTrialInstance(instanceStatus)) {
sx={theme => ({ return <StatusBarExpiresLater instanceStatus={instanceStatus} />;
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>
);
} }
return null; 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(); const navigate = useNavigate();
if (!hasAccess(ADMIN)) {
return null;
}
return ( return (
<StyledButton <StyledButton
onClick={() => navigate('/admin/billing')} onClick={() => navigate('/admin/billing')}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,11 +4,8 @@ import {
UPDATE_ENVIRONMENT, UPDATE_ENVIRONMENT,
} from 'component/providers/AccessProvider/permissions'; } from 'component/providers/AccessProvider/permissions';
import { Edit, Delete } from '@mui/icons-material'; 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 { useNavigate } from 'react-router-dom';
import AccessContext from 'contexts/AccessContext'; import { useState } from 'react';
import { useContext, useState } from 'react';
import { IEnvironment } from 'interfaces/environments'; import { IEnvironment } from 'interfaces/environments';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import EnvironmentToggleConfirm from '../../EnvironmentToggleConfirm/EnvironmentToggleConfirm'; 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 useProjectRolePermissions from 'hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions';
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments'; import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
import { useId } from 'hooks/useId';
import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch'; import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
interface IEnvironmentTableActionsProps { interface IEnvironmentTableActionsProps {
environment: IEnvironment; environment: IEnvironment;
@ -28,9 +25,6 @@ export const EnvironmentActionCell = ({
environment, environment,
}: IEnvironmentTableActionsProps) => { }: IEnvironmentTableActionsProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { hasAccess } = useContext(AccessContext);
const updatePermission = hasAccess(UPDATE_ENVIRONMENT);
const { setToastApiError, setToastData } = useToast(); const { setToastApiError, setToastData } = useToast();
const { refetchEnvironments } = useEnvironments(); const { refetchEnvironments } = useEnvironments();
const { refetch: refetchPermissions } = useProjectRolePermissions(); 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 ( return (
<ActionCell> <ActionCell>
<ConditionallyRender <PermissionSwitch
condition={updatePermission} permission={UPDATE_ENVIRONMENT}
show={ checked={environment.enabled}
<> disabled={environment.protected}
<Tooltip title={toggleIconTooltip} arrow describeChild> tooltip={
<PermissionSwitch environment.enabled
permission={UPDATE_ENVIRONMENT} ? `Disable environment ${environment.name}`
checked={environment.enabled} : `Enable environment ${environment.name}`
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>
} }
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 <EnvironmentDeleteConfirm
env={environment} env={environment}
setDeldialogue={setDeleteModal} setDeldialogue={setDeleteModal}

View File

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

View File

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

View File

@ -76,7 +76,7 @@ const CreateFeature = () => {
return ( return (
<FormTemplate <FormTemplate
loading={loading} 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. 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" The feature toggle is disabled when created and you decide when to enable"
documentationLink="https://docs.getunleash.io/advanced/feature_toggle_types" documentationLink="https://docs.getunleash.io/advanced/feature_toggle_types"
@ -102,7 +102,7 @@ const CreateFeature = () => {
clearErrors={clearErrors} clearErrors={clearErrors}
> >
<CreateButton <CreateButton
name="Feature" name="feature toggle"
permission={CREATE_FEATURE} permission={CREATE_FEATURE}
projectId={project} projectId={project}
data-testid={CF_CREATE_BTN_ID} data-testid={CF_CREATE_BTN_ID}

View File

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

View File

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

View File

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

View File

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

View File

@ -148,7 +148,7 @@ export const formatEditStrategyPath = (
return `/projects/${projectId}/features/${featureId}/strategies/edit?${params}`; return `/projects/${projectId}/features/${featureId}/strategies/edit?${params}`;
}; };
const formatUpdateStrategyApiCode = ( export const formatUpdateStrategyApiCode = (
projectId: string, projectId: string,
featureId: string, featureId: string,
environmentId: string, environmentId: string,
@ -159,7 +159,7 @@ const formatUpdateStrategyApiCode = (
return ''; 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); const payload = JSON.stringify(strategy, undefined, 2);
return `curl --location --request PUT '${url}' \\ return `curl --location --request PUT '${url}' \\

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,8 +2,7 @@ import { FeatureMetricsTable } from '../FeatureMetricsTable/FeatureMetricsTable'
import { IFeatureMetricsRaw } from 'interfaces/featureToggle'; import { IFeatureMetricsRaw } from 'interfaces/featureToggle';
import { FeatureMetricsStatsRaw } from '../FeatureMetricsStats/FeatureMetricsStatsRaw'; import { FeatureMetricsStatsRaw } from '../FeatureMetricsStats/FeatureMetricsStatsRaw';
import { FeatureMetricsChart } from '../FeatureMetricsChart/FeatureMetricsChart'; import { FeatureMetricsChart } from '../FeatureMetricsChart/FeatureMetricsChart';
import { FeatureMetricsEmpty } from '../FeatureMetricsEmpty/FeatureMetricsEmpty'; import { Box, Typography } from '@mui/material';
import { Box } from '@mui/material';
import theme from 'themes/theme'; import theme from 'themes/theme';
import { useId } from 'hooks/useId'; import { useId } from 'hooks/useId';
@ -22,7 +21,14 @@ export const FeatureMetricsContent = ({
if (metrics.length === 0) { if (metrics.length === 0) {
return ( return (
<Box mt={6}> <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> </Box>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import { VFC, ReactElement } from 'react';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { ReportProblemOutlined, Check } from '@mui/icons-material'; import { ReportProblemOutlined, Check } from '@mui/icons-material';
import { styled } from '@mui/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 }) => ({ const StyledTextPotentiallyStale = styled('span')(({ theme }) => ({
display: 'flex', display: 'flex',

View File

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

View File

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

View File

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

View File

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

View File

@ -6,7 +6,6 @@ import {
Button, Button,
InputAdornment, InputAdornment,
SelectChangeEvent, SelectChangeEvent,
Alert,
} from '@mui/material'; } from '@mui/material';
import { Search } from '@mui/icons-material'; import { Search } from '@mui/icons-material';
import Autocomplete from '@mui/material/Autocomplete'; import Autocomplete from '@mui/material/Autocomplete';
@ -152,10 +151,6 @@ export const ProjectAccessAddUser = ({ roles }: IProjectAccessAddUserProps) => {
return ( 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 container spacing={3} alignItems="flex-end">
<Grid item> <Grid item>
<Autocomplete <Autocomplete

View File

@ -57,7 +57,9 @@ export const ProjectAccessPage = () => {
refetchProjectAccess(); refetchProjectAccess();
setToastData({ setToastData({
type: 'success', 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) { } catch (err: any) {
setToastData({ setToastData({
@ -70,7 +72,7 @@ export const ProjectAccessPage = () => {
return ( return (
<PageContent <PageContent
header={<PageHeader title="Project roles" />} header={<PageHeader titleElement="Project roles" />}
className={styles.pageContent} className={styles.pageContent}
> >
<ProjectAccessAddUser roles={access?.roles} /> <ProjectAccessAddUser roles={access?.roles} />

View File

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

View File

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

View File

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

View File

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

View File

@ -1,26 +1,6 @@
import { Alert } from '@mui/material'; import { Alert } from '@mui/material';
import { useStyles } from 'component/segments/SegmentDocs/SegmentDocs.styles';
import { useSegmentLimits } from 'hooks/api/getters/useSegmentLimits/useSegmentLimits'; 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 = () => { export const SegmentDocsValuesWarning = () => {
const { segmentValuesLimit } = useSegmentLimits(); const { segmentValuesLimit } = useSegmentLimits();
@ -30,9 +10,8 @@ export const SegmentDocsValuesWarning = () => {
return ( return (
<Alert severity="warning"> <Alert severity="warning">
Segments is an experimental feature available to select users. Segments is an experimental feature, currently limited to at most{' '}
Currently, segments are limited to at most {segmentValuesLimit}{' '} {segmentValuesLimit} values. <SegmentLimitsLink />
values. <SegmentLimitsLink />
</Alert> </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 = () => { const SegmentLimitsLink = () => {
return ( return (
<> <>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
// All known splash IDs. // 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. // 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]; export type SplashId = typeof splashIds[number];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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