diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2780cfbdf2..b2eebf71a0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,7 @@ Have questions about this document or anything not covered here? Feel free to re * [Node and npm](#node-and-npm) * [Build the user interface](#build-the-user-interface) * [Accessing the AWX web interface](#accessing-the-awx-web-interface) +* [AWX REST API Interaction](#awx-rest-api-interaction) * [Working with React](#working-with-react) * [App structure](#app-structure) * [Naming files](#naming-files) @@ -58,6 +59,57 @@ Run the following to build the AWX UI: You can now log into the AWX web interface at [https://127.0.0.1:3001](https://127.0.0.1:3001). +## AWX REST API Interaction + +This interface is built on top of the AWX REST API. If a component needs to interact with the API then the model that corresponds to that base endpoint will need to be imported from the api module. + +Example: + +`import { OrganizationsAPI, UsersAPI } from '../../../api';` + +All models extend a `Base` class which provides an interface to the standard HTTP methods (GET, POST, PUT etc). Methods that are specific to that endpoint should be added directly to model's class. + +**Mixins** - For related endpoints that apply to several different models a mixin should be used. Mixins are classes with a number of methods and can be used to avoid adding the same methods to a number of different models. A good example of this is the Notifications mixin. This mixin provides generic methods for reading notification templates and toggling them on and off. +Note that mixins can be chained. See the example below. + +Example of a model using multiple mixins: + +``` +import NotificationsMixin from '../mixins/Notifications.mixin'; +import InstanceGroupsMixin from '../mixins/InstanceGroups.mixin'; + +class Organizations extends InstanceGroupsMixin(NotificationsMixin(Base)) { + ... +} + +export default Organizations; +``` + +**Testing** - The easiest way to mock the api module in tests is to use jest's [automatic mock](https://jestjs.io/docs/en/es6-class-mocks#automatic-mock). This syntax will replace the class with a mock constructor and mock out all methods to return undefined by default. If necessary, you can still override these mocks for specific tests. See the example below. + +Example of mocking a specific method for every test in a suite: + +``` +import { OrganizationsAPI } from '../../../../src/api'; + +// Mocks out all available methods. Comparable to: +// OrganizationsAPI.readAccessList = jest.fn(); +// but for every available method +jest.mock('../../../../src/api'); + +// Return a specific mock value for the readAccessList method +beforeEach(() => { + OrganizationsAPI.readAccessList.mockReturnValue({ foo: 'bar' }); +}); + +// Reset mocks +afterEach(() => { + jest.clearAllMocks(); +}); + +... +``` + ## Working with React ### App structure @@ -191,20 +243,19 @@ this.state = { We have several React contexts that wrap much of the app, including those from react-router, lingui, and some of our own. When testing a component that depends on one or more of these, you can use the `mountWithContexts()` helper function found in `__tests__/enzymeHelpers.jsx`. This can be used just like Enzyme's `mount()` function, except it will wrap the component tree with the necessary context providers and basic stub data. -If you want to stub the value of a context, or assert actions taken on it, you can customize a contexts value by passing a second parameter to `mountWithContexts`. For example, this provides a custom value for the `Network` context: +If you want to stub the value of a context, or assert actions taken on it, you can customize a contexts value by passing a second parameter to `mountWithContexts`. For example, this provides a custom value for the `Config` context: ``` -const network = { - api: { - getOrganizationInstanceGroups: jest.fn(), - } +const config = { + custom_virtualenvs: ['foo', 'bar'], }; mountWithContexts(, { - context: { network }, + context: { config }, }); ``` -In this test, when the `OrganizationForm` calls `api.getOrganizationInstanceGroups` from the network context, it will invoke the provided stub. You can assert that this stub is invoked when you expect or to provide stubbed data. +Now that these custom virtual environments are available in this `OrganizationForm` test we can assert that the component that displays +them is rendering properly. The object containing context values looks for five known contexts, identified by the keys `linguiPublisher`, `router`, `config`, `network`, and `dialog` — the latter three each referring to the contexts defined in `src/contexts`. You can pass `false` for any of these values, and the corresponding context will be omitted from your test. For example, this will mount your component without the dialog context: diff --git a/__tests__/App.test.jsx b/__tests__/App.test.jsx index 663cf231a8..bf3393aa31 100644 --- a/__tests__/App.test.jsx +++ b/__tests__/App.test.jsx @@ -6,6 +6,10 @@ import { asyncFlush } from '../jest.setup'; import App from '../src/App'; +import { RootAPI } from '../src/api'; + +jest.mock('../src/api'); + describe('', () => { test('expected content is rendered', () => { const appWrapper = mountWithContexts( @@ -89,15 +93,13 @@ describe('', () => { }); test('onLogout makes expected call to api client', async (done) => { - const logout = jest.fn(() => Promise.resolve()); - const appWrapper = mountWithContexts(, { - context: { network: { api: { logout }, handleHttpError: () => {} } } + context: { network: { handleHttpError: () => {} } } }).find('App'); appWrapper.instance().onLogout(); await asyncFlush(); - expect(logout).toHaveBeenCalledTimes(1); + expect(RootAPI.logout).toHaveBeenCalledTimes(1); done(); }); diff --git a/__tests__/__snapshots__/enzymeHelpers.test.jsx.snap b/__tests__/__snapshots__/enzymeHelpers.test.jsx.snap index 50ac85a193..a1f251ed2d 100644 --- a/__tests__/__snapshots__/enzymeHelpers.test.jsx.snap +++ b/__tests__/__snapshots__/enzymeHelpers.test.jsx.snap @@ -46,17 +46,6 @@ exports[`mountWithContexts injected I18nProvider should mount and render deeply `; -exports[`mountWithContexts injected Network should mount and render 1`] = ` - -
- test -
-
-`; - exports[`mountWithContexts injected Router should mount and render 1`] = `
{ - test('isAuthenticated returns false when cookie is invalid', () => { - APIClient.getCookie = jest.fn(() => invalidCookie); - - const api = new APIClient(); - expect(api.isAuthenticated()).toBe(false); - }); - - test('isAuthenticated returns false when cookie is unauthenticated', () => { - APIClient.getCookie = jest.fn(() => validLoggedOutCookie); - - const api = new APIClient(); - expect(api.isAuthenticated()).toBe(false); - }); - - test('isAuthenticated returns true when cookie is valid and authenticated', () => { - APIClient.getCookie = jest.fn(() => validLoggedInCookie); - - const api = new APIClient(); - expect(api.isAuthenticated()).toBe(true); - }); - - test('login calls get and post with expected content headers', async (done) => { - const headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; - - const createPromise = () => Promise.resolve(); - const mockHttp = ({ get: jest.fn(createPromise), post: jest.fn(createPromise) }); - - const api = new APIClient(mockHttp); - await api.login('username', 'password'); - - expect(mockHttp.get).toHaveBeenCalledTimes(1); - expect(mockHttp.get.mock.calls[0]).toContainEqual({ headers }); - - expect(mockHttp.post).toHaveBeenCalledTimes(1); - expect(mockHttp.post.mock.calls[0]).toContainEqual({ headers }); - - done(); - }); - - test('login sends expected data', async (done) => { - const createPromise = () => Promise.resolve(); - const mockHttp = ({ get: jest.fn(createPromise), post: jest.fn(createPromise) }); - - const api = new APIClient(mockHttp); - await api.login('foo', 'bar'); - await api.login('foo', 'bar', 'baz'); - - expect(mockHttp.post).toHaveBeenCalledTimes(2); - expect(mockHttp.post.mock.calls[0]).toContainEqual('username=foo&password=bar&next=%2Fapi%2Fv2%2Fconfig%2F'); - expect(mockHttp.post.mock.calls[1]).toContainEqual('username=foo&password=bar&next=baz'); - - done(); - }); - - test('logout calls expected http method', async (done) => { - const createPromise = () => Promise.resolve(); - const mockHttp = ({ get: jest.fn(createPromise) }); - - const api = new APIClient(mockHttp); - await api.logout(); - - expect(mockHttp.get).toHaveBeenCalledTimes(1); - - done(); - }); - - test('getConfig calls expected http method', async (done) => { - const createPromise = () => Promise.resolve(); - const mockHttp = ({ get: jest.fn(createPromise) }); - - const api = new APIClient(mockHttp); - await api.getConfig(); - - expect(mockHttp.get).toHaveBeenCalledTimes(1); - - done(); - }); - - test('getOrganizations calls http method with expected data', async (done) => { - const createPromise = () => Promise.resolve(); - const mockHttp = ({ get: jest.fn(createPromise) }); - const api = new APIClient(mockHttp); - - const defaultParams = {}; - const testParams = { foo: 'bar' }; - - await api.getOrganizations(testParams); - await api.getOrganizations(); - - expect(mockHttp.get).toHaveBeenCalledTimes(2); - expect(mockHttp.get.mock.calls[0][1]).toEqual({ params: testParams }); - expect(mockHttp.get.mock.calls[1][1]).toEqual({ params: defaultParams }); - done(); - }); - - test('createOrganization calls http method with expected data', async (done) => { - const createPromise = () => Promise.resolve(); - const mockHttp = ({ post: jest.fn(createPromise) }); - - const api = new APIClient(mockHttp); - const data = { name: 'test ' }; - await api.createOrganization(data); - - expect(mockHttp.post).toHaveBeenCalledTimes(1); - expect(mockHttp.post.mock.calls[0][1]).toEqual(data); - - done(); - }); - - test('getOrganizationDetails calls http method with expected data', async (done) => { - const createPromise = () => Promise.resolve(); - const mockHttp = ({ get: jest.fn(createPromise) }); - - const api = new APIClient(mockHttp); - await api.getOrganizationDetails(99); - - expect(mockHttp.get).toHaveBeenCalledTimes(1); - expect(mockHttp.get.mock.calls[0][0]).toContain('99'); - - done(); - }); - - test('getInstanceGroups calls expected http method', async (done) => { - const createPromise = () => Promise.resolve(); - const mockHttp = ({ get: jest.fn(createPromise) }); - - const api = new APIClient(mockHttp); - await api.getInstanceGroups(); - - expect(mockHttp.get).toHaveBeenCalledTimes(1); - - done(); - }); - - test('associateInstanceGroup calls expected http method with expected data', async (done) => { - const createPromise = () => Promise.resolve(); - const mockHttp = ({ post: jest.fn(createPromise) }); - - const api = new APIClient(mockHttp); - const url = 'foo/bar/'; - const id = 1; - await api.associateInstanceGroup(url, id); - - expect(mockHttp.post).toHaveBeenCalledTimes(1); - expect(mockHttp.post.mock.calls[0][0]).toEqual(url); - expect(mockHttp.post.mock.calls[0][1]).toEqual({ id }); - - done(); - }); - - test('disassociate calls expected http method with expected data', async (done) => { - const createPromise = () => Promise.resolve(); - const mockHttp = ({ post: jest.fn(createPromise) }); - - const api = new APIClient(mockHttp); - const url = 'foo/bar/'; - const id = 1; - await api.disassociate(url, id); - - expect(mockHttp.post).toHaveBeenCalledTimes(1); - expect(mockHttp.post.mock.calls[0][0]).toEqual(url); - expect(mockHttp.post.mock.calls[0][1]).toEqual({ id, disassociate: true }); - - done(); - }); -}); diff --git a/__tests__/api/base.test.jsx b/__tests__/api/base.test.jsx new file mode 100644 index 0000000000..e7b79fe5b2 --- /dev/null +++ b/__tests__/api/base.test.jsx @@ -0,0 +1,97 @@ +import Base from '../../src/api/Base'; + +describe('Base', () => { + const createPromise = () => Promise.resolve(); + const mockBaseURL = '/api/v2/organizations/'; + const mockHttp = ({ + delete: jest.fn(createPromise), + get: jest.fn(createPromise), + options: jest.fn(createPromise), + patch: jest.fn(createPromise), + post: jest.fn(createPromise), + put: jest.fn(createPromise) + }); + + const BaseAPI = new Base(mockHttp, mockBaseURL); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('create calls http method with expected data', async (done) => { + const data = { name: 'test ' }; + await BaseAPI.create(data); + + expect(mockHttp.post).toHaveBeenCalledTimes(1); + expect(mockHttp.post.mock.calls[0][1]).toEqual(data); + + done(); + }); + + test('destroy calls http method with expected data', async (done) => { + const resourceId = 1; + await BaseAPI.destroy(resourceId); + + expect(mockHttp.delete).toHaveBeenCalledTimes(1); + expect(mockHttp.delete.mock.calls[0][0]).toEqual(`${mockBaseURL}${resourceId}/`); + + done(); + }); + + test('read calls http method with expected data', async (done) => { + const defaultParams = {}; + const testParams = { foo: 'bar' }; + + await BaseAPI.read(testParams); + await BaseAPI.read(); + + expect(mockHttp.get).toHaveBeenCalledTimes(2); + expect(mockHttp.get.mock.calls[0][1]).toEqual({ params: testParams }); + expect(mockHttp.get.mock.calls[1][1]).toEqual({ params: defaultParams }); + done(); + }); + + test('readDetail calls http method with expected data', async (done) => { + const resourceId = 1; + + await BaseAPI.readDetail(resourceId); + + expect(mockHttp.get).toHaveBeenCalledTimes(1); + expect(mockHttp.get.mock.calls[0][0]).toEqual(`${mockBaseURL}${resourceId}/`); + done(); + }); + + test('readOptions calls http method with expected data', async (done) => { + await BaseAPI.readOptions(); + + expect(mockHttp.options).toHaveBeenCalledTimes(1); + expect(mockHttp.options.mock.calls[0][0]).toEqual(`${mockBaseURL}`); + done(); + }); + + test('replace calls http method with expected data', async (done) => { + const resourceId = 1; + const data = { name: 'test ' }; + + await BaseAPI.replace(resourceId, data); + + expect(mockHttp.put).toHaveBeenCalledTimes(1); + expect(mockHttp.put.mock.calls[0][0]).toEqual(`${mockBaseURL}${resourceId}/`); + expect(mockHttp.put.mock.calls[0][1]).toEqual(data); + + done(); + }); + + test('update calls http method with expected data', async (done) => { + const resourceId = 1; + const data = { name: 'test ' }; + + await BaseAPI.update(resourceId, data); + + expect(mockHttp.patch).toHaveBeenCalledTimes(1); + expect(mockHttp.patch.mock.calls[0][0]).toEqual(`${mockBaseURL}${resourceId}/`); + expect(mockHttp.patch.mock.calls[0][1]).toEqual(data); + + done(); + }); +}); diff --git a/__tests__/api/organizations.test.jsx b/__tests__/api/organizations.test.jsx new file mode 100644 index 0000000000..249661c634 --- /dev/null +++ b/__tests__/api/organizations.test.jsx @@ -0,0 +1,36 @@ +import Organizations from '../../src/api/models/Organizations'; + +describe('OrganizationsAPI', () => { + const orgId = 1; + const searchParams = { foo: 'bar' }; + const createPromise = () => Promise.resolve(); + const mockHttp = ({ get: jest.fn(createPromise) }); + + const OrganizationsAPI = new Organizations(mockHttp); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('read access list calls get with expected params', async (done) => { + await OrganizationsAPI.readAccessList(orgId); + await OrganizationsAPI.readAccessList(orgId, searchParams); + + expect(mockHttp.get).toHaveBeenCalledTimes(2); + expect(mockHttp.get.mock.calls[0]).toContainEqual(`/api/v2/organizations/${orgId}/access_list/`, { params: {} }); + expect(mockHttp.get.mock.calls[1]).toContainEqual(`/api/v2/organizations/${orgId}/access_list/`, { params: searchParams }); + + done(); + }); + + test('read teams calls get with expected params', async (done) => { + await OrganizationsAPI.readTeams(orgId); + await OrganizationsAPI.readTeams(orgId, searchParams); + + expect(mockHttp.get).toHaveBeenCalledTimes(2); + expect(mockHttp.get.mock.calls[0]).toContainEqual(`/api/v2/organizations/${orgId}/teams/`, { params: {} }); + expect(mockHttp.get.mock.calls[1]).toContainEqual(`/api/v2/organizations/${orgId}/teams/`, { params: searchParams }); + + done(); + }); +}); diff --git a/__tests__/api/root.test.jsx b/__tests__/api/root.test.jsx new file mode 100644 index 0000000000..8ef84a1f1b --- /dev/null +++ b/__tests__/api/root.test.jsx @@ -0,0 +1,45 @@ +import Root from '../../src/api/models/Root'; + +describe('RootAPI', () => { + const createPromise = () => Promise.resolve(); + const mockHttp = ({ get: jest.fn(createPromise), post: jest.fn(createPromise) }); + + const RootAPI = new Root(mockHttp); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('login calls get and post with expected content headers', async (done) => { + const headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; + + await RootAPI.login('username', 'password'); + + expect(mockHttp.get).toHaveBeenCalledTimes(1); + expect(mockHttp.get.mock.calls[0]).toContainEqual({ headers }); + + expect(mockHttp.post).toHaveBeenCalledTimes(1); + expect(mockHttp.post.mock.calls[0]).toContainEqual({ headers }); + + done(); + }); + + test('login sends expected data', async (done) => { + await RootAPI.login('foo', 'bar'); + await RootAPI.login('foo', 'bar', 'baz'); + + expect(mockHttp.post).toHaveBeenCalledTimes(2); + expect(mockHttp.post.mock.calls[0]).toContainEqual('username=foo&password=bar&next=%2Fapi%2Fv2%2Fconfig%2F'); + expect(mockHttp.post.mock.calls[1]).toContainEqual('username=foo&password=bar&next=baz'); + + done(); + }); + + test('logout calls expected http method', async (done) => { + await RootAPI.logout(); + + expect(mockHttp.get).toHaveBeenCalledTimes(1); + + done(); + }); +}); diff --git a/__tests__/api/teams.test.jsx b/__tests__/api/teams.test.jsx new file mode 100644 index 0000000000..0df8153c08 --- /dev/null +++ b/__tests__/api/teams.test.jsx @@ -0,0 +1,35 @@ +import Teams from '../../src/api/models/Teams'; + +describe('TeamsAPI', () => { + const teamId = 1; + const roleId = 7; + const createPromise = () => Promise.resolve(); + const mockHttp = ({ post: jest.fn(createPromise) }); + + const TeamsAPI = new Teams(mockHttp); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('associate role calls post with expected params', async (done) => { + await TeamsAPI.associateRole(teamId, roleId); + + expect(mockHttp.post).toHaveBeenCalledTimes(1); + expect(mockHttp.post.mock.calls[0]).toContainEqual(`/api/v2/teams/${teamId}/roles/`, { id: roleId }); + + done(); + }); + + test('read teams calls post with expected params', async (done) => { + await TeamsAPI.disassociateRole(teamId, roleId); + + expect(mockHttp.post).toHaveBeenCalledTimes(1); + expect(mockHttp.post.mock.calls[0]).toContainEqual(`/api/v2/teams/${teamId}/roles/`, { + id: roleId, + disassociate: true + }); + + done(); + }); +}); diff --git a/__tests__/api/users.test.jsx b/__tests__/api/users.test.jsx new file mode 100644 index 0000000000..3d72ce4a2d --- /dev/null +++ b/__tests__/api/users.test.jsx @@ -0,0 +1,35 @@ +import Users from '../../src/api/models/Users'; + +describe('UsersAPI', () => { + const userId = 1; + const roleId = 7; + const createPromise = () => Promise.resolve(); + const mockHttp = ({ post: jest.fn(createPromise) }); + + const UsersAPI = new Users(mockHttp); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('associate role calls post with expected params', async (done) => { + await UsersAPI.associateRole(userId, roleId); + + expect(mockHttp.post).toHaveBeenCalledTimes(1); + expect(mockHttp.post.mock.calls[0]).toContainEqual(`/api/v2/users/${userId}/roles/`, { id: roleId }); + + done(); + }); + + test('read users calls post with expected params', async (done) => { + await UsersAPI.disassociateRole(userId, roleId); + + expect(mockHttp.post).toHaveBeenCalledTimes(1); + expect(mockHttp.post.mock.calls[0]).toContainEqual(`/api/v2/users/${userId}/roles/`, { + id: roleId, + disassociate: true + }); + + done(); + }); +}); diff --git a/__tests__/components/AddResourceRole.test.jsx b/__tests__/components/AddResourceRole.test.jsx index 3a3b4a2241..0b0acb307e 100644 --- a/__tests__/components/AddResourceRole.test.jsx +++ b/__tests__/components/AddResourceRole.test.jsx @@ -2,9 +2,12 @@ import React from 'react'; import { shallow } from 'enzyme'; import { mountWithContexts } from '../enzymeHelpers'; import AddResourceRole, { _AddResourceRole } from '../../src/components/AddRole/AddResourceRole'; +import { TeamsAPI, UsersAPI } from '../../src/api'; + +jest.mock('../../src/api'); describe('<_AddResourceRole />', () => { - const readUsers = jest.fn().mockResolvedValue({ + UsersAPI.read.mockResolvedValue({ data: { count: 2, results: [ @@ -13,10 +16,6 @@ describe('<_AddResourceRole />', () => { ] } }); - const readTeams = jest.fn(); - const createUserRole = jest.fn(); - const createTeamRole = jest.fn(); - const api = { readUsers, readTeams, createUserRole, createTeamRole }; const roles = { admin_role: { description: 'Can manage all aspects of the organization', @@ -32,7 +31,6 @@ describe('<_AddResourceRole />', () => { test('initially renders without crashing', () => { shallow( <_AddResourceRole - api={api} onClose={() => {}} onSave={() => {}} roles={roles} @@ -43,7 +41,6 @@ describe('<_AddResourceRole />', () => { test('handleRoleCheckboxClick properly updates state', () => { const wrapper = shallow( <_AddResourceRole - api={api} onClose={() => {}} onSave={() => {}} roles={roles} @@ -79,7 +76,6 @@ describe('<_AddResourceRole />', () => { test('handleResourceCheckboxClick properly updates state', () => { const wrapper = shallow( <_AddResourceRole - api={api} onClose={() => {}} onSave={() => {}} roles={roles} @@ -115,7 +111,7 @@ describe('<_AddResourceRole />', () => { onClose={() => {}} onSave={() => {}} roles={roles} - />, { context: { network: { api, handleHttpError: () => {} } } } + />, { context: { network: { handleHttpError: () => {} } } } ).find('AddResourceRole'); const selectableCardWrapper = wrapper.find('SelectableCard'); expect(selectableCardWrapper.length).toBe(2); @@ -126,35 +122,9 @@ describe('<_AddResourceRole />', () => { expect(spy).toHaveBeenCalledWith('teams'); expect(wrapper.state('selectedResource')).toBe('teams'); }); - test('readUsers and readTeams call out to corresponding api functions', () => { - const wrapper = shallow( - <_AddResourceRole - api={api} - onClose={() => {}} - onSave={() => {}} - roles={roles} - i18n={{ _: val => val.toString() }} - /> - ); - wrapper.instance().readUsers({ - foo: 'bar' - }); - expect(readUsers).toHaveBeenCalledWith({ - foo: 'bar', - is_superuser: false - }); - wrapper.instance().readTeams({ - foo: 'bar' - }); - expect(readTeams).toHaveBeenCalledWith({ - foo: 'bar' - }); - }); - test('handleResourceSelect clears out selected lists and sets selectedResource', () => { const wrapper = shallow( <_AddResourceRole - api={api} onClose={() => {}} onSave={() => {}} roles={roles} @@ -192,7 +162,6 @@ describe('<_AddResourceRole />', () => { currentStepId: 1 }); }); - test('handleWizardSave makes correct api calls, calls onSave when done', async () => { const handleSave = jest.fn(); const wrapper = mountWithContexts( @@ -200,7 +169,7 @@ describe('<_AddResourceRole />', () => { onClose={() => {}} onSave={handleSave} roles={roles} - />, { context: { network: { api, handleHttpError: () => {} } } } + />, { context: { network: { handleHttpError: () => {} } } } ).find('AddResourceRole'); wrapper.setState({ selectedResource: 'users', @@ -224,7 +193,7 @@ describe('<_AddResourceRole />', () => { ] }); await wrapper.instance().handleWizardSave(); - expect(createUserRole).toHaveBeenCalledTimes(2); + expect(UsersAPI.associateRole).toHaveBeenCalledTimes(2); expect(handleSave).toHaveBeenCalled(); wrapper.setState({ selectedResource: 'teams', @@ -248,7 +217,7 @@ describe('<_AddResourceRole />', () => { ] }); await wrapper.instance().handleWizardSave(); - expect(createTeamRole).toHaveBeenCalledTimes(2); + expect(TeamsAPI.associateRole).toHaveBeenCalledTimes(2); expect(handleSave).toHaveBeenCalled(); }); }); diff --git a/__tests__/enzymeHelpers.jsx b/__tests__/enzymeHelpers.jsx index d8a5de7e4e..b7114e9cdf 100644 --- a/__tests__/enzymeHelpers.jsx +++ b/__tests__/enzymeHelpers.jsx @@ -70,10 +70,6 @@ const defaultContexts = { toJSON: () => '/router/', }, network: { - api: { - getConfig: () => {}, - toJSON: () => '/api/', - }, handleHttpError: () => {}, }, dialog: {} @@ -146,7 +142,6 @@ export function mountWithContexts (node, options = {}) { history: shape({}).isRequired, }), network: shape({ - api: shape({}).isRequired, handleHttpError: func.isRequired, }), dialog: shape({ diff --git a/__tests__/enzymeHelpers.test.jsx b/__tests__/enzymeHelpers.test.jsx index 8a92fe0ab9..6c8c16a99a 100644 --- a/__tests__/enzymeHelpers.test.jsx +++ b/__tests__/enzymeHelpers.test.jsx @@ -4,7 +4,6 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { mountWithContexts, waitForElement } from './enzymeHelpers'; import { Config } from '../src/contexts/Config'; -import { withNetwork } from '../src/contexts/Network'; import { withRootDialog } from '../src/contexts/RootDialog'; describe('mountWithContexts', () => { @@ -111,32 +110,6 @@ describe('mountWithContexts', () => { }); }); - describe('injected Network', () => { - it('should mount and render', () => { - const Foo = () => ( -
test
- ); - const Bar = withNetwork(Foo); - const wrapper = mountWithContexts(); - expect(wrapper.find('Foo')).toMatchSnapshot(); - }); - - it('should mount and render with stubbed api', () => { - const network = { - api: { - getFoo: jest.fn().mockReturnValue('foo value'), - }, - }; - const Foo = ({ api }) => ( -
{api.getFoo()}
- ); - const Bar = withNetwork(Foo); - const wrapper = mountWithContexts(, { context: { network } }); - expect(network.api.getFoo).toHaveBeenCalledTimes(1); - expect(wrapper.find('div').text()).toEqual('foo value'); - }); - }); - describe('injected root dialog', () => { it('should mount and render', () => { const Foo = ({ title, setRootDialogMessage }) => ( diff --git a/__tests__/pages/Login.test.jsx b/__tests__/pages/Login.test.jsx index 57d4be3634..51b79e742f 100644 --- a/__tests__/pages/Login.test.jsx +++ b/__tests__/pages/Login.test.jsx @@ -2,7 +2,9 @@ import React from 'react'; import { mountWithContexts } from '../enzymeHelpers'; import { asyncFlush } from '../../jest.setup'; import AWXLogin from '../../src/pages/Login'; -import APIClient from '../../src/api'; +import { RootAPI } from '../../src/api'; + +jest.mock('../../src/api'); describe('', () => { let loginWrapper; @@ -14,12 +16,8 @@ describe('', () => { let submitButton; let loginHeaderLogo; - const api = new APIClient({}); - const mountLogin = () => { - loginWrapper = mountWithContexts(, { context: { network: { - api, handleHttpError: () => {} - } } }); + loginWrapper = mountWithContexts(, { context: { network: {} } }); }; const findChildren = () => { @@ -33,6 +31,7 @@ describe('', () => { }; afterEach(() => { + jest.clearAllMocks(); loginWrapper.unmount(); }); @@ -98,32 +97,31 @@ describe('', () => { expect(loginWrapper.find('.pf-c-form__helper-text.pf-m-error').length).toBe(0); }); - test('api.login not called when loading', () => { - api.login = jest.fn().mockImplementation(() => Promise.resolve({})); + test('login API not called when loading', () => { mountLogin(); findChildren(); expect(awxLogin.state().isLoading).toBe(false); awxLogin.setState({ isLoading: true }); submitButton.simulate('click'); - expect(api.login).toHaveBeenCalledTimes(0); + expect(RootAPI.login).toHaveBeenCalledTimes(0); }); - test('submit calls api.login successfully', async () => { - api.login = jest.fn().mockImplementation(() => Promise.resolve({})); + test('submit calls login API successfully', async () => { + RootAPI.login = jest.fn().mockImplementation(() => Promise.resolve({})); mountLogin(); findChildren(); expect(awxLogin.state().isLoading).toBe(false); awxLogin.setState({ username: 'unamee', password: 'pwordd' }); submitButton.simulate('click'); - expect(api.login).toHaveBeenCalledTimes(1); - expect(api.login).toHaveBeenCalledWith('unamee', 'pwordd'); + expect(RootAPI.login).toHaveBeenCalledTimes(1); + expect(RootAPI.login).toHaveBeenCalledWith('unamee', 'pwordd'); expect(awxLogin.state().isLoading).toBe(true); await asyncFlush(); expect(awxLogin.state().isLoading).toBe(false); }); - test('submit calls api.login handles 401 error', async () => { - api.login = jest.fn().mockImplementation(() => { + test('submit calls login API and handles 401 error', async () => { + RootAPI.login = jest.fn().mockImplementation(() => { const err = new Error('401 error'); err.response = { status: 401, message: 'problem' }; return Promise.reject(err); @@ -134,16 +132,16 @@ describe('', () => { expect(awxLogin.state().isInputValid).toBe(true); awxLogin.setState({ username: 'unamee', password: 'pwordd' }); submitButton.simulate('click'); - expect(api.login).toHaveBeenCalledTimes(1); - expect(api.login).toHaveBeenCalledWith('unamee', 'pwordd'); + expect(RootAPI.login).toHaveBeenCalledTimes(1); + expect(RootAPI.login).toHaveBeenCalledWith('unamee', 'pwordd'); expect(awxLogin.state().isLoading).toBe(true); await asyncFlush(); expect(awxLogin.state().isInputValid).toBe(false); expect(awxLogin.state().isLoading).toBe(false); }); - test('submit calls api.login handles non-401 error', async () => { - api.login = jest.fn().mockImplementation(() => { + test('submit calls login API and handles non-401 error', async () => { + RootAPI.login = jest.fn().mockImplementation(() => { const err = new Error('500 error'); err.response = { status: 500, message: 'problem' }; return Promise.reject(err); @@ -153,8 +151,8 @@ describe('', () => { expect(awxLogin.state().isLoading).toBe(false); awxLogin.setState({ username: 'unamee', password: 'pwordd' }); submitButton.simulate('click'); - expect(api.login).toHaveBeenCalledTimes(1); - expect(api.login).toHaveBeenCalledWith('unamee', 'pwordd'); + expect(RootAPI.login).toHaveBeenCalledTimes(1); + expect(RootAPI.login).toHaveBeenCalledWith('unamee', 'pwordd'); expect(awxLogin.state().isLoading).toBe(true); await asyncFlush(); expect(awxLogin.state().isLoading).toBe(false); diff --git a/__tests__/pages/Organizations/components/OrganizationForm.test.jsx b/__tests__/pages/Organizations/components/OrganizationForm.test.jsx index 2a2c98a19b..e1a4e84048 100644 --- a/__tests__/pages/Organizations/components/OrganizationForm.test.jsx +++ b/__tests__/pages/Organizations/components/OrganizationForm.test.jsx @@ -2,9 +2,12 @@ import React from 'react'; import { mountWithContexts } from '../../../enzymeHelpers'; import { sleep } from '../../../testUtils'; import OrganizationForm from '../../../../src/pages/Organizations/components/OrganizationForm'; +import { OrganizationsAPI } from '../../../../src/api'; + +jest.mock('../../../../src/api'); describe('', () => { - let network; + const network = {}; const mockData = { id: 1, @@ -16,24 +19,11 @@ describe('', () => { } }; - beforeEach(() => { - network = {}; - }); - afterEach(() => { jest.clearAllMocks(); }); test('should request related instance groups from api', () => { - const mockInstanceGroups = [ - { name: 'One', id: 1 }, - { name: 'Two', id: 2 } - ]; - network.api = { - getOrganizationInstanceGroups: jest.fn(() => ( - Promise.resolve({ data: { results: mockInstanceGroups } }) - )) - }; mountWithContexts( ( ', () => { } ); - expect(network.api.getOrganizationInstanceGroups).toHaveBeenCalledTimes(1); + expect(OrganizationsAPI.readInstanceGroups).toHaveBeenCalledTimes(1); }); test('componentDidMount should set instanceGroups to state', async () => { @@ -54,11 +44,11 @@ describe('', () => { { name: 'One', id: 1 }, { name: 'Two', id: 2 } ]; - network.api = { - getOrganizationInstanceGroups: jest.fn(() => ( - Promise.resolve({ data: { results: mockInstanceGroups } }) - )) - }; + OrganizationsAPI.readInstanceGroups.mockReturnValue({ + data: { + results: mockInstanceGroups + } + }); const wrapper = mountWithContexts( ( ', () => { ); await sleep(0); - expect(network.api.getOrganizationInstanceGroups).toHaveBeenCalled(); + expect(OrganizationsAPI.readInstanceGroups).toHaveBeenCalled(); expect(wrapper.find('OrganizationForm').state().instanceGroups).toEqual(mockInstanceGroups); }); @@ -165,20 +155,20 @@ describe('', () => { { name: 'One', id: 1 }, { name: 'Two', id: 2 } ]; - network.api = { - getOrganizationInstanceGroups: jest.fn(() => ( - Promise.resolve({ data: { results: mockInstanceGroups } }) - )) - }; + OrganizationsAPI.readInstanceGroups.mockReturnValue({ + data: { + results: mockInstanceGroups + } + }); const mockDataForm = { name: 'Foo', description: 'Bar', custom_virtualenv: 'Fizz', }; const handleSubmit = jest.fn(); - network.api.updateOrganizationDetails = jest.fn().mockResolvedValue(1, mockDataForm); - network.api.associateInstanceGroup = jest.fn().mockResolvedValue('done'); - network.api.disassociate = jest.fn().mockResolvedValue('done'); + OrganizationsAPI.update.mockResolvedValue(1, mockDataForm); + OrganizationsAPI.associateInstanceGroup.mockResolvedValue('done'); + OrganizationsAPI.disassociateInstanceGroup.mockResolvedValue('done'); const wrapper = mountWithContexts( ( ', () => { const me = { @@ -12,8 +17,41 @@ describe.only('', () => { mountWithContexts(); }); - test('notifications tab shown/hidden based on permissions', () => { - const wrapper = mountWithContexts(); + test('notifications tab shown/hidden based on permissions', async () => { + OrganizationsAPI.readDetail.mockResolvedValue({ + data: { + id: 1, + name: 'foo' + } + }); + OrganizationsAPI.read.mockResolvedValue({ + data: { + results: [] + } + }); + const history = createMemoryHistory({ + initialEntries: ['/organizations/1/details'], + }); + const match = { path: '/organizations/:id', url: '/organizations/1' }; + const wrapper = mountWithContexts( + {}} + />, + { + context: { + router: { + history, + route: { + location: history.location, + match + } + } + } + } + ); + await sleep(0); + wrapper.update(); expect(wrapper.find('.pf-c-tabs__item').length).toBe(3); expect(wrapper.find('.pf-c-tabs__button[children="Notifications"]').length).toBe(0); wrapper.find('Organization').setState({ diff --git a/__tests__/pages/Organizations/screens/Organization/OrganizationAccess.test.jsx b/__tests__/pages/Organizations/screens/Organization/OrganizationAccess.test.jsx index 9733a88287..64b358c78f 100644 --- a/__tests__/pages/Organizations/screens/Organization/OrganizationAccess.test.jsx +++ b/__tests__/pages/Organizations/screens/Organization/OrganizationAccess.test.jsx @@ -3,8 +3,12 @@ import { mountWithContexts } from '../../../../enzymeHelpers'; import OrganizationAccess from '../../../../../src/pages/Organizations/screens/Organization/OrganizationAccess'; import { sleep } from '../../../../testUtils'; +import { OrganizationsAPI, TeamsAPI, UsersAPI } from '../../../../../src/api'; + +jest.mock('../../../../../src/api'); + describe('', () => { - let network; + const network = {}; const organization = { id: 1, name: 'Default', @@ -60,15 +64,11 @@ describe('', () => { }; beforeEach(() => { - network = { - api: { - getOrganizationAccessList: jest.fn() - .mockReturnValue(Promise.resolve({ data })), - disassociateTeamRole: jest.fn(), - disassociateUserRole: jest.fn(), - toJSON: () => '/api/', - }, - }; + OrganizationsAPI.readAccessList.mockReturnValue({ data }); + }); + + afterEach(() => { + jest.clearAllMocks(); }); test('initially renders succesfully', () => { @@ -86,7 +86,7 @@ describe('', () => { ); await sleep(0); wrapper.update(); - expect(network.api.getOrganizationAccessList).toHaveBeenCalled(); + expect(OrganizationsAPI.readAccessList).toHaveBeenCalled(); expect(wrapper.find('OrganizationAccess').state('isInitialized')).toBe(true); expect(wrapper.find('PaginatedDataList').prop('items')).toEqual(data.results); expect(wrapper.find('OrganizationAccessItem')).toHaveLength(2); @@ -127,8 +127,8 @@ describe('', () => { 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(); + expect(TeamsAPI.disassociateRole).not.toHaveBeenCalled(); + expect(UsersAPI.disassociateRole).not.toHaveBeenCalled(); }); it('should delete user role', async () => { @@ -149,9 +149,9 @@ describe('', () => { 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); + expect(TeamsAPI.disassociateRole).not.toHaveBeenCalled(); + expect(UsersAPI.disassociateRole).toHaveBeenCalledWith(1, 1); + expect(OrganizationsAPI.readAccessList).toHaveBeenCalledTimes(2); }); it('should delete team role', async () => { @@ -172,8 +172,8 @@ describe('', () => { 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); + expect(TeamsAPI.disassociateRole).toHaveBeenCalledWith(5, 3); + expect(UsersAPI.disassociateRole).not.toHaveBeenCalled(); + expect(OrganizationsAPI.readAccessList).toHaveBeenCalledTimes(2); }); }); diff --git a/__tests__/pages/Organizations/screens/Organization/OrganizationDetail.test.jsx b/__tests__/pages/Organizations/screens/Organization/OrganizationDetail.test.jsx index b9739a8aa6..a4f70f11da 100644 --- a/__tests__/pages/Organizations/screens/Organization/OrganizationDetail.test.jsx +++ b/__tests__/pages/Organizations/screens/Organization/OrganizationDetail.test.jsx @@ -1,6 +1,9 @@ import React from 'react'; import { mountWithContexts } from '../../../../enzymeHelpers'; import OrganizationDetail from '../../../../../src/pages/Organizations/screens/Organization/OrganizationDetail'; +import { OrganizationsAPI } from '../../../../../src/api'; + +jest.mock('../../../../../src/api'); describe('', () => { const mockDetails = { @@ -16,6 +19,10 @@ describe('', () => { } }; + afterEach(() => { + jest.clearAllMocks(); + }); + test('initially renders succesfully', () => { mountWithContexts( ', () => { }); test('should request instance groups from api', () => { - const getOrganizationInstanceGroups = jest.fn(); mountWithContexts( , { context: { - network: { api: { getOrganizationInstanceGroups }, handleHttpError: () => {} } + network: { handleHttpError: () => {} } } } ).find('OrganizationDetail'); - expect(getOrganizationInstanceGroups).toHaveBeenCalledTimes(1); + expect(OrganizationsAPI.readInstanceGroups).toHaveBeenCalledTimes(1); }); test('should handle setting instance groups to state', async () => { @@ -42,18 +48,18 @@ describe('', () => { { name: 'One', id: 1 }, { name: 'Two', id: 2 } ]; - const getOrganizationInstanceGroups = jest.fn(() => ( - Promise.resolve({ data: { results: mockInstanceGroups } }) - )); + OrganizationsAPI.readInstanceGroups.mockResolvedValue({ + data: { results: mockInstanceGroups } + }); const wrapper = mountWithContexts( , { context: { - network: { api: { getOrganizationInstanceGroups }, handleHttpError: () => {} } + network: { handleHttpError: () => {} } } } ).find('OrganizationDetail'); - await getOrganizationInstanceGroups(); + await OrganizationsAPI.readInstanceGroups(); expect(wrapper.state().instanceGroups).toEqual(mockInstanceGroups); }); diff --git a/__tests__/pages/Organizations/screens/Organization/OrganizationEdit.test.jsx b/__tests__/pages/Organizations/screens/Organization/OrganizationEdit.test.jsx index edd8296156..2b2d2eeea8 100644 --- a/__tests__/pages/Organizations/screens/Organization/OrganizationEdit.test.jsx +++ b/__tests__/pages/Organizations/screens/Organization/OrganizationEdit.test.jsx @@ -3,6 +3,10 @@ import { mountWithContexts } from '../../../../enzymeHelpers'; import OrganizationEdit from '../../../../../src/pages/Organizations/screens/Organization/OrganizationEdit'; +import { OrganizationsAPI } from '../../../../../src/api'; + +jest.mock('../../../../../src/api'); + const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); describe('', () => { @@ -44,10 +48,7 @@ describe('', () => { }; wrapper.find('OrganizationForm').prop('handleSubmit')(updatedOrgData, [], []); - expect(api.updateOrganizationDetails).toHaveBeenCalledWith( - 1, - updatedOrgData - ); + expect(OrganizationsAPI.update).toHaveBeenCalledWith(1, updatedOrgData); }); test('handleSubmit associates and disassociates instance groups', async () => { @@ -68,18 +69,9 @@ describe('', () => { wrapper.find('OrganizationForm').prop('handleSubmit')(updatedOrgData, [3, 4], [2]); await sleep(1); - expect(api.associateInstanceGroup).toHaveBeenCalledWith( - '/api/v2/organizations/1/instance_groups', - 3 - ); - expect(api.associateInstanceGroup).toHaveBeenCalledWith( - '/api/v2/organizations/1/instance_groups', - 4 - ); - expect(api.disassociate).toHaveBeenCalledWith( - '/api/v2/organizations/1/instance_groups', - 2 - ); + expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 3); + expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 4); + expect(OrganizationsAPI.disassociateInstanceGroup).toHaveBeenCalledWith(1, 2); }); test('should navigate to organization detail when cancel is clicked', () => { diff --git a/__tests__/pages/Organizations/screens/Organization/OrganizationNotifications.test.jsx b/__tests__/pages/Organizations/screens/Organization/OrganizationNotifications.test.jsx index 766f6c2b6e..bda435749e 100644 --- a/__tests__/pages/Organizations/screens/Organization/OrganizationNotifications.test.jsx +++ b/__tests__/pages/Organizations/screens/Organization/OrganizationNotifications.test.jsx @@ -2,10 +2,13 @@ import React from 'react'; import { mountWithContexts } from '../../../../enzymeHelpers'; import OrganizationNotifications from '../../../../../src/pages/Organizations/screens/Organization/OrganizationNotifications'; import { sleep } from '../../../../testUtils'; +import { OrganizationsAPI } from '../../../../../src/api'; + +jest.mock('../../../../../src/api'); describe('', () => { let data; - let network; + const network = {}; beforeEach(() => { data = { @@ -22,23 +25,13 @@ describe('', () => { 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/', - } - }; + OrganizationsAPI.readNotificationTemplates.mockReturnValue({ data }); + OrganizationsAPI.readNotificationTemplatesSuccess.mockReturnValue({ + data: { results: [{ id: 1 }] }, + }); + OrganizationsAPI.readNotificationTemplatesError.mockReturnValue({ + data: { results: [{ id: 2 }] }, + }); }); afterEach(() => { @@ -65,7 +58,7 @@ describe('', () => { await sleep(0); wrapper.update(); - expect(network.api.getOrganizationNotifications).toHaveBeenCalled(); + expect(OrganizationsAPI.readNotificationTemplates).toHaveBeenCalled(); expect(wrapper.find('OrganizationNotifications').state('notifications')) .toEqual(data.results); const items = wrapper.find('NotificationListItem'); @@ -91,7 +84,7 @@ describe('', () => { ).toEqual([1]); const items = wrapper.find('NotificationListItem'); items.at(1).find('Switch').at(0).prop('onChange')(); - expect(network.api.createOrganizationNotificationSuccess).toHaveBeenCalled(); + expect(OrganizationsAPI.associateNotificationTemplatesSuccess).toHaveBeenCalled(); await sleep(0); wrapper.update(); expect( @@ -114,7 +107,7 @@ describe('', () => { ).toEqual([2]); const items = wrapper.find('NotificationListItem'); items.at(0).find('Switch').at(1).prop('onChange')(); - expect(network.api.createOrganizationNotificationError).toHaveBeenCalled(); + expect(OrganizationsAPI.associateNotificationTemplatesError).toHaveBeenCalled(); await sleep(0); wrapper.update(); expect( @@ -137,7 +130,7 @@ describe('', () => { ).toEqual([1]); const items = wrapper.find('NotificationListItem'); items.at(0).find('Switch').at(0).prop('onChange')(); - expect(network.api.createOrganizationNotificationSuccess).toHaveBeenCalled(); + expect(OrganizationsAPI.disassociateNotificationTemplatesSuccess).toHaveBeenCalled(); await sleep(0); wrapper.update(); expect( @@ -160,7 +153,7 @@ describe('', () => { ).toEqual([2]); const items = wrapper.find('NotificationListItem'); items.at(1).find('Switch').at(1).prop('onChange')(); - expect(network.api.createOrganizationNotificationError).toHaveBeenCalled(); + expect(OrganizationsAPI.disassociateNotificationTemplatesError).toHaveBeenCalled(); await sleep(0); wrapper.update(); expect( diff --git a/__tests__/pages/Organizations/screens/Organization/OrganizationTeams.test.jsx b/__tests__/pages/Organizations/screens/Organization/OrganizationTeams.test.jsx index 7c07d21096..3f4321009d 100644 --- a/__tests__/pages/Organizations/screens/Organization/OrganizationTeams.test.jsx +++ b/__tests__/pages/Organizations/screens/Organization/OrganizationTeams.test.jsx @@ -3,6 +3,9 @@ import { shallow } from 'enzyme'; import { mountWithContexts } from '../../../../enzymeHelpers'; import { sleep } from '../../../../testUtils'; import OrganizationTeams, { _OrganizationTeams } from '../../../../../src/pages/Organizations/screens/Organization/OrganizationTeams'; +import { OrganizationsAPI } from '../../../../../src/api'; + +jest.mock('../../../../../src/api'); const listData = { data: { @@ -17,6 +20,14 @@ const listData = { } }; +beforeEach(() => { + OrganizationsAPI.readTeams.mockResolvedValue(listData); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + describe('', () => { test('renders succesfully', () => { shallow( @@ -24,25 +35,21 @@ describe('', () => { id={1} searchString="" location={{ search: '', pathname: '/organizations/1/teams' }} - api={{ - readOrganizationTeamsList: jest.fn(), - }} handleHttpError={() => {}} /> ); }); test('should load teams on mount', () => { - const readOrganizationTeamsList = jest.fn(() => Promise.resolve(listData)); mountWithContexts( , { context: { - network: { api: { readOrganizationTeamsList }, handleHttpError: () => {} } } + network: {} } } ).find('OrganizationTeams'); - expect(readOrganizationTeamsList).toHaveBeenCalledWith(1, { + expect(OrganizationsAPI.readTeams).toHaveBeenCalledWith(1, { page: 1, page_size: 5, order_by: 'name', @@ -50,13 +57,12 @@ describe('', () => { }); test('should pass fetched teams to PaginatedDatalist', async () => { - const readOrganizationTeamsList = jest.fn(() => Promise.resolve(listData)); const wrapper = mountWithContexts( , { context: { - network: { api: { readOrganizationTeamsList }, handleHttpError: () => {} } } + network: { handleHttpError: () => {} } } } ); diff --git a/__tests__/pages/Organizations/screens/Organization/__snapshots__/OrganizationAccess.test.jsx.snap b/__tests__/pages/Organizations/screens/Organization/__snapshots__/OrganizationAccess.test.jsx.snap index e03c838e8d..f520ba2c1e 100644 --- a/__tests__/pages/Organizations/screens/Organization/__snapshots__/OrganizationAccess.test.jsx.snap +++ b/__tests__/pages/Organizations/screens/Organization/__snapshots__/OrganizationAccess.test.jsx.snap @@ -2,7 +2,6 @@ exports[` initially renders succesfully 1`] = ` initially renders succesfully 1`] = ` initially renders succesfully 1`] = ` value={"/config/"} > initially renders succesfully 1`] = ` id={1} > new Promise(resolve => setTimeout(resolve, ms)); describe('', () => { - let api; let networkProviderValue; beforeEach(() => { - api = { - getInstanceGroups: jest.fn(), - createOrganization: jest.fn(), - associateInstanceGroup: jest.fn(), - disassociate: jest.fn(), - }; - networkProviderValue = { - api, handleHttpError: () => {} }; }); @@ -34,7 +28,7 @@ describe('', () => { custom_virtualenv: 'Buzz', }; wrapper.find('OrganizationForm').prop('handleSubmit')(updatedOrgData, [], []); - expect(api.createOrganization).toHaveBeenCalledWith(updatedOrgData); + expect(OrganizationsAPI.create).toHaveBeenCalledWith(updatedOrgData); }); test('should navigate to organizations list when cancel is clicked', () => { @@ -70,7 +64,7 @@ describe('', () => { description: 'new description', custom_virtualenv: 'Buzz', }; - api.createOrganization.mockReturnValueOnce({ + OrganizationsAPI.create.mockReturnValueOnce({ data: { id: 5, related: { @@ -96,7 +90,7 @@ describe('', () => { description: 'new description', custom_virtualenv: 'Buzz', }; - api.createOrganization.mockReturnValueOnce({ + OrganizationsAPI.create.mockReturnValueOnce({ data: { id: 5, related: { @@ -107,8 +101,8 @@ describe('', () => { }); wrapper.find('OrganizationForm').prop('handleSubmit')(orgData, [3], []); await sleep(0); - expect(api.associateInstanceGroup) - .toHaveBeenCalledWith('/api/v2/organizations/5/instance_groups', 3); + expect(OrganizationsAPI.associateInstanceGroup) + .toHaveBeenCalledWith(5, 3); }); test('AnsibleSelect component renders if there are virtual environments', () => { diff --git a/__tests__/pages/Organizations/screens/OrganizationsList.test.jsx b/__tests__/pages/Organizations/screens/OrganizationsList.test.jsx index cd59efb01a..c3e38f3a30 100644 --- a/__tests__/pages/Organizations/screens/OrganizationsList.test.jsx +++ b/__tests__/pages/Organizations/screens/OrganizationsList.test.jsx @@ -2,6 +2,9 @@ import React from 'react'; import { createMemoryHistory } from 'history'; import { mountWithContexts } from '../../../enzymeHelpers'; import OrganizationsList, { _OrganizationsList } from '../../../../src/pages/Organizations/screens/OrganizationsList'; +import { OrganizationsAPI } from '../../../../src/api'; + +jest.mock('../../../../src/api'); const mockAPIOrgsList = { data: { @@ -124,7 +127,7 @@ describe('', () => { selected: mockAPIOrgsList.data.results }); wrapper.find('ToolbarDeleteButton').prop('onDelete')(); - expect(api.destroyOrganization).toHaveBeenCalledTimes(component.state('selected').length); + expect(OrganizationsAPI.destroy).toHaveBeenCalledTimes(component.state('selected').length); }); test('call fetchOrganizations after org(s) have been deleted', () => { diff --git a/src/App.jsx b/src/App.jsx index 60d1e7900f..0d63d35ad0 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -15,6 +15,7 @@ import styled from 'styled-components'; import { RootDialog } from './contexts/RootDialog'; import { withNetwork } from './contexts/Network'; import { Config } from './contexts/Config'; +import { RootAPI } from './api'; import AlertModal from './components/AlertModal'; import About from './components/About'; @@ -56,9 +57,9 @@ class App extends Component { } async onLogout () { - const { api, handleHttpError } = this.props; + const { handleHttpError } = this.props; try { - await api.logout(); + await RootAPI.logout(); window.location.replace('/#/login'); } catch (err) { handleHttpError(err); diff --git a/src/api.js b/src/api.js deleted file mode 100644 index ac8af86208..0000000000 --- a/src/api.js +++ /dev/null @@ -1,182 +0,0 @@ -const API_ROOT = '/api/'; -const API_LOGIN = `${API_ROOT}login/`; -const API_LOGOUT = `${API_ROOT}logout/`; -const API_V2 = `${API_ROOT}v2/`; -const API_CONFIG = `${API_V2}config/`; -const API_ME = `${API_V2}me/`; -const API_ORGANIZATIONS = `${API_V2}organizations/`; -const API_INSTANCE_GROUPS = `${API_V2}instance_groups/`; -const API_USERS = `${API_V2}users/`; -const API_TEAMS = `${API_V2}teams/`; - -const LOGIN_CONTENT_TYPE = 'application/x-www-form-urlencoded'; - -class APIClient { - static getCookie () { - return document.cookie; - } - - constructor (httpAdapter) { - this.http = httpAdapter; - } - - isAuthenticated () { - const cookie = this.constructor.getCookie(); - const parsed = (`; ${cookie}`).split('; userLoggedIn='); - - let authenticated = false; - - if (parsed.length === 2) { - authenticated = parsed.pop().split(';').shift() === 'true'; - } - - return authenticated; - } - - async login (username, password, redirect = API_CONFIG) { - const un = encodeURIComponent(username); - const pw = encodeURIComponent(password); - const next = encodeURIComponent(redirect); - - const data = `username=${un}&password=${pw}&next=${next}`; - const headers = { 'Content-Type': LOGIN_CONTENT_TYPE }; - - await this.http.get(API_LOGIN, { headers }); - const response = await this.http.post(API_LOGIN, data, { headers }); - - return response; - } - - logout () { - return this.http.get(API_LOGOUT); - } - - getRoot () { - return this.http.get(API_ROOT); - } - - getConfig () { - return this.http.get(API_CONFIG); - } - - getMe () { - return this.http.get(API_ME); - } - - destroyOrganization (id) { - const endpoint = `${API_ORGANIZATIONS}${id}/`; - return (this.http.delete(endpoint)); - } - - getOrganizations (params = {}) { - return this.http.get(API_ORGANIZATIONS, { params }); - } - - createOrganization (data) { - return this.http.post(API_ORGANIZATIONS, data); - } - - optionsOrganizations () { - return this.http.options(API_ORGANIZATIONS); - } - - getOrganizationAccessList (id, params = {}) { - const endpoint = `${API_ORGANIZATIONS}${id}/access_list/`; - - return this.http.get(endpoint, { params }); - } - - readOrganizationTeamsList (id, params = {}) { - const endpoint = `${API_ORGANIZATIONS}${id}/teams/`; - - return this.http.get(endpoint, { params }); - } - - getOrganizationDetails (id) { - const endpoint = `${API_ORGANIZATIONS}${id}/`; - - return this.http.get(endpoint); - } - - updateOrganizationDetails (id, data) { - const endpoint = `${API_ORGANIZATIONS}${id}/`; - - return this.http.patch(endpoint, data); - } - - getOrganizationInstanceGroups (id, params = {}) { - const endpoint = `${API_ORGANIZATIONS}${id}/instance_groups/`; - - return this.http.get(endpoint, { params }); - } - - getOrganizationNotifications (id, params = {}) { - const endpoint = `${API_ORGANIZATIONS}${id}/notification_templates/`; - - return this.http.get(endpoint, { params }); - } - - getOrganizationNotificationSuccess (id, params = {}) { - const endpoint = `${API_ORGANIZATIONS}${id}/notification_templates_success/`; - - return this.http.get(endpoint, { params }); - } - - getOrganizationNotificationError (id, params = {}) { - const endpoint = `${API_ORGANIZATIONS}${id}/notification_templates_error/`; - - return this.http.get(endpoint, { params }); - } - - createOrganizationNotificationSuccess (id, data) { - const endpoint = `${API_ORGANIZATIONS}${id}/notification_templates_success/`; - - return this.http.post(endpoint, data); - } - - createOrganizationNotificationError (id, data) { - const endpoint = `${API_ORGANIZATIONS}${id}/notification_templates_error/`; - - return this.http.post(endpoint, data); - } - - getInstanceGroups (params) { - return this.http.get(API_INSTANCE_GROUPS, { params }); - } - - associateInstanceGroup (url, id) { - 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 }); - } - - readUsers (params) { - return this.http.get(API_USERS, { params }); - } - - readTeams (params) { - return this.http.get(API_TEAMS, { params }); - } - - createUserRole (userId, roleId) { - return this.http.post(`${API_USERS}${userId}/roles/`, { id: roleId }); - } - - createTeamRole (teamId, roleId) { - return this.http.post(`${API_TEAMS}${teamId}/roles/`, { id: roleId }); - } -} - -export default APIClient; diff --git a/src/api/Base.js b/src/api/Base.js new file mode 100644 index 0000000000..bc26bc0657 --- /dev/null +++ b/src/api/Base.js @@ -0,0 +1,43 @@ +import axios from 'axios'; + +const defaultHttp = axios.create({ + xsrfCookieName: 'csrftoken', + xsrfHeaderName: 'X-CSRFToken' +}); + +class Base { + constructor (http = defaultHttp, baseURL) { + this.http = http; + this.baseUrl = baseURL; + } + + create (data) { + return this.http.post(this.baseUrl, data); + } + + destroy (id) { + return this.http.delete(`${this.baseUrl}${id}/`); + } + + read (params = {}) { + return this.http.get(this.baseUrl, { params }); + } + + readDetail (id) { + return this.http.get(`${this.baseUrl}${id}/`); + } + + readOptions () { + return this.http.options(this.baseUrl); + } + + replace (id, data) { + return this.http.put(`${this.baseUrl}${id}/`, data); + } + + update (id, data) { + return this.http.patch(`${this.baseUrl}${id}/`, data); + } +} + +export default Base; diff --git a/src/api/index.js b/src/api/index.js new file mode 100644 index 0000000000..b908cf17d7 --- /dev/null +++ b/src/api/index.js @@ -0,0 +1,25 @@ +import Config from './models/Config'; +import InstanceGroups from './models/InstanceGroups'; +import Me from './models/Me'; +import Organizations from './models/Organizations'; +import Root from './models/Root'; +import Teams from './models/Teams'; +import Users from './models/Users'; + +const ConfigAPI = new Config(); +const InstanceGroupsAPI = new InstanceGroups(); +const MeAPI = new Me(); +const OrganizationsAPI = new Organizations(); +const RootAPI = new Root(); +const TeamsAPI = new Teams(); +const UsersAPI = new Users(); + +export { + ConfigAPI, + InstanceGroupsAPI, + MeAPI, + OrganizationsAPI, + RootAPI, + TeamsAPI, + UsersAPI +}; diff --git a/src/api/mixins/InstanceGroups.mixin.js b/src/api/mixins/InstanceGroups.mixin.js new file mode 100644 index 0000000000..2317fb9270 --- /dev/null +++ b/src/api/mixins/InstanceGroups.mixin.js @@ -0,0 +1,15 @@ +const InstanceGroupsMixin = (parent) => class extends parent { + readInstanceGroups (resourceId, params = {}) { + return this.http.get(`${this.baseUrl}${resourceId}/instance_groups/`, { params }); + } + + associateInstanceGroup (resourceId, instanceGroupId) { + return this.http.post(`${this.baseUrl}${resourceId}/instance_groups/`, { id: instanceGroupId }); + } + + disassociateInstanceGroup (resourceId, instanceGroupId) { + return this.http.post(`${this.baseUrl}${resourceId}/instance_groups/`, { id: instanceGroupId, disassociate: true }); + } +}; + +export default InstanceGroupsMixin; diff --git a/src/api/mixins/Notifications.mixin.js b/src/api/mixins/Notifications.mixin.js new file mode 100644 index 0000000000..4672b9989b --- /dev/null +++ b/src/api/mixins/Notifications.mixin.js @@ -0,0 +1,31 @@ +const NotificationsMixin = (parent) => class extends parent { + readNotificationTemplates (id, params = {}) { + return this.http.get(`${this.baseUrl}${id}/notification_templates/`, { params }); + } + + readNotificationTemplatesSuccess (id, params = {}) { + return this.http.get(`${this.baseUrl}${id}/notification_templates_success/`, { params }); + } + + readNotificationTemplatesError (id, params = {}) { + return this.http.get(`${this.baseUrl}${id}/notification_templates_error/`, { params }); + } + + associateNotificationTemplatesSuccess (resourceId, notificationId) { + return this.http.post(`${this.baseUrl}${resourceId}/notification_templates_success/`, { id: notificationId }); + } + + disassociateNotificationTemplatesSuccess (resourceId, notificationId) { + return this.http.post(`${this.baseUrl}${resourceId}/notification_templates_success/`, { id: notificationId, disassociate: true }); + } + + associateNotificationTemplatesError (resourceId, notificationId) { + return this.http.post(`${this.baseUrl}${resourceId}/notification_templates_error/`, { id: notificationId }); + } + + disassociateNotificationTemplatesError (resourceId, notificationId) { + return this.http.post(`${this.baseUrl}${resourceId}/notification_templates_error/`, { id: notificationId, disassociate: true }); + } +}; + +export default NotificationsMixin; diff --git a/src/api/models/Config.js b/src/api/models/Config.js new file mode 100644 index 0000000000..a944f4b994 --- /dev/null +++ b/src/api/models/Config.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class Config extends Base { + constructor (http) { + super(http); + this.baseUrl = '/api/v2/config/'; + } +} + +export default Config; diff --git a/src/api/models/InstanceGroups.js b/src/api/models/InstanceGroups.js new file mode 100644 index 0000000000..7eaad069e3 --- /dev/null +++ b/src/api/models/InstanceGroups.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class InstanceGroups extends Base { + constructor (http) { + super(http); + this.baseUrl = '/api/v2/instance_groups/'; + } +} + +export default InstanceGroups; diff --git a/src/api/models/Me.js b/src/api/models/Me.js new file mode 100644 index 0000000000..ca0b336e26 --- /dev/null +++ b/src/api/models/Me.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class Me extends Base { + constructor (http) { + super(http); + this.baseUrl = '/api/v2/me/'; + } +} + +export default Me; diff --git a/src/api/models/Organizations.js b/src/api/models/Organizations.js new file mode 100644 index 0000000000..77970ad00c --- /dev/null +++ b/src/api/models/Organizations.js @@ -0,0 +1,20 @@ +import Base from '../Base'; +import NotificationsMixin from '../mixins/Notifications.mixin'; +import InstanceGroupsMixin from '../mixins/InstanceGroups.mixin'; + +class Organizations extends InstanceGroupsMixin(NotificationsMixin(Base)) { + constructor (http) { + super(http); + this.baseUrl = '/api/v2/organizations/'; + } + + readAccessList (id, params = {}) { + return this.http.get(`${this.baseUrl}${id}/access_list/`, { params }); + } + + readTeams (id, params = {}) { + return this.http.get(`${this.baseUrl}${id}/teams/`, { params }); + } +} + +export default Organizations; diff --git a/src/api/models/Root.js b/src/api/models/Root.js new file mode 100644 index 0000000000..6e2bd0cfd9 --- /dev/null +++ b/src/api/models/Root.js @@ -0,0 +1,30 @@ +import Base from '../Base'; + +class Root extends Base { + constructor (http) { + super(http); + this.baseUrl = '/api/'; + this.redirectURL = '/api/v2/config/'; + } + + async login (username, password, redirect = this.redirectURL) { + const loginUrl = `${this.baseUrl}login/`; + const un = encodeURIComponent(username); + const pw = encodeURIComponent(password); + const next = encodeURIComponent(redirect); + + const data = `username=${un}&password=${pw}&next=${next}`; + const headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; + + await this.http.get(loginUrl, { headers }); + const response = await this.http.post(loginUrl, data, { headers }); + + return response; + } + + logout () { + return this.http.get(`${this.baseUrl}logout/`); + } +} + +export default Root; diff --git a/src/api/models/Teams.js b/src/api/models/Teams.js new file mode 100644 index 0000000000..fb373bb3bc --- /dev/null +++ b/src/api/models/Teams.js @@ -0,0 +1,18 @@ +import Base from '../Base'; + +class Teams extends Base { + constructor (http) { + super(http); + this.baseUrl = '/api/v2/teams/'; + } + + associateRole (teamId, roleId) { + return this.http.post(`${this.baseUrl}${teamId}/roles/`, { id: roleId }); + } + + disassociateRole (teamId, roleId) { + return this.http.post(`${this.baseUrl}${teamId}/roles/`, { id: roleId, disassociate: true }); + } +} + +export default Teams; diff --git a/src/api/models/Users.js b/src/api/models/Users.js new file mode 100644 index 0000000000..d386e2333c --- /dev/null +++ b/src/api/models/Users.js @@ -0,0 +1,18 @@ +import Base from '../Base'; + +class Users extends Base { + constructor (http) { + super(http); + this.baseUrl = '/api/v2/users/'; + } + + associateRole (userId, roleId) { + return this.http.post(`${this.baseUrl}${userId}/roles/`, { id: roleId }); + } + + disassociateRole (userId, roleId) { + return this.http.post(`${this.baseUrl}${userId}/roles/`, { id: roleId, disassociate: true }); + } +} + +export default Users; diff --git a/src/components/AddRole/AddResourceRole.jsx b/src/components/AddRole/AddResourceRole.jsx index 13039bde15..a282521750 100644 --- a/src/components/AddRole/AddResourceRole.jsx +++ b/src/components/AddRole/AddResourceRole.jsx @@ -7,6 +7,13 @@ import { withNetwork } from '../../contexts/Network'; import SelectResourceStep from './SelectResourceStep'; import SelectRoleStep from './SelectRoleStep'; import SelectableCard from './SelectableCard'; +import { TeamsAPI, UsersAPI } from '../../api'; + +const readUsers = async (queryParams) => UsersAPI.read( + Object.assign(queryParams, { is_superuser: false }) +); + +const readTeams = async (queryParams) => TeamsAPI.read(queryParams); class AddResourceRole extends React.Component { constructor (props) { @@ -24,8 +31,6 @@ class AddResourceRole extends React.Component { this.handleRoleCheckboxClick = this.handleRoleCheckboxClick.bind(this); this.handleWizardNext = this.handleWizardNext.bind(this); this.handleWizardSave = this.handleWizardSave.bind(this); - this.readTeams = this.readTeams.bind(this); - this.readUsers = this.readUsers.bind(this); } handleResourceCheckboxClick (user) { @@ -76,8 +81,7 @@ class AddResourceRole extends React.Component { async handleWizardSave () { const { - onSave, - api + onSave } = this.props; const { selectedResourceRows, @@ -92,11 +96,11 @@ class AddResourceRole extends React.Component { for (let j = 0; j < selectedRoleRows.length; j++) { if (selectedResource === 'users') { roleRequests.push( - api.createUserRole(selectedResourceRows[i].id, selectedRoleRows[j].id) + UsersAPI.associateRole(selectedResourceRows[i].id, selectedRoleRows[j].id) ); } else if (selectedResource === 'teams') { roleRequests.push( - api.createTeamRole(selectedResourceRows[i].id, selectedRoleRows[j].id) + TeamsAPI.associateRole(selectedResourceRows[i].id, selectedRoleRows[j].id) ); } } @@ -109,16 +113,6 @@ class AddResourceRole extends React.Component { } } - async readUsers (queryParams) { - const { api } = this.props; - return api.readUsers(Object.assign(queryParams, { is_superuser: false })); - } - - async readTeams (queryParams) { - const { api } = this.props; - return api.readTeams(queryParams); - } - render () { const { selectedResource, @@ -183,7 +177,7 @@ class AddResourceRole extends React.Component { columns={userColumns} displayKey="username" onRowClick={this.handleResourceCheckboxClick} - onSearch={this.readUsers} + onSearch={readUsers} selectedLabel={i18n._(t`Selected`)} selectedResourceRows={selectedResourceRows} sortedColumnKey="username" @@ -194,7 +188,7 @@ class AddResourceRole extends React.Component { ({ value: { ...prevState.value, @@ -75,13 +77,13 @@ class Provider extends Component { } async fetchConfig () { - const { api, handleHttpError } = this.props; + const { handleHttpError } = this.props; try { const [configRes, rootRes, meRes] = await Promise.all([ - api.getConfig(), - api.getRoot(), - api.getMe() + ConfigAPI.read(), + RootAPI.read(), + MeAPI.read() ]); this.setState({ value: { diff --git a/src/contexts/Network.jsx b/src/contexts/Network.jsx index 0ee2106bf7..84628aba5e 100644 --- a/src/contexts/Network.jsx +++ b/src/contexts/Network.jsx @@ -1,5 +1,4 @@ -import axios from 'axios'; import React, { Component } from 'react'; import { withRouter } from 'react-router-dom'; @@ -9,8 +8,6 @@ import { t } from '@lingui/macro'; import { withRootDialog } from './RootDialog'; -import APIClient from '../api'; - const NetworkContext = React.createContext({}); class Provider extends Component { @@ -19,7 +16,6 @@ class Provider extends Component { this.state = { value: { - api: new APIClient(axios.create({ xsrfCookieName: 'csrftoken', xsrfHeaderName: 'X-CSRFToken' })), handleHttpError: err => { if (err.response.status === 401) { this.handle401(); diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx index 50b58765ae..b2c5f0bc57 100644 --- a/src/pages/Login.jsx +++ b/src/pages/Login.jsx @@ -9,6 +9,7 @@ import { import { withRootDialog } from '../contexts/RootDialog'; import { withNetwork } from '../contexts/Network'; +import { RootAPI } from '../api'; import towerLogo from '../../images/tower-logo-header.svg'; @@ -39,7 +40,7 @@ class AWXLogin extends Component { async onLoginButtonClick (event) { const { username, password, isLoading } = this.state; - const { api, handleHttpError, clearRootDialogMessage, fetchMe, updateConfig } = this.props; + const { handleHttpError, clearRootDialogMessage, fetchMe, updateConfig } = this.props; event.preventDefault(); @@ -51,7 +52,7 @@ class AWXLogin extends Component { this.setState({ isLoading: true }); try { - const { data } = await api.login(username, password); + const { data } = await RootAPI.login(username, password); updateConfig(data); await fetchMe(); this.setState({ isAuthenticated: true, isLoading: false }); diff --git a/src/pages/Organizations/components/InstanceGroupsLookup.jsx b/src/pages/Organizations/components/InstanceGroupsLookup.jsx index e93f93ebf5..89a8f82fc7 100644 --- a/src/pages/Organizations/components/InstanceGroupsLookup.jsx +++ b/src/pages/Organizations/components/InstanceGroupsLookup.jsx @@ -9,19 +9,11 @@ import Lookup from '../../../components/Lookup'; import { withNetwork } from '../../../contexts/Network'; +import { InstanceGroupsAPI } from '../../../api'; + +const getInstanceGroups = async (params) => InstanceGroupsAPI.read(params); + class InstanceGroupsLookup extends React.Component { - constructor (props) { - super(props); - - this.getInstanceGroups = this.getInstanceGroups.bind(this); - } - - async getInstanceGroups (params) { - const { api } = this.props; - const data = await api.getInstanceGroups(params); - return data; - } - render () { const { value, tooltip, onChange, i18n } = this.props; @@ -51,7 +43,7 @@ class InstanceGroupsLookup extends React.Component { name="instanceGroups" value={value} onLookupSave={onChange} - getItems={this.getInstanceGroups} + getItems={getInstanceGroups} columns={[ { name: i18n._(t`Name`), key: 'name', isSortable: true }, { name: i18n._(t`Modified`), key: 'modified', isSortable: false, isNumeric: true }, diff --git a/src/pages/Organizations/components/OrganizationForm.jsx b/src/pages/Organizations/components/OrganizationForm.jsx index fcf0ec8809..c848b34e61 100644 --- a/src/pages/Organizations/components/OrganizationForm.jsx +++ b/src/pages/Organizations/components/OrganizationForm.jsx @@ -17,6 +17,7 @@ import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup import AnsibleSelect from '../../../components/AnsibleSelect'; import InstanceGroupsLookup from './InstanceGroupsLookup'; import { required } from '../../../util/validators'; +import { OrganizationsAPI } from '../../../api'; class OrganizationForm extends Component { constructor (props) { @@ -52,10 +53,9 @@ class OrganizationForm extends Component { async getRelatedInstanceGroups () { const { - api, organization: { id } } = this.props; - const { data } = await api.getOrganizationInstanceGroups(id); + const { data } = await OrganizationsAPI.readInstanceGroups(id); return data.results; } diff --git a/src/pages/Organizations/screens/Organization/Organization.jsx b/src/pages/Organizations/screens/Organization/Organization.jsx index 2fd96b673d..931736b3d5 100644 --- a/src/pages/Organizations/screens/Organization/Organization.jsx +++ b/src/pages/Organizations/screens/Organization/Organization.jsx @@ -12,6 +12,7 @@ import OrganizationEdit from './OrganizationEdit'; import OrganizationNotifications from './OrganizationNotifications'; import OrganizationTeams from './OrganizationTeams'; import RoutedTabs from '../../../../components/Tabs/RoutedTabs'; +import { OrganizationsAPI } from '../../../../api'; class Organization extends Component { constructor (props) { @@ -45,22 +46,21 @@ class Organization extends Component { const { match, setBreadcrumb, - api, handleHttpError } = this.props; try { const [{ data }, notifAdminRest, auditorRes, adminRes] = await Promise.all([ - api.getOrganizationDetails(parseInt(match.params.id, 10)), - api.getOrganizations({ + OrganizationsAPI.readDetail(parseInt(match.params.id, 10)), + OrganizationsAPI.read({ role_level: 'notification_admin_role', page_size: 1 }), - api.getOrganizations({ + OrganizationsAPI.read({ role_level: 'auditor_role', id: parseInt(match.params.id, 10) }), - api.getOrganizations({ + OrganizationsAPI.read({ role_level: 'admin_role', id: parseInt(match.params.id, 10) }) @@ -82,12 +82,11 @@ class Organization extends Component { const { match, setBreadcrumb, - api, handleHttpError } = this.props; try { - const { data } = await api.getOrganizationDetails(parseInt(match.params.id, 10)); + const { data } = await OrganizationsAPI.readDetail(parseInt(match.params.id, 10)); setBreadcrumb(data); this.setState({ organization: data, loading: false }); } catch (error) { diff --git a/src/pages/Organizations/screens/Organization/OrganizationAccess.jsx b/src/pages/Organizations/screens/Organization/OrganizationAccess.jsx index d4465b3a99..2e654d347f 100644 --- a/src/pages/Organizations/screens/Organization/OrganizationAccess.jsx +++ b/src/pages/Organizations/screens/Organization/OrganizationAccess.jsx @@ -10,6 +10,7 @@ import AddResourceRole from '../../../../components/AddRole/AddResourceRole'; import { withNetwork } from '../../../../contexts/Network'; import { getQSConfig, parseNamespacedQueryString } from '../../../../util/qs'; import { Organization } from '../../../../types'; +import { OrganizationsAPI, TeamsAPI, UsersAPI } from '../../../../api'; const QS_CONFIG = getQSConfig('access', { page: 1, @@ -56,10 +57,10 @@ class OrganizationAccess extends React.Component { } async readOrgAccessList () { - const { organization, api, handleHttpError, location } = this.props; + const { organization, handleHttpError, location } = this.props; this.setState({ isLoading: true }); try { - const { data } = await api.getOrganizationAccessList( + const { data } = await OrganizationsAPI.readAccessList( organization.id, parseNamespacedQueryString(QS_CONFIG, location.search) ); @@ -92,7 +93,7 @@ class OrganizationAccess extends React.Component { } async removeRole () { - const { api, handleHttpError } = this.props; + const { handleHttpError } = this.props; const { roleToDelete: role, roleToDeleteAccessRecord: accessRecord } = this.state; if (!role || !accessRecord) { return; @@ -101,9 +102,9 @@ class OrganizationAccess extends React.Component { this.setState({ isLoading: true }); try { if (type === 'teams') { - await api.disassociateTeamRole(role.team_id, role.id); + await TeamsAPI.disassociateRole(role.team_id, role.id); } else { - await api.disassociateUserRole(accessRecord.id, role.id); + await UsersAPI.disassociateRole(accessRecord.id, role.id); } this.setState({ isLoading: false, diff --git a/src/pages/Organizations/screens/Organization/OrganizationDetail.jsx b/src/pages/Organizations/screens/Organization/OrganizationDetail.jsx index 811317df80..a0c7b21ba1 100644 --- a/src/pages/Organizations/screens/Organization/OrganizationDetail.jsx +++ b/src/pages/Organizations/screens/Organization/OrganizationDetail.jsx @@ -7,6 +7,7 @@ import styled from 'styled-components'; import { DetailList, Detail } from '../../../../components/DetailList'; import { withNetwork } from '../../../../contexts/Network'; import { ChipGroup, Chip } from '../../../../components/Chip'; +import { OrganizationsAPI } from '../../../../api'; const CardBody = styled(PFCardBody)` padding-top: 20px; @@ -29,14 +30,13 @@ class OrganizationDetail extends Component { async loadInstanceGroups () { const { - api, handleHttpError, match } = this.props; try { const { data - } = await api.getOrganizationInstanceGroups(match.params.id); + } = await OrganizationsAPI.readInstanceGroups(match.params.id); this.setState({ instanceGroups: [...data.results] }); diff --git a/src/pages/Organizations/screens/Organization/OrganizationEdit.jsx b/src/pages/Organizations/screens/Organization/OrganizationEdit.jsx index 2b5fe68ca1..d4931b8125 100644 --- a/src/pages/Organizations/screens/Organization/OrganizationEdit.jsx +++ b/src/pages/Organizations/screens/Organization/OrganizationEdit.jsx @@ -4,6 +4,7 @@ import { withRouter } from 'react-router-dom'; import { CardBody } from '@patternfly/react-core'; import OrganizationForm from '../../components/OrganizationForm'; import { withNetwork } from '../../../../contexts/Network'; +import { OrganizationsAPI } from '../../../../api'; class OrganizationEdit extends Component { constructor (props) { @@ -20,9 +21,9 @@ class OrganizationEdit extends Component { } async handleSubmit (values, groupsToAssociate, groupsToDisassociate) { - const { api, organization, handleHttpError } = this.props; + const { organization, handleHttpError } = this.props; try { - await api.updateOrganizationDetails(organization.id, values); + await OrganizationsAPI.update(organization.id, values); await this.submitInstanceGroups(groupsToAssociate, groupsToDisassociate); this.handleSuccess(); } catch (err) { @@ -41,12 +42,17 @@ class OrganizationEdit extends Component { } async submitInstanceGroups (groupsToAssociate, groupsToDisassociate) { - const { api, organization, handleHttpError } = this.props; - const url = organization.related.instance_groups; + const { organization, handleHttpError } = this.props; try { - await Promise.all(groupsToAssociate.map(id => api.associateInstanceGroup(url, id))); - await Promise.all(groupsToDisassociate.map(id => api.disassociate(url, id))); + await Promise.all( + groupsToAssociate.map(id => OrganizationsAPI.associateInstanceGroup(organization.id, id)) + ); + await Promise.all( + groupsToDisassociate.map( + id => OrganizationsAPI.disassociateInstanceGroup(organization.id, id) + ) + ); } catch (err) { handleHttpError(err) || this.setState({ error: err }); } diff --git a/src/pages/Organizations/screens/Organization/OrganizationNotifications.jsx b/src/pages/Organizations/screens/Organization/OrganizationNotifications.jsx index 59e5b43eb9..6fd667f0d5 100644 --- a/src/pages/Organizations/screens/Organization/OrganizationNotifications.jsx +++ b/src/pages/Organizations/screens/Organization/OrganizationNotifications.jsx @@ -5,6 +5,7 @@ import { withNetwork } from '../../../../contexts/Network'; import PaginatedDataList from '../../../../components/PaginatedDataList'; import NotificationListItem from '../../../../components/NotificationsList/NotificationListItem'; import { getQSConfig, parseNamespacedQueryString } from '../../../../util/qs'; +import { OrganizationsAPI } from '../../../../api'; const QS_CONFIG = getQSConfig('notification', { page: 1, @@ -49,11 +50,11 @@ class OrganizationNotifications extends Component { } async readNotifications () { - const { id, api, handleHttpError, location } = this.props; + const { id, handleHttpError, location } = this.props; const params = parseNamespacedQueryString(QS_CONFIG, location.search); this.setState({ isLoading: true }); try { - const { data } = await api.getOrganizationNotifications(id, params); + const { data } = await OrganizationsAPI.readNotificationTemplates(id, params); this.setState( { itemCount: data.count || 0, @@ -72,21 +73,22 @@ class OrganizationNotifications extends Component { } async readSuccessesAndErrors () { - const { api, handleHttpError, id } = this.props; + const { 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( + const successTemplatesPromise = OrganizationsAPI.readNotificationTemplatesSuccess( id, { id__in: ids } ); - const errorTemplatesPromise = api.getOrganizationNotificationError( + const errorTemplatesPromise = OrganizationsAPI.readNotificationTemplatesError( id, { id__in: ids } ); + const { data: successTemplates } = await successTemplatesPromise; const { data: errorTemplates } = await errorTemplatesPromise; @@ -104,53 +106,65 @@ class OrganizationNotifications extends Component { toggleNotification = (notificationId, isCurrentlyOn, status) => { if (status === 'success') { - this.createSuccess(notificationId, isCurrentlyOn); + if (isCurrentlyOn) { + this.disassociateSuccess(notificationId); + } else { + this.associateSuccess(notificationId); + } } else if (status === 'error') { - this.createError(notificationId, isCurrentlyOn); + if (isCurrentlyOn) { + this.disassociateError(notificationId); + } else { + this.associateError(notificationId); + } } }; - async createSuccess (notificationId, isCurrentlyOn) { - const { id, api, handleHttpError } = this.props; - const postParams = { id: notificationId }; - if (isCurrentlyOn) { - postParams.disassociate = true; - } + async associateSuccess (notificationId) { + const { id, handleHttpError } = this.props; 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] - })); - } + await OrganizationsAPI.associateNotificationTemplatesSuccess(id, notificationId); + 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; - } + async disassociateSuccess (notificationId) { + const { id, handleHttpError } = this.props; 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] - })); - } + await OrganizationsAPI.disassociateNotificationTemplatesSuccess(id, notificationId); + this.setState((prevState) => ({ + successTemplateIds: prevState.successTemplateIds + .filter((templateId) => templateId !== notificationId) + })); + } catch (err) { + handleHttpError(err) || this.setState({ error: true }); + } + } + + async associateError (notificationId) { + const { id, handleHttpError } = this.props; + try { + await OrganizationsAPI.associateNotificationTemplatesError(id, notificationId); + this.setState(prevState => ({ + errorTemplateIds: [...prevState.errorTemplateIds, notificationId] + })); + } catch (err) { + handleHttpError(err) || this.setState({ error: true }); + } + } + + async disassociateError (notificationId) { + const { id, handleHttpError } = this.props; + try { + await OrganizationsAPI.disassociateNotificationTemplatesError(id, notificationId); + this.setState((prevState) => ({ + errorTemplateIds: prevState.errorTemplateIds + .filter((templateId) => templateId !== notificationId) + })); } catch (err) { handleHttpError(err) || this.setState({ error: true }); } @@ -205,13 +219,6 @@ 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, diff --git a/src/pages/Organizations/screens/Organization/OrganizationTeams.jsx b/src/pages/Organizations/screens/Organization/OrganizationTeams.jsx index e54081b935..5f0cd85e8d 100644 --- a/src/pages/Organizations/screens/Organization/OrganizationTeams.jsx +++ b/src/pages/Organizations/screens/Organization/OrganizationTeams.jsx @@ -4,6 +4,7 @@ import { withRouter } from 'react-router-dom'; import PaginatedDataList from '../../../../components/PaginatedDataList'; import { getQSConfig, parseNamespacedQueryString } from '../../../../util/qs'; import { withNetwork } from '../../../../contexts/Network'; +import { OrganizationsAPI } from '../../../../api'; const QS_CONFIG = getQSConfig('team', { page: 1, @@ -38,13 +39,13 @@ class OrganizationTeams extends React.Component { } async readOrganizationTeamsList () { - const { id, api, handleHttpError, location } = this.props; + const { id, handleHttpError, location } = this.props; const params = parseNamespacedQueryString(QS_CONFIG, location.search); this.setState({ isLoading: true, error: null }); try { const { data: { count = 0, results = [] }, - } = await api.readOrganizationTeamsList(id, params); + } = await OrganizationsAPI.readTeams(id, params); this.setState({ itemCount: count, teams: results, diff --git a/src/pages/Organizations/screens/OrganizationAdd.jsx b/src/pages/Organizations/screens/OrganizationAdd.jsx index abd652d66d..1b5fd2d355 100644 --- a/src/pages/Organizations/screens/OrganizationAdd.jsx +++ b/src/pages/Organizations/screens/OrganizationAdd.jsx @@ -14,6 +14,7 @@ import { import { withNetwork } from '../../../contexts/Network'; import CardCloseButton from '../../../components/CardCloseButton'; import OrganizationForm from '../components/OrganizationForm'; +import { OrganizationsAPI } from '../../../api'; class OrganizationAdd extends React.Component { constructor (props) { @@ -29,13 +30,12 @@ class OrganizationAdd extends React.Component { } async handleSubmit (values, groupsToAssociate) { - const { api, handleHttpError } = this.props; + const { handleHttpError } = this.props; try { - const { data: response } = await api.createOrganization(values); - const instanceGroupsUrl = response.related.instance_groups; + const { data: response } = await OrganizationsAPI.create(values); try { - await Promise.all(groupsToAssociate.map(id => api - .associateInstanceGroup(instanceGroupsUrl, id))); + await Promise.all(groupsToAssociate.map(id => OrganizationsAPI + .associateInstanceGroup(response.id, id))); this.handleSuccess(response.id); } catch (err) { handleHttpError(err) || this.setState({ error: err }); @@ -83,10 +83,6 @@ class OrganizationAdd extends React.Component { } } -OrganizationAdd.propTypes = { - api: PropTypes.shape().isRequired, -}; - OrganizationAdd.contextTypes = { custom_virtualenvs: PropTypes.arrayOf(PropTypes.string) }; diff --git a/src/pages/Organizations/screens/OrganizationsList.jsx b/src/pages/Organizations/screens/OrganizationsList.jsx index cbe00f9b51..c8bd1350c1 100644 --- a/src/pages/Organizations/screens/OrganizationsList.jsx +++ b/src/pages/Organizations/screens/OrganizationsList.jsx @@ -16,6 +16,7 @@ import PaginatedDataList, { import DataListToolbar from '../../../components/DataListToolbar'; import OrganizationListItem from '../components/OrganizationListItem'; import { getQSConfig, parseNamespacedQueryString } from '../../../util/qs'; +import { OrganizationsAPI } from '../../../api'; const QS_CONFIG = getQSConfig('organization', { page: 1, @@ -73,11 +74,11 @@ class OrganizationsList extends Component { async handleOrgDelete () { const { selected } = this.state; - const { api, handleHttpError } = this.props; + const { handleHttpError } = this.props; let errorHandled; try { - await Promise.all(selected.map((org) => api.destroyOrganization(org.id))); + await Promise.all(selected.map((org) => OrganizationsAPI.destroy(org.id))); this.setState({ selected: [] }); @@ -91,13 +92,13 @@ class OrganizationsList extends Component { } async fetchOrganizations () { - const { api, handleHttpError, location } = this.props; + const { handleHttpError, location } = this.props; const params = parseNamespacedQueryString(QS_CONFIG, location.search); this.setState({ error: false, isLoading: true }); try { - const { data } = await api.getOrganizations(params); + const { data } = await OrganizationsAPI.read(params); const { count, results } = data; const stateToUpdate = { @@ -115,10 +116,8 @@ class OrganizationsList extends Component { } async fetchOptionsOrganizations () { - const { api } = this.props; - try { - const { data } = await api.optionsOrganizations(); + const { data } = await OrganizationsAPI.readOptions(); const { actions } = data; const stateToUpdate = {