- Changes permissions layout

- Update tests
This commit is contained in:
Bassel Kanso 2022-08-31 01:57:31 +03:00
parent 89c0fcb22d
commit 3e301e9c1e
12 changed files with 870 additions and 713 deletions

View File

@ -0,0 +1,53 @@
import React from 'react';
import { useIntl } from 'react-intl';
import { Typography } from '@strapi/design-system/Typography';
import { Stack } from '@strapi/design-system/Stack';
import { GridItem } from '@strapi/design-system/Grid';
import BoundRoute from '../BoundRoute';
import { useApiTokenPermissionsContext } from '../../../../../../../contexts/ApiTokenPermissions';
const ActionBoundRoutes = () => {
const {
value: { selectedAction, routes },
} = useApiTokenPermissionsContext();
const { formatMessage } = useIntl();
return (
<GridItem
col={5}
background="neutral150"
paddingTop={6}
paddingBottom={6}
paddingLeft={7}
paddingRight={7}
style={{ minHeight: '100%' }}
>
{selectedAction ? (
<Stack spacing={2}>
{routes.map((route, key) => (
// eslint-disable-next-line react/no-array-index-key
<BoundRoute key={key} route={route} />
))}
</Stack>
) : (
<Stack spacing={2}>
<Typography variant="delta" as="h3">
{formatMessage({
id: 'Settings.apiTokens.createPage.permissions.header.title',
defaultMessage: 'Advanced settings',
})}
</Typography>
<Typography as="p" textColor="neutral600">
{formatMessage({
id: 'Settings.apiTokens.createPage.permissions.header.hint',
defaultMessage:
"Select the application's actions or the plugin's actions and click on the cog icon to display the bound route",
})}
</Typography>
</Stack>
)}
</GridItem>
);
};
export default ActionBoundRoutes;

View File

@ -0,0 +1,41 @@
const getMethodColor = (verb) => {
switch (verb) {
case 'POST': {
return {
text: 'success600',
border: 'success200',
background: 'success100',
};
}
case 'GET': {
return {
text: 'secondary600',
border: 'secondary200',
background: 'secondary100',
};
}
case 'PUT': {
return {
text: 'warning600',
border: 'warning200',
background: 'warning100',
};
}
case 'DELETE': {
return {
text: 'danger600',
border: 'danger200',
background: 'danger100',
};
}
default: {
return {
text: 'neutral600',
border: 'neutral200',
background: 'neutral100',
};
}
}
};
export default getMethodColor;

View File

@ -0,0 +1,72 @@
import React from 'react';
import styled from 'styled-components';
import { Stack } from '@strapi/design-system/Stack';
import { Box } from '@strapi/design-system/Box';
import { Typography } from '@strapi/design-system/Typography';
import map from 'lodash/map';
import tail from 'lodash/tail';
import { useIntl } from 'react-intl';
import PropTypes from 'prop-types';
import getMethodColor from './getMethodColor';
const MethodBox = styled(Box)`
margin: -1px;
border-radius: ${({ theme }) => theme.spaces[1]} 0 0 ${({ theme }) => theme.spaces[1]};
`;
function BoundRoute({ route }) {
const { formatMessage } = useIntl();
const { method, handler: title, path } = route;
const formattedRoute = path ? tail(path.split('/')) : [];
const [controller = '', action = ''] = title ? title.split('.') : [];
const colors = getMethodColor(route.method);
return (
<Stack spacing={2}>
<Typography variant="delta" as="h3">
{formatMessage({
id: 'Settings.apiTokens.createPage.BoundRoute.title',
defaultMessage: 'Bound route to',
})}
&nbsp;
<span>{controller}</span>
<Typography variant="delta" textColor="primary600">
.{action}
</Typography>
</Typography>
<Stack horizontal hasRadius background="neutral0" borderColor="neutral200" spacing={0}>
<MethodBox background={colors.background} borderColor={colors.border} padding={2}>
<Typography fontWeight="bold" textColor={colors.text}>
{method}
</Typography>
</MethodBox>
<Box paddingLeft={2} paddingRight={2}>
{map(formattedRoute, (value) => (
<Typography key={value} textColor={value.includes(':') ? 'neutral600' : 'neutral900'}>
/{value}
</Typography>
))}
</Box>
</Stack>
</Stack>
);
}
BoundRoute.defaultProps = {
route: {
handler: 'Nocontroller.error',
method: 'GET',
path: '/there-is-no-path',
},
};
BoundRoute.propTypes = {
route: PropTypes.shape({
handler: PropTypes.string,
method: PropTypes.string,
path: PropTypes.string,
}),
};
export default BoundRoute;

View File

@ -0,0 +1,30 @@
import styled, { css } from 'styled-components';
import { Box } from '@strapi/design-system/Box';
const activeCheckboxWrapperStyles = css`
background: ${(props) => props.theme.colors.primary100};
svg {
opacity: 1;
}
`;
const CheckboxWrapper = styled(Box)`
display: flex;
justify-content: space-between;
align-items: center;
svg {
opacity: 0;
path {
fill: ${(props) => props.theme.colors.primary600};
}
}
/* Show active style both on hover and when the action is selected */
${(props) => props.isActive && activeCheckboxWrapperStyles}
&:hover {
${activeCheckboxWrapperStyles}
}
`;
export default CheckboxWrapper;

View File

@ -6,9 +6,11 @@ import { Grid, GridItem } from '@strapi/design-system/Grid';
import { Typography } from '@strapi/design-system/Typography';
import { Box } from '@strapi/design-system/Box';
import { Flex } from '@strapi/design-system/Flex';
import CogIcon from '@strapi/icons/Cog';
import styled from 'styled-components';
import PropTypes from 'prop-types';
import { useApiTokenPermissionsContext } from '../../../../../../../contexts/ApiTokenPermissions';
import CheckboxWrapper from './CheckBoxWrapper';
const Border = styled.div`
flex: 1;
@ -25,7 +27,7 @@ const CollapsableContentType = ({
indexExpandendCollapsedContent,
}) => {
const {
value: { onChangeSelectAll, onChange, selectedActions },
value: { onChangeSelectAll, onChange, selectedActions, setSelectedAction, selectedAction },
} = useApiTokenPermissionsContext();
const [expanded, setExpanded] = useState(false);
@ -44,6 +46,8 @@ const CollapsableContentType = ({
}
}, [indexExpandendCollapsedContent, orderNumber, expanded]);
const isActionSelected = (actionId) => actionId === selectedAction;
return (
<Accordion
expanded={expanded}
@ -87,17 +91,33 @@ const CollapsableContentType = ({
{controller?.actions &&
controller?.actions.map((action) => {
return (
<GridItem col={4} key={action.actionId}>
<Checkbox
value={selectedActions.includes(action.actionId)}
name={action.actionId}
onValueChange={() => {
onChange({ target: { value: action.actionId } });
}}
disabled={disabled}
<GridItem col={6} key={action.actionId}>
<CheckboxWrapper
isActive={isActionSelected(action.actionId)}
padding={2}
hasRadius
>
{action.action}
</Checkbox>
<Checkbox
value={selectedActions.includes(action.actionId)}
name={action.actionId}
onValueChange={() => {
onChange({ target: { value: action.actionId } });
}}
disabled={disabled}
>
{action.action}
</Checkbox>
<button
type="button"
data-testid="action-cog"
onClick={() =>
setSelectedAction({ target: { value: action.actionId } })
}
style={{ display: 'inline-flex', alignItems: 'center' }}
>
<CogIcon />
</button>
</CheckboxWrapper>
</GridItem>
);
})}

View File

@ -1,9 +1,10 @@
import React, { memo } from 'react';
import { useIntl } from 'react-intl';
import { Box } from '@strapi/design-system/Box';
import { Typography } from '@strapi/design-system/Typography';
import { Stack } from '@strapi/design-system/Stack';
import { Grid, GridItem } from '@strapi/design-system/Grid';
import ContentTypesSection from '../ContenTypesSection';
import ActionBoundRoutes from '../ActionBoundRoutes';
import { useApiTokenPermissionsContext } from '../../../../../../../contexts/ApiTokenPermissions';
const Permissions = ({ ...props }) => {
@ -13,23 +14,26 @@ const Permissions = ({ ...props }) => {
const { formatMessage } = useIntl();
return (
<Box shadow="filterShadow" padding={4} background="neutral0">
<Stack spacing={2}>
<Typography variant="delta" as="h2">
{formatMessage({
id: 'Settings.apiTokens.createPage.permissions.title',
defaultMessage: 'Permissions',
})}
</Typography>
<Typography as="p" textColor="neutral600">
{formatMessage({
id: 'Settings.apiTokens.createPage.permissions.description',
defaultMessage: 'Only actions bound by a route are listed below.',
})}
</Typography>
</Stack>
{data.permissions && <ContentTypesSection section={data.permissions} {...props} />}
</Box>
<Grid gap={0} shadow="filterShadow" hasRadius background="neutral0">
<GridItem col={7} paddingTop={6} paddingBottom={6} paddingLeft={7} paddingRight={7}>
<Stack spacing={2}>
<Typography variant="delta" as="h2">
{formatMessage({
id: 'Settings.apiTokens.createPage.permissions.title',
defaultMessage: 'Permissions',
})}
</Typography>
<Typography as="p" textColor="neutral600">
{formatMessage({
id: 'Settings.apiTokens.createPage.permissions.description',
defaultMessage: 'Only actions bound by a route are listed below.',
})}
</Typography>
</Stack>
{data?.permissions && <ContentTypesSection section={data?.permissions} {...props} />}
</GridItem>
<ActionBoundRoutes />
</Grid>
);
};

View File

@ -100,7 +100,7 @@ const ApiTokenCreateView = () => {
dispatch({
type: 'UPDATE_PERMISSIONS',
value: data.permissions,
value: data?.permissions,
});
return data;
@ -247,10 +247,18 @@ const ApiTokenCreateView = () => {
}
};
const setSelectedAction = ({ target: { value } }) => {
dispatch({
type: 'SET_SELECTED_ACTION',
value,
});
};
const providerValue = {
...state,
onChange: handleChangeCheckbox,
onChangeSelectAll: handleChangeSelectAllCheckbox,
setSelectedAction,
};
const canEditInputs = (canUpdate && !isCreating) || (canCreate && isCreating);
@ -272,7 +280,6 @@ const ApiTokenCreateView = () => {
description: apiToken?.description || '',
type: apiToken?.type,
lifespan: apiToken?.lifespan,
permissions: apiToken?.permissions,
}}
onSubmit={handleSubmit}
>

View File

@ -3,6 +3,8 @@ import { transformPermissionsData } from './utils';
const init = (state, permissions = []) => {
return {
...state,
selectedAction: null,
routes: [],
selectedActions: [],
data: transformPermissionsData(permissions),
};

View File

@ -39,6 +39,10 @@ const reducer = (state, action) =>
draftState.selectedActions = [...action.value];
break;
}
case 'SET_SELECTED_ACTION': {
draftState.selectedAction = action.value;
break;
}
default:
return draftState;
}

View File

@ -9,6 +9,7 @@ import { axiosInstance } from '../../../../../../core/utils';
import Theme from '../../../../../../components/Theme';
import ThemeToggleProvider from '../../../../../../components/ThemeToggleProvider';
import EditView from '../index';
import { data } from '../utils/tests/dataMock';
jest.mock('@strapi/helper-plugin', () => ({
...jest.requireActual('@strapi/helper-plugin'),
@ -27,18 +28,27 @@ jest.mock('@strapi/helper-plugin', () => ({
})),
}));
jest.spyOn(axiosInstance, 'get').mockResolvedValue({
data: {
jest.spyOn(axiosInstance, 'get').mockImplementation((path) => {
if (path === '/admin/content-api/permissions') {
return { data };
}
return {
data: {
id: 1,
name: 'My super token',
description: 'This describe my super token',
type: 'read-only',
createdAt: '2021-11-15T00:00:00.000Z',
data: {
id: 1,
name: 'My super token',
description: 'This describe my super token',
type: 'read-only',
createdAt: '2021-11-15T00:00:00.000Z',
permissions: [],
},
},
},
};
});
// jest.spyOn(axiosInstance, 'get').mockResolvedValue({ data });
jest.spyOn(Date, 'now').mockImplementation(() => new Date('2015-10-01T08:00:00.000Z'));
const client = new QueryClient({
@ -72,13 +82,17 @@ describe('ADMIN | Pages | API TOKENS | EditView', () => {
jest.resetAllMocks();
});
it('renders and matches the snapshot when creating token', () => {
it('renders and matches the snapshot when creating token', async () => {
const history = createMemoryHistory();
const App = makeApp(history);
const { container } = render(App);
const { container, getByText } = render(App);
history.push('/settings/api-tokens/create');
await waitFor(() => {
expect(getByText('Address')).toBeInTheDocument();
});
expect(container).toMatchSnapshot();
});
@ -92,6 +106,7 @@ describe('ADMIN | Pages | API TOKENS | EditView', () => {
await waitFor(() => {
expect(getByText('My super token')).toBeInTheDocument();
expect(getByText('This describe my super token')).toBeInTheDocument();
expect(getByText('Address')).toBeInTheDocument();
});
expect(container).toMatchSnapshot();

View File

@ -59,4 +59,13 @@ describe('ADMIN | Pages | API TOKENS | EditView | reducer', () => {
'api::category.category.findOne',
]);
});
it('should add a selectedAction', () => {
const action = {
type: 'SET_SELECTED_ACTION',
value: 'api::address.address.find',
};
expect(reducer(initialState, action).selectedAction).toBe('api::address.address.find');
});
});