Skip to content

Commit

Permalink
Users abilities frontend (#2611)
Browse files Browse the repository at this point in the history
* Add abilities api

* Load abilities and user abilities

* Update tests and stories
  • Loading branch information
arbulu89 committed May 9, 2024
1 parent be0d05a commit bb23191
Show file tree
Hide file tree
Showing 9 changed files with 174 additions and 7 deletions.
3 changes: 3 additions & 0 deletions assets/js/lib/api/abilities.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { get } from '@lib/network';

export const listAbilities = () => get('/abilities');
8 changes: 8 additions & 0 deletions assets/js/lib/test-utils/factories/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,21 @@ import { faker } from '@faker-js/faker';
import { Factory } from 'fishery';
import { formatISO } from 'date-fns';

export const abilityFactory = Factory.define(() => ({
id: faker.number.int(),
name: faker.word.noun(),
resource: faker.word.noun(),
label: faker.hacker.phrase(),
}));

export const userFactory = Factory.define(() => ({
id: faker.number.int(),
username: faker.internet.userName(),
actions: 'Delete',
enabled: faker.datatype.boolean(),
fullname: faker.internet.displayName(),
email: faker.internet.email(),
abilities: abilityFactory.buildList(2),
created_at: formatISO(faker.date.past()),
updated_at: formatISO(faker.date.past()),
}));
Expand Down
23 changes: 22 additions & 1 deletion assets/js/pages/Users/CreateUserPage.jsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,33 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'react-hot-toast';

import BackButton from '@common/BackButton';
import PageHeader from '@common/PageHeader';

import { listAbilities } from '@lib/api/abilities';
import { createUser } from '@lib/api/users';

import UserForm from './UserForm';

const ERROR_GETTING_ABILITIES = 'Error getting user abilities';

export const fetchAbilities = (setAbilities) => {
listAbilities()
.then(({ data }) => {
setAbilities(data);
})
.catch(() => {
toast.error(ERROR_GETTING_ABILITIES);
setAbilities([]);
});
};

function CreateUserPage() {
const navigate = useNavigate();
const [savingState, setSaving] = useState(false);
const [errorsState, setErrors] = useState([]);
const [abilitiesState, setAbilities] = useState([]);

const onCreateUser = (payload) => {
setSaving(true);
Expand All @@ -37,11 +53,16 @@ function CreateUserPage() {
navigate('/users');
};

useEffect(() => {
fetchAbilities(setAbilities);
}, []);

return (
<div>
<BackButton url="/users">Back to Users</BackButton>
<PageHeader className="font-bold">Create User</PageHeader>
<UserForm
abilities={abilitiesState}
saveText="Create"
saving={savingState}
errors={errorsState}
Expand Down
16 changes: 14 additions & 2 deletions assets/js/pages/Users/CreateUserPage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ import * as router from 'react-router';

import { networkClient } from '@lib/network';

import { userFactory } from '@lib/test-utils/factories/users';
import { abilityFactory, userFactory } from '@lib/test-utils/factories/users';

import CreateUserPage from './CreateUserPage';

const ABILITIES_URL = `/api/v1/abilities`;
const USERS_URL = '/api/v1/users';
const axiosMock = new MockAdapter(networkClient);

Expand Down Expand Up @@ -56,8 +57,13 @@ describe('CreateUserPage', () => {
jest.spyOn(router, 'useNavigate').mockImplementation(() => navigate);
const { fullname, email, username } = userFactory.build();
const password = faker.internet.password();
const abilities = abilityFactory.buildList(2);

axiosMock.onPost(USERS_URL).reply(202, {});
axiosMock
.onPost(USERS_URL)
.reply(202, {})
.onGet(ABILITIES_URL)
.reply(200, abilities);

render(<CreateUserPage />);

Expand All @@ -67,6 +73,12 @@ describe('CreateUserPage', () => {
await user.type(screen.getByPlaceholderText('Enter password'), password);
await user.type(screen.getByPlaceholderText('Re-enter password'), password);

await user.click(screen.getByLabelText('permissions'));

abilities.forEach(({ name, resource }) =>
expect(screen.getAllByText(`${name}:${resource}`).length).toBe(1)
);

await user.click(screen.getByRole('button', { name: 'Create' }));

expect(navigate).toHaveBeenCalledWith('/users');
Expand Down
9 changes: 9 additions & 0 deletions assets/js/pages/Users/EditUserPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import PageHeader from '@common/PageHeader';
import { isAdmin } from '@lib/model/users';
import { editUser, getUser } from '@lib/api/users';

import { fetchAbilities } from './CreateUserPage';
import UserForm from './UserForm';

function EditUserPage() {
Expand All @@ -19,6 +20,7 @@ function EditUserPage() {
const [userState, setUser] = useState(null);
const [userVersion, setUserVersion] = useState(null);
const [updatedByOther, setUpdatedByOther] = useState(false);
const [abilitiesState, setAbilities] = useState([]);

useEffect(() => {
getUser(userID)
Expand All @@ -32,6 +34,10 @@ function EditUserPage() {
});
}, [userID]);

useEffect(() => {
fetchAbilities(setAbilities);
}, []);

const onEditUser = (payload) => {
setSaving(true);
editUser(userID, payload, userVersion)
Expand Down Expand Up @@ -74,6 +80,7 @@ function EditUserPage() {
email,
username,
enabled,
abilities: userAbilities,
created_at: createdAt,
updated_at: updatedAt,
} = userState;
Expand All @@ -92,6 +99,8 @@ function EditUserPage() {
)}
<PageHeader className="font-bold">Edit User</PageHeader>
<UserForm
abilities={abilitiesState}
userAbilities={userAbilities}
saveText="Edit"
fullName={fullname}
emailAddress={email}
Expand Down
16 changes: 15 additions & 1 deletion assets/js/pages/Users/EditUserPage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,15 @@ import * as router from 'react-router';
import { networkClient } from '@lib/network';

import { renderWithRouterMatch } from '@lib/test-utils';
import { adminUser, userFactory } from '@lib/test-utils/factories/users';
import {
abilityFactory,
adminUser,
userFactory,
} from '@lib/test-utils/factories/users';

import EditUserPage from './EditUserPage';

const ABILITIES_URL = `/api/v1/abilities`;
const USERS_URL = '/api/v1/users/';
const axiosMock = new MockAdapter(networkClient);

Expand Down Expand Up @@ -81,10 +86,13 @@ describe('EditUserPage', () => {
const navigate = jest.fn();
jest.spyOn(router, 'useNavigate').mockImplementation(() => navigate);
const userData = userFactory.build();
const abilities = abilityFactory.buildList(2);

axiosMock
.onGet(USERS_URL.concat(userData.id))
.reply(200, userData)
.onGet(ABILITIES_URL)
.reply(200, abilities)
.onPatch(USERS_URL.concat(userData.id))
.reply(204, {});

Expand All @@ -95,6 +103,12 @@ describe('EditUserPage', () => {

await screen.findByText('Edit User');

await user.click(screen.getByLabelText('permissions'));

abilities.forEach(({ name, resource }) =>
expect(screen.getAllByText(`${name}:${resource}`).length).toBe(1)
);

await user.click(screen.getByRole('button', { name: 'Edit' }));

expect(navigate).toHaveBeenCalledWith('/users');
Expand Down
24 changes: 23 additions & 1 deletion assets/js/pages/Users/UserForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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 Select from '@common/Select';
import Tooltip from '@common/Tooltip';
import { getError } from '@lib/api/validationErrors';
Expand All @@ -26,17 +27,27 @@ const PASSWORD_POLICY_TEXT = (
</div>
);

const defaultAbilities = [];
const defaultErrors = [];

const errorMessage = (message) => (
<p className="text-red-500 mt-1">{capitalize(message)}</p>
);

const mapAbilities = (abilities) =>
abilities.map(({ id, name, resource, label }) => ({
value: id,
label: `${name}:${resource}`,
tooltip: label,
}));

function UserForm({
fullName = '',
emailAddress = '',
username = '',
status = 'Enabled',
abilities = defaultAbilities,
userAbilities = defaultAbilities,
createdAt = '',
updatedAt = '',
errors = defaultErrors,
Expand All @@ -58,6 +69,9 @@ function UserForm({
const [confirmPasswordState, setConfirmPassword] = useState('');
const [confirmPasswordErrorState, setConfirmPasswordError] = useState(null);
const [statusState, setStatus] = useState(status);
const [selectedAbilities, setAbilities] = useState(
userAbilities.map(({ id }) => id)
);

useEffect(() => {
setFullNameError(getError('fullname', errors));
Expand Down Expand Up @@ -111,6 +125,7 @@ function UserForm({
...(confirmPasswordState && {
password_confirmation: confirmPasswordState,
}),
abilities: abilities.filter(({ id }) => selectedAbilities.includes(id)),
};

onSave(user);
Expand Down Expand Up @@ -223,7 +238,14 @@ function UserForm({
</div>
<Label className="col-start-1 col-span-1">Permissions</Label>
<div className="col-start-2 col-span-3">
<Input value="" placeholder="all:all" error={false} disabled />
<MultiSelect
aria-label="permissions"
values={mapAbilities(userAbilities)}
options={mapAbilities(abilities)}
onChange={(values) =>
setAbilities(values.map(({ value }) => value))
}
/>
</div>
<Label className="col-start-1 col-span-1">Status</Label>
<div className="col-start-2 col-span-3">
Expand Down
25 changes: 24 additions & 1 deletion assets/js/pages/Users/UserForm.stories.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';

import { adminUser, userFactory } from '@lib/test-utils/factories/users';
import {
abilityFactory,
adminUser,
userFactory,
} from '@lib/test-utils/factories/users';

import UserForm from './UserForm';

Expand All @@ -19,6 +23,9 @@ const {
username: adminUsername,
} = adminUser.build();

const abilities = abilityFactory.buildList(3);
const userAbilities = abilities.slice(0, 1);

function ContainerWrapper({ children }) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">{children}</div>
Expand Down Expand Up @@ -56,6 +63,14 @@ export default {
defaultValue: { summary: 'Enabled' },
},
},
abilities: {
description: 'Available abilities',
control: { type: 'object' },
},
userAbilities: {
description: 'User abilities',
control: { type: 'object' },
},
createdAt: {
description: 'User creation timestamp',
control: {
Expand Down Expand Up @@ -142,6 +157,14 @@ export const Admin = {
},
};

export const WithAbilities = {
args: {
...Editing.args,
abilities,
userAbilities,
},
};

export const WithErrors = {
args: {
...Editing.args,
Expand Down
Loading

0 comments on commit bb23191

Please sign in to comment.