diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-06-20 11:10:13 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-06-20 11:10:13 +0000 |
commit | 0ea3fcec397b69815975647f5e2aa5fe944a8486 (patch) | |
tree | 7979381b89d26011bcf9bdc989a40fcc2f1ed4ff /spec/frontend/access_tokens/components | |
parent | 72123183a20411a36d607d70b12d57c484394c8e (diff) | |
download | gitlab-ce-0ea3fcec397b69815975647f5e2aa5fe944a8486.tar.gz |
Add latest changes from gitlab-org/gitlab@15-1-stable-eev15.1.0-rc42
Diffstat (limited to 'spec/frontend/access_tokens/components')
3 files changed, 436 insertions, 7 deletions
diff --git a/spec/frontend/access_tokens/components/access_token_table_app_spec.js b/spec/frontend/access_tokens/components/access_token_table_app_spec.js new file mode 100644 index 00000000000..b45abe418e4 --- /dev/null +++ b/spec/frontend/access_tokens/components/access_token_table_app_spec.js @@ -0,0 +1,241 @@ +import { GlPagination, GlTable } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import AccessTokenTableApp from '~/access_tokens/components/access_token_table_app.vue'; +import { EVENT_SUCCESS, PAGE_SIZE } from '~/access_tokens/components/constants'; +import { __, s__, sprintf } from '~/locale'; +import DomElementListener from '~/vue_shared/components/dom_element_listener.vue'; + +describe('~/access_tokens/components/access_token_table_app', () => { + let wrapper; + + const accessTokenType = 'personal access token'; + const accessTokenTypePlural = 'personal access tokens'; + const initialActiveAccessTokens = []; + const noActiveTokensMessage = 'This user has no active personal access tokens.'; + const showRole = false; + + const defaultActiveAccessTokens = [ + { + name: 'a', + scopes: ['api'], + created_at: '2021-05-01T00:00:00.000Z', + last_used_at: null, + expired: false, + expires_soon: true, + expires_at: null, + revoked: false, + revoke_path: '/-/profile/personal_access_tokens/1/revoke', + role: 'Maintainer', + }, + { + name: 'b', + scopes: ['api', 'sudo'], + created_at: '2022-04-21T00:00:00.000Z', + last_used_at: '2022-04-21T00:00:00.000Z', + expired: true, + expires_soon: false, + expires_at: new Date().toISOString(), + revoked: false, + revoke_path: '/-/profile/personal_access_tokens/2/revoke', + role: 'Maintainer', + }, + ]; + + const createComponent = (props = {}) => { + wrapper = mount(AccessTokenTableApp, { + provide: { + accessTokenType, + accessTokenTypePlural, + initialActiveAccessTokens, + noActiveTokensMessage, + showRole, + ...props, + }, + }); + }; + + const triggerSuccess = async (activeAccessTokens = defaultActiveAccessTokens) => { + wrapper + .findComponent(DomElementListener) + .vm.$emit(EVENT_SUCCESS, { detail: [{ active_access_tokens: activeAccessTokens }] }); + await nextTick(); + }; + + const findTable = () => wrapper.findComponent(GlTable); + const findHeaders = () => findTable().findAll('th > :first-child'); + const findCells = () => findTable().findAll('td'); + const findPagination = () => wrapper.findComponent(GlPagination); + + afterEach(() => { + wrapper?.destroy(); + }); + + it('should render the `GlTable` with default empty message', () => { + createComponent(); + + const cells = findCells(); + expect(cells).toHaveLength(1); + expect(cells.at(0).text()).toBe( + sprintf(__('This user has no active %{accessTokenTypePlural}.'), { accessTokenTypePlural }), + ); + }); + + it('should render the `GlTable` with custom empty message', () => { + const noTokensMessage = 'This group has no active access tokens.'; + createComponent({ noActiveTokensMessage: noTokensMessage }); + + const cells = findCells(); + expect(cells).toHaveLength(1); + expect(cells.at(0).text()).toBe(noTokensMessage); + }); + + it('should render an h5 element', () => { + createComponent(); + + expect(wrapper.find('h5').text()).toBe( + sprintf(__('Active %{accessTokenTypePlural} (%{totalAccessTokens})'), { + accessTokenTypePlural, + totalAccessTokens: initialActiveAccessTokens.length, + }), + ); + }); + + it('should render the `GlTable` component with default 6 column headers', () => { + createComponent(); + + const headers = findHeaders(); + expect(headers).toHaveLength(6); + [ + __('Token name'), + __('Scopes'), + s__('AccessTokens|Created'), + __('Last Used'), + __('Expires'), + __('Action'), + ].forEach((text, index) => { + expect(headers.at(index).text()).toBe(text); + }); + }); + + it('should render the `GlTable` component with 7 headers', () => { + createComponent({ showRole: true }); + + const headers = findHeaders(); + expect(headers).toHaveLength(7); + [ + __('Token name'), + __('Scopes'), + s__('AccessTokens|Created'), + __('Last Used'), + __('Expires'), + __('Role'), + __('Action'), + ].forEach((text, index) => { + expect(headers.at(index).text()).toBe(text); + }); + }); + + it('`Last Used` header should contain a link and an assistive message', () => { + createComponent(); + + const headers = wrapper.findAll('th'); + const lastUsed = headers.at(3); + const anchor = lastUsed.find('a'); + const assistiveElement = lastUsed.find('.gl-sr-only'); + expect(anchor.exists()).toBe(true); + expect(anchor.attributes('href')).toBe( + '/help/user/profile/personal_access_tokens.md#view-the-last-time-a-token-was-used', + ); + expect(assistiveElement.text()).toBe(s__('AccessTokens|The last time a token was used')); + }); + + it('updates the table after a success AJAX event', async () => { + createComponent({ showRole: true }); + await triggerSuccess(); + + const cells = findCells(); + expect(cells).toHaveLength(14); + + // First row + expect(cells.at(0).text()).toBe('a'); + expect(cells.at(1).text()).toBe('api'); + expect(cells.at(2).text()).not.toBe(__('Never')); + expect(cells.at(3).text()).toBe(__('Never')); + expect(cells.at(4).text()).toBe(__('Never')); + expect(cells.at(5).text()).toBe('Maintainer'); + let anchor = cells.at(6).find('a'); + expect(anchor.attributes()).toMatchObject({ + 'aria-label': __('Revoke'), + 'data-qa-selector': __('revoke_button'), + href: '/-/profile/personal_access_tokens/1/revoke', + 'data-confirm': sprintf( + __( + 'Are you sure you want to revoke this %{accessTokenType}? This action cannot be undone.', + ), + { accessTokenType }, + ), + }); + + expect(anchor.classes()).toContain('btn-danger-secondary'); + + // Second row + expect(cells.at(7).text()).toBe('b'); + expect(cells.at(8).text()).toBe('api, sudo'); + expect(cells.at(9).text()).not.toBe(__('Never')); + expect(cells.at(10).text()).not.toBe(__('Never')); + expect(cells.at(11).text()).toBe(__('Expired')); + expect(cells.at(12).text()).toBe('Maintainer'); + anchor = cells.at(13).find('a'); + expect(anchor.attributes('href')).toBe('/-/profile/personal_access_tokens/2/revoke'); + expect(anchor.classes()).toEqual(['btn', 'btn-danger', 'btn-md', 'gl-button', 'btn-icon']); + }); + + it('sorts rows alphabetically', async () => { + createComponent({ showRole: true }); + await triggerSuccess(); + + const cells = findCells(); + + // First and second rows + expect(cells.at(0).text()).toBe('a'); + expect(cells.at(7).text()).toBe('b'); + + const headers = findHeaders(); + await headers.at(0).trigger('click'); + await headers.at(0).trigger('click'); + + // First and second rows have swapped + expect(cells.at(0).text()).toBe('b'); + expect(cells.at(7).text()).toBe('a'); + }); + + it('sorts rows by date', async () => { + createComponent({ showRole: true }); + await triggerSuccess(); + + const cells = findCells(); + + // First and second rows + expect(cells.at(3).text()).toBe('Never'); + expect(cells.at(10).text()).not.toBe('Never'); + + const headers = findHeaders(); + await headers.at(3).trigger('click'); + + // First and second rows have swapped + expect(cells.at(3).text()).not.toBe('Never'); + expect(cells.at(10).text()).toBe('Never'); + }); + + it('should show the pagination component when needed', async () => { + createComponent(); + expect(findPagination().exists()).toBe(false); + + await triggerSuccess(Array(PAGE_SIZE).fill(defaultActiveAccessTokens[0])); + expect(findPagination().exists()).toBe(false); + + await triggerSuccess(Array(PAGE_SIZE + 1).fill(defaultActiveAccessTokens[0])); + expect(findPagination().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/access_tokens/components/expires_at_field_spec.js b/spec/frontend/access_tokens/components/expires_at_field_spec.js index fc8edcb573f..cb899d10ba7 100644 --- a/spec/frontend/access_tokens/components/expires_at_field_spec.js +++ b/spec/frontend/access_tokens/components/expires_at_field_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import { GlDatepicker } from '@gitlab/ui'; import ExpiresAtField from '~/access_tokens/components/expires_at_field.vue'; describe('~/access_tokens/components/expires_at_field', () => { @@ -12,22 +13,40 @@ describe('~/access_tokens/components/expires_at_field', () => { }, }; - const createComponent = (propsData = defaultPropsData) => { + const findDatepicker = () => wrapper.findComponent(GlDatepicker); + + const createComponent = (props = {}) => { wrapper = shallowMount(ExpiresAtField, { - propsData, + propsData: { + ...defaultPropsData, + ...props, + }, }); }; - beforeEach(() => { - createComponent(); - }); - afterEach(() => { wrapper.destroy(); - wrapper = null; }); it('should render datepicker with input info', () => { + createComponent(); + expect(wrapper.element).toMatchSnapshot(); }); + + it('should set the date pickers minimum date', () => { + const minDate = new Date('1970-01-01'); + + createComponent({ minDate }); + + expect(findDatepicker().props('minDate')).toStrictEqual(minDate); + }); + + it('should set the date pickers maximum date', () => { + const maxDate = new Date('1970-01-01'); + + createComponent({ maxDate }); + + expect(findDatepicker().props('maxDate')).toStrictEqual(maxDate); + }); }); diff --git a/spec/frontend/access_tokens/components/new_access_token_app_spec.js b/spec/frontend/access_tokens/components/new_access_token_app_spec.js new file mode 100644 index 00000000000..9ccadbebf7a --- /dev/null +++ b/spec/frontend/access_tokens/components/new_access_token_app_spec.js @@ -0,0 +1,169 @@ +import { GlAlert } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import NewAccessTokenApp from '~/access_tokens/components/new_access_token_app.vue'; +import { EVENT_ERROR, EVENT_SUCCESS, FORM_SELECTOR } from '~/access_tokens/components/constants'; +import { createAlert, VARIANT_INFO } from '~/flash'; +import { __, sprintf } from '~/locale'; +import DomElementListener from '~/vue_shared/components/dom_element_listener.vue'; +import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue'; + +jest.mock('~/flash'); + +describe('~/access_tokens/components/new_access_token_app', () => { + let wrapper; + + const accessTokenType = 'personal access token'; + + const createComponent = (provide = { accessTokenType }) => { + wrapper = mountExtended(NewAccessTokenApp, { + provide, + }); + }; + + const triggerSuccess = async (newToken = 'new token') => { + wrapper.find(DomElementListener).vm.$emit(EVENT_SUCCESS, { detail: [{ new_token: newToken }] }); + await nextTick(); + }; + + const triggerError = async (errors = ['1', '2']) => { + wrapper.find(DomElementListener).vm.$emit(EVENT_ERROR, { detail: [{ errors }] }); + await nextTick(); + }; + + beforeEach(() => { + // NewAccessTokenApp observes a form element + setHTMLFixture(`<form id="${FORM_SELECTOR.slice(1)}"><input type="submit"/></form>`); + + createComponent(); + }); + + afterEach(() => { + resetHTMLFixture(); + wrapper.destroy(); + createAlert.mockClear(); + }); + + it('should render nothing', () => { + expect(wrapper.findComponent(InputCopyToggleVisibility).exists()).toBe(false); + expect(wrapper.findComponent(GlAlert).exists()).toBe(false); + }); + + describe('on success', () => { + it('should render `InputCopyToggleVisibility` component', async () => { + const newToken = '12345'; + await triggerSuccess(newToken); + + expect(wrapper.findComponent(GlAlert).exists()).toBe(false); + + const InputCopyToggleVisibilityComponent = wrapper.findComponent(InputCopyToggleVisibility); + expect(InputCopyToggleVisibilityComponent.props('value')).toBe(newToken); + expect(InputCopyToggleVisibilityComponent.props('copyButtonTitle')).toBe( + sprintf(__('Copy %{accessTokenType}'), { accessTokenType }), + ); + expect(InputCopyToggleVisibilityComponent.props('initialVisibility')).toBe(true); + expect(InputCopyToggleVisibilityComponent.attributes('label')).toBe( + sprintf(__('Your new %{accessTokenType}'), { accessTokenType }), + ); + }); + + it('input field should contain QA-related selectors', async () => { + const newToken = '12345'; + await triggerSuccess(newToken); + + expect(wrapper.findComponent(GlAlert).exists()).toBe(false); + + const inputAttributes = wrapper + .findByLabelText(sprintf(__('Your new %{accessTokenType}'), { accessTokenType })) + .attributes(); + expect(inputAttributes).toMatchObject({ + class: expect.stringContaining('qa-created-access-token'), + 'data-qa-selector': 'created_access_token_field', + }); + }); + + it('should render an info alert', async () => { + await triggerSuccess(); + + expect(createAlert).toHaveBeenCalledWith({ + message: sprintf(__('Your new %{accessTokenType} has been created.'), { + accessTokenType, + }), + variant: VARIANT_INFO, + }); + }); + + it('should reset the form', async () => { + const resetSpy = jest.spyOn(wrapper.vm.form, 'reset'); + + await triggerSuccess(); + + expect(resetSpy).toHaveBeenCalled(); + }); + }); + + describe('on error', () => { + it('should render an error alert', async () => { + await triggerError(['first', 'second']); + + expect(wrapper.findComponent(InputCopyToggleVisibility).exists()).toBe(false); + + let GlAlertComponent = wrapper.findComponent(GlAlert); + expect(GlAlertComponent.props('title')).toBe(__('The form contains the following errors:')); + expect(GlAlertComponent.props('variant')).toBe('danger'); + let itemEls = wrapper.findAll('li'); + expect(itemEls).toHaveLength(2); + expect(itemEls.at(0).text()).toBe('first'); + expect(itemEls.at(1).text()).toBe('second'); + + await triggerError(['one']); + + GlAlertComponent = wrapper.findComponent(GlAlert); + expect(GlAlertComponent.props('title')).toBe(__('The form contains the following error:')); + expect(GlAlertComponent.props('variant')).toBe('danger'); + itemEls = wrapper.findAll('li'); + expect(itemEls).toHaveLength(1); + }); + + it('the error alert should be dismissible', async () => { + await triggerError(); + + const GlAlertComponent = wrapper.findComponent(GlAlert); + expect(GlAlertComponent.exists()).toBe(true); + + GlAlertComponent.vm.$emit('dismiss'); + await nextTick(); + + expect(wrapper.findComponent(GlAlert).exists()).toBe(false); + }); + }); + + describe('before error or success', () => { + it('should scroll to the container', async () => { + const containerEl = wrapper.vm.$refs.container; + const scrollIntoViewSpy = jest.spyOn(containerEl, 'scrollIntoView'); + + await triggerSuccess(); + + expect(scrollIntoViewSpy).toHaveBeenCalledWith(false); + expect(scrollIntoViewSpy).toHaveBeenCalledTimes(1); + + await triggerError(); + + expect(scrollIntoViewSpy).toHaveBeenCalledWith(false); + expect(scrollIntoViewSpy).toHaveBeenCalledTimes(2); + }); + + it('should dismiss the info alert', async () => { + const dismissSpy = jest.fn(); + createAlert.mockReturnValue({ dismiss: dismissSpy }); + + await triggerSuccess(); + await triggerError(); + + expect(dismissSpy).toHaveBeenCalled(); + expect(dismissSpy).toHaveBeenCalledTimes(1); + }); + }); +}); |