158 paginated data list (#180)

* working: rename OrganizationTeamsList to PaginatedDataList

* convert org notifications list fully to PaginatedDataList

* update NotificationList tests

* refactor org access to use PaginatedDataList

* update tests for org access refactor; fix pagination & sorting

* restore Add Role functionality to Org roles

* fix displayed text when list of items is empty

* preserve query params when navigating through pagination

* fix bugs after RBAC rebase

* fix lint errors, fix add org access button
This commit is contained in:
Keith Grant 2019-04-29 10:08:50 -04:00 committed by GitHub
parent 3c06c97c32
commit 9d66b583b7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 4133 additions and 1427 deletions

View File

@ -51,6 +51,7 @@
"no-unused-expressions": ["error", { "allowShortCircuit": true }],
"react/prefer-stateless-function": "off",
"react/prop-types": "off",
"react/sort-comp": ["error", {}],
"jsx-a11y/label-has-for": "off",
"jsx-a11y/label-has-associated-control": "off"
}

View File

@ -49,11 +49,7 @@ exports[`mountWithContexts injected I18nProvider should mount and render deeply
exports[`mountWithContexts injected Network should mount and render 1`] = `
<Foo
api={
Object {
"getConfig": [Function],
}
}
api={"/api/"}
handleHttpError={[Function]}
>
<div>

View File

@ -1,168 +0,0 @@
import React from 'react';
import { mountWithContexts } from '../enzymeHelpers';
import Notifications, { _Notifications } from '../../src/components/NotificationsList/Notifications.list';
describe('<Notifications />', () => {
test('initially renders succesfully', () => {
mountWithContexts(
<Notifications
onReadError={() => {}}
onReadNotifications={() => {}}
onReadSuccess={() => {}}
onCreateError={() => {}}
onCreateSuccess={() => {}}
canToggleNotifications
/>
);
});
test('fetches notifications on mount', () => {
const spy = jest.spyOn(_Notifications.prototype, 'readNotifications');
mountWithContexts(
<Notifications
onReadError={() => {}}
onReadNotifications={() => {}}
onReadSuccess={() => {}}
onCreateError={() => {}}
onCreateSuccess={() => {}}
canToggleNotifications
/>
);
expect(spy).toHaveBeenCalled();
});
test('toggle success calls post', () => {
const spy = jest.spyOn(_Notifications.prototype, 'createSuccess');
const wrapper = mountWithContexts(
<Notifications
onReadError={() => {}}
onReadNotifications={() => {}}
onReadSuccess={() => {}}
onCreateError={() => {}}
onCreateSuccess={() => {}}
canToggleNotifications
/>
).find('Notifications');
wrapper.instance().toggleNotification(1, true, 'success');
expect(spy).toHaveBeenCalledWith(1, true);
});
test('post success makes request and updates state properly', async () => {
const onCreateSuccess = jest.fn();
const wrapper = mountWithContexts(
<_Notifications
match={{ path: '/organizations/:id/?tab=notifications', url: '/organizations/:id/?tab=notifications', params: { id: 1 } }}
location={{ search: '', pathname: '/organizations/:id/?tab=notifications' }}
handleHttpError={() => {}}
onReadError={() => {}}
onReadNotifications={() => {}}
onReadSuccess={() => {}}
onCreateError={() => {}}
onCreateSuccess={onCreateSuccess}
canToggleNotifications
/>
).find('Notifications');
wrapper.setState({ successTemplateIds: [44] });
await wrapper.instance().createSuccess(44, true);
expect(onCreateSuccess).toHaveBeenCalledWith(1, { id: 44, disassociate: true });
expect(wrapper.state('successTemplateIds')).not.toContain(44);
await wrapper.instance().createSuccess(44, false);
expect(onCreateSuccess).toHaveBeenCalledWith(1, { id: 44 });
expect(wrapper.state('successTemplateIds')).toContain(44);
});
test('toggle error calls post', () => {
const spy = jest.spyOn(_Notifications.prototype, 'createError');
const wrapper = mountWithContexts(
<Notifications
onReadError={() => {}}
onReadNotifications={() => {}}
onReadSuccess={() => {}}
onCreateError={() => {}}
onCreateSuccess={() => {}}
canToggleNotifications
/>
).find('Notifications');
wrapper.instance().toggleNotification(1, true, 'error');
expect(spy).toHaveBeenCalledWith(1, true);
});
test('post error makes request and updates state properly', async () => {
const onCreateError = jest.fn();
const wrapper = mountWithContexts(
<_Notifications
match={{ path: '/organizations/:id/?tab=notifications', url: '/organizations/:id/?tab=notifications', params: { id: 1 } }}
location={{ search: '', pathname: '/organizations/:id/?tab=notifications' }}
handleHttpError={() => {}}
onReadError={() => {}}
onReadNotifications={() => {}}
onReadSuccess={() => {}}
onCreateError={onCreateError}
onCreateSuccess={() => {}}
canToggleNotifications
/>
).find('Notifications');
wrapper.setState({ errorTemplateIds: [44] });
await wrapper.instance().createError(44, true);
expect(onCreateError).toHaveBeenCalledWith(1, { id: 44, disassociate: true });
expect(wrapper.state('errorTemplateIds')).not.toContain(44);
await wrapper.instance().createError(44, false);
expect(onCreateError).toHaveBeenCalledWith(1, { id: 44 });
expect(wrapper.state('errorTemplateIds')).toContain(44);
});
test('fetchNotifications', async () => {
const mockQueryParams = {
page: 44,
page_size: 10,
order_by: 'name'
};
const onReadNotifications = jest.fn().mockResolvedValue({
data: {
results: [
{ id: 1, notification_type: 'slack' },
{ id: 2, notification_type: 'email' },
{ id: 3, notification_type: 'github' }
]
}
});
const onReadSuccess = jest.fn().mockResolvedValue({
data: {
results: [
{ id: 1 }
]
}
});
const onReadError = jest.fn().mockResolvedValue({
data: {
results: [
{ id: 2 }
]
}
});
const wrapper = mountWithContexts(
<_Notifications
match={{ path: '/organizations/:id/?tab=notifications', url: '/organizations/:id/?tab=notifications', params: { id: 1 } }}
location={{ search: '', pathname: '/organizations/:id/?tab=notifications' }}
handleHttpError={() => {}}
onReadError={onReadError}
onReadNotifications={onReadNotifications}
onReadSuccess={onReadSuccess}
onCreateError={() => {}}
onCreateSuccess={() => {}}
canToggleNotifications
/>
).find('Notifications');
wrapper.instance().updateUrl = jest.fn();
await wrapper.instance().readNotifications(mockQueryParams);
expect(onReadNotifications).toHaveBeenCalledWith(1, mockQueryParams);
expect(onReadSuccess).toHaveBeenCalledWith(1, {
id__in: '1,2,3'
});
expect(onReadError).toHaveBeenCalledWith(1, {
id__in: '1,2,3'
});
expect(wrapper.state('successTemplateIds')).toContain(1);
expect(wrapper.state('errorTemplateIds')).toContain(2);
});
});

View File

@ -2,38 +2,49 @@ import React from 'react';
import { mountWithContexts } from '../enzymeHelpers';
import NotificationListItem from '../../src/components/NotificationsList/NotificationListItem';
describe('<NotificationListItem />', () => {
describe('<NotificationListItem canToggleNotifications />', () => {
let wrapper;
const toggleNotification = jest.fn();
let toggleNotification;
beforeEach(() => {
toggleNotification = jest.fn();
});
afterEach(() => {
if (wrapper) {
wrapper.unmount();
wrapper = null;
}
jest.clearAllMocks();
});
test('initially renders succesfully', () => {
wrapper = mountWithContexts(
<NotificationListItem
itemId={9000}
notification={{
id: 9000,
name: 'Foo',
notification_type: 'slack',
}}
toggleNotification={toggleNotification}
detailUrl="/foo"
notificationType="slack"
canToggleNotifications
/>
);
expect(wrapper.length).toBe(1);
expect(wrapper.find('NotificationListItem')).toMatchSnapshot();
});
test('handles success click when toggle is on', () => {
wrapper = mountWithContexts(
<NotificationListItem
itemId={9000}
notification={{
id: 9000,
name: 'Foo',
notification_type: 'slack',
}}
successTurnedOn
toggleNotification={toggleNotification}
detailUrl="/foo"
notificationType="slack"
canToggleNotifications
/>
);
@ -44,11 +55,14 @@ describe('<NotificationListItem />', () => {
test('handles success click when toggle is off', () => {
wrapper = mountWithContexts(
<NotificationListItem
itemId={9000}
notification={{
id: 9000,
name: 'Foo',
notification_type: 'slack',
}}
successTurnedOn={false}
toggleNotification={toggleNotification}
detailUrl="/foo"
notificationType="slack"
canToggleNotifications
/>
);
@ -59,11 +73,14 @@ describe('<NotificationListItem />', () => {
test('handles error click when toggle is on', () => {
wrapper = mountWithContexts(
<NotificationListItem
itemId={9000}
notification={{
id: 9000,
name: 'Foo',
notification_type: 'slack',
}}
errorTurnedOn
toggleNotification={toggleNotification}
detailUrl="/foo"
notificationType="slack"
canToggleNotifications
/>
);
@ -74,11 +91,14 @@ describe('<NotificationListItem />', () => {
test('handles error click when toggle is off', () => {
wrapper = mountWithContexts(
<NotificationListItem
itemId={9000}
notification={{
id: 9000,
name: 'Foo',
notification_type: 'slack',
}}
errorTurnedOn={false}
toggleNotification={toggleNotification}
detailUrl="/foo"
notificationType="slack"
canToggleNotifications
/>
);

View File

@ -1,8 +1,8 @@
import React from 'react';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../enzymeHelpers';
import { sleep } from '../../../testUtils';
import OrganizationTeamsList from '../../../../src/pages/Organizations/components/OrganizationTeamsList';
import { mountWithContexts } from '../enzymeHelpers';
import { sleep } from '../testUtils';
import PaginatedDataList from '../../src/components/PaginatedDataList';
const mockData = [
{ id: 1, name: 'one', url: '/org/team/1' },
@ -12,15 +12,15 @@ const mockData = [
{ id: 5, name: 'five', url: '/org/team/5' },
];
describe('<OrganizationTeamsList />', () => {
describe('<PaginatedDataList />', () => {
afterEach(() => {
jest.restoreAllMocks();
});
test('initially renders succesfully', () => {
mountWithContexts(
<OrganizationTeamsList
teams={mockData}
<PaginatedDataList
items={mockData}
itemCount={7}
queryParams={{
page: 1,
@ -37,8 +37,8 @@ describe('<OrganizationTeamsList />', () => {
initialEntries: ['/organizations/1/teams'],
});
const wrapper = mountWithContexts(
<OrganizationTeamsList
teams={mockData}
<PaginatedDataList
items={mockData}
itemCount={7}
queryParams={{
page: 1,
@ -69,8 +69,8 @@ describe('<OrganizationTeamsList />', () => {
initialEntries: ['/organizations/1/teams'],
});
const wrapper = mountWithContexts(
<OrganizationTeamsList
teams={mockData}
<PaginatedDataList
items={mockData}
itemCount={7}
queryParams={{
page: 1,

View File

@ -0,0 +1,369 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<NotificationListItem /> initially renders succesfully 1`] = `
<NotificationListItem
canToggleNotifications={true}
detailUrl="/foo"
errorTurnedOn={false}
notification={
Object {
"id": 9000,
"name": "Foo",
"notification_type": "slack",
}
}
successTurnedOn={false}
toggleNotification={[MockFunction]}
>
<I18n
update={true}
withHash={true}
>
<DataListItem
aria-labelledby="items-list-item-9000"
className=""
isExpanded={false}
key="9000"
>
<li
aria-labelledby="items-list-item-9000"
className="pf-c-data-list__item"
>
<DataListCell
className=""
key=".0"
rowid="items-list-item-9000"
width={1}
>
<div
className="pf-c-data-list__cell"
>
<Link
replace={false}
style={
Object {
"marginRight": "1.5em",
}
}
to={
Object {
"pathname": "/foo",
}
}
>
<a
onClick={[Function]}
style={
Object {
"marginRight": "1.5em",
}
}
>
<b
id="items-list-item-9000"
>
Foo
</b>
</a>
</Link>
<Badge
className=""
isRead={true}
style={
Object {
"textTransform": "capitalize",
}
}
>
<span
className="pf-c-badge pf-m-read"
style={
Object {
"textTransform": "capitalize",
}
}
>
slack
</span>
</Badge>
</div>
</DataListCell>
<DataListCell
alignRight={true}
className=""
key=".1"
rowid="items-list-item-9000"
width={1}
>
<div
alignRight={true}
className="pf-c-data-list__cell"
>
<Switch
aria-label="Toggle notification success"
className=""
id="notification-9000-success-toggle"
isChecked={false}
isDisabled={false}
label="Successful"
onChange={[Function]}
>
<label
className="pf-c-switch"
htmlFor="notification-9000-success-toggle"
>
<input
aria-label="Toggle notification success"
checked={false}
className="pf-c-switch__input"
disabled={false}
id="notification-9000-success-toggle"
onChange={[Function]}
type="checkbox"
/>
<span
className="pf-c-switch__toggle"
/>
<span
aria-hidden="true"
className="pf-c-switch__label pf-m-on"
>
Successful
</span>
<span
aria-hidden="true"
className="pf-c-switch__label pf-m-off"
>
Successful
</span>
</label>
</Switch>
<Switch
aria-label="Toggle notification failure"
className=""
id="notification-9000-error-toggle"
isChecked={false}
isDisabled={false}
label="Failure"
onChange={[Function]}
>
<label
className="pf-c-switch"
htmlFor="notification-9000-error-toggle"
>
<input
aria-label="Toggle notification failure"
checked={false}
className="pf-c-switch__input"
disabled={false}
id="notification-9000-error-toggle"
onChange={[Function]}
type="checkbox"
/>
<span
className="pf-c-switch__toggle"
/>
<span
aria-hidden="true"
className="pf-c-switch__label pf-m-on"
>
Failure
</span>
<span
aria-hidden="true"
className="pf-c-switch__label pf-m-off"
>
Failure
</span>
</label>
</Switch>
</div>
</DataListCell>
</li>
</DataListItem>
</I18n>
</NotificationListItem>
`;
exports[`<NotificationListItem canToggleNotifications /> initially renders succesfully 1`] = `
<NotificationListItem
canToggleNotifications={true}
detailUrl="/foo"
errorTurnedOn={false}
notification={
Object {
"id": 9000,
"name": "Foo",
"notification_type": "slack",
}
}
successTurnedOn={false}
toggleNotification={[MockFunction]}
>
<I18n
update={true}
withHash={true}
>
<DataListItem
aria-labelledby="items-list-item-9000"
className=""
isExpanded={false}
key="9000"
>
<li
aria-labelledby="items-list-item-9000"
className="pf-c-data-list__item"
>
<DataListCell
className=""
key=".0"
rowid="items-list-item-9000"
width={1}
>
<div
className="pf-c-data-list__cell"
>
<Link
replace={false}
style={
Object {
"marginRight": "1.5em",
}
}
to={
Object {
"pathname": "/foo",
}
}
>
<a
onClick={[Function]}
style={
Object {
"marginRight": "1.5em",
}
}
>
<b>
Foo
</b>
</a>
</Link>
<Badge
className=""
isRead={true}
style={
Object {
"textTransform": "capitalize",
}
}
>
<span
className="pf-c-badge pf-m-read"
style={
Object {
"textTransform": "capitalize",
}
}
>
slack
</span>
</Badge>
</div>
</DataListCell>
<DataListCell
alignRight={true}
className=""
key=".1"
rowid="items-list-item-9000"
width={1}
>
<div
alignRight={true}
className="pf-c-data-list__cell"
>
<Switch
aria-label="Toggle notification success"
className=""
id="notification-9000-success-toggle"
isChecked={false}
isDisabled={false}
label="Successful"
onChange={[Function]}
>
<label
className="pf-c-switch"
htmlFor="notification-9000-success-toggle"
>
<input
aria-label="Toggle notification success"
checked={false}
className="pf-c-switch__input"
disabled={false}
id="notification-9000-success-toggle"
onChange={[Function]}
type="checkbox"
/>
<span
className="pf-c-switch__toggle"
/>
<span
aria-hidden="true"
className="pf-c-switch__label pf-m-on"
>
Successful
</span>
<span
aria-hidden="true"
className="pf-c-switch__label pf-m-off"
>
Successful
</span>
</label>
</Switch>
<Switch
aria-label="Toggle notification failure"
className=""
id="notification-9000-error-toggle"
isChecked={false}
isDisabled={false}
label="Failure"
onChange={[Function]}
>
<label
className="pf-c-switch"
htmlFor="notification-9000-error-toggle"
>
<input
aria-label="Toggle notification failure"
checked={false}
className="pf-c-switch__input"
disabled={false}
id="notification-9000-error-toggle"
onChange={[Function]}
type="checkbox"
/>
<span
className="pf-c-switch__toggle"
/>
<span
aria-hidden="true"
className="pf-c-switch__label pf-m-on"
>
Failure
</span>
<span
aria-hidden="true"
className="pf-c-switch__label pf-m-off"
>
Failure
</span>
</label>
</Switch>
</div>
</DataListCell>
</li>
</DataListItem>
</I18n>
</NotificationListItem>
`;

View File

@ -50,7 +50,8 @@ const defaultContexts = {
pathname: '',
search: '',
state: '',
}
},
toJSON: () => '/history/',
},
route: {
location: {
@ -71,6 +72,7 @@ const defaultContexts = {
network: {
api: {
getConfig: () => {},
toJSON: () => '/api/',
},
handleHttpError: () => {},
},

View File

@ -0,0 +1,27 @@
import React from 'react';
import { mountWithContexts } from '../../../enzymeHelpers';
import DeleteRoleConfirmationModal from '../../../../src/pages/Organizations/components/DeleteRoleConfirmationModal';
const role = {
id: 3,
name: 'Member',
resource_name: 'Org',
resource_type: 'organization',
team_id: 5,
team_name: 'The Team',
};
describe('<DeleteRoleConfirmationModal />', () => {
test('should render initially', () => {
const wrapper = mountWithContexts(
<DeleteRoleConfirmationModal
role={role}
username="jane"
onCancel={() => {}}
onConfirm={() => {}}
/>
);
wrapper.update();
expect(wrapper.find('DeleteRoleConfirmationModal')).toMatchSnapshot();
});
});

View File

@ -0,0 +1,37 @@
import React from 'react';
import { mountWithContexts } from '../../../enzymeHelpers';
import OrganizationAccessItem from '../../../../src/pages/Organizations/components/OrganizationAccessItem';
const accessRecord = {
id: 2,
username: 'jane',
url: '/bar',
first_name: 'jane',
last_name: 'brown',
summary_fields: {
direct_access: [{
role: {
id: 3,
name: 'Member',
resource_name: 'Org',
resource_type: 'organization',
team_id: 5,
team_name: 'The Team',
user_capabilities: { unattach: true },
}
}],
indirect_access: [],
}
};
describe('<OrganizationAccessItem />', () => {
test('initially renders succesfully', () => {
const wrapper = mountWithContexts(
<OrganizationAccessItem
accessRecord={accessRecord}
onRoleDelete={() => {}}
/>
);
expect(wrapper.find('OrganizationAccessItem')).toMatchSnapshot();
});
});

View File

@ -0,0 +1,531 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
<DeleteRoleConfirmationModal
onCancel={[Function]}
onConfirm={[Function]}
role={
Object {
"id": 3,
"name": "Member",
"resource_name": "Org",
"resource_type": "organization",
"team_id": 5,
"team_name": "The Team",
}
}
username="jane"
>
<I18n
update={true}
withHash={true}
>
<_default
actions={
Array [
<Button
aria-label="Confirm delete"
className=""
component="button"
isActive={false}
isBlock={false}
isDisabled={false}
isFocus={false}
isHover={false}
onClick={[Function]}
type="button"
variant="danger"
>
Delete
</Button>,
<Button
aria-label={null}
className=""
component="button"
isActive={false}
isBlock={false}
isDisabled={false}
isFocus={false}
isHover={false}
onClick={[Function]}
type="button"
variant="secondary"
>
Cancel
</Button>,
]
}
isOpen={true}
title="Remove Team Access"
variant="danger"
>
<Modal
actions={
Array [
<Button
aria-label="Confirm delete"
className=""
component="button"
isActive={false}
isBlock={false}
isDisabled={false}
isFocus={false}
isHover={false}
onClick={[Function]}
type="button"
variant="danger"
>
Delete
</Button>,
<Button
aria-label={null}
className=""
component="button"
isActive={false}
isBlock={false}
isDisabled={false}
isFocus={false}
isHover={false}
onClick={[Function]}
type="button"
variant="secondary"
>
Cancel
</Button>,
]
}
ariaDescribedById=""
className="awx-c-modal at-c-alertModal at-c-alertModal--danger"
hideTitle={false}
isLarge={false}
isOpen={true}
isSmall={false}
onClose={[Function]}
title="Remove Team Access"
width={null}
>
<Portal
containerInfo={
<div>
<div
class="pf-c-backdrop"
>
<div
class="pf-l-bullseye"
>
<div
class="pf-l-bullseye"
>
<div
aria-describedby="pf-modal-0"
aria-label="Remove Team Access"
aria-modal="true"
class="pf-c-modal-box awx-c-modal at-c-alertModal at-c-alertModal--danger"
role="dialog"
>
<button
aria-label="Close"
class="pf-c-button pf-m-plain"
type="button"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
role="img"
style="vertical-align: -0.125em;"
viewBox="0 0 352 512"
width="1em"
>
<path
d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"
transform=""
/>
</svg>
</button>
<h3
class="pf-c-title pf-m-2xl"
>
Remove Team Access
</h3>
<div
class="pf-c-modal-box__body"
id="pf-modal-0"
>
<svg
aria-hidden="true"
class="at-c-alertModal__icon"
fill="currentColor"
height="1em"
role="img"
style="vertical-align: -0.125em;"
viewBox="0 0 512 512"
width="1em"
>
<path
d="M504 256c0 136.997-111.043 248-248 248S8 392.997 8 256C8 119.083 119.043 8 256 8s248 111.083 248 248zm-248 50c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"
transform=""
/>
</svg>
</div>
<div
class="pf-c-modal-box__footer"
>
<button
aria-label="Confirm delete"
class="pf-c-button pf-m-danger"
type="button"
>
Delete
</button>
<button
class="pf-c-button pf-m-secondary"
type="button"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
</div>
}
>
<ModalContent
actions={
Array [
<Button
aria-label="Confirm delete"
className=""
component="button"
isActive={false}
isBlock={false}
isDisabled={false}
isFocus={false}
isHover={false}
onClick={[Function]}
type="button"
variant="danger"
>
Delete
</Button>,
<Button
aria-label={null}
className=""
component="button"
isActive={false}
isBlock={false}
isDisabled={false}
isFocus={false}
isHover={false}
onClick={[Function]}
type="button"
variant="secondary"
>
Cancel
</Button>,
]
}
ariaDescribedById=""
className="awx-c-modal at-c-alertModal at-c-alertModal--danger"
hideTitle={false}
id="pf-modal-0"
isLarge={false}
isOpen={true}
isSmall={false}
onClose={[Function]}
title="Remove Team Access"
width={null}
>
<Backdrop
className=""
>
<div
className="pf-c-backdrop"
>
<Bullseye
className=""
component="div"
>
<div
className="pf-l-bullseye"
>
<FocusTrap
_createFocusTrap={[Function]}
active={true}
className="pf-l-bullseye"
focusTrapOptions={
Object {
"clickOutsideDeactivates": true,
}
}
paused={false}
tag="div"
>
<div
className="pf-l-bullseye"
>
<ModalBox
className="awx-c-modal at-c-alertModal at-c-alertModal--danger"
id="pf-modal-0"
isLarge={false}
isSmall={false}
style={
Object {
"width": null,
}
}
title="Remove Team Access"
>
<div
aria-describedby="pf-modal-0"
aria-label="Remove Team Access"
aria-modal="true"
className="pf-c-modal-box awx-c-modal at-c-alertModal at-c-alertModal--danger"
role="dialog"
style={
Object {
"width": null,
}
}
>
<ModalBoxCloseButton
className=""
onClose={[Function]}
>
<Button
aria-label="Close"
className=""
component="button"
isActive={false}
isBlock={false}
isDisabled={false}
isFocus={false}
isHover={false}
onClick={[Function]}
type="button"
variant="plain"
>
<button
aria-disabled={null}
aria-label="Close"
className="pf-c-button pf-m-plain"
disabled={false}
onClick={[Function]}
tabIndex={null}
type="button"
>
<TimesIcon
color="currentColor"
size="sm"
title={null}
>
<svg
aria-hidden={true}
aria-labelledby={null}
fill="currentColor"
height="1em"
role="img"
style={
Object {
"verticalAlign": "-0.125em",
}
}
viewBox="0 0 352 512"
width="1em"
>
<path
d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"
transform=""
/>
</svg>
</TimesIcon>
</button>
</Button>
</ModalBoxCloseButton>
<ModalBoxHeader
className=""
hideTitle={false}
>
<Title
className=""
headingLevel="h3"
size="2xl"
>
<h3
className="pf-c-title pf-m-2xl"
>
Remove Team Access
</h3>
</Title>
</ModalBoxHeader>
<ModalBoxBody
className=""
id="pf-modal-0"
>
<div
className="pf-c-modal-box__body"
id="pf-modal-0"
>
<WithI18n
components={
Array [
<b />,
<b />,
<br />,
<br />,
<b />,
<i />,
]
}
id="Are you sure you want to remove<0> {0} </0>access from<1> {1}</1>? Doing so affects all members of the team.<2/><3/>If you<4><5> only </5></4>want to remove access for this particular user, please remove them from the team."
values={
Object {
"0": "Member",
"1": "The Team",
}
}
>
<I18n
update={true}
withHash={true}
>
<Trans
components={
Array [
<b />,
<b />,
<br />,
<br />,
<b />,
<i />,
]
}
i18n={"/i18n/"}
id="Are you sure you want to remove<0> {0} </0>access from<1> {1}</1>? Doing so affects all members of the team.<2/><3/>If you<4><5> only </5></4>want to remove access for this particular user, please remove them from the team."
values={
Object {
"0": "Member",
"1": "The Team",
}
}
>
<Render
value={null}
/>
</Trans>
</I18n>
</WithI18n>
<ExclamationCircleIcon
className="at-c-alertModal__icon"
color="currentColor"
size="sm"
title={null}
>
<svg
aria-hidden={true}
aria-labelledby={null}
className="at-c-alertModal__icon"
fill="currentColor"
height="1em"
role="img"
style={
Object {
"verticalAlign": "-0.125em",
}
}
viewBox="0 0 512 512"
width="1em"
>
<path
d="M504 256c0 136.997-111.043 248-248 248S8 392.997 8 256C8 119.083 119.043 8 256 8s248 111.083 248 248zm-248 50c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"
transform=""
/>
</svg>
</ExclamationCircleIcon>
</div>
</ModalBoxBody>
<ModalBoxFooter
className=""
>
<div
className="pf-c-modal-box__footer"
>
<Button
aria-label="Confirm delete"
className=""
component="button"
isActive={false}
isBlock={false}
isDisabled={false}
isFocus={false}
isHover={false}
key="delete"
onClick={[Function]}
type="button"
variant="danger"
>
<button
aria-disabled={null}
aria-label="Confirm delete"
className="pf-c-button pf-m-danger"
disabled={false}
onClick={[Function]}
tabIndex={null}
type="button"
>
Delete
</button>
</Button>
<Button
aria-label={null}
className=""
component="button"
isActive={false}
isBlock={false}
isDisabled={false}
isFocus={false}
isHover={false}
key="cancel"
onClick={[Function]}
type="button"
variant="secondary"
>
<button
aria-disabled={null}
aria-label={null}
className="pf-c-button pf-m-secondary"
disabled={false}
onClick={[Function]}
tabIndex={null}
type="button"
>
Cancel
</button>
</Button>
</div>
</ModalBoxFooter>
</div>
</ModalBox>
</div>
</FocusTrap>
</div>
</Bullseye>
</div>
</Backdrop>
</ModalContent>
</Portal>
</Modal>
</_default>
</I18n>
</DeleteRoleConfirmationModal>
`;

View File

@ -0,0 +1,326 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<OrganizationAccessItem /> initially renders succesfully 1`] = `
<OrganizationAccessItem
accessRecord={
Object {
"first_name": "jane",
"id": 2,
"last_name": "brown",
"summary_fields": Object {
"direct_access": Array [
Object {
"role": Object {
"id": 3,
"name": "Member",
"resource_name": "Org",
"resource_type": "organization",
"team_id": 5,
"team_name": "The Team",
"user_capabilities": Object {
"unattach": true,
},
},
},
],
"indirect_access": Array [],
},
"url": "/bar",
"username": "jane",
}
}
onRoleDelete={[Function]}
>
<I18n
update={true}
withHash={true}
>
<DataListItem
aria-labelledby="access-list-item"
className=""
isExpanded={false}
key="2"
>
<li
aria-labelledby="access-list-item"
className="pf-c-data-list__item"
>
<DataListCell
className=""
key=".0"
rowid="access-list-item"
width={1}
>
<div
className="pf-c-data-list__cell"
>
<TextContent
className=""
style={
Object {
"display": "grid",
"gridTemplateColumns": "minmax(70px, max-content) minmax(60px, max-content)",
}
}
>
<div
className="pf-c-content"
style={
Object {
"display": "grid",
"gridTemplateColumns": "minmax(70px, max-content) minmax(60px, max-content)",
}
}
>
<Link
replace={false}
to={
Object {
"pathname": "/bar",
}
}
>
<a
onClick={[Function]}
>
<Text
className=""
component="h6"
style={
Object {
"fontWeight": "700",
"lineHeight": "24px",
"marginRight": "20px",
}
}
>
<h6
className=""
data-pf-content={true}
style={
Object {
"fontWeight": "700",
"lineHeight": "24px",
"marginRight": "20px",
}
}
>
jane
</h6>
</Text>
</a>
</Link>
</div>
</TextContent>
<Detail
customStyles={null}
label="Name"
url={null}
value="jane brown"
>
<TextContent
className=""
style={
Object {
"display": "grid",
"gridTemplateColumns": "minmax(70px, max-content) minmax(60px, max-content)",
}
}
>
<div
className="pf-c-content"
style={
Object {
"display": "grid",
"gridTemplateColumns": "minmax(70px, max-content) minmax(60px, max-content)",
}
}
>
<Text
className=""
component="h6"
style={
Object {
"fontWeight": "700",
"lineHeight": "24px",
"marginRight": "20px",
}
}
>
<h6
className=""
data-pf-content={true}
style={
Object {
"fontWeight": "700",
"lineHeight": "24px",
"marginRight": "20px",
}
}
>
Name
</h6>
</Text>
<Text
className=""
component="p"
style={
Object {
"lineHeight": "28px",
"overflow": "visible",
}
}
>
<p
className=""
data-pf-content={true}
style={
Object {
"lineHeight": "28px",
"overflow": "visible",
}
}
>
jane brown
</p>
</Text>
</div>
</TextContent>
</Detail>
</div>
</DataListCell>
<DataListCell
className=""
key=".1"
rowid="access-list-item"
width={1}
>
<div
className="pf-c-data-list__cell"
>
<ul
style={
Object {
"display": "flex",
"flexWrap": "wrap",
}
}
>
<Text
className=""
component="h6"
style={
Object {
"fontWeight": "700",
"lineHeight": "24px",
"marginRight": "20px",
}
}
>
<h6
className=""
data-pf-content={true}
style={
Object {
"fontWeight": "700",
"lineHeight": "24px",
"marginRight": "20px",
}
}
>
Team Roles
</h6>
</Text>
<Chip
className="awx-c-chip"
closeBtnAriaLabel="close"
isOverflowChip={false}
key="3"
onClick={[Function]}
tooltipPosition="top"
>
<GenerateId
prefix="pf-random-id-"
>
<li
className="pf-c-chip awx-c-chip"
>
<span
className="pf-c-chip__text"
id="pf-random-id-0"
>
Member
</span>
<ChipButton
aria-labelledby="remove_pf-random-id-0 pf-random-id-0"
ariaLabel="close"
className=""
id="remove_pf-random-id-0"
onClick={[Function]}
>
<Button
aria-label="close"
aria-labelledby="remove_pf-random-id-0 pf-random-id-0"
className=""
component="button"
id="remove_pf-random-id-0"
isActive={false}
isBlock={false}
isDisabled={false}
isFocus={false}
isHover={false}
onClick={[Function]}
type="button"
variant="plain"
>
<button
aria-disabled={null}
aria-label="close"
aria-labelledby="remove_pf-random-id-0 pf-random-id-0"
className="pf-c-button pf-m-plain"
disabled={false}
id="remove_pf-random-id-0"
onClick={[Function]}
tabIndex={null}
type="button"
>
<TimesCircleIcon
aria-hidden="true"
color="currentColor"
size="sm"
title={null}
>
<svg
aria-hidden="true"
aria-labelledby={null}
fill="currentColor"
height="1em"
role="img"
style={
Object {
"verticalAlign": "-0.125em",
}
}
viewBox="0 0 512 512"
width="1em"
>
<path
d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm121.6 313.1c4.7 4.7 4.7 12.3 0 17L338 377.6c-4.7 4.7-12.3 4.7-17 0L256 312l-65.1 65.6c-4.7 4.7-12.3 4.7-17 0L134.4 338c-4.7-4.7-4.7-12.3 0-17l65.6-65-65.6-65.1c-4.7-4.7-4.7-12.3 0-17l39.6-39.6c4.7-4.7 12.3-4.7 17 0l65 65.7 65.1-65.6c4.7-4.7 12.3-4.7 17 0l39.6 39.6c4.7 4.7 4.7 12.3 0 17L312 256l65.6 65.1z"
transform=""
/>
</svg>
</TimesCircleIcon>
</button>
</Button>
</ChipButton>
</li>
</GenerateId>
</Chip>
</ul>
</div>
</DataListCell>
</li>
</DataListItem>
</I18n>
</OrganizationAccessItem>
`;

View File

@ -1,34 +1,173 @@
import React from 'react';
import { mountWithContexts } from '../../../../enzymeHelpers';
import OrganizationAccess from '../../../../../src/pages/Organizations/screens/Organization/OrganizationAccess';
import { sleep } from '../../../../testUtils';
describe('<OrganizationAccess />', () => {
let network;
const organization = {
id: 1,
name: 'Default'
};
test('initially renders succesfully', () => {
mountWithContexts(<OrganizationAccess organization={organization} />);
const data = {
count: 2,
results: [{
id: 1,
username: 'joe',
url: '/foo',
first_name: 'joe',
last_name: 'smith',
summary_fields: {
direct_access: [{
role: {
id: 1,
name: 'Member',
resource_name: 'Org',
resource_type: 'organization',
user_capabilities: { unattach: true },
}
}],
indirect_access: [],
}
}, {
id: 2,
username: 'jane',
url: '/bar',
first_name: 'jane',
last_name: 'brown',
summary_fields: {
direct_access: [{
role: {
id: 3,
name: 'Member',
resource_name: 'Org',
resource_type: 'organization',
team_id: 5,
team_name: 'The Team',
user_capabilities: { unattach: true },
}
}],
indirect_access: [],
}
}]
};
beforeEach(() => {
network = {
api: {
getOrganizationAccessList: jest.fn()
.mockReturnValue(Promise.resolve({ data })),
disassociateTeamRole: jest.fn(),
disassociateUserRole: jest.fn(),
toJSON: () => '/api/',
},
};
});
test('passed methods as props are called appropriately', async () => {
const mockAPIAccessList = {
foo: 'bar',
};
const mockResponse = {
status: 'success',
};
const wrapper = mountWithContexts(<OrganizationAccess organization={organization} />,
{ context: { network: {
api: {
getOrganizationAccessList: () => Promise.resolve(mockAPIAccessList),
disassociate: () => Promise.resolve(mockResponse)
},
handleHttpError: () => {}
} } }).find('OrganizationAccess');
const accessList = await wrapper.instance().getOrgAccessList();
expect(accessList).toEqual(mockAPIAccessList);
const resp = await wrapper.instance().removeRole(2, 3, 'users');
expect(resp).toEqual(mockResponse);
test.only('initially renders succesfully', () => {
const wrapper = mountWithContexts(
<OrganizationAccess id={1} organization={organization} />,
{ context: { network } }
);
expect(wrapper.find('OrganizationAccess')).toMatchSnapshot();
});
test('should fetch and display access records on mount', async () => {
const wrapper = mountWithContexts(
<OrganizationAccess id={1} />,
{ context: { network } }
);
await sleep(0);
wrapper.update();
expect(network.api.getOrganizationAccessList).toHaveBeenCalled();
expect(wrapper.find('OrganizationAccess').state('isInitialized')).toBe(true);
expect(wrapper.find('PaginatedDataList').prop('items')).toEqual(data.results);
expect(wrapper.find('OrganizationAccessItem')).toHaveLength(2);
});
test('should open confirmation dialog when deleting role', async () => {
const wrapper = mountWithContexts(
<OrganizationAccess id={1} />,
{ context: { network } }
);
await sleep(0);
wrapper.update();
const button = wrapper.find('ChipButton').at(0);
button.prop('onClick')();
wrapper.update();
const component = wrapper.find('OrganizationAccess');
expect(component.state('roleToDelete'))
.toEqual(data.results[0].summary_fields.direct_access[0].role);
expect(component.state('roleToDeleteAccessRecord'))
.toEqual(data.results[0]);
expect(component.find('DeleteRoleConfirmationModal')).toHaveLength(1);
});
it('should close dialog when cancel button clicked', async () => {
const wrapper = mountWithContexts(
<OrganizationAccess id={1} />,
{ context: { network } }
);
await sleep(0);
wrapper.update();
const button = wrapper.find('ChipButton').at(0);
button.prop('onClick')();
wrapper.update();
wrapper.find('DeleteRoleConfirmationModal').prop('onCancel')();
const component = wrapper.find('OrganizationAccess');
expect(component.state('roleToDelete')).toBeNull();
expect(component.state('roleToDeleteAccessRecord')).toBeNull();
expect(network.api.disassociateTeamRole).not.toHaveBeenCalled();
expect(network.api.disassociateUserRole).not.toHaveBeenCalled();
});
it('should delete user role', async () => {
const wrapper = mountWithContexts(
<OrganizationAccess id={1} />,
{ context: { network } }
);
await sleep(0);
wrapper.update();
const button = wrapper.find('ChipButton').at(0);
button.prop('onClick')();
wrapper.update();
wrapper.find('DeleteRoleConfirmationModal').prop('onConfirm')();
await sleep(0);
wrapper.update();
const component = wrapper.find('OrganizationAccess');
expect(component.state('roleToDelete')).toBeNull();
expect(component.state('roleToDeleteAccessRecord')).toBeNull();
expect(network.api.disassociateTeamRole).not.toHaveBeenCalled();
expect(network.api.disassociateUserRole).toHaveBeenCalledWith(1, 1);
expect(network.api.getOrganizationAccessList).toHaveBeenCalledTimes(2);
});
it('should delete team role', async () => {
const wrapper = mountWithContexts(
<OrganizationAccess id={1} />,
{ context: { network } }
);
await sleep(0);
wrapper.update();
const button = wrapper.find('ChipButton').at(1);
button.prop('onClick')();
wrapper.update();
wrapper.find('DeleteRoleConfirmationModal').prop('onConfirm')();
await sleep(0);
wrapper.update();
const component = wrapper.find('OrganizationAccess');
expect(component.state('roleToDelete')).toBeNull();
expect(component.state('roleToDeleteAccessRecord')).toBeNull();
expect(network.api.disassociateTeamRole).toHaveBeenCalledWith(5, 3);
expect(network.api.disassociateUserRole).not.toHaveBeenCalled();
expect(network.api.getOrganizationAccessList).toHaveBeenCalledTimes(2);
});
});

View File

@ -1,45 +1,170 @@
import React from 'react';
import { mountWithContexts } from '../../../../enzymeHelpers';
import OrganizationNotifications from '../../../../../src/pages/Organizations/screens/Organization/OrganizationNotifications';
import { sleep } from '../../../../testUtils';
describe('<OrganizationNotifications />', () => {
let api;
let data;
let network;
beforeEach(() => {
api = {
getOrganizationNotifications: jest.fn(),
getOrganizationNotificationSuccess: jest.fn(),
getOrganizationNotificationError: jest.fn(),
createOrganizationNotificationSuccess: jest.fn(),
createOrganizationNotificationError: jest.fn()
data = {
count: 2,
results: [{
id: 1,
name: 'Notification one',
url: '/api/v2/notification_templates/1/',
notification_type: 'email',
}, {
id: 2,
name: 'Notification two',
url: '/api/v2/notification_templates/2/',
notification_type: 'email',
}]
};
network = {
api: {
getOrganizationNotifications: jest.fn()
.mockReturnValue(Promise.resolve({ data })),
getOrganizationNotificationSuccess: jest.fn()
.mockReturnValue(Promise.resolve({
data: { results: [{ id: 1 }] },
})),
getOrganizationNotificationError: jest.fn()
.mockReturnValue(Promise.resolve({
data: { results: [{ id: 2 }] },
})),
createOrganizationNotificationSuccess: jest.fn(),
createOrganizationNotificationError: jest.fn(),
toJSON: () => '/api/',
}
};
});
test('initially renders succesfully', () => {
mountWithContexts(
<OrganizationNotifications canToggleNotifications />, { context: { network: {
api,
handleHttpError: () => {}
} } }
);
afterEach(() => {
jest.clearAllMocks();
});
test('handles api requests', () => {
test('initially renders succesfully', async () => {
const wrapper = mountWithContexts(
<OrganizationNotifications canToggleNotifications />, { context: { network: {
api,
handleHttpError: () => {}
} } }
).find('OrganizationNotifications');
wrapper.instance().readOrgNotifications(1, { foo: 'bar' });
expect(api.getOrganizationNotifications).toHaveBeenCalledWith(1, { foo: 'bar' });
wrapper.instance().readOrgNotificationSuccess(1, { foo: 'bar' });
expect(api.getOrganizationNotificationSuccess).toHaveBeenCalledWith(1, { foo: 'bar' });
wrapper.instance().readOrgNotificationError(1, { foo: 'bar' });
expect(api.getOrganizationNotificationError).toHaveBeenCalledWith(1, { foo: 'bar' });
wrapper.instance().createOrgNotificationSuccess(1, { id: 2 });
expect(api.createOrganizationNotificationSuccess).toHaveBeenCalledWith(1, { id: 2 });
wrapper.instance().createOrgNotificationError(1, { id: 2 });
expect(api.createOrganizationNotificationError).toHaveBeenCalledWith(1, { id: 2 });
<OrganizationNotifications id={1} canToggleNotifications />,
{ context: { network } }
);
await sleep(0);
wrapper.update();
expect(wrapper).toMatchSnapshot();
});
test('should render list fetched of items', async () => {
const wrapper = mountWithContexts(
<OrganizationNotifications id={1} canToggleNotifications />,
{
context: { network }
}
);
await sleep(0);
wrapper.update();
expect(network.api.getOrganizationNotifications).toHaveBeenCalled();
expect(wrapper.find('OrganizationNotifications').state('notifications'))
.toEqual(data.results);
const items = wrapper.find('NotificationListItem');
expect(items).toHaveLength(2);
expect(items.at(0).prop('successTurnedOn')).toEqual(true);
expect(items.at(0).prop('errorTurnedOn')).toEqual(false);
expect(items.at(1).prop('successTurnedOn')).toEqual(false);
expect(items.at(1).prop('errorTurnedOn')).toEqual(true);
});
test('should enable success notification', async () => {
const wrapper = mountWithContexts(
<OrganizationNotifications id={1} canToggleNotifications />,
{
context: { network }
}
);
await sleep(0);
wrapper.update();
expect(
wrapper.find('OrganizationNotifications').state('successTemplateIds')
).toEqual([1]);
const items = wrapper.find('NotificationListItem');
items.at(1).find('Switch').at(0).prop('onChange')();
expect(network.api.createOrganizationNotificationSuccess).toHaveBeenCalled();
await sleep(0);
wrapper.update();
expect(
wrapper.find('OrganizationNotifications').state('successTemplateIds')
).toEqual([1, 2]);
});
test('should enable error notification', async () => {
const wrapper = mountWithContexts(
<OrganizationNotifications id={1} canToggleNotifications />,
{
context: { network }
}
);
await sleep(0);
wrapper.update();
expect(
wrapper.find('OrganizationNotifications').state('errorTemplateIds')
).toEqual([2]);
const items = wrapper.find('NotificationListItem');
items.at(0).find('Switch').at(1).prop('onChange')();
expect(network.api.createOrganizationNotificationError).toHaveBeenCalled();
await sleep(0);
wrapper.update();
expect(
wrapper.find('OrganizationNotifications').state('errorTemplateIds')
).toEqual([2, 1]);
});
test('should disable success notification', async () => {
const wrapper = mountWithContexts(
<OrganizationNotifications id={1} canToggleNotifications />,
{
context: { network }
}
);
await sleep(0);
wrapper.update();
expect(
wrapper.find('OrganizationNotifications').state('successTemplateIds')
).toEqual([1]);
const items = wrapper.find('NotificationListItem');
items.at(0).find('Switch').at(0).prop('onChange')();
expect(network.api.createOrganizationNotificationSuccess).toHaveBeenCalled();
await sleep(0);
wrapper.update();
expect(
wrapper.find('OrganizationNotifications').state('successTemplateIds')
).toEqual([]);
});
test('should disable error notification', async () => {
const wrapper = mountWithContexts(
<OrganizationNotifications id={1} canToggleNotifications />,
{
context: { network }
}
);
await sleep(0);
wrapper.update();
expect(
wrapper.find('OrganizationNotifications').state('errorTemplateIds')
).toEqual([2]);
const items = wrapper.find('NotificationListItem');
items.at(1).find('Switch').at(1).prop('onChange')();
expect(network.api.createOrganizationNotificationError).toHaveBeenCalled();
await sleep(0);
wrapper.update();
expect(
wrapper.find('OrganizationNotifications').state('errorTemplateIds')
).toEqual([]);
});
});

View File

@ -5,7 +5,6 @@ import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../enzymeHelpers';
import { sleep } from '../../../../testUtils';
import OrganizationTeams, { _OrganizationTeams } from '../../../../../src/pages/Organizations/screens/Organization/OrganizationTeams';
import OrganizationTeamsList from '../../../../../src/pages/Organizations/components/OrganizationTeamsList';
const listData = {
data: {
@ -52,7 +51,7 @@ describe('<OrganizationTeams />', () => {
});
});
test('should pass fetched teams to list component', async () => {
test('should pass fetched teams to PaginatedDatalist', async () => {
const readOrganizationTeamsList = jest.fn(() => Promise.resolve(listData));
const wrapper = mountWithContexts(
<OrganizationTeams
@ -66,8 +65,8 @@ describe('<OrganizationTeams />', () => {
await sleep(0);
wrapper.update();
const list = wrapper.find('OrganizationTeamsList');
expect(list.prop('teams')).toEqual(listData.data.results);
const list = wrapper.find('PaginatedDataList');
expect(list.prop('items')).toEqual(listData.data.results);
expect(list.prop('itemCount')).toEqual(listData.data.count);
expect(list.prop('queryParams')).toEqual({
page: 1,
@ -76,7 +75,7 @@ describe('<OrganizationTeams />', () => {
});
});
test('should pass queryParams to OrganizationTeamsList', async () => {
test('should pass queryParams to PaginatedDataList', async () => {
const page1Data = listData;
const page2Data = {
data: {
@ -111,7 +110,7 @@ describe('<OrganizationTeams />', () => {
await sleep(0);
wrapper.update();
const list = wrapper.find(OrganizationTeamsList);
const list = wrapper.find('PaginatedDataList');
expect(list.prop('queryParams')).toEqual({
page: 1,
page_size: 5,
@ -123,7 +122,7 @@ describe('<OrganizationTeams />', () => {
await sleep(0);
wrapper.update();
const list2 = wrapper.find(OrganizationTeamsList);
const list2 = wrapper.find('PaginatedDataList');
expect(list2.prop('queryParams')).toEqual({
page: 2,
page_size: 5,

View File

@ -0,0 +1,36 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<OrganizationAccess /> initially renders succesfully 1`] = `
<OrganizationAccess
api={"/api/"}
handleHttpError={[Function]}
history={"/history/"}
id={1}
location={
Object {
"hash": "",
"pathname": "",
"search": "",
"state": "",
}
}
match={
Object {
"isExact": false,
"params": Object {},
"path": "",
"url": "",
}
}
organization={
Object {
"id": 1,
"name": "Default",
}
}
>
<div>
Loading...
</div>
</OrganizationAccess>
`;

View File

@ -15,6 +15,14 @@ describe('qs (qs.js)', () => {
});
});
test('encodeQueryString omits null values', () => {
const vals = {
order_by: 'name',
page: null,
};
expect(encodeQueryString(vals)).toEqual('order_by=name');
});
test('parseQueryString returns the expected queryParams', () => {
[
['order_by=name&page=1&page_size=5', ['page', 'page_size'], { order_by: 'name', page: 1, page_size: 5 }],
@ -26,4 +34,16 @@ describe('qs (qs.js)', () => {
expect(actualQueryParams).toEqual(expectedQueryParams);
});
});
test('parseQueryString should strip leading "?"', () => {
expect(parseQueryString('?foo=bar&order_by=win')).toEqual({
foo: 'bar',
order_by: 'win',
});
expect(parseQueryString('foo=bar&order_by=?win')).toEqual({
foo: 'bar',
order_by: '?win',
});
});
});

View File

@ -0,0 +1,34 @@
import { pluralize, getArticle, ucFirst } from '../../src/util/strings';
describe('string utils', () => {
describe('pluralize', () => {
test('should add an "s"', () => {
expect(pluralize('team')).toEqual('teams');
});
test('should add an "es"', () => {
expect(pluralize('class')).toEqual('classes');
});
});
describe('getArticle', () => {
test('should return "a"', () => {
expect(getArticle('team')).toEqual('a');
expect(getArticle('notification')).toEqual('a');
});
test('should return "an"', () => {
expect(getArticle('aardvark')).toEqual('an');
expect(getArticle('ear')).toEqual('an');
expect(getArticle('interest')).toEqual('an');
expect(getArticle('ogre')).toEqual('an');
expect(getArticle('umbrella')).toEqual('an');
});
});
describe('ucFirst', () => {
test('should capitalize first character', () => {
expect(ucFirst('team')).toEqual('Team');
});
});
});

View File

@ -148,6 +148,16 @@ class APIClient {
return this.http.post(url, { id });
}
disassociateTeamRole (teamId, roleId) {
const url = `/api/v2/teams/${teamId}/roles/`;
return this.disassociate(url, roleId);
}
disassociateUserRole (accessRecordId, roleId) {
const url = `/api/v2/users/${accessRecordId}/roles/`;
return this.disassociate(url, roleId);
}
disassociate (url, id) {
return this.http.post(url, { id, disassociate: true });
}

View File

@ -1,88 +1,93 @@
import React from 'react';
import PropTypes from 'prop-types';
import { shape, number, string, bool, func } from 'prop-types';
import { I18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Link
} from 'react-router-dom';
import {
Badge,
Switch
} from '@patternfly/react-core';
import { Link } from 'react-router-dom';
import { Badge, Switch, DataListItem, DataListCell } from '@patternfly/react-core';
class NotificationListItem extends React.Component {
render () {
const {
canToggleNotifications,
itemId,
name,
notificationType,
detailUrl,
successTurnedOn,
errorTurnedOn,
toggleNotification
} = this.props;
const capText = {
textTransform: 'capitalize'
};
function NotificationListItem (props) {
const {
canToggleNotifications,
notification,
detailUrl,
successTurnedOn,
errorTurnedOn,
toggleNotification
} = props;
const capText = {
textTransform: 'capitalize'
};
return (
<I18n>
{({ i18n }) => (
<li key={itemId} className="pf-c-data-list__item">
<div className="pf-c-data-list__cell" style={{ display: 'flex' }}>
<Link
to={{
pathname: detailUrl
}}
style={{ marginRight: '1.5em' }}
>
<b>{name}</b>
</Link>
<Badge
style={capText}
isRead
>
{notificationType}
</Badge>
</div>
<div className="pf-c-data-list__cell" style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Switch
label={i18n._(t`Successful`)}
isChecked={successTurnedOn}
isDisabled={!canToggleNotifications}
onChange={() => toggleNotification(itemId, successTurnedOn, 'success')}
aria-label={i18n._(t`Notification success toggle`)}
/>
<Switch
label={i18n._(t`Failure`)}
isChecked={errorTurnedOn}
isDisabled={!canToggleNotifications}
onChange={() => toggleNotification(itemId, errorTurnedOn, 'error')}
aria-label={i18n._(t`Notification failure toggle`)}
/>
</div>
</li>
)}
</I18n>
);
}
return (
<I18n>
{({ i18n }) => (
<DataListItem
aria-labelledby={`items-list-item-${notification.id}`}
key={notification.id}
>
<DataListCell>
<Link
to={{
pathname: detailUrl
}}
style={{ marginRight: '1.5em' }}
>
<b id={`items-list-item-${notification.id}`}>{notification.name}</b>
</Link>
<Badge
style={capText}
isRead
>
{notification.notification_type}
</Badge>
</DataListCell>
<DataListCell alignRight>
<Switch
id={`notification-${notification.id}-success-toggle`}
label={i18n._(t`Successful`)}
isChecked={successTurnedOn}
isDisabled={!canToggleNotifications}
onChange={() => toggleNotification(
notification.id,
successTurnedOn,
'success'
)}
aria-label={i18n._(t`Toggle notification success`)}
/>
<Switch
id={`notification-${notification.id}-error-toggle`}
label={i18n._(t`Failure`)}
isChecked={errorTurnedOn}
isDisabled={!canToggleNotifications}
onChange={() => toggleNotification(
notification.id,
errorTurnedOn,
'error'
)}
aria-label={i18n._(t`Toggle notification failure`)}
/>
</DataListCell>
</DataListItem>
)}
</I18n>
);
}
NotificationListItem.propTypes = {
canToggleNotifications: PropTypes.bool.isRequired,
detailUrl: PropTypes.string.isRequired,
errorTurnedOn: PropTypes.bool,
itemId: PropTypes.number.isRequired,
name: PropTypes.string,
notificationType: PropTypes.string.isRequired,
successTurnedOn: PropTypes.bool,
toggleNotification: PropTypes.func.isRequired,
notification: shape({
id: number.isRequired,
canToggleNotifications: bool.isRequired,
name: string.isRequired,
notification_type: string.isRequired,
}).isRequired,
detailUrl: string.isRequired,
errorTurnedOn: bool,
successTurnedOn: bool,
toggleNotification: func.isRequired,
};
NotificationListItem.defaultProps = {
errorTurnedOn: false,
name: null,
successTurnedOn: false,
};

View File

@ -1,351 +0,0 @@
import React, {
Component,
Fragment
} from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import { Title, EmptyState, EmptyStateIcon, EmptyStateBody } from '@patternfly/react-core';
import { CubesIcon } from '@patternfly/react-icons';
import { I18n, i18nMark } from '@lingui/react';
import { Trans, t } from '@lingui/macro';
import { withNetwork } from '../../contexts/Network';
import DataListToolbar from '../DataListToolbar';
import NotificationListItem from './NotificationListItem';
import Pagination from '../Pagination';
import { parseQueryString } from '../../util/qs';
class Notifications extends Component {
columns = [
{ name: i18nMark('Name'), key: 'name', isSortable: true },
{ name: i18nMark('Modified'), key: 'modified', isSortable: true, isNumeric: true },
{ name: i18nMark('Created'), key: 'created', isSortable: true, isNumeric: true },
];
defaultParams = {
page: 1,
page_size: 5,
order_by: 'name',
};
constructor (props) {
super(props);
const { page, page_size } = this.getQueryParams();
this.state = {
page,
page_size,
sortedColumnKey: 'name',
sortOrder: 'ascending',
count: null,
error: null,
loading: true,
results: [],
selected: [],
successTemplateIds: [],
errorTemplateIds: []
};
this.handleSearch = this.handleSearch.bind(this);
this.getQueryParams = this.getQueryParams.bind(this);
this.handleSort = this.handleSort.bind(this);
this.handleSetPage = this.handleSetPage.bind(this);
this.handleSelectAll = this.handleSelectAll.bind(this);
this.toggleNotification = this.toggleNotification.bind(this);
this.createError = this.createError.bind(this);
this.createSuccess = this.createSuccess.bind(this);
this.readNotifications = this.readNotifications.bind(this);
}
componentDidMount () {
const queryParams = this.getQueryParams();
this.readNotifications(queryParams);
}
getQueryParams (overrides = {}) {
const { location } = this.props;
const { search } = location;
const searchParams = parseQueryString(search.substring(1));
return Object.assign({}, this.defaultParams, searchParams, overrides);
}
handleSort = (sortedColumnKey, sortOrder) => {
const { page_size } = this.state;
let order_by = sortedColumnKey;
if (sortOrder === 'descending') {
order_by = `-${order_by}`;
}
const queryParams = this.getQueryParams({ order_by, page_size });
this.readNotifications(queryParams);
};
handleSetPage = (pageNumber, pageSize) => {
const page = parseInt(pageNumber, 10);
const page_size = parseInt(pageSize, 10);
const queryParams = this.getQueryParams({ page, page_size });
this.readNotifications(queryParams);
};
handleSelectAll = isSelected => {
const { results } = this.state;
const selected = isSelected ? results.map(o => o.id) : [];
this.setState({ selected });
};
toggleNotification = (id, isCurrentlyOn, status) => {
if (status === 'success') {
this.createSuccess(id, isCurrentlyOn);
} else if (status === 'error') {
this.createError(id, isCurrentlyOn);
}
};
handleSearch () {
const { sortedColumnKey, sortOrder } = this.state;
this.handleSort(sortedColumnKey, sortOrder);
}
async createError (id, isCurrentlyOn) {
const { onCreateError, match, handleHttpError } = this.props;
const postParams = { id };
let errorHandled;
if (isCurrentlyOn) {
postParams.disassociate = true;
}
try {
await onCreateError(match.params.id, postParams);
} catch (err) {
errorHandled = handleHttpError(err);
if (!errorHandled) {
this.setState({ error: true });
}
} finally {
if (!errorHandled) {
if (isCurrentlyOn) {
// Remove it from state
this.setState((prevState) => ({
errorTemplateIds: prevState.errorTemplateIds.filter((templateId) => templateId !== id)
}));
} else {
// Add it to state
this.setState(prevState => ({
errorTemplateIds: [...prevState.errorTemplateIds, id]
}));
}
}
}
}
async createSuccess (id, isCurrentlyOn) {
const { onCreateSuccess, match, handleHttpError } = this.props;
const postParams = { id };
let errorHandled;
if (isCurrentlyOn) {
postParams.disassociate = true;
}
try {
await onCreateSuccess(match.params.id, postParams);
} catch (err) {
errorHandled = handleHttpError(err);
if (!errorHandled) {
this.setState({ error: true });
}
} finally {
if (!errorHandled) {
if (isCurrentlyOn) {
// Remove it from state
this.setState((prevState) => ({
successTemplateIds: prevState.successTemplateIds
.filter((templateId) => templateId !== id)
}));
} else {
// Add it to state
this.setState(prevState => ({
successTemplateIds: [...prevState.successTemplateIds, id]
}));
}
}
}
}
async readNotifications (queryParams) {
const { noInitialResults } = this.state;
const { onReadNotifications, onReadSuccess, onReadError, match, handleHttpError } = this.props;
const { page, page_size, order_by } = queryParams;
let sortOrder = 'ascending';
let sortedColumnKey = order_by;
if (order_by.startsWith('-')) {
sortOrder = 'descending';
sortedColumnKey = order_by.substring(1);
}
this.setState({ error: false, loading: true });
try {
const { data } = await onReadNotifications(match.params.id, queryParams);
const { count, results } = data;
const pageCount = Math.ceil(count / page_size);
const stateToUpdate = {
count,
page,
pageCount,
page_size,
sortOrder,
sortedColumnKey,
results,
noInitialResults,
selected: []
};
// This is in place to track whether or not the initial request
// return any results. If it did not, we show the empty state.
// This will become problematic once search is in play because
// the first load may have query params (think bookmarked search)
if (typeof noInitialResults === 'undefined') {
stateToUpdate.noInitialResults = results.length === 0;
}
this.setState(stateToUpdate);
const notificationTemplateIds = results
.map(notificationTemplate => notificationTemplate.id)
.join(',');
let successTemplateIds = [];
let errorTemplateIds = [];
if (results.length > 0) {
const successTemplatesPromise = onReadSuccess(match.params.id, {
id__in: notificationTemplateIds
});
const errorTemplatesPromise = onReadError(match.params.id, {
id__in: notificationTemplateIds
});
const successTemplatesResult = await successTemplatesPromise;
const errorTemplatesResult = await errorTemplatesPromise;
successTemplateIds = successTemplatesResult.data.results
.map(successTemplate => successTemplate.id);
errorTemplateIds = errorTemplatesResult.data.results
.map(errorTemplate => errorTemplate.id);
}
this.setState({
successTemplateIds,
errorTemplateIds,
loading: false
});
} catch (err) {
handleHttpError(err) || this.setState({ error: true, loading: false });
}
}
render () {
const {
count,
error,
loading,
page,
pageCount,
page_size,
sortedColumnKey,
sortOrder,
results,
noInitialResults,
selected,
successTemplateIds,
errorTemplateIds
} = this.state;
const { canToggleNotifications } = this.props;
return (
<Fragment>
{noInitialResults && (
<EmptyState>
<EmptyStateIcon icon={CubesIcon} />
<Title size="lg">
<Trans>No Notifications Found</Trans>
</Title>
<EmptyStateBody>
<Trans>Please add a notification template to populate this list</Trans>
</EmptyStateBody>
</EmptyState>
)}
{(
typeof noInitialResults !== 'undefined'
&& !noInitialResults
&& !loading
&& !error
) && (
<Fragment>
<DataListToolbar
isAllSelected={selected.length === results.length}
sortedColumnKey={sortedColumnKey}
sortOrder={sortOrder}
columns={this.columns}
onSearch={this.handleSearch}
onSort={this.handleSort}
onSelectAll={this.handleSelectAll}
/>
<I18n>
{({ i18n }) => (
<ul className="pf-c-data-list" aria-label={i18n._(t`Organizations List`)}>
{results.map(o => (
<NotificationListItem
key={o.id}
itemId={o.id}
name={o.name}
notificationType={o.notification_type}
detailUrl={`/notifications/${o.id}`}
toggleNotification={this.toggleNotification}
errorTurnedOn={errorTemplateIds.includes(o.id)}
successTurnedOn={successTemplateIds.includes(o.id)}
canToggleNotifications={canToggleNotifications}
/>
))}
</ul>
)}
</I18n>
<Pagination
count={count}
page={page}
pageCount={pageCount}
page_size={page_size}
onSetPage={this.handleSetPage}
/>
</Fragment>
)}
{loading ? <div>loading...</div> : ''}
{error ? <div>error</div> : ''}
</Fragment>
);
}
}
Notifications.propTypes = {
canToggleNotifications: PropTypes.bool.isRequired,
onReadError: PropTypes.func.isRequired,
onReadNotifications: PropTypes.func.isRequired,
onReadSuccess: PropTypes.func.isRequired,
onCreateError: PropTypes.func.isRequired,
onCreateSuccess: PropTypes.func.isRequired,
};
export { Notifications as _Notifications };
export default withRouter(withNetwork(Notifications));

View File

@ -0,0 +1,230 @@
import React, { Fragment } from 'react';
import PropTypes, { arrayOf, shape, string, bool } from 'prop-types';
import {
DataList,
DataListItem,
DataListCell,
Text,
TextContent,
TextVariants,
Title,
EmptyState,
EmptyStateIcon,
EmptyStateBody,
} from '@patternfly/react-core';
import { CubesIcon } from '@patternfly/react-icons';
import { I18n, i18nMark } from '@lingui/react';
import { Trans, t } from '@lingui/macro';
import { withRouter, Link } from 'react-router-dom';
import Pagination from '../Pagination';
import DataListToolbar from '../DataListToolbar';
import { encodeQueryString, parseQueryString } from '../../util/qs';
import { pluralize, getArticle, ucFirst } from '../../util/strings';
const detailWrapperStyle = {
display: 'grid',
gridTemplateColumns: 'minmax(70px, max-content) minmax(60px, max-content)',
};
const detailLabelStyle = {
fontWeight: '700',
lineHeight: '24px',
marginRight: '20px',
};
class PaginatedDataList extends React.Component {
constructor (props) {
super(props);
this.state = {
error: null,
};
this.handleSetPage = this.handleSetPage.bind(this);
this.handleSort = this.handleSort.bind(this);
}
getPageCount () {
const { itemCount, queryParams: { page_size } } = this.props;
return Math.ceil(itemCount / page_size);
}
getSortOrder () {
const { queryParams } = this.props;
if (queryParams.order_by && queryParams.order_by.startsWith('-')) {
return [queryParams.order_by.substr(1), 'descending'];
}
return [queryParams.order_by, 'ascending'];
}
handleSetPage (pageNumber, pageSize) {
this.pushHistoryState({
page: pageNumber,
page_size: pageSize,
});
}
handleSort (sortedColumnKey, sortOrder) {
this.pushHistoryState({
order_by: sortOrder === 'ascending' ? sortedColumnKey : `-${sortedColumnKey}`,
page: null,
});
}
pushHistoryState (newParams) {
const { history } = this.props;
const { pathname, search } = history.location;
const currentParams = parseQueryString(search);
const qs = encodeQueryString({
...currentParams,
...newParams
});
history.push(`${pathname}?${qs}`);
}
getPluralItemName () {
const { itemName, itemNamePlural } = this.props;
return itemNamePlural || `${itemName}s`;
}
render () {
const {
items,
itemCount,
queryParams,
renderItem,
toolbarColumns,
additionalControls,
itemName,
itemNamePlural,
} = this.props;
const { error } = this.state;
const [orderBy, sortOrder] = this.getSortOrder();
return (
<I18n>
{({ i18n }) => (
<Fragment>
{error && (
<Fragment>
<div>{error.message}</div>
{error.response && (
<div>{error.response.data.detail}</div>
)}
</Fragment> // TODO: replace with proper error handling
)}
{items.length === 0 ? (
<EmptyState>
<EmptyStateIcon icon={CubesIcon} />
<Title size="lg">
<Trans>
No
{' '}
{ucFirst(itemNamePlural || pluralize(itemName))}
{' '}
Found
</Trans>
</Title>
<EmptyStateBody>
<Trans>
Please add
{' '}
{getArticle(itemName)}
{' '}
{itemName}
{' '}
to populate this list
</Trans>
</EmptyStateBody>
</EmptyState>
) : (
<Fragment>
<DataListToolbar
sortedColumnKey={orderBy}
sortOrder={sortOrder}
columns={toolbarColumns}
onSearch={() => { }}
onSort={this.handleSort}
showAdd={!!additionalControls}
add={additionalControls}
/>
<DataList aria-label={i18n._(t`${ucFirst(pluralize(itemName))} List`)}>
{items.map(item => (renderItem ? renderItem(item) : (
<DataListItem
aria-labelledby={`items-list-item-${item.id}`}
key={item.id}
>
<DataListCell>
<TextContent style={detailWrapperStyle}>
<Link to={{ pathname: item.url }}>
<Text
id="items-list-item"
component={TextVariants.h6}
style={detailLabelStyle}
>
<span id={`items-list-item-${item.id}`}>
{item.name}
</span>
</Text>
</Link>
</TextContent>
</DataListCell>
</DataListItem>
)))}
</DataList>
<Pagination
count={itemCount}
page={queryParams.page}
pageCount={this.getPageCount()}
page_size={queryParams.page_size}
onSetPage={this.handleSetPage}
/>
</Fragment>
)}
</Fragment>
)}
</I18n>
);
}
}
const Item = PropTypes.shape({
id: PropTypes.number.isRequired,
url: PropTypes.string.isRequired,
name: PropTypes.string,
});
const QueryParams = PropTypes.shape({
page: PropTypes.number,
page_size: PropTypes.number,
order_by: PropTypes.string,
});
PaginatedDataList.propTypes = {
items: PropTypes.arrayOf(Item).isRequired,
itemCount: PropTypes.number.isRequired,
itemName: PropTypes.string,
itemNamePlural: PropTypes.string,
// TODO: determine this internally but pass in defaults?
queryParams: QueryParams.isRequired,
renderItem: PropTypes.func,
toolbarColumns: arrayOf(shape({
name: string.isRequired,
key: string.isRequired,
isSortable: bool,
})),
additionalControls: PropTypes.node,
};
PaginatedDataList.defaultProps = {
renderItem: null,
toolbarColumns: [
{ name: i18nMark('Name'), key: 'name', isSortable: true },
],
additionalControls: null,
itemName: 'item',
itemNamePlural: '',
};
export { PaginatedDataList as _PaginatedDataList };
export default withRouter(PaginatedDataList);

View File

@ -0,0 +1,3 @@
import PaginatedDataList from './PaginatedDataList';
export default PaginatedDataList;

View File

@ -36,7 +36,7 @@ class Search extends React.Component {
const { columns } = this.props;
const { innerText } = target;
const [{ key: searchKey }] = columns.filter(({ name }) => name === innerText);
const { key: searchKey } = columns.find(({ name }) => name === innerText);
this.setState({ isSearchDropdownOpen: false, searchKey });
}
@ -62,7 +62,7 @@ class Search extends React.Component {
searchValue,
} = this.state;
const [{ name: searchColumnName }] = columns.filter(({ key }) => key === searchKey);
const { name: searchColumnName } = columns.find(({ key }) => key === searchKey);
const searchDropdownItems = columns
.filter(({ key }) => key !== searchKey)

View File

@ -0,0 +1,80 @@
import React from 'react';
import { func, string } from 'prop-types';
import { Button } from '@patternfly/react-core';
import { I18n, i18nMark } from '@lingui/react';
import { t, Trans } from '@lingui/macro';
import AlertModal from '../../../components/AlertModal';
import { Role } from '../../../types';
class DeleteRoleConfirmationModal extends React.Component {
static propTypes = {
role: Role.isRequired,
username: string,
onCancel: func.isRequired,
onConfirm: func.isRequired,
}
static defaultProps = {
username: '',
}
isTeamRole () {
const { role } = this.props;
return typeof role.team_id !== 'undefined';
}
render () {
const { role, username, onCancel, onConfirm } = this.props;
const title = `Remove ${this.isTeamRole() ? 'Team' : 'User'} Access`;
return (
<I18n>
{({ i18n }) => (
<AlertModal
variant="danger"
title={i18nMark(title)}
isOpen
onClose={this.hideWarning}
actions={[
<Button
key="delete"
variant="danger"
aria-label="Confirm delete"
onClick={onConfirm}
>
{i18n._(t`Delete`)}
</Button>,
<Button key="cancel" variant="secondary" onClick={onCancel}>
{i18n._(t`Cancel`)}
</Button>
]}
>
{this.isTeamRole() ? (
<Trans>
Are you sure you want to remove
<b>{` ${role.name} `}</b>
access from
<b>{` ${role.team_name}`}</b>
? Doing so affects all members of the team.
<br />
<br />
If you
<b><i> only </i></b>
want to remove access for this particular user, please remove them from the team.
</Trans>
) : (
<Trans>
Are you sure you want to remove
<b>{` ${role.name} `}</b>
access from
<b>{` ${username}`}</b>
?
</Trans>
)}
</AlertModal>
)}
</I18n>
);
}
}
export default DeleteRoleConfirmationModal;

View File

@ -0,0 +1,169 @@
import React from 'react';
import { func } from 'prop-types';
import { I18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
DataListItem,
DataListCell,
Text,
TextContent,
TextVariants,
Chip,
} from '@patternfly/react-core';
import { Link } from 'react-router-dom';
import { AccessRecord } from '../../../types';
import BasicChip from '../../../components/BasicChip/BasicChip';
const userRolesWrapperStyle = {
display: 'flex',
flexWrap: 'wrap',
};
const detailWrapperStyle = {
display: 'grid',
gridTemplateColumns: 'minmax(70px, max-content) minmax(60px, max-content)',
};
const detailLabelStyle = {
fontWeight: '700',
lineHeight: '24px',
marginRight: '20px',
};
const detailValueStyle = {
lineHeight: '28px',
overflow: 'visible',
};
/* TODO: does PF offer any sort of <dl> treatment for this? */
const Detail = ({ label, value, url, customStyles }) => {
let detail = null;
if (value) {
detail = (
<TextContent style={{ ...detailWrapperStyle, ...customStyles }}>
{url ? (
<Link to={{ pathname: url }}>
<Text component={TextVariants.h6} style={detailLabelStyle}>{label}</Text>
</Link>) : (<Text component={TextVariants.h6} style={detailLabelStyle}>{label}</Text>
)}
<Text component={TextVariants.p} style={detailValueStyle}>{value}</Text>
</TextContent>
);
}
return detail;
};
class OrganizationAccessItem extends React.Component {
static propTypes = {
accessRecord: AccessRecord.isRequired,
onRoleDelete: func.isRequired,
};
getRoleLists () {
const { accessRecord } = this.props;
const teamRoles = [];
const userRoles = [];
function sort (item) {
const { role } = item;
if (role.team_id) {
teamRoles.push(role);
} else {
userRoles.push(role);
}
}
accessRecord.summary_fields.direct_access.map(sort);
accessRecord.summary_fields.indirect_access.map(sort);
return [teamRoles, userRoles];
}
render () {
const { accessRecord, onRoleDelete } = this.props;
const [teamRoles, userRoles] = this.getRoleLists();
return (
<I18n>
{({ i18n }) => (
<DataListItem aria-labelledby="access-list-item" key={accessRecord.id}>
<DataListCell>
{accessRecord.username && (
<TextContent style={detailWrapperStyle}>
{accessRecord.url ? (
<Link to={{ pathname: accessRecord.url }}>
<Text component={TextVariants.h6} style={detailLabelStyle}>
{accessRecord.username}
</Text>
</Link>
) : (
<Text component={TextVariants.h6} style={detailLabelStyle}>
{accessRecord.username}
</Text>
)}
</TextContent>
)}
{accessRecord.first_name || accessRecord.last_name ? (
<Detail
label={i18n._(t`Name`)}
value={`${accessRecord.first_name} ${accessRecord.last_name}`}
url={null}
customStyles={null}
/>
) : (
null
)}
</DataListCell>
<DataListCell>
{userRoles.length > 0 && (
<ul style={userRolesWrapperStyle}>
<Text component={TextVariants.h6} style={detailLabelStyle}>
{i18n._(t`User Roles`)}
</Text>
{userRoles.map(role => (
role.user_capabilities.unattach ? (
<Chip
key={role.id}
className="awx-c-chip"
onClick={() => { onRoleDelete(role, accessRecord); }}
>
{role.name}
</Chip>
) : (
<BasicChip key={role.id}>
{role.name}
</BasicChip>
)
))}
</ul>
)}
{teamRoles.length > 0 && (
<ul style={userRolesWrapperStyle}>
<Text component={TextVariants.h6} style={detailLabelStyle}>
{i18n._(t`Team Roles`)}
</Text>
{teamRoles.map(role => (
role.user_capabilities.unattach ? (
<Chip
key={role.id}
className="awx-c-chip"
onClick={() => { onRoleDelete(role, accessRecord); }}
>
{role.name}
</Chip>
) : (
<BasicChip key={role.id}>
{role.name}
</BasicChip>
)
))}
</ul>
)}
</DataListCell>
</DataListItem>
)}
</I18n>
);
}
}
export default OrganizationAccessItem;

View File

@ -1,486 +0,0 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import {
DataList, DataListItem, DataListCell, Text,
TextContent, TextVariants, Chip, Button
} from '@patternfly/react-core';
import {
PlusIcon,
} from '@patternfly/react-icons';
import { I18n, i18nMark } from '@lingui/react';
import { t, Trans } from '@lingui/macro';
import {
Link,
withRouter
} from 'react-router-dom';
import { withNetwork } from '../../../contexts/Network';
import AlertModal from '../../../components/AlertModal';
import BasicChip from '../../../components/BasicChip/BasicChip';
import Pagination from '../../../components/Pagination';
import DataListToolbar from '../../../components/DataListToolbar';
import AddResourceRole from '../../../components/AddRole/AddResourceRole';
import {
parseQueryString,
} from '../../../util/qs';
const userRolesWrapperStyle = {
display: 'flex',
flexWrap: 'wrap',
};
const detailWrapperStyle = {
display: 'grid',
gridTemplateColumns: 'minmax(70px, max-content) minmax(60px, max-content)',
};
const detailLabelStyle = {
fontWeight: '700',
lineHeight: '24px',
marginRight: '20px',
};
const detailValueStyle = {
lineHeight: '28px',
overflow: 'visible',
};
const Detail = ({ label, value, url, customStyles }) => {
let detail = null;
if (value) {
detail = (
<TextContent style={{ ...detailWrapperStyle, ...customStyles }}>
{url ? (
<Link to={{ pathname: url }}>
<Text component={TextVariants.h6} style={detailLabelStyle}>{label}</Text>
</Link>) : (<Text component={TextVariants.h6} style={detailLabelStyle}>{label}</Text>
)}
<Text component={TextVariants.p} style={detailValueStyle}>{value}</Text>
</TextContent>
);
}
return detail;
};
const UserName = ({ value, url }) => {
let username = null;
if (value) {
username = (
<TextContent style={detailWrapperStyle}>
{url ? (
<Link to={{ pathname: url }}>
<Text component={TextVariants.h6} style={detailLabelStyle}>{value}</Text>
</Link>) : (<Text component={TextVariants.h6} style={detailLabelStyle}>{value}</Text>
)}
</TextContent>
);
}
return username;
};
class OrganizationAccessList extends React.Component {
columns = [
{ name: i18nMark('Name'), key: 'first_name', isSortable: true },
{ name: i18nMark('Username'), key: 'username', isSortable: true },
{ name: i18nMark('Last Name'), key: 'last_name', isSortable: true },
];
defaultParams = {
page: 1,
page_size: 5,
order_by: 'first_name',
};
constructor (props) {
super(props);
const { page, page_size } = this.getQueryParams();
this.state = {
page,
page_size,
count: 0,
sortOrder: 'ascending',
sortedColumnKey: 'username',
showWarning: false,
warningTitle: '',
warningMsg: '',
deleteType: '',
deleteRoleId: null,
deleteResourceId: null,
results: [],
isModalOpen: false
};
this.fetchOrgAccessList = this.fetchOrgAccessList.bind(this);
this.onSetPage = this.onSetPage.bind(this);
this.onSort = this.onSort.bind(this);
this.getQueryParams = this.getQueryParams.bind(this);
this.removeAccessRole = this.removeAccessRole.bind(this);
this.handleWarning = this.handleWarning.bind(this);
this.hideWarning = this.hideWarning.bind(this);
this.confirmDelete = this.confirmDelete.bind(this);
this.handleModalToggle = this.handleModalToggle.bind(this);
this.handleSuccessfulRoleAdd = this.handleSuccessfulRoleAdd.bind(this);
}
componentDidMount () {
const queryParams = this.getQueryParams();
try {
this.fetchOrgAccessList(queryParams);
} catch (error) {
this.setState({ error });
}
}
onSetPage (pageNumber, pageSize) {
const { sortOrder, sortedColumnKey } = this.state;
const page = parseInt(pageNumber, 10);
const page_size = parseInt(pageSize, 10);
let order_by = sortedColumnKey;
// Preserve sort order when paginating
if (sortOrder === 'descending') {
order_by = `-${order_by}`;
}
const queryParams = this.getQueryParams({ page, page_size, order_by });
this.fetchOrgAccessList(queryParams);
}
onSort (sortedColumnKey, sortOrder) {
const { page_size } = this.state;
let order_by = sortedColumnKey;
if (sortOrder === 'descending') {
order_by = `-${order_by}`;
}
const queryParams = this.getQueryParams({ order_by, page_size });
this.fetchOrgAccessList(queryParams);
}
getQueryParams (overrides = {}) {
const { history } = this.props;
const { search } = history.location;
const searchParams = parseQueryString(search.substring(1));
return Object.assign({}, this.defaultParams, searchParams, overrides);
}
async fetchOrgAccessList (queryParams) {
const { match, getAccessList } = this.props;
const { page, page_size, order_by } = queryParams;
let sortOrder = 'ascending';
let sortedColumnKey = order_by;
if (order_by.startsWith('-')) {
sortOrder = 'descending';
sortedColumnKey = order_by.substring(1);
}
try {
const { data:
{ count = 0, results = [] }
} = await getAccessList(match.params.id, queryParams);
const pageCount = Math.ceil(count / page_size);
const stateToUpdate = {
count,
page,
pageCount,
page_size,
sortOrder,
sortedColumnKey,
results,
};
results.forEach((result) => {
// Separate out roles into user roles or team roles
// based on whether or not a team_id attribute is present
const teamRoles = [];
const userRoles = [];
Object.values(result.summary_fields).forEach(field => {
if (field.length > 0) {
field.forEach(item => {
const { role } = item;
if (role.team_id) {
teamRoles.push(role);
} else {
userRoles.push(role);
}
});
}
});
result.teamRoles = teamRoles;
result.userRoles = userRoles;
});
this.setState(stateToUpdate);
} catch (error) {
this.setState({ error });
}
}
async removeAccessRole (roleId, resourceId, type) {
const { removeRole, handleHttpError } = this.props;
const url = `/api/v2/${type}/${resourceId}/roles/`;
try {
await removeRole(url, roleId);
const queryParams = this.getQueryParams();
await this.fetchOrgAccessList(queryParams);
this.setState({ showWarning: false });
} catch (error) {
handleHttpError(error) || this.setState({ error });
}
}
handleWarning (roleName, roleId, resourceName, resourceId, type) {
let warningTitle;
let warningMsg;
if (type === 'users') {
warningTitle = i18nMark('Remove User Access');
warningMsg = (
<Trans>
Are you sure you want to remove
<b>{` ${roleName} `}</b>
access from
<strong>{` ${resourceName}`}</strong>
?
</Trans>
);
}
if (type === 'teams') {
warningTitle = i18nMark('Remove Team Access');
warningMsg = (
<Trans>
Are you sure you want to remove
<b>{` ${roleName} `}</b>
access from
<b>{` ${resourceName}`}</b>
? Doing so affects all members of the team.
<br />
<br />
If you
<b><i> only </i></b>
want to remove access for this particular user, please remove them from the team.
</Trans>
);
}
this.setState({
showWarning: true,
warningMsg,
warningTitle,
deleteType: type,
deleteRoleId: roleId,
deleteResourceId: resourceId
});
}
handleSuccessfulRoleAdd () {
this.handleModalToggle();
const queryParams = this.getQueryParams();
try {
this.fetchOrgAccessList(queryParams);
} catch (error) {
this.setState({ error });
}
}
handleModalToggle () {
this.setState((prevState) => ({
isModalOpen: !prevState.isModalOpen,
}));
}
hideWarning () {
this.setState({ showWarning: false });
}
confirmDelete () {
const { deleteType, deleteResourceId, deleteRoleId } = this.state;
this.removeAccessRole(deleteRoleId, deleteResourceId, deleteType);
}
render () {
const {
results,
error,
count,
page_size,
pageCount,
page,
sortedColumnKey,
sortOrder,
warningMsg,
warningTitle,
showWarning,
isModalOpen
} = this.state;
const {
api,
organization
} = this.props;
return (
<I18n>
{({ i18n }) => (
<Fragment>
{!error && results.length <= 0 && (
<h1>Loading...</h1> // TODO: replace with proper loading state
)}
{error && results.length <= 0 && (
<Fragment>
<div>{error.message}</div>
{error.response && (
<div>{error.response.data.detail}</div>
)}
</Fragment> // TODO: replace with proper error handling
)}
{results.length > 0 && (
<Fragment>
<DataListToolbar
sortedColumnKey={sortedColumnKey}
sortOrder={sortOrder}
columns={this.columns}
onSearch={() => { }}
onSort={this.onSort}
showAdd={organization.summary_fields.user_capabilities.edit}
add={(
<Fragment>
<Button
variant="primary"
aria-label={i18n._(t`Add Access Role`)}
onClick={this.handleModalToggle}
>
<PlusIcon />
</Button>
{isModalOpen && (
<AddResourceRole
onClose={this.handleModalToggle}
onSave={this.handleSuccessfulRoleAdd}
api={api}
roles={organization.summary_fields.object_roles}
/>
)}
</Fragment>
)}
/>
{showWarning && (
<AlertModal
variant="danger"
title={warningTitle}
isOpen={showWarning}
onClose={this.hideWarning}
actions={[
<Button key="delete" variant="danger" aria-label="Confirm delete" onClick={this.confirmDelete}>{i18n._(t`Delete`)}</Button>,
<Button key="cancel" variant="secondary" onClick={this.hideWarning}>{i18n._(t`Cancel`)}</Button>
]}
>
{warningMsg}
</AlertModal>
)}
<DataList aria-label={i18n._(t`Access List`)}>
{results.map(result => (
<DataListItem aria-labelledby={i18n._(t`access-list-item`)} key={result.id}>
<DataListCell>
<UserName
value={result.username}
url={result.url}
/>
{result.first_name || result.last_name ? (
<Detail
label={i18n._(t`Name`)}
value={`${result.first_name} ${result.last_name}`}
url={null}
customStyles={null}
/>
) : (
null
)}
</DataListCell>
<DataListCell>
<Detail
label=" "
value=" "
url={null}
customStyles={null}
/>
{result.userRoles.length > 0 && (
<ul style={userRolesWrapperStyle}>
<Text component={TextVariants.h6} style={detailLabelStyle}>{i18n._(t`User Roles`)}</Text>
{result.userRoles.map(role => (
role.user_capabilities.unattach ? (
<Chip
key={role.id}
className="awx-c-chip"
onClick={() => this.handleWarning(role.name, role.id, result.username, result.id, 'users')}
>
{role.name}
</Chip>
) : (
<BasicChip
key={role.id}
>
{role.name}
</BasicChip>
)
))}
</ul>
)}
{result.teamRoles.length > 0 && (
<ul style={userRolesWrapperStyle}>
<Text component={TextVariants.h6} style={detailLabelStyle}>{i18n._(t`Team Roles`)}</Text>
{result.teamRoles.map(role => (
<Chip
key={role.id}
className="awx-c-chip"
onClick={() => this.handleWarning(role.name, role.id, role.team_name, role.team_id, 'teams')}
>
{role.name}
</Chip>
))}
</ul>
)}
</DataListCell>
</DataListItem>
))}
</DataList>
<Pagination
count={count}
page={page}
pageCount={pageCount}
page_size={page_size}
onSetPage={this.onSetPage}
/>
</Fragment>
)}
</Fragment>
)}
</I18n>
);
}
}
OrganizationAccessList.propTypes = {
api: PropTypes.shape().isRequired,
getAccessList: PropTypes.func.isRequired,
organization: PropTypes.shape().isRequired,
removeRole: PropTypes.func.isRequired
};
export { OrganizationAccessList as _OrganizationAccessList };
export default withRouter(withNetwork(OrganizationAccessList));

View File

@ -1,173 +0,0 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import {
DataList,
DataListItem,
DataListCell,
Text,
TextContent,
TextVariants,
Title,
EmptyState,
EmptyStateIcon,
EmptyStateBody,
} from '@patternfly/react-core';
import { CubesIcon } from '@patternfly/react-icons';
import { I18n, i18nMark } from '@lingui/react';
import { Trans, t } from '@lingui/macro';
import { withRouter, Link } from 'react-router-dom';
import Pagination from '../../../components/Pagination';
import DataListToolbar from '../../../components/DataListToolbar';
import { encodeQueryString } from '../../../util/qs';
const detailWrapperStyle = {
display: 'grid',
gridTemplateColumns: 'minmax(70px, max-content) minmax(60px, max-content)',
};
const detailLabelStyle = {
fontWeight: '700',
lineHeight: '24px',
marginRight: '20px',
};
class OrganizationTeamsList extends React.Component {
columns = [
{ name: i18nMark('Name'), key: 'name', isSortable: true },
];
defaultParams = {
page: 1,
page_size: 5,
order_by: 'name',
};
constructor (props) {
super(props);
this.state = {
error: null,
};
this.handleSetPage = this.handleSetPage.bind(this);
this.handleSort = this.handleSort.bind(this);
}
getPageCount () {
const { itemCount, queryParams: { page_size } } = this.props;
return Math.ceil(itemCount / page_size);
}
getSortOrder () {
const { queryParams } = this.props;
if (queryParams.order_by && queryParams.order_by.startsWith('-')) {
return 'descending';
}
return 'ascending';
}
handleSetPage (pageNumber, pageSize) {
this.pushHistoryState({
page: pageNumber,
page_size: pageSize,
});
}
handleSort (sortedColumnKey, sortOrder) {
this.pushHistoryState({
order_by: sortOrder === 'ascending' ? sortedColumnKey : `-${sortedColumnKey}`,
});
}
pushHistoryState (params) {
const { history } = this.props;
const { pathname } = history.location;
const qs = encodeQueryString(params);
history.push(`${pathname}?${qs}`);
}
render () {
const { teams, itemCount, queryParams } = this.props;
const { error } = this.state;
return (
<I18n>
{({ i18n }) => (
<Fragment>
{error && (
<Fragment>
<div>{error.message}</div>
{error.response && (
<div>{error.response.data.detail}</div>
)}
</Fragment> // TODO: replace with proper error handling
)}
{teams.length === 0 ? (
<EmptyState>
<EmptyStateIcon icon={CubesIcon} />
<Title size="lg">
<Trans>No Teams Found</Trans>
</Title>
<EmptyStateBody>
<Trans>Please add a team to populate this list</Trans>
</EmptyStateBody>
</EmptyState>
) : (
<Fragment>
<DataListToolbar
sortedColumnKey={queryParams.sort_by}
sortOrder={this.getSortOrder()}
columns={this.columns}
onSearch={() => { }}
onSort={this.handleSort}
/>
<DataList aria-label={i18n._(t`Teams List`)}>
{teams.map(({ url, id, name }) => (
<DataListItem aria-labelledby={i18n._(t`teams-list-item`)} key={id}>
<DataListCell>
<TextContent style={detailWrapperStyle}>
<Link to={{ pathname: url }}>
<Text component={TextVariants.h6} style={detailLabelStyle}>{name}</Text>
</Link>
</TextContent>
</DataListCell>
</DataListItem>
))}
</DataList>
<Pagination
count={itemCount}
page={queryParams.page}
pageCount={this.getPageCount()}
page_size={queryParams.page_size}
onSetPage={this.handleSetPage}
/>
</Fragment>
)}
</Fragment>
)}
</I18n>
);
}
}
const Item = PropTypes.shape({
id: PropTypes.number.isRequired,
url: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
});
const QueryParams = PropTypes.shape({
page: PropTypes.number,
page_size: PropTypes.number,
order_by: PropTypes.string,
});
OrganizationTeamsList.propTypes = {
teams: PropTypes.arrayOf(Item).isRequired,
itemCount: PropTypes.number.isRequired,
queryParams: QueryParams.isRequired
};
export { OrganizationTeamsList as _OrganizationTeamsList };
export default withRouter(OrganizationTeamsList);

View File

@ -216,23 +216,20 @@ class Organization extends Component {
)}
/>
)}
<Route
path="/organizations/:id/access"
render={() => (
<OrganizationAccess
organization={organization}
/>
)}
/>
{organization && (
<Route
path="/organizations/:id/access"
render={() => (
<OrganizationAccess
organization={organization}
/>
)}
/>
)}
<Route
path="/organizations/:id/teams"
render={() => (
<OrganizationTeams
id={Number(match.params.id)}
match={match}
location={location}
history={history}
/>
<OrganizationTeams id={Number(match.params.id)} />
)}
/>
{canSeeNotificationsTab && (
@ -240,12 +237,17 @@ class Organization extends Component {
path="/organizations/:id/notifications"
render={() => (
<OrganizationNotifications
id={Number(match.params.id)}
canToggleNotifications={canToggleNotifications}
/>
)}
/>
)}
{organization && <NotifyAndRedirect to={`/organizations/${match.params.id}/details`} />}
{organization && (
<NotifyAndRedirect
to={`/organizations/${match.params.id}/details`}
/>
)}
</Switch>
{error ? 'error!' : ''}
{loading ? 'loading...' : ''}

View File

@ -1,37 +1,223 @@
import React from 'react';
import React, { Fragment } from 'react';
import { withRouter } from 'react-router-dom';
import { I18n, i18nMark } from '@lingui/react';
import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core';
import { PlusIcon } from '@patternfly/react-icons';
import PaginatedDataList from '../../../../components/PaginatedDataList';
import OrganizationAccessItem from '../../components/OrganizationAccessItem';
import DeleteRoleConfirmationModal from '../../components/DeleteRoleConfirmationModal';
import AddResourceRole from '../../../../components/AddRole/AddResourceRole';
import { withNetwork } from '../../../../contexts/Network';
import { parseQueryString } from '../../../../util/qs';
import { Organization } from '../../../../types';
import OrganizationAccessList from '../../components/OrganizationAccessList';
const DEFAULT_QUERY_PARAMS = {
page: 1,
page_size: 5,
order_by: 'first_name',
};
class OrganizationAccess extends React.Component {
static propTypes = {
organization: Organization.isRequired,
};
constructor (props) {
super(props);
this.getOrgAccessList = this.getOrgAccessList.bind(this);
this.readOrgAccessList = this.readOrgAccessList.bind(this);
this.confirmRemoveRole = this.confirmRemoveRole.bind(this);
this.cancelRemoveRole = this.cancelRemoveRole.bind(this);
this.removeRole = this.removeRole.bind(this);
this.toggleAddModal = this.toggleAddModal.bind(this);
this.handleSuccessfulRoleAdd = this.handleSuccessfulRoleAdd.bind(this);
this.state = {
isLoading: false,
isInitialized: false,
isAddModalOpen: false,
error: null,
itemCount: 0,
accessRecords: [],
roleToDelete: null,
roleToDeleteAccessRecord: null,
};
}
getOrgAccessList (id, params) {
const { api } = this.props;
return api.getOrganizationAccessList(id, params);
componentDidMount () {
this.readOrgAccessList();
}
removeRole (url, id) {
const { api } = this.props;
return api.disassociate(url, id);
componentDidUpdate (prevProps) {
const { location } = this.props;
if (location !== prevProps.location) {
this.readOrgAccessList();
}
}
async readOrgAccessList () {
const { organization, api, handleHttpError } = this.props;
this.setState({ isLoading: true });
try {
const { data } = await api.getOrganizationAccessList(
organization.id,
this.getQueryParams()
);
this.setState({
itemCount: data.count || 0,
accessRecords: data.results || [],
isLoading: false,
isInitialized: true,
});
} catch (error) {
handleHttpError(error) || this.setState({
error,
isLoading: false,
});
}
}
getQueryParams () {
const { location } = this.props;
const searchParams = parseQueryString(location.search.substring(1));
return {
...DEFAULT_QUERY_PARAMS,
...searchParams,
};
}
confirmRemoveRole (role, accessRecord) {
this.setState({
roleToDelete: role,
roleToDeleteAccessRecord: accessRecord,
});
}
cancelRemoveRole () {
this.setState({
roleToDelete: null,
roleToDeleteAccessRecord: null
});
}
async removeRole () {
const { api, handleHttpError } = this.props;
const { roleToDelete: role, roleToDeleteAccessRecord: accessRecord } = this.state;
if (!role || !accessRecord) {
return;
}
const type = typeof role.team_id === 'undefined' ? 'users' : 'teams';
this.setState({ isLoading: true });
try {
if (type === 'teams') {
await api.disassociateTeamRole(role.team_id, role.id);
} else {
await api.disassociateUserRole(accessRecord.id, role.id);
}
this.setState({
isLoading: false,
roleToDelete: null,
roleToDeleteAccessRecord: null,
});
this.readOrgAccessList();
} catch (error) {
handleHttpError(error) || this.setState({
error,
isLoading: false,
});
}
}
toggleAddModal () {
const { isAddModalOpen } = this.state;
this.setState({
isAddModalOpen: !isAddModalOpen,
});
}
handleSuccessfulRoleAdd () {
this.toggleAddModal();
this.readOrgAccessList();
}
render () {
const { organization } = this.props;
const { api, organization } = this.props;
const {
isLoading,
isInitialized,
itemCount,
isAddModalOpen,
accessRecords,
roleToDelete,
roleToDeleteAccessRecord,
error,
} = this.state;
const canEdit = organization.summary_fields.user_capabilities.edit;
if (error) {
// TODO: better error state
return <div>{error.message}</div>;
}
// TODO: better loading state
return (
<OrganizationAccessList
getAccessList={this.getOrgAccessList}
removeRole={this.removeRole}
organization={organization}
/>
<Fragment>
{isLoading && (<div>Loading...</div>)}
{roleToDelete && (
<DeleteRoleConfirmationModal
role={roleToDelete}
username={roleToDeleteAccessRecord.username}
onCancel={this.cancelRemoveRole}
onConfirm={this.removeRole}
/>
)}
{isInitialized && (
<PaginatedDataList
items={accessRecords}
itemCount={itemCount}
itemName="role"
queryParams={this.getQueryParams()}
toolbarColumns={[
{ name: i18nMark('Name'), key: 'first_name', isSortable: true },
{ name: i18nMark('Username'), key: 'username', isSortable: true },
{ name: i18nMark('Last Name'), key: 'last_name', isSortable: true },
]}
additionalControls={canEdit ? (
<I18n>
{({ i18n }) => (
<Button
variant="primary"
aria-label={i18n._(t`Add Access Role`)}
onClick={this.toggleAddModal}
>
<PlusIcon />
</Button>
)}
</I18n>
) : null}
renderItem={accessRecord => (
<OrganizationAccessItem
key={accessRecord.id}
accessRecord={accessRecord}
onRoleDelete={this.confirmRemoveRole}
/>
)}
/>
)}
{isAddModalOpen && (
<AddResourceRole
onClose={this.toggleAddModal}
onSave={this.handleSuccessfulRoleAdd}
api={api}
roles={organization.summary_fields.object_roles}
/>
)}
</Fragment>
);
}
}
export default withNetwork(OrganizationAccess);
export { OrganizationAccess as _OrganizationAccess };
export default withNetwork(withRouter(OrganizationAccess));

View File

@ -1,62 +1,224 @@
import React, { Component } from 'react';
import React, { Component, Fragment } from 'react';
import { number, shape, func, string, bool } from 'prop-types';
import { withRouter } from 'react-router-dom';
import { withNetwork } from '../../../../contexts/Network';
import PaginatedDataList from '../../../../components/PaginatedDataList';
import NotificationListItem from '../../../../components/NotificationsList/NotificationListItem';
import { parseQueryString } from '../../../../util/qs';
import NotificationsList from '../../../../components/NotificationsList/Notifications.list';
const DEFAULT_QUERY_PARAMS = {
page: 1,
page_size: 5,
order_by: 'name',
};
class OrganizationNotifications extends Component {
constructor (props) {
super(props);
this.readOrgNotifications = this.readOrgNotifications.bind(this);
this.readOrgNotificationSuccess = this.readOrgNotificationSuccess.bind(this);
this.readOrgNotificationError = this.readOrgNotificationError.bind(this);
this.createOrgNotificationSuccess = this.createOrgNotificationSuccess.bind(this);
this.createOrgNotificationError = this.createOrgNotificationError.bind(this);
this.readNotifications = this.readNotifications.bind(this);
this.readSuccessesAndErrors = this.readSuccessesAndErrors.bind(this);
this.toggleNotification = this.toggleNotification.bind(this);
this.state = {
isInitialized: false,
isLoading: false,
error: null,
itemCount: 0,
notifications: [],
successTemplateIds: [],
errorTemplateIds: [],
};
}
readOrgNotifications (id, reqParams) {
const { api } = this.props;
return api.getOrganizationNotifications(id, reqParams);
componentDidMount () {
this.readNotifications();
}
readOrgNotificationSuccess (id, reqParams) {
const { api } = this.props;
return api.getOrganizationNotificationSuccess(id, reqParams);
componentDidUpdate (prevProps) {
const { location } = this.props;
if (location !== prevProps.location) {
this.readNotifications();
}
}
readOrgNotificationError (id, reqParams) {
const { api } = this.props;
return api.getOrganizationNotificationError(id, reqParams);
getQueryParams () {
const { location } = this.props;
const searchParams = parseQueryString(location.search.substring(1));
return {
...DEFAULT_QUERY_PARAMS,
...searchParams,
};
}
createOrgNotificationSuccess (id, data) {
const { api } = this.props;
return api.createOrganizationNotificationSuccess(id, data);
async readNotifications () {
const { api, handleHttpError, id } = this.props;
const params = this.getQueryParams();
this.setState({ isLoading: true });
try {
const { data } = await api.getOrganizationNotifications(id, params);
this.setState(
{
itemCount: data.count || 0,
notifications: data.results || [],
isLoading: false,
isInitialized: true,
},
this.readSuccessesAndErrors
);
} catch (error) {
handleHttpError(error) || this.setState({
error,
isLoading: false,
});
}
}
createOrgNotificationError (id, data) {
const { api } = this.props;
return api.createOrganizationNotificationError(id, data);
async readSuccessesAndErrors () {
const { api, handleHttpError, id } = this.props;
const { notifications } = this.state;
if (!notifications.length) {
return;
}
const ids = notifications.map(n => n.id).join(',');
try {
const successTemplatesPromise = api.getOrganizationNotificationSuccess(
id,
{ id__in: ids }
);
const errorTemplatesPromise = api.getOrganizationNotificationError(
id,
{ id__in: ids }
);
const { data: successTemplates } = await successTemplatesPromise;
const { data: errorTemplates } = await errorTemplatesPromise;
this.setState({
successTemplateIds: successTemplates.results.map(s => s.id),
errorTemplateIds: errorTemplates.results.map(e => e.id),
});
} catch (error) {
handleHttpError(error) || this.setState({
error,
isLoading: false,
});
}
}
toggleNotification = (notificationId, isCurrentlyOn, status) => {
if (status === 'success') {
this.createSuccess(notificationId, isCurrentlyOn);
} else if (status === 'error') {
this.createError(notificationId, isCurrentlyOn);
}
};
async createSuccess (notificationId, isCurrentlyOn) {
const { id, api, handleHttpError } = this.props;
const postParams = { id: notificationId };
if (isCurrentlyOn) {
postParams.disassociate = true;
}
try {
await api.createOrganizationNotificationSuccess(id, postParams);
if (isCurrentlyOn) {
this.setState((prevState) => ({
successTemplateIds: prevState.successTemplateIds
.filter((templateId) => templateId !== notificationId)
}));
} else {
this.setState(prevState => ({
successTemplateIds: [...prevState.successTemplateIds, notificationId]
}));
}
} catch (err) {
handleHttpError(err) || this.setState({ error: true });
}
}
async createError (notificationId, isCurrentlyOn) {
const { id, api, handleHttpError } = this.props;
const postParams = { id: notificationId };
if (isCurrentlyOn) {
postParams.disassociate = true;
}
try {
await api.createOrganizationNotificationError(id, postParams);
if (isCurrentlyOn) {
this.setState((prevState) => ({
errorTemplateIds: prevState.errorTemplateIds
.filter((templateId) => templateId !== notificationId)
}));
} else {
this.setState(prevState => ({
errorTemplateIds: [...prevState.errorTemplateIds, notificationId]
}));
}
} catch (err) {
handleHttpError(err) || this.setState({ error: true });
}
}
render () {
const { canToggleNotifications } = this.props;
const {
canToggleNotifications
} = this.props;
notifications,
itemCount,
isLoading,
isInitialized,
error,
successTemplateIds,
errorTemplateIds,
} = this.state;
if (error) {
// TODO: better error state
return <div>{error.message}</div>;
}
// TODO: better loading state
return (
<NotificationsList
canToggleNotifications={canToggleNotifications}
onCreateError={this.createOrgNotificationError}
onCreateSuccess={this.createOrgNotificationSuccess}
onReadError={this.readOrgNotificationError}
onReadNotifications={this.readOrgNotifications}
onReadSuccess={this.readOrgNotificationSuccess}
/>
<Fragment>
{isLoading && (<div>Loading...</div>)}
{isInitialized && (
<PaginatedDataList
items={notifications}
itemCount={itemCount}
itemName="notification"
queryParams={this.getQueryParams()}
renderItem={(notification) => (
<NotificationListItem
key={notification.id}
notification={notification}
detailUrl={`/notifications/${notification.id}`}
canToggleNotifications={canToggleNotifications}
toggleNotification={this.toggleNotification}
errorTurnedOn={errorTemplateIds.includes(notification.id)}
successTurnedOn={successTemplateIds.includes(notification.id)}
/>
)}
/>
)}
</Fragment>
);
}
}
OrganizationNotifications.propTypes = {
id: number.isRequired,
canToggleNotifications: bool.isRequired,
handleHttpError: func.isRequired,
api: shape({
getOrganizationNotifications: func.isRequired,
getOrganizationNotificationSuccess: func.isRequired,
getOrganizationNotificationError: func.isRequired,
createOrganizationNotificationSuccess: func.isRequired,
createOrganizationNotificationError: func.isRequired,
}).isRequired,
location: shape({
search: string.isRequired,
}).isRequired,
};
export { OrganizationNotifications as _OrganizationNotifications };
export default withNetwork(OrganizationNotifications);
export default withNetwork(withRouter(OrganizationNotifications));

View File

@ -1,7 +1,7 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import OrganizationTeamsList from '../../components/OrganizationTeamsList';
import PaginatedDataList from '../../../../components/PaginatedDataList';
import { parseQueryString } from '../../../../util/qs';
import { withNetwork } from '../../../../contexts/Network';
@ -62,10 +62,9 @@ class OrganizationTeams extends React.Component {
isInitialized: true,
});
} catch (error) {
handleHttpError(error) && this.setState({
handleHttpError(error) || this.setState({
error,
isLoading: false,
isInitialized: true,
});
}
}
@ -83,9 +82,10 @@ class OrganizationTeams extends React.Component {
<Fragment>
{isLoading && (<div>Loading...</div>)}
{isInitialized && (
<OrganizationTeamsList
teams={teams}
<PaginatedDataList
items={teams}
itemCount={itemCount}
itemName="team"
queryParams={this.getQueryParams()}
/>
)}

View File

@ -34,19 +34,19 @@ import {
parseQueryString,
} from '../../../util/qs';
const COLUMNS = [
{ name: i18nMark('Name'), key: 'name', isSortable: true },
{ name: i18nMark('Modified'), key: 'modified', isSortable: true, isNumeric: true },
{ name: i18nMark('Created'), key: 'created', isSortable: true, isNumeric: true },
];
const DEFAULT_PARAMS = {
page: 1,
page_size: 5,
order_by: 'name',
};
class OrganizationsList extends Component {
columns = [
{ name: i18nMark('Name'), key: 'name', isSortable: true },
{ name: i18nMark('Modified'), key: 'modified', isSortable: true, isNumeric: true },
{ name: i18nMark('Created'), key: 'created', isSortable: true, isNumeric: true },
];
defaultParams = {
page: 1,
page_size: 5,
order_by: 'name',
};
constructor (props) {
super(props);
@ -141,7 +141,7 @@ class OrganizationsList extends Component {
const searchParams = parseQueryString(search.substring(1));
return Object.assign({}, this.defaultParams, searchParams, overrides);
return Object.assign({}, DEFAULT_PARAMS, searchParams, overrides);
}
handleCloseOrgDeleteModal () {
@ -333,7 +333,7 @@ class OrganizationsList extends Component {
isAllSelected={selected.length === results.length}
sortedColumnKey={sortedColumnKey}
sortOrder={sortOrder}
columns={this.columns}
columns={COLUMNS}
onSearch={this.onSearch}
onSort={this.onSort}
onSelectAll={this.onSelectAll}

49
src/types.js Normal file
View File

@ -0,0 +1,49 @@
import { shape, arrayOf, number, string, bool } from 'prop-types';
export const Role = shape({
descendent_roles: arrayOf(string),
role: shape({
id: number.isRequired,
name: string.isRequired,
description: string,
user_capabilities: shape({
unattach: bool,
}).isRequired,
}),
});
export const AccessRecord = shape({
id: number.isRequired,
username: string.isRequired,
url: string.isRequired,
email: string,
first_name: string,
last_name: string,
is_superuser: bool,
is_system_auditor: bool,
created: string,
last_login: string,
ldap_dn: string,
related: shape({}),
summary_fields: shape({
direct_access: arrayOf(Role).isRequired,
indirect_access: arrayOf(Role).isRequired,
}).isRequired,
type: string,
});
export const Organization = shape({
id: number.isRequired,
name: string.isRequired,
custom_virtualenv: string, // ?
description: string,
max_hosts: number,
related: shape(),
summary_fields: shape({
object_roles: shape(),
}),
type: string,
url: string,
created: string,
modified: string,
});

View File

@ -11,6 +11,7 @@ export const encodeQueryString = (params) => {
return Object.keys(params)
.sort()
.filter(key => params[key] !== null)
.map(key => ([key, params[key]]))
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
@ -27,7 +28,7 @@ export const encodeQueryString = (params) => {
export const parseQueryString = (queryString, integerFields = ['page', 'page_size']) => {
if (!queryString) return {};
const keyValuePairs = queryString.split('&')
const keyValuePairs = queryString.replace(/^\?/, '').split('&')
.map(s => s.split('='))
.map(([key, value]) => {
if (integerFields.includes(key)) {

16
src/util/strings.js Normal file
View File

@ -0,0 +1,16 @@
export function pluralize (str) {
return str[str.length - 1] === 's' ? `${str}es` : `${str}s`;
}
export function getArticle (str) {
const first = str[0];
if (('aeiou').includes(first)) {
return 'an';
}
return 'a';
}
export function ucFirst (str) {
return `${str[0].toUpperCase()}${str.substr(1)}`;
}