Skip to content

Commit

Permalink
Group abilities frontend (#2745)
Browse files Browse the repository at this point in the history
* Create AbilitiesMultiSelect component

* Use the new component in the users forms

* Group tag abilities

* Group settings abilities
  • Loading branch information
arbulu89 authored Jul 11, 2024
1 parent e231aa9 commit b0458c6
Show file tree
Hide file tree
Showing 6 changed files with 230 additions and 22 deletions.
100 changes: 100 additions & 0 deletions assets/js/common/AbilitiesMultiSelect/AbilitiesMultiSelect.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import React from 'react';
import { assign, find } from 'lodash';

import MultiSelect from '@common/MultiSelect';

const groupedAbilities = [
{
ability: 'all:checks_selection',
tooltip: 'Permits all operations on checks selection',
groupAbilities: [
'all:host_checks_selection',
'all:cluster_checks_selection',
],
},
{
ability: 'all:checks_execution',
tooltip: 'Permits all operations on checks execution',
groupAbilities: [
'all:host_checks_execution',
'all:cluster_checks_execution',
],
},
{
ability: 'cleanup:all',
tooltip: 'Permits cleanup of resources',
groupAbilities: [
'cleanup:host',
'cleanup:database_instance',
'cleanup:application_instance',
],
},
{
ability: 'all:tags',
tooltip: 'Permits all operations on tags',
groupAbilities: [
'all:host_tags',
'all:cluster_tags',
'all:database_tags',
'all:sap_system_tags',
],
},
{
ability: 'all:settings',
tooltip: 'Permits all operations on settings',
groupAbilities: [
'all:api_key_settings',
'all:suma_settings',
'all:activity_logs_settings',
],
},
];

const mapAbilities = (abilities) =>
abilities.reduce((acc, { id, name, resource, label }) => {
const valueLabel = `${name}:${resource}`;
const groupedAbility = find(groupedAbilities, ({ groupAbilities }) =>
groupAbilities.includes(valueLabel)
);

if (!groupedAbility) {
return acc.concat({ value: id, label: valueLabel, tooltip: label });
}

const currentOption = find(acc, { label: groupedAbility.ability });
if (currentOption) {
assign(currentOption, { value: currentOption.value.concat(id) });
return acc;
}

return acc.concat({
value: [id],
label: groupedAbility.ability,
tooltip: groupedAbility.tooltip,
});
}, []);

const unmapAbilities = (abilities) =>
abilities.map(({ value }) => value).flat();

function AbilitiesMultiSelect({
abilities,
userAbilities,
placeholder,
setAbilities,
...props
}) {
return (
<MultiSelect
aria-label="permissions"
placeholder={placeholder}
values={mapAbilities(userAbilities)}
options={mapAbilities(abilities)}
onChange={(values) => setAbilities(unmapAbilities(values))}
getOptionValue={(option) => option.value.toString()}
{...props}
/>
);
}

export default AbilitiesMultiSelect;
117 changes: 117 additions & 0 deletions assets/js/common/AbilitiesMultiSelect/AbilitiesMultiSelect.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import React from 'react';

import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import userEvent from '@testing-library/user-event';
import { noop } from 'lodash';

import AbilitiesMultiSelect from './AbilitiesMultiSelect';

describe('AbilitiesMultiSelect Component', () => {
it('should group abilities', async () => {
const user = userEvent.setup();

const groups = [
'all:checks_selection',
'all:checks_execution',
'cleanup:all',
];
const abilities = [
{ id: 1, name: 'all', resource: 'host_checks_selection' },
{ id: 2, name: 'all', resource: 'cluster_checks_selection' },
{ id: 3, name: 'all', resource: 'host_checks_execution' },
{ id: 4, name: 'all', resource: 'cluster_checks_execution' },
{ id: 5, name: 'cleanup', resource: 'host' },
{ id: 6, name: 'cleanup', resource: 'database_instance' },
{ id: 7, name: 'cleanup', resource: 'application_instance' },
{ id: 8, name: 'all', resource: 'host_tags' },
{ id: 9, name: 'all', resource: 'cluster_tags' },
{ id: 10, name: 'all', resource: 'database_tags' },
{ id: 11, name: 'all', resource: 'sap_system_tags' },
{ id: 12, name: 'all', resource: 'api_key_settings' },
{ id: 13, name: 'all', resource: 'suma_settings' },
{ id: 14, name: 'all', resource: 'activity_logs_settings' },
];

render(
<AbilitiesMultiSelect
abilities={abilities}
userAbilities={[]}
setAbilities={noop}
/>
);

await user.click(screen.getByLabelText('permissions'));
groups.forEach((group) => {
expect(screen.getByText(group)).toBeVisible();
});

abilities.forEach(({ name, resource }) => {
expect(screen.queryByText(`${name}:${resource}`)).not.toBeInTheDocument();
});

await user.click(screen.getByText('all:checks_selection'));
await user.click(screen.getByLabelText('permissions'));
await user.click(screen.getByText('all:checks_execution'));
await user.click(screen.getByLabelText('permissions'));
await user.click(screen.getByText('cleanup:all'));
await user.click(screen.getByLabelText('permissions'));
await user.click(screen.getByText('all:tags'));
await user.click(screen.getByLabelText('permissions'));
await user.click(screen.getByText('all:settings'));
await user.click(screen.getByLabelText('permissions'));

expect(screen.getByText('No options')).toBeVisible();
});

it('should display individual abilities', async () => {
const user = userEvent.setup();

render(
<AbilitiesMultiSelect
abilities={[
{ id: 1, name: 'all', resource: 'all' },
{ id: 2, name: 'all', resource: 'users' },
]}
userAbilities={[]}
setAbilities={noop}
/>
);

await user.click(screen.getByLabelText('permissions'));
expect(screen.getByText('all:all')).toBeVisible();
expect(screen.getByText('all:users')).toBeVisible();
});

it('should preload grouped abilities', async () => {
const user = userEvent.setup();

render(
<AbilitiesMultiSelect
abilities={[
{ id: 1, name: 'all', resource: 'all' },
{ id: 2, name: 'all', resource: 'host_checks_selection' },
{ id: 3, name: 'all', resource: 'cluster_checks_selection' },
]}
userAbilities={[
{ id: 1, name: 'all', resource: 'all' },
{ id: 2, name: 'all', resource: 'host_checks_selection' },
{ id: 3, name: 'all', resource: 'cluster_checks_selection' },
]}
setAbilities={noop}
/>
);

screen.getByText('all:checks_selection');
screen.getByText('all:all');
expect(
screen.queryByText('all:host_checks_selection')
).not.toBeInTheDocument();
expect(
screen.queryByText('all:cluster_checks_selection')
).not.toBeInTheDocument();

await user.click(screen.getByLabelText('permissions'));
expect(screen.getByText('No options')).toBeVisible();
});
});
3 changes: 3 additions & 0 deletions assets/js/common/AbilitiesMultiSelect/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import AbilitiesMultiSelect from './AbilitiesMultiSelect';

export default AbilitiesMultiSelect;
7 changes: 0 additions & 7 deletions assets/js/lib/forms/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,3 @@ export const REQUIRED_FIELD_TEXT = 'Required field';
export const errorMessage = (message) => (
<p className="text-red-500 mt-1">{capitalize(message)}</p>
);

export const mapAbilities = (abilities) =>
abilities.map(({ id, name, resource, label }) => ({
value: id,
label: `${name}:${resource}`,
tooltip: label,
}));
11 changes: 5 additions & 6 deletions assets/js/pages/Profile/ProfileForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import Input from '@common/Input';
import Label from '@common/Label';
import Modal from '@common/Modal';
import Switch from '@common/Switch';
import MultiSelect from '@common/MultiSelect';
import AbilitiesMultiSelect from '@common/AbilitiesMultiSelect';
import ProfilePasswordChangeForm from '@pages/Profile/ProfilePasswordChangeForm';
import TotpEnrollementBox from '@pages/Profile/TotpEnrollmentBox';

import { REQUIRED_FIELD_TEXT, errorMessage, mapAbilities } from '@lib/forms';
import { REQUIRED_FIELD_TEXT, errorMessage } from '@lib/forms';

function ProfileForm({
fullName = '',
Expand Down Expand Up @@ -166,12 +166,11 @@ function ProfileForm({

<Label className="col-start-1 col-span-1">Permissions</Label>
<div className="col-start-2 col-span-3">
<MultiSelect
aria-label="permissions"
<AbilitiesMultiSelect
userAbilities={abilities}
abilities={abilities}
placeholder=""
values={mapAbilities(abilities)}
disabled
options={mapAbilities(abilities)}
/>
</div>
</div>
Expand Down
14 changes: 5 additions & 9 deletions assets/js/pages/Users/UserForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,14 @@ import { format, parseISO } from 'date-fns';
import Button from '@common/Button';
import Input, { Password } from '@common/Input';
import Label from '@common/Label';
import MultiSelect from '@common/MultiSelect';
import AbilitiesMultiSelect from '@common/AbilitiesMultiSelect';
import Select from '@common/Select';
import Tooltip from '@common/Tooltip';
import {
PASSWORD_POLICY_TEXT,
PASSWORD_PLACEHOLDER,
REQUIRED_FIELD_TEXT,
errorMessage,
mapAbilities,
} from '@lib/forms';
import { getError } from '@lib/api/validationErrors';

Expand Down Expand Up @@ -224,14 +223,11 @@ function UserForm({
</div>
<Label className="col-start-1 col-span-1">Permissions</Label>
<div className="col-start-2 col-span-3">
<MultiSelect
aria-label="permissions"
<AbilitiesMultiSelect
userAbilities={userAbilities}
abilities={abilities}
placeholder="Default"
values={mapAbilities(userAbilities)}
options={mapAbilities(abilities)}
onChange={(values) =>
setAbilities(values.map(({ value }) => value))
}
setAbilities={setAbilities}
/>
</div>
<Label className="col-start-1 col-span-1">Status</Label>
Expand Down

0 comments on commit b0458c6

Please sign in to comment.