diff options
Diffstat (limited to 'spec/frontend/vue_shared')
29 files changed, 1014 insertions, 188 deletions
diff --git a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap index dfd114a2d1c..ec4a81054db 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap @@ -39,6 +39,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` tag="div" > <gl-button-stub + buttontextclasses="" category="primary" class="d-inline-flex" data-clipboard-text="ssh://foo.bar" @@ -80,6 +81,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` tag="div" > <gl-button-stub + buttontextclasses="" category="primary" class="d-inline-flex" data-clipboard-text="http://foo.bar" diff --git a/spec/frontend/vue_shared/components/alert_detail_table_spec.js b/spec/frontend/vue_shared/components/alert_details_table_spec.js index 9c38ccad8a7..dbdb7705d3c 100644 --- a/spec/frontend/vue_shared/components/alert_detail_table_spec.js +++ b/spec/frontend/vue_shared/components/alert_details_table_spec.js @@ -1,5 +1,5 @@ +import { GlLoadingIcon, GlTable } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import { GlTable, GlLoadingIcon } from '@gitlab/ui'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; const mockAlert = { @@ -14,6 +14,7 @@ const mockAlert = { assignees: { nodes: [] }, notes: { nodes: [] }, todos: { nodes: [] }, + __typename: 'AlertManagementAlert', }; describe('AlertDetails', () => { @@ -35,6 +36,8 @@ describe('AlertDetails', () => { }); const findTableComponent = () => wrapper.find(GlTable); + const findTableKeys = () => findTableComponent().findAll('tbody td:first-child'); + const findTableField = (fields, fieldName) => fields.filter(row => row.text() === fieldName); describe('Alert details', () => { describe('empty state', () => { @@ -58,8 +61,10 @@ describe('AlertDetails', () => { }); describe('with table data', () => { + const environment = 'myEnvironment'; + const environmentUrl = 'fake/url'; beforeEach(() => { - mountComponent(); + mountComponent({ alert: { ...mockAlert, environment, environmentUrl } }); }); it('renders a table', () => { @@ -69,6 +74,26 @@ describe('AlertDetails', () => { it('renders a cell based on alert data', () => { expect(findTableComponent().text()).toContain('SyntaxError: Invalid or unexpected token'); }); + + it('should show allowed alert fields', () => { + const fields = findTableKeys(); + + expect(findTableField(fields, 'Iid').exists()).toBe(true); + expect(findTableField(fields, 'Title').exists()).toBe(true); + expect(findTableField(fields, 'Severity').exists()).toBe(true); + expect(findTableField(fields, 'Status').exists()).toBe(true); + expect(findTableField(fields, 'Environment').exists()).toBe(true); + }); + + it('should not show disallowed alert fields', () => { + const fields = findTableKeys(); + + expect(findTableField(fields, 'Typename').exists()).toBe(false); + expect(findTableField(fields, 'Todos').exists()).toBe(false); + expect(findTableField(fields, 'Notes').exists()).toBe(false); + expect(findTableField(fields, 'Assignees').exists()).toBe(false); + expect(findTableField(fields, 'EnvironmentUrl').exists()).toBe(false); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/clipboard_button_spec.js b/spec/frontend/vue_shared/components/clipboard_button_spec.js index 7f0b7ba8cf8..51a2653befc 100644 --- a/spec/frontend/vue_shared/components/clipboard_button_spec.js +++ b/spec/frontend/vue_shared/components/clipboard_button_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDeprecatedButton, GlIcon } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; describe('clipboard button', () => { @@ -26,9 +26,8 @@ describe('clipboard button', () => { }); it('renders a button for clipboard', () => { - expect(wrapper.find(GlDeprecatedButton).exists()).toBe(true); + expect(wrapper.find(GlButton).exists()).toBe(true); expect(wrapper.attributes('data-clipboard-text')).toBe('copy me'); - expect(wrapper.find(GlIcon).props('name')).toBe('copy-to-clipboard'); }); it('should have a tooltip with default values', () => { diff --git a/spec/frontend/vue_shared/components/confirm_modal_spec.js b/spec/frontend/vue_shared/components/confirm_modal_spec.js index 5d92af64de0..8456ca9d125 100644 --- a/spec/frontend/vue_shared/components/confirm_modal_spec.js +++ b/spec/frontend/vue_shared/components/confirm_modal_spec.js @@ -86,6 +86,22 @@ describe('vue_shared/components/confirm_modal', () => { expect(findForm().element.submit).not.toHaveBeenCalled(); }); + describe('with handleSubmit prop', () => { + const handleSubmit = jest.fn(); + beforeEach(() => { + createComponent({ handleSubmit }); + findModal().vm.$emit('primary'); + }); + + it('will call handleSubmit', () => { + expect(handleSubmit).toHaveBeenCalled(); + }); + + it('does not submit the form', () => { + expect(findForm().element.submit).not.toHaveBeenCalled(); + }); + }); + describe('when modal submitted', () => { beforeEach(() => { findModal().vm.$emit('primary'); diff --git a/spec/frontend/vue_shared/components/local_storage_sync_spec.js b/spec/frontend/vue_shared/components/local_storage_sync_spec.js index 5470171a21e..3ff4c0917f2 100644 --- a/spec/frontend/vue_shared/components/local_storage_sync_spec.js +++ b/spec/frontend/vue_shared/components/local_storage_sync_spec.js @@ -12,7 +12,9 @@ describe('Local Storage Sync', () => { }; afterEach(() => { - wrapper.destroy(); + if (wrapper) { + wrapper.destroy(); + } wrapper = null; localStorage.clear(); }); @@ -45,23 +47,23 @@ describe('Local Storage Sync', () => { expect(wrapper.emitted('input')).toBeFalsy(); }); - it('saves updated value to localStorage', () => { - createComponent({ - props: { - storageKey, - value: 'ascending', - }, - }); - - const newValue = 'descending'; - wrapper.setProps({ - value: newValue, - }); - - return wrapper.vm.$nextTick().then(() => { - expect(localStorage.getItem(storageKey)).toBe(newValue); - }); - }); + it.each('foo', 3, true, ['foo', 'bar'], { foo: 'bar' })( + 'saves updated value to localStorage', + newValue => { + createComponent({ + props: { + storageKey, + value: 'initial', + }, + }); + + wrapper.setProps({ value: newValue }); + + return wrapper.vm.$nextTick().then(() => { + expect(localStorage.getItem(storageKey)).toBe(String(newValue)); + }); + }, + ); it('does not save default value', () => { const value = 'ascending'; @@ -125,4 +127,88 @@ describe('Local Storage Sync', () => { }); }); }); + + describe('with "asJson" prop set to "true"', () => { + const storageKey = 'testStorageKey'; + + describe.each` + value | serializedValue + ${null} | ${'null'} + ${''} | ${'""'} + ${true} | ${'true'} + ${false} | ${'false'} + ${42} | ${'42'} + ${'42'} | ${'"42"'} + ${'{ foo: '} | ${'"{ foo: "'} + ${['test']} | ${'["test"]'} + ${{ foo: 'bar' }} | ${'{"foo":"bar"}'} + `('given $value', ({ value, serializedValue }) => { + describe('is a new value', () => { + beforeEach(() => { + createComponent({ + props: { + storageKey, + value: 'initial', + asJson: true, + }, + }); + + wrapper.setProps({ value }); + + return wrapper.vm.$nextTick(); + }); + + it('serializes the value correctly to localStorage', () => { + expect(localStorage.getItem(storageKey)).toBe(serializedValue); + }); + }); + + describe('is already stored', () => { + beforeEach(() => { + localStorage.setItem(storageKey, serializedValue); + + createComponent({ + props: { + storageKey, + value: 'initial', + asJson: true, + }, + }); + }); + + it('emits an input event with the deserialized value', () => { + expect(wrapper.emitted('input')).toEqual([[value]]); + }); + }); + }); + + describe('with bad JSON in storage', () => { + const badJSON = '{ badJSON'; + + beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(); + localStorage.setItem(storageKey, badJSON); + + createComponent({ + props: { + storageKey, + value: 'initial', + asJson: true, + }, + }); + }); + + it('should console warn', () => { + // eslint-disable-next-line no-console + expect(console.warn).toHaveBeenCalledWith( + `[gitlab] Failed to deserialize value from localStorage (key=${storageKey})`, + badJSON, + ); + }); + + it('should not emit an input event', () => { + expect(wrapper.emitted('input')).toBeUndefined(); + }); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap b/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap index cdd7a3ccaf0..b8a9143bc79 100644 --- a/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap +++ b/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap @@ -10,6 +10,7 @@ exports[`Suggestion Diff component matches snapshot 1`] = ` helppagepath="path_to_docs" isapplyingbatch="true" isbatched="true" + suggestionscount="0" /> <table diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js index 3da0a35f05a..f1ead33ec68 100644 --- a/spec/frontend/vue_shared/components/markdown/field_spec.js +++ b/spec/frontend/vue_shared/components/markdown/field_spec.js @@ -7,6 +7,7 @@ import axios from '~/lib/utils/axios_utils'; const markdownPreviewPath = `${TEST_HOST}/preview`; const markdownDocsPath = `${TEST_HOST}/docs`; +const textareaValue = 'testing\n123'; function assertMarkdownTabs(isWrite, writeLink, previewLink, wrapper) { expect(writeLink.element.parentNode.classList.contains('active')).toBe(isWrite); @@ -20,23 +21,11 @@ function createComponent() { markdownDocsPath, markdownPreviewPath, isSubmitting: false, + textareaValue, }, slots: { - textarea: '<textarea>testing\n123</textarea>', + textarea: `<textarea>${textareaValue}</textarea>`, }, - template: ` - <field-component - markdown-preview-path="${markdownPreviewPath}" - markdown-docs-path="${markdownDocsPath}" - :isSubmitting="false" - > - <textarea - slot="textarea" - v-model="text"> - <slot>this is a test</slot> - </textarea> - </field-component> - `, }); return wrapper; } diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js index a521668b15c..b19e74b5b11 100644 --- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js @@ -57,7 +57,9 @@ describe('Suggestion Diff component', () => { }); it('renders apply suggestion and add to batch buttons', () => { - createComponent(); + createComponent({ + suggestionsCount: 2, + }); const applyBtn = findApplyButton(); const addToBatchBtn = findAddToBatchButton(); @@ -104,7 +106,9 @@ describe('Suggestion Diff component', () => { describe('when add to batch is clicked', () => { it('emits addToBatch', () => { - createComponent(); + createComponent({ + suggestionsCount: 2, + }); findAddToBatchButton().vm.$emit('click'); diff --git a/spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js b/spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js new file mode 100644 index 00000000000..d6f5773295c --- /dev/null +++ b/spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js @@ -0,0 +1,46 @@ +import { mount, createWrapper } from '@vue/test-utils'; +import { getByText as getByTextHelper } from '@testing-library/dom'; +import { GlAvatarLink } from '@gitlab/ui'; +import { group as member } from '../mock_data'; +import GroupAvatar from '~/vue_shared/components/members/avatars/group_avatar.vue'; + +describe('MemberList', () => { + let wrapper; + + const group = member.sharedWithGroup; + + const createComponent = (propsData = {}) => { + wrapper = mount(GroupAvatar, { + propsData: { + member, + ...propsData, + }, + }); + }; + + const getByText = (text, options) => + createWrapper(getByTextHelper(wrapper.element, text, options)); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders link to group', () => { + const link = wrapper.find(GlAvatarLink); + + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe(group.webUrl); + }); + + it("renders group's full name", () => { + expect(getByText(group.fullName).exists()).toBe(true); + }); + + it("renders group's avatar", () => { + expect(wrapper.find('img').attributes('src')).toBe(group.avatarUrl); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js b/spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js new file mode 100644 index 00000000000..7948da7eb40 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js @@ -0,0 +1,38 @@ +import { mount, createWrapper } from '@vue/test-utils'; +import { getByText as getByTextHelper } from '@testing-library/dom'; +import { invite as member } from '../mock_data'; +import InviteAvatar from '~/vue_shared/components/members/avatars/invite_avatar.vue'; + +describe('MemberList', () => { + let wrapper; + + const { invite } = member; + + const createComponent = (propsData = {}) => { + wrapper = mount(InviteAvatar, { + propsData: { + member, + ...propsData, + }, + }); + }; + + const getByText = (text, options) => + createWrapper(getByTextHelper(wrapper.element, text, options)); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders email as name', () => { + expect(getByText(invite.email).exists()).toBe(true); + }); + + it('renders avatar', () => { + expect(wrapper.find('img').attributes('src')).toBe(invite.avatarUrl); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js b/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js new file mode 100644 index 00000000000..6c0ba8afede --- /dev/null +++ b/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js @@ -0,0 +1,85 @@ +import { mount, createWrapper } from '@vue/test-utils'; +import { within } from '@testing-library/dom'; +import { GlAvatarLink, GlBadge } from '@gitlab/ui'; +import { member as memberMock, orphanedMember } from '../mock_data'; +import UserAvatar from '~/vue_shared/components/members/avatars/user_avatar.vue'; + +describe('UserAvatar', () => { + let wrapper; + + const { user } = memberMock; + + const createComponent = (propsData = {}) => { + wrapper = mount(UserAvatar, { + propsData: { + member: memberMock, + isCurrentUser: false, + ...propsData, + }, + }); + }; + + const getByText = (text, options) => + createWrapper(within(wrapper.element).findByText(text, options)); + + afterEach(() => { + wrapper.destroy(); + }); + + it("renders link to user's profile", () => { + createComponent(); + + const link = wrapper.find(GlAvatarLink); + + expect(link.exists()).toBe(true); + expect(link.attributes()).toMatchObject({ + href: user.webUrl, + 'data-user-id': `${user.id}`, + 'data-username': user.username, + }); + }); + + it("renders user's name", () => { + createComponent(); + + expect(getByText(user.name).exists()).toBe(true); + }); + + it("renders user's username", () => { + createComponent(); + + expect(getByText(`@${user.username}`).exists()).toBe(true); + }); + + it("renders user's avatar", () => { + createComponent(); + + expect(wrapper.find('img').attributes('src')).toBe(user.avatarUrl); + }); + + describe('when user property does not exist', () => { + it('displays an orphaned user', () => { + createComponent({ member: orphanedMember }); + + expect(getByText('Orphaned member').exists()).toBe(true); + }); + }); + + describe('badges', () => { + it.each` + member | badgeText + ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${'Blocked'} + ${{ ...memberMock, user: { ...memberMock.user, twoFactorEnabled: true } }} | ${'2FA'} + `('renders the "$badgeText" badge', ({ member, badgeText }) => { + createComponent({ member }); + + expect(wrapper.find(GlBadge).text()).toBe(badgeText); + }); + + it('renders the "It\'s you" badge when member is current user', () => { + createComponent({ isCurrentUser: true }); + + expect(getByText("It's you").exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/mock_data.js b/spec/frontend/vue_shared/components/members/mock_data.js new file mode 100644 index 00000000000..3195f04f202 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/mock_data.js @@ -0,0 +1,61 @@ +export const member = { + requestedAt: null, + canUpdate: false, + canRemove: false, + canOverride: false, + accessLevel: { integerValue: 50, stringValue: 'Owner' }, + source: { + id: 178, + name: 'Foo Bar', + webUrl: 'https://gitlab.com/groups/foo-bar', + }, + user: { + id: 123, + name: 'Administrator', + username: 'root', + webUrl: 'https://gitlab.com/root', + avatarUrl: 'https://www.gravatar.com/avatar/4816142ef496f956a277bedf1a40607b?s=80&d=identicon', + blocked: false, + twoFactorEnabled: false, + }, + id: 238, + createdAt: '2020-07-17T16:22:46.923Z', + expiresAt: null, + usingLicense: false, + groupSso: false, + groupManagedAccount: false, +}; + +export const group = { + accessLevel: { integerValue: 10, stringValue: 'Guest' }, + sharedWithGroup: { + id: 24, + name: 'Commit451', + avatarUrl: '/uploads/-/system/user/avatar/1/avatar.png?width=40', + fullPath: 'parent-group/commit451', + fullName: 'Parent group / Commit451', + webUrl: 'https://gitlab.com/groups/parent-group/commit451', + }, + id: 3, + createdAt: '2020-08-06T15:31:07.662Z', + expiresAt: null, +}; + +const { user, ...memberNoUser } = member; +export const invite = { + ...memberNoUser, + invite: { + email: 'jewel@hudsonwalter.biz', + avatarUrl: 'https://www.gravatar.com/avatar/cbab7510da7eec2f60f638261b05436d?s=80&d=identicon', + canResend: true, + }, +}; + +export const orphanedMember = memberNoUser; + +export const accessRequest = { + ...member, + requestedAt: '2020-07-17T16:22:46.923Z', +}; + +export const members = [member]; diff --git a/spec/frontend/vue_shared/components/members/table/created_at_spec.js b/spec/frontend/vue_shared/components/members/table/created_at_spec.js new file mode 100644 index 00000000000..cf3821baf44 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/table/created_at_spec.js @@ -0,0 +1,61 @@ +import { mount, createWrapper } from '@vue/test-utils'; +import { within } from '@testing-library/dom'; +import { useFakeDate } from 'helpers/fake_date'; +import CreatedAt from '~/vue_shared/components/members/table/created_at.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +describe('CreatedAt', () => { + // March 15th, 2020 + useFakeDate(2020, 2, 15); + + const date = '2020-03-01T00:00:00.000'; + const dateTimeAgo = '2 weeks ago'; + + let wrapper; + + const createComponent = propsData => { + wrapper = mount(CreatedAt, { + propsData: { + date, + ...propsData, + }, + }); + }; + + const getByText = (text, options) => + createWrapper(within(wrapper.element).getByText(text, options)); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('created at text', () => { + beforeEach(() => { + createComponent(); + }); + + it('displays created at text', () => { + expect(getByText(dateTimeAgo).exists()).toBe(true); + }); + + it('uses `TimeAgoTooltip` component to display tooltip', () => { + expect(wrapper.find(TimeAgoTooltip).exists()).toBe(true); + }); + }); + + describe('when `createdBy` prop is provided', () => { + it('displays a link to the user that created the member', () => { + createComponent({ + createdBy: { + name: 'Administrator', + webUrl: 'https://gitlab.com/root', + }, + }); + + const link = getByText('Administrator'); + + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe('https://gitlab.com/root'); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/table/expires_at_spec.js b/spec/frontend/vue_shared/components/members/table/expires_at_spec.js new file mode 100644 index 00000000000..95ae251b0fd --- /dev/null +++ b/spec/frontend/vue_shared/components/members/table/expires_at_spec.js @@ -0,0 +1,86 @@ +import { mount, createWrapper } from '@vue/test-utils'; +import { within } from '@testing-library/dom'; +import { useFakeDate } from 'helpers/fake_date'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import ExpiresAt from '~/vue_shared/components/members/table/expires_at.vue'; + +describe('ExpiresAt', () => { + // March 15th, 2020 + useFakeDate(2020, 2, 15); + + let wrapper; + + const createComponent = propsData => { + wrapper = mount(ExpiresAt, { + propsData, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + const getByText = (text, options) => + createWrapper(within(wrapper.element).getByText(text, options)); + + const getTooltipDirective = elementWrapper => getBinding(elementWrapper.element, 'gl-tooltip'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when no expiration date is set', () => { + it('displays "No expiration set"', () => { + createComponent({ date: null }); + + expect(getByText('No expiration set').exists()).toBe(true); + }); + }); + + describe('when expiration date is in the past', () => { + let expiredText; + + beforeEach(() => { + createComponent({ date: '2019-03-15T00:00:00.000' }); + + expiredText = getByText('Expired'); + }); + + it('displays "Expired"', () => { + expect(expiredText.exists()).toBe(true); + expect(expiredText.classes()).toContain('gl-text-red-500'); + }); + + it('displays tooltip with formatted date', () => { + const tooltipDirective = getTooltipDirective(expiredText); + + expect(tooltipDirective).not.toBeUndefined(); + expect(expiredText.attributes('title')).toBe('Mar 15, 2019 12:00am GMT+0000'); + }); + }); + + describe('when expiration date is in the future', () => { + it.each` + date | expected | warningColor + ${'2020-03-23T00:00:00.000'} | ${'in 8 days'} | ${false} + ${'2020-03-20T00:00:00.000'} | ${'in 5 days'} | ${true} + ${'2020-03-16T00:00:00.000'} | ${'in 1 day'} | ${true} + ${'2020-03-15T05:00:00.000'} | ${'in about 5 hours'} | ${true} + ${'2020-03-15T01:00:00.000'} | ${'in about 1 hour'} | ${true} + ${'2020-03-15T00:30:00.000'} | ${'in 30 minutes'} | ${true} + ${'2020-03-15T00:01:15.000'} | ${'in 1 minute'} | ${true} + ${'2020-03-15T00:00:15.000'} | ${'in less than a minute'} | ${true} + `('displays "$expected"', ({ date, expected, warningColor }) => { + createComponent({ date }); + + const expiredText = getByText(expected); + + expect(expiredText.exists()).toBe(true); + + if (warningColor) { + expect(expiredText.classes()).toContain('gl-text-orange-500'); + } else { + expect(expiredText.classes()).not.toContain('gl-text-orange-500'); + } + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/table/member_avatar_spec.js b/spec/frontend/vue_shared/components/members/table/member_avatar_spec.js new file mode 100644 index 00000000000..a171dd830c1 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/table/member_avatar_spec.js @@ -0,0 +1,39 @@ +import { shallowMount } from '@vue/test-utils'; +import { MEMBER_TYPES } from '~/vue_shared/components/members/constants'; +import { member as memberMock, group, invite, accessRequest } from '../mock_data'; +import MemberAvatar from '~/vue_shared/components/members/table/member_avatar.vue'; +import UserAvatar from '~/vue_shared/components/members/avatars/user_avatar.vue'; +import GroupAvatar from '~/vue_shared/components/members/avatars/group_avatar.vue'; +import InviteAvatar from '~/vue_shared/components/members/avatars/invite_avatar.vue'; + +describe('MemberList', () => { + let wrapper; + + const createComponent = propsData => { + wrapper = shallowMount(MemberAvatar, { + propsData: { + isCurrentUser: false, + ...propsData, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + test.each` + memberType | member | expectedComponent | expectedComponentName + ${MEMBER_TYPES.user} | ${memberMock} | ${UserAvatar} | ${'UserAvatar'} + ${MEMBER_TYPES.group} | ${group} | ${GroupAvatar} | ${'GroupAvatar'} + ${MEMBER_TYPES.invite} | ${invite} | ${InviteAvatar} | ${'InviteAvatar'} + ${MEMBER_TYPES.accessRequest} | ${accessRequest} | ${UserAvatar} | ${'UserAvatar'} + `( + 'renders $expectedComponentName when `memberType` is $memberType', + ({ memberType, member, expectedComponent }) => { + createComponent({ memberType, member }); + + expect(wrapper.find(expectedComponent).exists()).toBe(true); + }, + ); +}); diff --git a/spec/frontend/vue_shared/components/members/table/member_source_spec.js b/spec/frontend/vue_shared/components/members/table/member_source_spec.js new file mode 100644 index 00000000000..8b914d76674 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/table/member_source_spec.js @@ -0,0 +1,71 @@ +import { mount, createWrapper } from '@vue/test-utils'; +import { getByText as getByTextHelper } from '@testing-library/dom'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import MemberSource from '~/vue_shared/components/members/table/member_source.vue'; + +describe('MemberSource', () => { + let wrapper; + + const createComponent = propsData => { + wrapper = mount(MemberSource, { + propsData: { + memberSource: { + id: 102, + name: 'Foo bar', + webUrl: 'https://gitlab.com/groups/foo-bar', + }, + ...propsData, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + const getByText = (text, options) => + createWrapper(getByTextHelper(wrapper.element, text, options)); + + const getTooltipDirective = elementWrapper => getBinding(elementWrapper.element, 'gl-tooltip'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('direct member', () => { + it('displays "Direct member"', () => { + createComponent({ + isDirectMember: true, + }); + + expect(getByText('Direct member').exists()).toBe(true); + }); + }); + + describe('inherited member', () => { + let sourceGroupLink; + + beforeEach(() => { + createComponent({ + isDirectMember: false, + }); + + sourceGroupLink = getByText('Foo bar'); + }); + + it('displays a link to source group', () => { + createComponent({ + isDirectMember: false, + }); + + expect(sourceGroupLink.exists()).toBe(true); + expect(sourceGroupLink.attributes('href')).toBe('https://gitlab.com/groups/foo-bar'); + }); + + it('displays tooltip with "Inherited"', () => { + const tooltipDirective = getTooltipDirective(sourceGroupLink); + + expect(tooltipDirective).not.toBeUndefined(); + expect(sourceGroupLink.attributes('title')).toBe('Inherited'); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js b/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js new file mode 100644 index 00000000000..960d9bc164c --- /dev/null +++ b/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js @@ -0,0 +1,130 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { MEMBER_TYPES } from '~/vue_shared/components/members/constants'; +import { member as memberMock, group, invite, accessRequest } from '../mock_data'; +import MembersTableCell from '~/vue_shared/components/members/table/members_table_cell.vue'; + +describe('MemberList', () => { + const WrappedComponent = { + props: { + memberType: { + type: String, + required: true, + }, + isDirectMember: { + type: Boolean, + required: true, + }, + isCurrentUser: { + type: Boolean, + required: true, + }, + }, + render(createElement) { + return createElement('div', this.memberType); + }, + }; + + const localVue = createLocalVue(); + localVue.use(Vuex); + localVue.component('wrapped-component', WrappedComponent); + + const createStore = (state = {}) => { + return new Vuex.Store({ + state: { + sourceId: 1, + currentUserId: 1, + ...state, + }, + }); + }; + + let wrapper; + + const createComponent = (propsData, state = {}) => { + wrapper = mount(MembersTableCell, { + localVue, + propsData, + store: createStore(state), + scopedSlots: { + default: ` + <wrapped-component + :member-type="props.memberType" + :is-direct-member="props.isDirectMember" + :is-current-user="props.isCurrentUser" + /> + `, + }, + }); + }; + + const findWrappedComponent = () => wrapper.find(WrappedComponent); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + test.each` + member | expectedMemberType + ${memberMock} | ${MEMBER_TYPES.user} + ${group} | ${MEMBER_TYPES.group} + ${invite} | ${MEMBER_TYPES.invite} + ${accessRequest} | ${MEMBER_TYPES.accessRequest} + `( + 'sets scoped slot prop `memberType` to $expectedMemberType', + ({ member, expectedMemberType }) => { + createComponent({ member }); + + expect(findWrappedComponent().props('memberType')).toBe(expectedMemberType); + }, + ); + + describe('isDirectMember', () => { + it('returns `true` when member source has same ID as `sourceId`', () => { + createComponent({ + member: { + ...memberMock, + source: { + ...memberMock.source, + id: 1, + }, + }, + }); + + expect(findWrappedComponent().props('isDirectMember')).toBe(true); + }); + + it('returns `false` when member is inherited', () => { + createComponent({ + member: memberMock, + }); + + expect(findWrappedComponent().props('isDirectMember')).toBe(false); + }); + }); + + describe('isCurrentUser', () => { + it('returns `true` when `member.user` has the same ID as `currentUserId`', () => { + createComponent({ + member: { + ...memberMock, + user: { + ...memberMock.user, + id: 1, + }, + }, + }); + + expect(findWrappedComponent().props('isCurrentUser')).toBe(true); + }); + + it('returns `false` when `member.user` does not have the same ID as `currentUserId`', () => { + createComponent({ + member: memberMock, + }); + + expect(findWrappedComponent().props('isCurrentUser')).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/table/members_table_spec.js b/spec/frontend/vue_shared/components/members/table/members_table_spec.js new file mode 100644 index 00000000000..4979a7096ac --- /dev/null +++ b/spec/frontend/vue_shared/components/members/table/members_table_spec.js @@ -0,0 +1,104 @@ +import { mount, createLocalVue, createWrapper } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { + getByText as getByTextHelper, + getByTestId as getByTestIdHelper, +} from '@testing-library/dom'; +import MembersTable from '~/vue_shared/components/members/table/members_table.vue'; +import MemberAvatar from '~/vue_shared/components/members/table/member_avatar.vue'; +import MemberSource from '~/vue_shared/components/members/table/member_source.vue'; +import ExpiresAt from '~/vue_shared/components/members/table/expires_at.vue'; +import CreatedAt from '~/vue_shared/components/members/table/created_at.vue'; +import * as initUserPopovers from '~/user_popovers'; +import { member as memberMock, invite, accessRequest } from '../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('MemberList', () => { + let wrapper; + + const createStore = (state = {}) => { + return new Vuex.Store({ + state: { + members: [], + tableFields: [], + ...state, + }, + }); + }; + + const createComponent = state => { + wrapper = mount(MembersTable, { + localVue, + store: createStore(state), + stubs: ['member-avatar', 'member-source', 'expires-at', 'created-at'], + }); + }; + + const getByText = (text, options) => + createWrapper(getByTextHelper(wrapper.element, text, options)); + + const getByTestId = (id, options) => + createWrapper(getByTestIdHelper(wrapper.element, id, options)); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('fields', () => { + it.each` + field | label | member | expectedComponent + ${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar} + ${'source'} | ${'Source'} | ${memberMock} | ${MemberSource} + ${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt} + ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt} + ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt} + ${'expires'} | ${'Access expires'} | ${memberMock} | ${ExpiresAt} + ${'maxRole'} | ${'Max role'} | ${memberMock} | ${null} + ${'expiration'} | ${'Expiration'} | ${memberMock} | ${null} + `('renders the $label field', ({ field, label, member, expectedComponent }) => { + createComponent({ + members: [member], + tableFields: [field], + }); + + expect(getByText(label, { selector: '[role="columnheader"]' }).exists()).toBe(true); + + if (expectedComponent) { + expect( + wrapper + .find(`[data-label="${label}"][role="cell"]`) + .find(expectedComponent) + .exists(), + ).toBe(true); + } + }); + + it('renders "Actions" field for screen readers', () => { + createComponent({ tableFields: ['actions'] }); + + const actionField = getByTestId('col-actions'); + + expect(actionField.exists()).toBe(true); + expect(actionField.classes('gl-sr-only')).toBe(true); + }); + }); + + describe('when `members` is an empty array', () => { + it('displays a "No members found" message', () => { + createComponent(); + + expect(getByText('No members found').exists()).toBe(true); + }); + }); + + it('initializes user popovers when mounted', () => { + const initUserPopoversMock = jest.spyOn(initUserPopovers, 'default'); + + createComponent(); + + expect(initUserPopoversMock).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/utils_spec.js b/spec/frontend/vue_shared/components/members/utils_spec.js new file mode 100644 index 00000000000..f183abc08d6 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/utils_spec.js @@ -0,0 +1,29 @@ +import { generateBadges } from '~/vue_shared/components/members/utils'; +import { member as memberMock } from './mock_data'; + +describe('Members Utils', () => { + describe('generateBadges', () => { + it('has correct properties for each badge', () => { + const badges = generateBadges(memberMock, true); + + badges.forEach(badge => { + expect(badge).toEqual( + expect.objectContaining({ + show: expect.any(Boolean), + text: expect.any(String), + variant: expect.stringMatching(/muted|neutral|info|success|danger|warning/), + }), + ); + }); + }); + + it.each` + member | expected + ${memberMock} | ${{ show: true, text: "It's you", variant: 'success' }} + ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${{ show: true, text: 'Blocked', variant: 'danger' }} + ${{ ...memberMock, user: { ...memberMock.user, twoFactorEnabled: true } }} | ${{ show: true, text: '2FA', variant: 'info' }} + `('returns expected output for "$expected.text" badge', ({ member, expected }) => { + expect(generateBadges(member, true)).toContainEqual(expect.objectContaining(expected)); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap index 16094a42668..27276faf333 100644 --- a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap +++ b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap @@ -38,7 +38,8 @@ exports[`Package code instruction single line to match the default snapshot 1`] data-testid="instruction-button" > <button - class="btn input-group-text btn-secondary btn-md btn-default" + aria-label="Copy this value" + class="btn input-group-text btn-default btn-md gl-button btn-default-secondary btn-icon" data-clipboard-text="npm i @my-package" title="Copy npm install command" type="button" @@ -53,6 +54,8 @@ exports[`Package code instruction single line to match the default snapshot 1`] href="#copy-to-clipboard" /> </svg> + + <!----> </button> </span> </div> diff --git a/spec/frontend/vue_shared/components/registry/title_area_spec.js b/spec/frontend/vue_shared/components/registry/title_area_spec.js index 6740d6097a4..a513f178f45 100644 --- a/spec/frontend/vue_shared/components/registry/title_area_spec.js +++ b/spec/frontend/vue_shared/components/registry/title_area_spec.js @@ -1,4 +1,4 @@ -import { GlAvatar } from '@gitlab/ui'; +import { GlAvatar, GlSprintf, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import component from '~/vue_shared/components/registry/title_area.vue'; @@ -10,10 +10,12 @@ describe('title area', () => { const findMetadataSlot = name => wrapper.find(`[data-testid="${name}"]`); const findTitle = () => wrapper.find('[data-testid="title"]'); const findAvatar = () => wrapper.find(GlAvatar); + const findInfoMessages = () => wrapper.findAll('[data-testid="info-message"]'); const mountComponent = ({ propsData = { title: 'foo' }, slots } = {}) => { wrapper = shallowMount(component, { propsData, + stubs: { GlSprintf }, slots: { 'sub-header': '<div data-testid="sub-header" />', 'right-actions': '<div data-testid="right-actions" />', @@ -95,4 +97,33 @@ describe('title area', () => { }); }); }); + + describe('info-messages', () => { + it('shows a message when the props contains one', () => { + mountComponent({ propsData: { infoMessages: [{ text: 'foo foo bar bar' }] } }); + + const messages = findInfoMessages(); + expect(messages).toHaveLength(1); + expect(messages.at(0).text()).toBe('foo foo bar bar'); + }); + + it('shows a link when the props contains one', () => { + mountComponent({ + propsData: { + infoMessages: [{ text: 'foo %{docLinkStart}link%{docLinkEnd}', link: 'bar' }], + }, + }); + + const message = findInfoMessages().at(0); + + expect(message.find(GlLink).attributes('href')).toBe('bar'); + expect(message.text()).toBe('foo link'); + }); + + it('multiple messages generates multiple spans', () => { + mountComponent({ propsData: { infoMessages: [{ text: 'foo' }, { text: 'bar' }] } }); + + expect(findInfoMessages()).toHaveLength(2); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js index 16f60b5ff21..81d31a284df 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js @@ -9,9 +9,11 @@ import { } from '~/vue_shared/components/rich_content_editor/services/editor_service'; import buildHTMLToMarkdownRenderer from '~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer'; import buildCustomRenderer from '~/vue_shared/components/rich_content_editor/services/build_custom_renderer'; +import sanitizeHTML from '~/vue_shared/components/rich_content_editor/services/sanitize_html'; jest.mock('~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer'); jest.mock('~/vue_shared/components/rich_content_editor/services/build_custom_renderer'); +jest.mock('~/vue_shared/components/rich_content_editor/services/sanitize_html'); describe('Editor Service', () => { let mockInstance; @@ -143,5 +145,14 @@ describe('Editor Service', () => { getEditorOptions(externalOptions); expect(buildCustomRenderer).toHaveBeenCalledWith(externalOptions.customRenderers); }); + + it('uses the internal sanitizeHTML service for HTML sanitization', () => { + const options = getEditorOptions(); + const html = '<div></div>'; + + options.customHTMLSanitizer(html); + + expect(sanitizeHTML).toHaveBeenCalledWith(html); + }); }); }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js index a6c712eeb31..b31684a400e 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js @@ -1,22 +1,21 @@ import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_html_block'; import { buildUneditableHtmlAsTextTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; -import { normalTextNode } from './mock_data'; +describe('rich_content_editor/services/renderers/render_html_block', () => { + const htmlBlockNode = { + literal: '<div><h1>Heading</h1><p>Paragraph.</p></div>', + type: 'htmlBlock', + }; -const htmlBlockNode = { - firstChild: null, - literal: '<div><h1>Heading</h1><p>Paragraph.</p></div>', - type: 'htmlBlock', -}; - -describe('Render HTML renderer', () => { describe('canRender', () => { - it('should return true when the argument is an html block', () => { - expect(renderer.canRender(htmlBlockNode)).toBe(true); - }); - - it('should return false when the argument is not an html block', () => { - expect(renderer.canRender(normalTextNode)).toBe(false); + it.each` + input | result + ${htmlBlockNode} | ${true} + ${{ literal: '<iframe></iframe>', type: 'htmlBlock' }} | ${true} + ${{ literal: '<iframe src="https://www.youtube.com"></iframe>', type: 'htmlBlock' }} | ${false} + ${{ literal: '<iframe></iframe>', type: 'text' }} | ${false} + `('returns $result when input=$input', ({ input, result }) => { + expect(renderer.canRender(input)).toBe(result); }); }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/sanitize_html_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/sanitize_html_spec.js new file mode 100644 index 00000000000..f2182ef60d7 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/sanitize_html_spec.js @@ -0,0 +1,11 @@ +import sanitizeHTML from '~/vue_shared/components/rich_content_editor/services/sanitize_html'; + +describe('rich_content_editor/services/sanitize_html', () => { + it.each` + input | result + ${'<iframe src="https://www.youtube.com"></iframe>'} | ${'<iframe src="https://www.youtube.com"></iframe>'} + ${'<iframe src="https://gitlab.com"></iframe>'} | ${''} + `('removes iframes if the iframe source origin is not allowed', ({ input, result }) => { + expect(sanitizeHTML(input)).toBe(result); + }); +}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js index 589be0ad7a4..a9350bc059d 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js @@ -69,6 +69,16 @@ describe('DropdownContentsLabelsView', () => { expect(wrapper.vm.visibleLabels[0].title).toBe('Bug'); }); + it('returns matching labels with fuzzy filtering', () => { + wrapper.setData({ + searchKey: 'bg', + }); + + expect(wrapper.vm.visibleLabels.length).toBe(2); + expect(wrapper.vm.visibleLabels[0].title).toBe('Bug'); + expect(wrapper.vm.visibleLabels[1].title).toBe('Boog'); + }); + it('returns all labels when `searchKey` is empty', () => { wrapper.setData({ searchKey: '', @@ -133,6 +143,19 @@ describe('DropdownContentsLabelsView', () => { expect(wrapper.vm.currentHighlightItem).toBe(2); }); + it('resets the search text when the Enter key is pressed', () => { + wrapper.setData({ + currentHighlightItem: 1, + searchKey: 'bug', + }); + + wrapper.vm.handleKeyDown({ + keyCode: ENTER_KEY_CODE, + }); + + expect(wrapper.vm.searchKey).toBe(''); + }); + it('calls action `updateSelectedLabels` with currently highlighted label when Enter key is pressed', () => { jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation(); wrapper.setData({ diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js index e1008d13fc2..9697d6c30f2 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js @@ -24,6 +24,13 @@ export const mockLabels = [ color: '#FF0000', textColor: '#FFFFFF', }, + { + id: 29, + title: 'Boog', + description: 'Label for bugs', + color: '#FF0000', + textColor: '#FFFFFF', + }, ]; export const mockConfig = { diff --git a/spec/frontend/vue_shared/components/todo_button_spec.js b/spec/frontend/vue_shared/components/todo_button_spec.js index 482b5de11f6..1f8a214d632 100644 --- a/spec/frontend/vue_shared/components/todo_button_spec.js +++ b/spec/frontend/vue_shared/components/todo_button_spec.js @@ -33,7 +33,7 @@ describe('Todo Button', () => { it.each` label | isTodo ${'Mark as done'} | ${true} - ${'Add a To-Do'} | ${false} + ${'Add a To Do'} | ${false} `('sets correct label when isTodo is $isTodo', ({ label, isTodo }) => { createComponent({ isTodo }); diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js index 57f511903d9..5532a27b767 100644 --- a/spec/frontend/vue_shared/components/web_ide_link_spec.js +++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js @@ -60,6 +60,7 @@ describe('Web IDE link component', () => { it.each` props | expectedActions ${{}} | ${[ACTION_WEB_IDE]} + ${{ webIdeIsFork: true }} | ${[{ ...ACTION_WEB_IDE, text: 'Edit fork in Web IDE' }]} ${{ needsToFork: true }} | ${[ACTION_WEB_IDE_FORK]} ${{ showWebIdeButton: false, showGitpodButton: true, gitpodEnabled: true }} | ${[ACTION_GITPOD]} ${{ showWebIdeButton: false, showGitpodButton: true, gitpodEnabled: false }} | ${[ACTION_GITPOD_ENABLE]} diff --git a/spec/frontend/vue_shared/droplab_dropdown_button_spec.js b/spec/frontend/vue_shared/droplab_dropdown_button_spec.js deleted file mode 100644 index e57c730ecee..00000000000 --- a/spec/frontend/vue_shared/droplab_dropdown_button_spec.js +++ /dev/null @@ -1,132 +0,0 @@ -import { mount } from '@vue/test-utils'; - -import DroplabDropdownButton from '~/vue_shared/components/droplab_dropdown_button.vue'; - -const mockActions = [ - { - title: 'Foo', - description: 'Some foo action', - }, - { - title: 'Bar', - description: 'Some bar action', - }, -]; - -const createComponent = ({ - size = '', - dropdownClass = '', - actions = mockActions, - defaultAction = 0, -}) => - mount(DroplabDropdownButton, { - propsData: { - size, - dropdownClass, - actions, - defaultAction, - }, - }); - -describe('DroplabDropdownButton', () => { - let wrapper; - - beforeEach(() => { - wrapper = createComponent({}); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('data', () => { - it('contains `selectedAction` representing value of `defaultAction` prop', () => { - expect(wrapper.vm.selectedAction).toBe(0); - }); - }); - - describe('computed', () => { - describe('selectedActionTitle', () => { - it('returns string containing title of selected action', () => { - wrapper.setData({ selectedAction: 0 }); - - expect(wrapper.vm.selectedActionTitle).toBe(mockActions[0].title); - - wrapper.setData({ selectedAction: 1 }); - - expect(wrapper.vm.selectedActionTitle).toBe(mockActions[1].title); - }); - }); - - describe('buttonSizeClass', () => { - it('returns string containing button sizing class based on `size` prop', done => { - const wrapperWithSize = createComponent({ - size: 'sm', - }); - - wrapperWithSize.vm.$nextTick(() => { - expect(wrapperWithSize.vm.buttonSizeClass).toBe('btn-sm'); - - done(); - wrapperWithSize.destroy(); - }); - }); - }); - }); - - describe('methods', () => { - describe('handlePrimaryActionClick', () => { - it('emits `onActionClick` event on component with selectedAction object as param', () => { - jest.spyOn(wrapper.vm, '$emit'); - - wrapper.setData({ selectedAction: 0 }); - wrapper.vm.handlePrimaryActionClick(); - - expect(wrapper.vm.$emit).toHaveBeenCalledWith('onActionClick', mockActions[0]); - }); - }); - - describe('handleActionClick', () => { - it('emits `onActionSelect` event on component with selectedAction index as param', () => { - jest.spyOn(wrapper.vm, '$emit'); - - wrapper.vm.handleActionClick(1); - - expect(wrapper.vm.$emit).toHaveBeenCalledWith('onActionSelect', 1); - }); - }); - }); - - describe('template', () => { - it('renders default action button', () => { - const defaultButton = wrapper.findAll('.btn').at(0); - - expect(defaultButton.text()).toBe(mockActions[0].title); - }); - - it('renders dropdown button', () => { - const dropdownButton = wrapper.findAll('.dropdown-toggle').at(0); - - expect(dropdownButton.isVisible()).toBe(true); - }); - - it('renders dropdown actions', () => { - const dropdownActions = wrapper.findAll('.dropdown-menu li button'); - - Array(dropdownActions.length) - .fill() - .forEach((_, index) => { - const actionContent = dropdownActions.at(index).find('.description'); - - expect(actionContent.find('strong').text()).toBe(mockActions[index].title); - expect(actionContent.find('p').text()).toBe(mockActions[index].description); - }); - }); - - it('renders divider between dropdown actions', () => { - const dropdownDivider = wrapper.find('.dropdown-menu .divider'); - - expect(dropdownDivider.isVisible()).toBe(true); - }); - }); -}); |