diff options
Diffstat (limited to 'spec/frontend/organizations')
12 files changed, 798 insertions, 111 deletions
diff --git a/spec/frontend/organizations/index/components/app_spec.js b/spec/frontend/organizations/index/components/app_spec.js index 175b1e1c552..670eb34bffd 100644 --- a/spec/frontend/organizations/index/components/app_spec.js +++ b/spec/frontend/organizations/index/components/app_spec.js @@ -5,9 +5,9 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/alert'; -import { organizations } from '~/organizations/mock_data'; -import resolvers from '~/organizations/shared/graphql/resolvers'; -import organizationsQuery from '~/organizations/index/graphql/organizations.query.graphql'; +import { DEFAULT_PER_PAGE } from '~/api'; +import { organizations as nodes, pageInfo, pageInfoEmpty } from '~/organizations/mock_data'; +import organizationsQuery from '~/organizations/shared/graphql/queries/organizations.query.graphql'; import OrganizationsIndexApp from '~/organizations/index/components/app.vue'; import OrganizationsView from '~/organizations/index/components/organizations_view.vue'; import { MOCK_NEW_ORG_URL } from '../mock_data'; @@ -20,8 +20,27 @@ describe('OrganizationsIndexApp', () => { let wrapper; let mockApollo; - const createComponent = (mockResolvers = resolvers) => { - mockApollo = createMockApollo([[organizationsQuery, mockResolvers]]); + const organizations = { + nodes, + pageInfo, + }; + + const organizationEmpty = { + nodes: [], + pageInfo: pageInfoEmpty, + }; + + const successHandler = jest.fn().mockResolvedValue({ + data: { + currentUser: { + id: 'gid://gitlab/User/1', + organizations, + }, + }, + }); + + const createComponent = (handler = successHandler) => { + mockApollo = createMockApollo([[organizationsQuery, handler]]); wrapper = shallowMountExtended(OrganizationsIndexApp, { apolloProvider: mockApollo, @@ -35,53 +54,168 @@ describe('OrganizationsIndexApp', () => { mockApollo = null; }); + // Finders const findOrganizationHeaderText = () => wrapper.findByText('Organizations'); const findNewOrganizationButton = () => wrapper.findComponent(GlButton); const findOrganizationsView = () => wrapper.findComponent(OrganizationsView); - const loadingResolver = jest.fn().mockReturnValue(new Promise(() => {})); - const successfulResolver = (nodes) => - jest.fn().mockResolvedValue({ - data: { currentUser: { id: 1, organizations: { nodes } } }, + // Assertions + const itRendersHeaderText = () => { + it('renders the header text', () => { + expect(findOrganizationHeaderText().exists()).toBe(true); + }); + }; + + const itRendersNewOrganizationButton = () => { + it('render new organization button with correct link', () => { + expect(findNewOrganizationButton().attributes('href')).toBe(MOCK_NEW_ORG_URL); + }); + }; + + const itDoesNotRenderErrorMessage = () => { + it('does not render an error message', () => { + expect(createAlert).not.toHaveBeenCalled(); + }); + }; + + const itDoesNotRenderHeaderText = () => { + it('does not render the header text', () => { + expect(findOrganizationHeaderText().exists()).toBe(false); + }); + }; + + const itDoesNotRenderNewOrganizationButton = () => { + it('does not render new organization button', () => { + expect(findNewOrganizationButton().exists()).toBe(false); + }); + }; + + describe('when API call is loading', () => { + beforeEach(() => { + createComponent(jest.fn().mockReturnValue(new Promise(() => {}))); + }); + + itRendersHeaderText(); + itRendersNewOrganizationButton(); + itDoesNotRenderErrorMessage(); + + it('renders the organizations view with loading prop set to true', () => { + expect(findOrganizationsView().props('loading')).toBe(true); + }); + }); + + describe('when API call is successful', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + itRendersHeaderText(); + itRendersNewOrganizationButton(); + itDoesNotRenderErrorMessage(); + + it('passes organizations to view component', () => { + expect(findOrganizationsView().props()).toMatchObject({ + loading: false, + organizations, + }); }); - const errorResolver = jest.fn().mockRejectedValue('error'); + }); - describe.each` - description | mockResolver | headerText | newOrgLink | loading | orgsData | error - ${'when API call is loading'} | ${loadingResolver} | ${true} | ${MOCK_NEW_ORG_URL} | ${true} | ${[]} | ${false} - ${'when API returns successful with results'} | ${successfulResolver(organizations)} | ${true} | ${MOCK_NEW_ORG_URL} | ${false} | ${organizations} | ${false} - ${'when API returns successful without results'} | ${successfulResolver([])} | ${false} | ${false} | ${false} | ${[]} | ${false} - ${'when API returns error'} | ${errorResolver} | ${false} | ${false} | ${false} | ${[]} | ${true} - `('$description', ({ mockResolver, headerText, newOrgLink, loading, orgsData, error }) => { + describe('when API call is successful and returns no organizations', () => { beforeEach(async () => { - createComponent(mockResolver); + createComponent( + jest.fn().mockResolvedValue({ + data: { + currentUser: { + id: 'gid://gitlab/User/1', + organizations: organizationEmpty, + }, + }, + }), + ); await waitForPromises(); }); - it(`does ${headerText ? '' : 'not '}render the header text`, () => { - expect(findOrganizationHeaderText().exists()).toBe(headerText); + itDoesNotRenderHeaderText(); + itDoesNotRenderNewOrganizationButton(); + itDoesNotRenderErrorMessage(); + + it('renders view component with correct organizations and loading props', () => { + expect(findOrganizationsView().props()).toMatchObject({ + loading: false, + organizations: organizationEmpty, + }); }); + }); + + describe('when API call is not successful', () => { + const error = new Error(); - it(`does ${newOrgLink ? '' : 'not '}render new organization button with correct link`, () => { - expect( - findNewOrganizationButton().exists() && findNewOrganizationButton().attributes('href'), - ).toBe(newOrgLink); + beforeEach(async () => { + createComponent(jest.fn().mockRejectedValue(error)); + await waitForPromises(); }); - it(`renders the organizations view with ${loading} loading prop`, () => { - expect(findOrganizationsView().props('loading')).toBe(loading); + itDoesNotRenderHeaderText(); + itDoesNotRenderNewOrganizationButton(); + + it('renders view component with correct organizations and loading props', () => { + expect(findOrganizationsView().props()).toMatchObject({ + loading: false, + organizations: {}, + }); }); - it(`renders the organizations view with ${ - orgsData ? 'correct' : 'empty' - } organizations array prop`, () => { - expect(findOrganizationsView().props('organizations')).toStrictEqual(orgsData); + it('renders error message', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: + 'An error occurred loading user organizations. Please refresh the page to try again.', + error, + captureError: true, + }); }); + }); + + describe('when view component emits `next` event', () => { + const endCursor = 'mockEndCursor'; + + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('calls GraphQL query with correct pageInfo variables', async () => { + findOrganizationsView().vm.$emit('next', endCursor); + await waitForPromises(); + + expect(successHandler).toHaveBeenCalledWith({ + first: DEFAULT_PER_PAGE, + after: endCursor, + last: null, + before: null, + }); + }); + }); + + describe('when view component emits `prev` event', () => { + const startCursor = 'mockStartCursor'; + + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('calls GraphQL query with correct pageInfo variables', async () => { + findOrganizationsView().vm.$emit('prev', startCursor); + await waitForPromises(); - it(`does ${error ? '' : 'not '}render an error message`, () => { - return error - ? expect(createAlert).toHaveBeenCalled() - : expect(createAlert).not.toHaveBeenCalled(); + expect(successHandler).toHaveBeenCalledWith({ + first: null, + after: null, + last: DEFAULT_PER_PAGE, + before: startCursor, + }); }); }); }); diff --git a/spec/frontend/organizations/index/components/organizations_list_spec.js b/spec/frontend/organizations/index/components/organizations_list_spec.js index 0b59c212314..7d904ee802f 100644 --- a/spec/frontend/organizations/index/components/organizations_list_spec.js +++ b/spec/frontend/organizations/index/components/organizations_list_spec.js @@ -1,28 +1,84 @@ +import { GlKeysetPagination } from '@gitlab/ui'; +import { omit } from 'lodash'; import { shallowMount } from '@vue/test-utils'; import OrganizationsList from '~/organizations/index/components/organizations_list.vue'; import OrganizationsListItem from '~/organizations/index/components/organizations_list_item.vue'; -import { organizations } from '~/organizations/mock_data'; +import { organizations as nodes, pageInfo, pageInfoOnePage } from '~/organizations/mock_data'; describe('OrganizationsList', () => { let wrapper; - const createComponent = () => { + const createComponent = ({ propsData = {} } = {}) => { wrapper = shallowMount(OrganizationsList, { propsData: { - organizations, + organizations: { + nodes, + pageInfo, + }, + ...propsData, }, }); }; const findAllOrganizationsListItem = () => wrapper.findAllComponents(OrganizationsListItem); + const findPagination = () => wrapper.findComponent(GlKeysetPagination); describe('template', () => { - beforeEach(() => { + it('renders a list item for each organization', () => { createComponent(); + + expect(findAllOrganizationsListItem()).toHaveLength(nodes.length); }); - it('renders a list item for each organization', () => { - expect(findAllOrganizationsListItem()).toHaveLength(organizations.length); + describe('when there is one page of organizations', () => { + beforeEach(() => { + createComponent({ + propsData: { + organizations: { + nodes, + pageInfo: pageInfoOnePage, + }, + }, + }); + }); + + it('does not render pagination', () => { + expect(findPagination().exists()).toBe(false); + }); + }); + + describe('when there are multiple pages of organizations', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders pagination', () => { + expect(findPagination().props()).toMatchObject(omit(pageInfo, '__typename')); + }); + + describe('when `GlKeysetPagination` emits `next` event', () => { + const endCursor = 'mockEndCursor'; + + beforeEach(() => { + findPagination().vm.$emit('next', endCursor); + }); + + it('emits `next` event', () => { + expect(wrapper.emitted('next')).toEqual([[endCursor]]); + }); + }); + + describe('when `GlKeysetPagination` emits `prev` event', () => { + const startCursor = 'startEndCursor'; + + beforeEach(() => { + findPagination().vm.$emit('prev', startCursor); + }); + + it('emits `prev` event', () => { + expect(wrapper.emitted('prev')).toEqual([[startCursor]]); + }); + }); }); }); }); diff --git a/spec/frontend/organizations/index/components/organizations_view_spec.js b/spec/frontend/organizations/index/components/organizations_view_spec.js index 85a1c11a2b1..fe167a1418f 100644 --- a/spec/frontend/organizations/index/components/organizations_view_spec.js +++ b/spec/frontend/organizations/index/components/organizations_view_spec.js @@ -31,7 +31,7 @@ describe('OrganizationsView', () => { ${'when not loading and has no organizations'} | ${false} | ${[]} | ${MOCK_ORG_EMPTY_STATE_SVG} | ${MOCK_NEW_ORG_URL} `('$description', ({ loading, orgsData, emptyStateSvg, emptyStateUrl }) => { beforeEach(() => { - createComponent({ loading, organizations: orgsData }); + createComponent({ loading, organizations: { nodes: orgsData, pageInfo: {} } }); }); it(`does ${loading ? '' : 'not '}render loading icon`, () => { @@ -54,4 +54,30 @@ describe('OrganizationsView', () => { ).toBe(emptyStateUrl); }); }); + + describe('when `OrganizationsList` emits `next` event', () => { + const endCursor = 'mockEndCursor'; + + beforeEach(() => { + createComponent({ loading: false, organizations: { nodes: organizations, pageInfo: {} } }); + findOrganizationsList().vm.$emit('next', endCursor); + }); + + it('emits `next` event', () => { + expect(wrapper.emitted('next')).toEqual([[endCursor]]); + }); + }); + + describe('when `OrganizationsList` emits `prev` event', () => { + const startCursor = 'mockStartCursor'; + + beforeEach(() => { + createComponent({ loading: false, organizations: { nodes: organizations, pageInfo: {} } }); + findOrganizationsList().vm.$emit('prev', startCursor); + }); + + it('emits `next` event', () => { + expect(wrapper.emitted('prev')).toEqual([[startCursor]]); + }); + }); }); diff --git a/spec/frontend/organizations/settings/general/components/advanced_settings_spec.js b/spec/frontend/organizations/settings/general/components/advanced_settings_spec.js new file mode 100644 index 00000000000..34793200b0d --- /dev/null +++ b/spec/frontend/organizations/settings/general/components/advanced_settings_spec.js @@ -0,0 +1,25 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import AdvancedSettings from '~/organizations/settings/general/components/advanced_settings.vue'; +import ChangeUrl from '~/organizations/settings/general/components/change_url.vue'; +import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; + +describe('AdvancedSettings', () => { + let wrapper; + const createComponent = () => { + wrapper = shallowMountExtended(AdvancedSettings); + }; + + const findSettingsBlock = () => wrapper.findComponent(SettingsBlock); + + beforeEach(() => { + createComponent(); + }); + + it('renders settings block', () => { + expect(findSettingsBlock().exists()).toBe(true); + }); + + it('renders `ChangeUrl` component', () => { + expect(findSettingsBlock().findComponent(ChangeUrl).exists()).toBe(true); + }); +}); diff --git a/spec/frontend/organizations/settings/general/components/app_spec.js b/spec/frontend/organizations/settings/general/components/app_spec.js index 6d75f8a9949..e954b927715 100644 --- a/spec/frontend/organizations/settings/general/components/app_spec.js +++ b/spec/frontend/organizations/settings/general/components/app_spec.js @@ -1,8 +1,9 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import OrganizationSettings from '~/organizations/settings/general/components/organization_settings.vue'; +import AdvancedSettings from '~/organizations/settings/general/components/advanced_settings.vue'; import App from '~/organizations/settings/general/components/app.vue'; -describe('OrganizationSettings', () => { +describe('OrganizationSettingsGeneralApp', () => { let wrapper; const createComponent = () => { @@ -16,4 +17,8 @@ describe('OrganizationSettings', () => { it('renders `Organization settings` section', () => { expect(wrapper.findComponent(OrganizationSettings).exists()).toBe(true); }); + + it('renders `Advanced` section', () => { + expect(wrapper.findComponent(AdvancedSettings).exists()).toBe(true); + }); }); diff --git a/spec/frontend/organizations/settings/general/components/change_url_spec.js b/spec/frontend/organizations/settings/general/components/change_url_spec.js new file mode 100644 index 00000000000..a4e3db0557c --- /dev/null +++ b/spec/frontend/organizations/settings/general/components/change_url_spec.js @@ -0,0 +1,191 @@ +import { GlButton, GlForm } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import Vue, { nextTick } from 'vue'; + +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import ChangeUrl from '~/organizations/settings/general/components/change_url.vue'; +import organizationUpdateMutation from '~/organizations/settings/general/graphql/mutations/organization_update.mutation.graphql'; +import { + organizationUpdateResponse, + organizationUpdateResponseWithErrors, +} from '~/organizations/mock_data'; +import { createAlert } from '~/alert'; +import { visitUrlWithAlerts } from '~/lib/utils/url_utility'; +import FormErrorsAlert from '~/vue_shared/components/form/errors_alert.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; + +jest.mock('~/alert'); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + visitUrlWithAlerts: jest.fn(), +})); + +Vue.use(VueApollo); + +describe('ChangeUrl', () => { + let wrapper; + let mockApollo; + + const defaultProvide = { + organization: { + id: 1, + name: 'GitLab', + path: 'foo-bar', + }, + organizationsPath: '/-/organizations', + rootUrl: 'http://127.0.0.1:3000/', + }; + + const successfulResponseHandler = jest.fn().mockResolvedValue(organizationUpdateResponse); + + const createComponent = ({ + handlers = [[organizationUpdateMutation, successfulResponseHandler]], + } = {}) => { + mockApollo = createMockApollo(handlers); + + wrapper = mountExtended(ChangeUrl, { + attachTo: document.body, + provide: defaultProvide, + apolloProvider: mockApollo, + }); + }; + + const findSubmitButton = () => wrapper.findComponent(GlButton); + const findOrganizationUrlField = () => wrapper.findByLabelText('Organization URL'); + const submitForm = async () => { + await wrapper.findComponent(GlForm).trigger('submit'); + await nextTick(); + }; + + afterEach(() => { + mockApollo = null; + }); + + it('renders `Organization URL` field', () => { + createComponent(); + + expect(findOrganizationUrlField().exists()).toBe(true); + }); + + it('disables submit button until `Organization URL` field is changed', async () => { + createComponent(); + + expect(findSubmitButton().props('disabled')).toBe(true); + + await findOrganizationUrlField().setValue('foo-bar-baz'); + + expect(findSubmitButton().props('disabled')).toBe(false); + }); + + describe('when form is submitted', () => { + it('requires `Organization URL` field', async () => { + createComponent(); + + await findOrganizationUrlField().setValue(''); + await submitForm(); + + expect(wrapper.findByText('Organization URL is required.').exists()).toBe(true); + }); + + it('requires `Organization URL` field to be a minimum of two characters', async () => { + createComponent(); + + await findOrganizationUrlField().setValue('f'); + await submitForm(); + + expect( + wrapper.findByText('Organization URL is too short (minimum is 2 characters).').exists(), + ).toBe(true); + }); + + describe('when API is loading', () => { + beforeEach(async () => { + createComponent({ + handlers: [ + [organizationUpdateMutation, jest.fn().mockReturnValueOnce(new Promise(() => {}))], + ], + }); + + await findOrganizationUrlField().setValue('foo-bar-baz'); + await submitForm(); + }); + + it('shows submit button as loading', () => { + expect(findSubmitButton().props('loading')).toBe(true); + }); + }); + + describe('when API request is successful', () => { + beforeEach(async () => { + createComponent(); + await findOrganizationUrlField().setValue('foo-bar-baz'); + await submitForm(); + await waitForPromises(); + }); + + it('calls mutation with correct variables and redirects user to new organization settings page with success alert', () => { + expect(successfulResponseHandler).toHaveBeenCalledWith({ + input: { + id: 'gid://gitlab/Organizations::Organization/1', + path: 'foo-bar-baz', + }, + }); + expect(visitUrlWithAlerts).toHaveBeenCalledWith( + `${organizationUpdateResponse.data.organizationUpdate.organization.webUrl}/settings/general`, + [ + { + id: 'organization-url-successfully-changed', + message: 'Organization URL successfully changed.', + variant: 'info', + }, + ], + ); + }); + }); + + describe('when API request is not successful', () => { + describe('when there is a network error', () => { + const error = new Error(); + + beforeEach(async () => { + createComponent({ + handlers: [[organizationUpdateMutation, jest.fn().mockRejectedValue(error)]], + }); + await findOrganizationUrlField().setValue('foo-bar-baz'); + await submitForm(); + await waitForPromises(); + }); + + it('displays error alert', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: 'An error occurred changing your organization URL. Please try again.', + error, + captureError: true, + }); + }); + }); + + describe('when there are GraphQL errors', () => { + beforeEach(async () => { + createComponent({ + handlers: [ + [ + organizationUpdateMutation, + jest.fn().mockResolvedValue(organizationUpdateResponseWithErrors), + ], + ], + }); + await submitForm(); + await waitForPromises(); + }); + + it('displays form errors alert', () => { + expect(wrapper.findComponent(FormErrorsAlert).props('errors')).toEqual( + organizationUpdateResponseWithErrors.data.organizationUpdate.errors, + ); + }); + }); + }); + }); +}); diff --git a/spec/frontend/organizations/settings/general/components/organization_settings_spec.js b/spec/frontend/organizations/settings/general/components/organization_settings_spec.js index 7645b41e3bd..d1c637331a8 100644 --- a/spec/frontend/organizations/settings/general/components/organization_settings_spec.js +++ b/spec/frontend/organizations/settings/general/components/organization_settings_spec.js @@ -6,14 +6,26 @@ import OrganizationSettings from '~/organizations/settings/general/components/or import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; import NewEditForm from '~/organizations/shared/components/new_edit_form.vue'; import { FORM_FIELD_NAME, FORM_FIELD_ID } from '~/organizations/shared/constants'; -import resolvers from '~/organizations/shared/graphql/resolvers'; -import { createAlert, VARIANT_INFO } from '~/alert'; +import organizationUpdateMutation from '~/organizations/settings/general/graphql/mutations/organization_update.mutation.graphql'; +import { + organizationUpdateResponse, + organizationUpdateResponseWithErrors, +} from '~/organizations/mock_data'; +import { createAlert } from '~/alert'; +import { visitUrlWithAlerts } from '~/lib/utils/url_utility'; +import FormErrorsAlert from '~/vue_shared/components/form/errors_alert.vue'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; Vue.use(VueApollo); -jest.useFakeTimers(); jest.mock('~/alert'); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + visitUrlWithAlerts: jest.fn(), +})); + +useMockLocationHelper(); describe('OrganizationSettings', () => { let wrapper; @@ -26,8 +38,12 @@ describe('OrganizationSettings', () => { }, }; - const createComponent = ({ mockResolvers = resolvers } = {}) => { - mockApollo = createMockApollo([], mockResolvers); + const successfulResponseHandler = jest.fn().mockResolvedValue(organizationUpdateResponse); + + const createComponent = ({ + handlers = [[organizationUpdateMutation, successfulResponseHandler]], + } = {}) => { + mockApollo = createMockApollo(handlers); wrapper = shallowMountExtended(OrganizationSettings, { provide: defaultProvide, @@ -66,13 +82,11 @@ describe('OrganizationSettings', () => { describe('when form is submitted', () => { describe('when API is loading', () => { beforeEach(async () => { - const mockResolvers = { - Mutation: { - updateOrganization: jest.fn().mockReturnValueOnce(new Promise(() => {})), - }, - }; - - createComponent({ mockResolvers }); + createComponent({ + handlers: [ + [organizationUpdateMutation, jest.fn().mockReturnValueOnce(new Promise(() => {}))], + ], + }); await submitForm(); }); @@ -86,39 +100,65 @@ describe('OrganizationSettings', () => { beforeEach(async () => { createComponent(); await submitForm(); - jest.runAllTimers(); await waitForPromises(); }); - it('displays info alert', () => { - expect(createAlert).toHaveBeenCalledWith({ - message: 'Organization was successfully updated.', - variant: VARIANT_INFO, + it('calls mutation with correct variables and displays info alert', () => { + expect(successfulResponseHandler).toHaveBeenCalledWith({ + input: { + id: 'gid://gitlab/Organizations::Organization/1', + name: 'Foo bar', + }, }); + expect(visitUrlWithAlerts).toHaveBeenCalledWith(window.location.href, [ + { + id: 'organization-successfully-updated', + message: 'Organization was successfully updated.', + variant: 'info', + }, + ]); }); }); describe('when API request is not successful', () => { - const error = new Error(); - - beforeEach(async () => { - const mockResolvers = { - Mutation: { - updateOrganization: jest.fn().mockRejectedValueOnce(error), - }, - }; + describe('when there is a network error', () => { + const error = new Error(); + + beforeEach(async () => { + createComponent({ + handlers: [[organizationUpdateMutation, jest.fn().mockRejectedValue(error)]], + }); + await submitForm(); + await waitForPromises(); + }); - createComponent({ mockResolvers }); - await submitForm(); - jest.runAllTimers(); - await waitForPromises(); + it('displays error alert', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: 'An error occurred updating your organization. Please try again.', + error, + captureError: true, + }); + }); }); - it('displays error alert', () => { - expect(createAlert).toHaveBeenCalledWith({ - message: 'An error occurred updating your organization. Please try again.', - error, - captureError: true, + describe('when there are GraphQL errors', () => { + beforeEach(async () => { + createComponent({ + handlers: [ + [ + organizationUpdateMutation, + jest.fn().mockResolvedValue(organizationUpdateResponseWithErrors), + ], + ], + }); + await submitForm(); + await waitForPromises(); + }); + + it('displays form errors alert', () => { + expect(wrapper.findComponent(FormErrorsAlert).props('errors')).toEqual( + organizationUpdateResponseWithErrors.data.organizationUpdate.errors, + ); }); }); }); diff --git a/spec/frontend/organizations/shared/components/new_edit_form_spec.js b/spec/frontend/organizations/shared/components/new_edit_form_spec.js index 93f022a3259..1fcfc20bf1a 100644 --- a/spec/frontend/organizations/shared/components/new_edit_form_spec.js +++ b/spec/frontend/organizations/shared/components/new_edit_form_spec.js @@ -1,6 +1,8 @@ -import { GlButton, GlInputGroupText, GlTruncate } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; +import { nextTick } from 'vue'; import NewEditForm from '~/organizations/shared/components/new_edit_form.vue'; +import OrganizationUrlField from '~/organizations/shared/components/organization_url_field.vue'; import { FORM_FIELD_NAME, FORM_FIELD_ID, FORM_FIELD_PATH } from '~/organizations/shared/constants'; import { mountExtended } from 'helpers/vue_test_utils_helper'; @@ -29,7 +31,12 @@ describe('NewEditForm', () => { const findNameField = () => wrapper.findByLabelText('Organization name'); const findIdField = () => wrapper.findByLabelText('Organization ID'); - const findUrlField = () => wrapper.findByLabelText('Organization URL'); + const findUrlField = () => wrapper.findComponent(OrganizationUrlField); + + const setUrlFieldValue = async (value) => { + findUrlField().vm.$emit('input', value); + await nextTick(); + }; const submitForm = async () => { await wrapper.findByRole('button', { name: 'Create organization' }).trigger('click'); }; @@ -43,20 +50,17 @@ describe('NewEditForm', () => { it('renders `Organization URL` field', () => { createComponent(); - expect(wrapper.findComponent(GlInputGroupText).findComponent(GlTruncate).props('text')).toBe( - 'http://127.0.0.1:3000/-/organizations/', - ); expect(findUrlField().exists()).toBe(true); }); it('requires `Organization URL` field to be a minimum of two characters', async () => { createComponent(); - await findUrlField().setValue('f'); + await setUrlFieldValue('f'); await submitForm(); expect( - wrapper.findByText('Organization URL must be a minimum of two characters.').exists(), + wrapper.findByText('Organization URL is too short (minimum is 2 characters).').exists(), ).toBe(true); }); @@ -89,7 +93,7 @@ describe('NewEditForm', () => { it('sets initial values for fields', () => { expect(findNameField().element.value).toBe('Foo bar'); expect(findIdField().element.value).toBe('1'); - expect(findUrlField().element.value).toBe('foo-bar'); + expect(findUrlField().props('value')).toBe('foo-bar'); }); }); @@ -116,7 +120,7 @@ describe('NewEditForm', () => { createComponent(); await findNameField().setValue('Foo bar'); - await findUrlField().setValue('foo-bar'); + await setUrlFieldValue('foo-bar'); await submitForm(); }); @@ -134,7 +138,7 @@ describe('NewEditForm', () => { }); it('sets `Organization URL` when typing in `Organization name`', () => { - expect(findUrlField().element.value).toBe('foo-bar'); + expect(findUrlField().props('value')).toBe('foo-bar'); }); }); @@ -142,13 +146,13 @@ describe('NewEditForm', () => { beforeEach(async () => { createComponent(); - await findUrlField().setValue('foo-bar-baz'); + await setUrlFieldValue('foo-bar-baz'); await findNameField().setValue('Foo bar'); await submitForm(); }); it('does not modify `Organization URL` when typing in `Organization name`', () => { - expect(findUrlField().element.value).toBe('foo-bar-baz'); + expect(findUrlField().props('value')).toBe('foo-bar-baz'); }); }); diff --git a/spec/frontend/organizations/shared/components/organization_url_field_spec.js b/spec/frontend/organizations/shared/components/organization_url_field_spec.js new file mode 100644 index 00000000000..d854134e596 --- /dev/null +++ b/spec/frontend/organizations/shared/components/organization_url_field_spec.js @@ -0,0 +1,66 @@ +import { GlFormInputGroup, GlInputGroupText, GlTruncate, GlFormInput } from '@gitlab/ui'; + +import OrganizedUrlField from '~/organizations/shared/components/organization_url_field.vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; + +describe('OrganizationUrlField', () => { + let wrapper; + + const defaultProvide = { + organizationsPath: '/-/organizations', + rootUrl: 'http://127.0.0.1:3000/', + }; + + const defaultPropsData = { + id: 'organization-url', + value: 'foo-bar', + validation: { + invalidFeedback: 'Invalid', + state: false, + }, + }; + + const createComponent = ({ propsData = {} } = {}) => { + wrapper = mountExtended(OrganizedUrlField, { + attachTo: document.body, + provide: defaultProvide, + propsData: { + ...defaultPropsData, + ...propsData, + }, + }); + }; + + const findInputGroup = () => wrapper.findComponent(GlFormInputGroup); + const findInput = () => findInputGroup().findComponent(GlFormInput); + + it('renders organization url field with correct props', () => { + createComponent(); + + expect( + findInputGroup().findComponent(GlInputGroupText).findComponent(GlTruncate).props('text'), + ).toBe('http://127.0.0.1:3000/-/organizations/'); + expect(findInput().attributes('id')).toBe(defaultPropsData.id); + expect(findInput().vm.$attrs).toMatchObject({ + value: defaultPropsData.value, + invalidFeedback: defaultPropsData.validation.invalidFeedback, + state: defaultPropsData.validation.state, + }); + }); + + it('emits `input` event', () => { + createComponent(); + + findInput().vm.$emit('input', 'foo'); + + expect(wrapper.emitted('input')).toEqual([['foo']]); + }); + + it('emits `blur` event', () => { + createComponent(); + + findInput().vm.$emit('blur', true); + + expect(wrapper.emitted('blur')).toEqual([[true]]); + }); +}); diff --git a/spec/frontend/organizations/users/components/app_spec.js b/spec/frontend/organizations/users/components/app_spec.js index b30fd984099..30380bcf6a5 100644 --- a/spec/frontend/organizations/users/components/app_spec.js +++ b/spec/frontend/organizations/users/components/app_spec.js @@ -4,9 +4,16 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/alert'; +import { ORGANIZATION_USERS_PER_PAGE } from '~/organizations/constants'; import organizationUsersQuery from '~/organizations/users/graphql/organization_users.query.graphql'; import OrganizationsUsersApp from '~/organizations/users/components/app.vue'; -import { MOCK_ORGANIZATION_GID, MOCK_USERS } from '../mock_data'; +import OrganizationsUsersView from '~/organizations/users/components/users_view.vue'; +import { + MOCK_ORGANIZATION_GID, + MOCK_USERS, + MOCK_USERS_FORMATTED, + MOCK_PAGE_INFO, +} from '../mock_data'; jest.mock('~/alert'); @@ -15,10 +22,11 @@ Vue.use(VueApollo); const mockError = new Error(); const loadingResolver = jest.fn().mockReturnValue(new Promise(() => {})); -const successfulResolver = (nodes) => - jest.fn().mockResolvedValue({ - data: { organization: { id: 1, organizationUsers: { nodes } } }, +const successfulResolver = (nodes, pageInfo = {}) => { + return jest.fn().mockResolvedValue({ + data: { organization: { id: 1, organizationUsers: { nodes, pageInfo } } }, }); +}; const errorResolver = jest.fn().mockRejectedValueOnce(mockError); describe('OrganizationsUsersApp', () => { @@ -40,31 +48,31 @@ describe('OrganizationsUsersApp', () => { mockApollo = null; }); - const findOrganizationUsersLoading = () => wrapper.findByText('Loading'); - const findOrganizationUsers = () => wrapper.findByTestId('organization-users'); + const findOrganizationUsersView = () => wrapper.findComponent(OrganizationsUsersView); describe.each` - description | mockResolver | loading | userData | error - ${'when API call is loading'} | ${loadingResolver} | ${true} | ${[]} | ${false} - ${'when API returns successful with results'} | ${successfulResolver(MOCK_USERS)} | ${false} | ${MOCK_USERS} | ${false} - ${'when API returns successful without results'} | ${successfulResolver([])} | ${false} | ${[]} | ${false} - ${'when API returns error'} | ${errorResolver} | ${false} | ${[]} | ${true} - `('$description', ({ mockResolver, loading, userData, error }) => { + description | mockResolver | loading | userData | pageInfo | error + ${'when API call is loading'} | ${loadingResolver} | ${true} | ${[]} | ${{}} | ${false} + ${'when API returns successful with one page of results'} | ${successfulResolver(MOCK_USERS)} | ${false} | ${MOCK_USERS_FORMATTED} | ${{}} | ${false} + ${'when API returns successful with multiple pages of results'} | ${successfulResolver(MOCK_USERS, MOCK_PAGE_INFO)} | ${false} | ${MOCK_USERS_FORMATTED} | ${MOCK_PAGE_INFO} | ${false} + ${'when API returns successful without results'} | ${successfulResolver([])} | ${false} | ${[]} | ${{}} | ${false} + ${'when API returns error'} | ${errorResolver} | ${false} | ${[]} | ${{}} | ${true} + `('$description', ({ mockResolver, loading, userData, pageInfo, error }) => { beforeEach(async () => { createComponent(mockResolver); await waitForPromises(); }); - it(`does ${ - loading ? '' : 'not ' - }render the organization users view with loading placeholder`, () => { - expect(findOrganizationUsersLoading().exists()).toBe(loading); + it(`renders OrganizationUsersView with loading prop set to ${loading}`, () => { + expect(findOrganizationUsersView().props('loading')).toBe(loading); }); - it(`renders the organization users view with ${ - userData.length ? 'correct' : 'empty' - } users array raw data`, () => { - expect(JSON.parse(findOrganizationUsers().text())).toStrictEqual(userData); + it('renders OrganizationUsersView with correct users prop', () => { + expect(findOrganizationUsersView().props('users')).toStrictEqual(userData); + }); + + it('renders OrganizationUsersView with correct pageInfo prop', () => { + expect(findOrganizationUsersView().props('pageInfo')).toStrictEqual(pageInfo); }); it(`does ${error ? '' : 'not '}render an error message`, () => { @@ -78,4 +86,40 @@ describe('OrganizationsUsersApp', () => { : expect(createAlert).not.toHaveBeenCalled(); }); }); + + describe('Pagination', () => { + const mockResolver = successfulResolver(MOCK_USERS, MOCK_PAGE_INFO); + + beforeEach(async () => { + createComponent(mockResolver); + await waitForPromises(); + mockResolver.mockClear(); + }); + + it('handleNextPage calls organizationUsersQuery with correct pagination data', async () => { + findOrganizationUsersView().vm.$emit('next'); + await waitForPromises(); + + expect(mockResolver).toHaveBeenCalledWith({ + id: MOCK_ORGANIZATION_GID, + before: '', + after: MOCK_PAGE_INFO.endCursor, + first: ORGANIZATION_USERS_PER_PAGE, + last: null, + }); + }); + + it('handlePrevPage calls organizationUsersQuery with correct pagination data', async () => { + findOrganizationUsersView().vm.$emit('prev'); + await waitForPromises(); + + expect(mockResolver).toHaveBeenCalledWith({ + id: MOCK_ORGANIZATION_GID, + before: MOCK_PAGE_INFO.startCursor, + after: '', + first: ORGANIZATION_USERS_PER_PAGE, + last: null, + }); + }); + }); }); diff --git a/spec/frontend/organizations/users/components/users_view_spec.js b/spec/frontend/organizations/users/components/users_view_spec.js new file mode 100644 index 00000000000..d665c60d425 --- /dev/null +++ b/spec/frontend/organizations/users/components/users_view_spec.js @@ -0,0 +1,68 @@ +import { GlLoadingIcon, GlKeysetPagination } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import UsersView from '~/organizations/users/components/users_view.vue'; +import UsersTable from '~/vue_shared/components/users_table/users_table.vue'; +import { MOCK_PATHS, MOCK_USERS_FORMATTED, MOCK_PAGE_INFO } from '../mock_data'; + +describe('UsersView', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(UsersView, { + propsData: { + loading: false, + users: MOCK_USERS_FORMATTED, + pageInfo: MOCK_PAGE_INFO, + ...props, + }, + provide: { + paths: MOCK_PATHS, + }, + }); + }; + + const findGlLoading = () => wrapper.findComponent(GlLoadingIcon); + const findUsersTable = () => wrapper.findComponent(UsersTable); + const findGlKeysetPagination = () => wrapper.findComponent(GlKeysetPagination); + + describe.each` + description | loading | usersData + ${'when loading'} | ${true} | ${[]} + ${'when not loading and has users'} | ${false} | ${MOCK_USERS_FORMATTED} + ${'when not loading and has no users'} | ${false} | ${[]} + `('$description', ({ loading, usersData }) => { + beforeEach(() => { + createComponent({ loading, users: usersData }); + }); + + it(`does ${loading ? '' : 'not '}render loading icon`, () => { + expect(findGlLoading().exists()).toBe(loading); + }); + + it(`does ${!loading ? '' : 'not '}render users table`, () => { + expect(findUsersTable().exists()).toBe(!loading); + }); + + it(`does ${!loading ? '' : 'not '}render pagination`, () => { + expect(findGlKeysetPagination().exists()).toBe(Boolean(!loading)); + }); + }); + + describe('Pagination', () => { + beforeEach(() => { + createComponent(); + }); + + it('@next event forwards up to the parent component', () => { + findGlKeysetPagination().vm.$emit('next'); + + expect(wrapper.emitted('next')).toHaveLength(1); + }); + + it('@prev event forwards up to the parent component', () => { + findGlKeysetPagination().vm.$emit('prev'); + + expect(wrapper.emitted('prev')).toHaveLength(1); + }); + }); +}); diff --git a/spec/frontend/organizations/users/mock_data.js b/spec/frontend/organizations/users/mock_data.js index 4f159c70c2c..16b3ec3bbcb 100644 --- a/spec/frontend/organizations/users/mock_data.js +++ b/spec/frontend/organizations/users/mock_data.js @@ -1,15 +1,31 @@ +const createUser = (id) => { + return { + id: `gid://gitlab/User/${id}`, + username: `test_user_${id}`, + avatarUrl: `/path/test_user_${id}`, + name: `Test User ${id}`, + publicEmail: `test_user_${id}@gitlab.com`, + createdAt: Date.now(), + lastActivityOn: Date.now(), + }; +}; + export const MOCK_ORGANIZATION_GID = 'gid://gitlab/Organizations::Organization/1'; +export const MOCK_PATHS = { + adminUser: '/admin/users/:id', +}; + export const MOCK_USERS = [ { badges: [], id: 'gid://gitlab/Organizations::OrganizationUser/3', - user: { id: 'gid://gitlab/User/3' }, + user: createUser(3), }, { badges: [], id: 'gid://gitlab/Organizations::OrganizationUser/2', - user: { id: 'gid://gitlab/User/2' }, + user: createUser(2), }, { badges: [ @@ -17,6 +33,18 @@ export const MOCK_USERS = [ { text: "It's you!", variant: 'muted' }, ], id: 'gid://gitlab/Organizations::OrganizationUser/1', - user: { id: 'gid://gitlab/User/1' }, + user: createUser(1), }, ]; + +export const MOCK_USERS_FORMATTED = MOCK_USERS.map(({ badges, user }) => { + return { ...user, badges, email: user.publicEmail }; +}); + +export const MOCK_PAGE_INFO = { + startCursor: 'aaaa', + endCursor: 'bbbb', + hasNextPage: true, + hasPreviousPage: true, + __typename: 'PageInfo', +}; |