Commit 47c780c0 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch '258978-convert-jest-tests-to-use-vtu-in-ee-spec-frontend-sidebar' into 'master'

Convert Jest tests to use VTU in 'ee/spec/frontend/sidebar'

See merge request gitlab-org/gitlab!43824
parents f6ce33ae a414c3f5
...@@ -6,13 +6,13 @@ import { ...@@ -6,13 +6,13 @@ import {
GlDatepicker, GlDatepicker,
GlLink, GlLink,
GlSprintf, GlSprintf,
GlSearchBoxByType,
GlButton, GlButton,
GlFormInput, GlFormInput,
} from '@gitlab/ui'; } from '@gitlab/ui';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import Api from '~/api'; import Api from '~/api';
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
export default { export default {
name: 'InviteMembersModal', name: 'InviteMembersModal',
...@@ -23,9 +23,9 @@ export default { ...@@ -23,9 +23,9 @@ export default {
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
GlSprintf, GlSprintf,
GlSearchBoxByType,
GlButton, GlButton,
GlFormInput, GlFormInput,
MembersTokenSelect,
}, },
props: { props: {
groupId: { groupId: {
...@@ -129,44 +129,45 @@ export default { ...@@ -129,44 +129,45 @@ export default {
}, },
labels: { labels: {
modalTitle: s__('InviteMembersModal|Invite team members'), modalTitle: s__('InviteMembersModal|Invite team members'),
userToInvite: s__('InviteMembersModal|GitLab member or Email address'), newUsersToInvite: s__('InviteMembersModal|GitLab member or Email address'),
userPlaceholder: s__('InviteMembersModal|Search for members to invite'), userPlaceholder: s__('InviteMembersModal|Search for members to invite'),
accessLevel: s__('InviteMembersModal|Choose a role permission'), accessLevel: s__('InviteMembersModal|Choose a role permission'),
accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'), accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'),
toastMessageSuccessful: s__('InviteMembersModal|Users were succesfully added'), toastMessageSuccessful: s__('InviteMembersModal|Members were successfully added'),
toastMessageUnsuccessful: s__('InviteMembersModal|User not invited. Feature coming soon!'), toastMessageUnsuccessful: s__('InviteMembersModal|Some of the members could not be added'),
readMoreText: s__(`InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`), readMoreText: s__(`InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`),
inviteButtonText: s__('InviteMembersModal|Invite'), inviteButtonText: s__('InviteMembersModal|Invite'),
cancelButtonText: s__('InviteMembersModal|Cancel'), cancelButtonText: s__('InviteMembersModal|Cancel'),
headerCloseLabel: s__('InviteMembersModal|Close invite team members'),
}, },
membersTokenSelectLabelId: 'invite-members-input',
}; };
</script> </script>
<template> <template>
<gl-modal :modal-id="modalId" size="sm" :title="$options.labels.modalTitle"> <gl-modal
:modal-id="modalId"
size="sm"
:title="$options.labels.modalTitle"
:header-close-label="$options.labels.headerCloseLabel"
>
<div class="gl-ml-5 gl-mr-5"> <div class="gl-ml-5 gl-mr-5">
<div>{{ introText }}</div> <div>{{ introText }}</div>
<label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.userToInvite }}</label> <label :id="$options.membersTokenSelectLabelId" class="gl-font-weight-bold gl-mt-5">{{
$options.labels.newUsersToInvite
}}</label>
<div class="gl-mt-2"> <div class="gl-mt-2">
<gl-search-box-by-type <members-token-select
v-model="newUsersToInvite" v-model="newUsersToInvite"
:label="$options.labels.newUsersToInvite"
:aria-labelledby="$options.membersTokenSelectLabelId"
:placeholder="$options.labels.userPlaceholder" :placeholder="$options.labels.userPlaceholder"
type="text"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
/> />
</div> </div>
<label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.accessLevel }}</label> <label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.accessLevel }}</label>
<div class="gl-mt-2 gl-w-half gl-xs-w-full"> <div class="gl-mt-2 gl-w-half gl-xs-w-full">
<gl-dropdown <gl-dropdown class="gl-shadow-none gl-w-full" v-bind="$attrs" :text="selectedRoleName">
menu-class="dropdown-menu-selectable"
class="gl-shadow-none gl-w-full"
v-bind="$attrs"
:text="selectedRoleName"
>
<template v-for="(key, item) in accessLevels"> <template v-for="(key, item) in accessLevels">
<gl-dropdown-item <gl-dropdown-item
:key="key" :key="key"
...@@ -215,9 +216,13 @@ export default { ...@@ -215,9 +216,13 @@ export default {
{{ $options.labels.cancelButtonText }} {{ $options.labels.cancelButtonText }}
</gl-button> </gl-button>
<div class="gl-mr-3"></div> <div class="gl-mr-3"></div>
<gl-button ref="inviteButton" variant="success" @click="sendInvite">{{ <gl-button
$options.labels.inviteButtonText ref="inviteButton"
}}</gl-button> :disabled="!newUsersToInvite"
variant="success"
@click="sendInvite"
>{{ $options.labels.inviteButtonText }}</gl-button
>
</div> </div>
</template> </template>
</gl-modal> </gl-modal>
......
<script>
import { debounce } from 'lodash';
import { GlTokenSelector, GlAvatar, GlAvatarLabeled } from '@gitlab/ui';
import { USER_SEARCH_DELAY } from '../constants';
import Api from '~/api';
export default {
components: {
GlTokenSelector,
GlAvatar,
GlAvatarLabeled,
},
props: {
placeholder: {
type: String,
required: false,
default: '',
},
ariaLabelledby: {
type: String,
required: true,
},
},
data() {
return {
loading: false,
query: '',
users: [],
selectedTokens: [],
hasBeenFocused: false,
hideDropdownWithNoItems: true,
};
},
computed: {
newUsersToInvite() {
return this.selectedTokens
.map(obj => {
return obj.id;
})
.join(',');
},
placeholderText() {
if (this.selectedTokens.length === 0) {
return this.placeholder;
}
return '';
},
},
methods: {
handleTextInput(query) {
this.hideDropdownWithNoItems = false;
this.query = query;
this.loading = true;
this.retrieveUsers(query);
},
retrieveUsers: debounce(function debouncedRetrieveUsers() {
return Api.users(this.query, this.$options.queryOptions)
.then(response => {
this.users = response.data.map(token => ({
id: token.id,
name: token.name,
username: token.username,
avatar_url: token.avatar_url,
}));
this.loading = false;
})
.catch(() => {
this.loading = false;
});
}, USER_SEARCH_DELAY),
handleInput() {
this.$emit('input', this.newUsersToInvite);
},
handleBlur() {
this.hideDropdownWithNoItems = false;
},
handleFocus() {
// The modal auto-focuses on the input when opened.
// This prevents the dropdown from opening when the modal opens.
if (this.hasBeenFocused) {
this.loading = true;
this.retrieveUsers();
}
this.hasBeenFocused = true;
},
},
queryOptions: { exclude_internal: true, active: true },
};
</script>
<template>
<gl-token-selector
v-model="selectedTokens"
:dropdown-items="users"
:loading="loading"
:allow-user-defined-tokens="false"
:hide-dropdown-with-no-items="hideDropdownWithNoItems"
:placeholder="placeholderText"
:aria-labelledby="ariaLabelledby"
@blur="handleBlur"
@text-input="handleTextInput"
@input="handleInput"
@focus="handleFocus"
>
<template #token-content="{ token }">
<gl-avatar v-if="token.avatar_url" :src="token.avatar_url" :size="16" />
{{ token.name }}
</template>
<template #dropdown-item-content="{ dropdownItem }">
<gl-avatar-labeled
:src="dropdownItem.avatar_url"
:size="32"
:label="dropdownItem.name"
:sub-label="dropdownItem.username"
/>
</template>
</gl-token-selector>
</template>
export const USER_SEARCH_DELAY = 200;
...@@ -115,14 +115,10 @@ code { ...@@ -115,14 +115,10 @@ code {
background-color: $gray-50; background-color: $gray-50;
border-radius: $border-radius-default; border-radius: $border-radius-default;
.code > & { .code > &,
background-color: inherit;
padding: unset;
}
.build-trace & { .build-trace & {
background-color: inherit; background-color: inherit;
padding: inherit; padding: unset;
} }
} }
......
---
title: Fix code lines being cut-off on failed job tab
merge_request: 46885
author:
type: fixed
...@@ -83,3 +83,25 @@ inject scripts into the web app. ...@@ -83,3 +83,25 @@ inject scripts into the web app.
Inline styles should be avoided in almost all cases, they should only be used Inline styles should be avoided in almost all cases, they should only be used
when no alternatives can be found. This allows reusability of styles as well as when no alternatives can be found. This allows reusability of styles as well as
readability. readability.
### Sanitize HTML output
If you need to output raw HTML, you should sanitize it.
If you are using Vue, you can use the[`v-safe-html` directive](https://gitlab-org.gitlab.io/gitlab-ui/?path=/story/directives-safe-html-directive--default) from GitLab UI.
For other use cases, wrap a preconfigured version of [`dompurify`](https://www.npmjs.com/package/dompurify)
that also allows the icons to be rendered:
```javascript
import { sanitize } from '~/lib/dompurify';
const unsafeHtml = '<some unsafe content ... >';
// ...
element.appendChild(sanitize(unsafeHtml));
```
This `sanitize` function takes the same configuration as the
original.
...@@ -329,6 +329,7 @@ References: ...@@ -329,6 +329,7 @@ References:
- When updating the content of an HTML element using JavaScript, mark user-controlled values as `textContent` or `nodeValue` instead of `innerHTML`. - When updating the content of an HTML element using JavaScript, mark user-controlled values as `textContent` or `nodeValue` instead of `innerHTML`.
- Avoid using `v-html` with user-controlled data, use [`v-safe-html`](https://gitlab-org.gitlab.io/gitlab-ui/?path=/story/directives-safe-html-directive--default) instead. - Avoid using `v-html` with user-controlled data, use [`v-safe-html`](https://gitlab-org.gitlab.io/gitlab-ui/?path=/story/directives-safe-html-directive--default) instead.
- Render unsafe or unsanitized content using [`dompurify`](fe_guide/security.md#sanitize-html-output).
- Consider using [`gl-sprintf`](../../ee/development/i18n/externalization.md#interpolation) to interpolate translated strings securely. - Consider using [`gl-sprintf`](../../ee/development/i18n/externalization.md#interpolation) to interpolate translated strings securely.
- Avoid `__()` with translations that contain user-controlled values. - Avoid `__()` with translations that contain user-controlled values.
- When working with `postMessage`, ensure the `origin` of the message is allowlisted. - When working with `postMessage`, ensure the `origin` of the message is allowlisted.
......
...@@ -14732,6 +14732,9 @@ msgstr "" ...@@ -14732,6 +14732,9 @@ msgstr ""
msgid "InviteMembersModal|Choose a role permission" msgid "InviteMembersModal|Choose a role permission"
msgstr "" msgstr ""
msgid "InviteMembersModal|Close invite team members"
msgstr ""
msgid "InviteMembersModal|GitLab member or Email address" msgid "InviteMembersModal|GitLab member or Email address"
msgstr "" msgstr ""
...@@ -14741,13 +14744,13 @@ msgstr "" ...@@ -14741,13 +14744,13 @@ msgstr ""
msgid "InviteMembersModal|Invite team members" msgid "InviteMembersModal|Invite team members"
msgstr "" msgstr ""
msgid "InviteMembersModal|Search for members to invite" msgid "InviteMembersModal|Members were successfully added"
msgstr "" msgstr ""
msgid "InviteMembersModal|User not invited. Feature coming soon!" msgid "InviteMembersModal|Search for members to invite"
msgstr "" msgstr ""
msgid "InviteMembersModal|Users were succesfully added" msgid "InviteMembersModal|Some of the members could not be added"
msgstr "" msgstr ""
msgid "InviteMembersModal|You're inviting members to the %{group_name} group" msgid "InviteMembersModal|You're inviting members to the %{group_name} group"
......
...@@ -2,11 +2,8 @@ ...@@ -2,11 +2,8 @@
module QA module QA
RSpec.describe 'Verify' do RSpec.describe 'Verify' do
describe 'Run pipeline', :requires_admin, :skip_live_env do describe 'Run pipeline', only: { subdomain: :staging } do
# [TODO]: Developer to remove :requires_admin and :skip_live_env once FF is removed in https://gitlab.com/gitlab-org/gitlab/-/issues/229632
context 'with web only rule' do context 'with web only rule' do
let(:feature_flag) { :new_pipeline_form }
let(:job_name) { 'test_job' } let(:job_name) { 'test_job' }
let(:project) do let(:project) do
Resource::Project.fabricate_via_api! do |project| Resource::Project.fabricate_via_api! do |project|
...@@ -29,6 +26,7 @@ module QA ...@@ -29,6 +26,7 @@ module QA
script: echo 'OK' script: echo 'OK'
only: only:
- web - web
YAML YAML
} }
] ]
...@@ -37,16 +35,11 @@ module QA ...@@ -37,16 +35,11 @@ module QA
end end
before do before do
Runtime::Feature.enable(feature_flag) # [TODO]: Developer to remove when feature flag is removed
Flow::Login.sign_in Flow::Login.sign_in
project.visit! project.visit!
Page::Project::Menu.perform(&:click_ci_cd_pipelines) Page::Project::Menu.perform(&:click_ci_cd_pipelines)
end end
after do
Runtime::Feature.disable(feature_flag) # [TODO]: Developer to remove when feature flag is removed
end
it 'can trigger pipeline', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/946' do it 'can trigger pipeline', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/946' do
Page::Project::Pipeline::Index.perform do |index| Page::Project::Pipeline::Index.perform do |index|
expect(index).not_to have_pipeline # should not auto trigger pipeline expect(index).not_to have_pipeline # should not auto trigger pipeline
......
...@@ -9,7 +9,7 @@ const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, O ...@@ -9,7 +9,7 @@ const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, O
const defaultAccessLevel = '10'; const defaultAccessLevel = '10';
const helpLink = 'https://example.com'; const helpLink = 'https://example.com';
const createComponent = () => { const createComponent = (data = {}) => {
return shallowMount(InviteMembersModal, { return shallowMount(InviteMembersModal, {
propsData: { propsData: {
groupId, groupId,
...@@ -18,9 +18,14 @@ const createComponent = () => { ...@@ -18,9 +18,14 @@ const createComponent = () => {
defaultAccessLevel, defaultAccessLevel,
helpLink, helpLink,
}, },
data() {
return data;
},
stubs: { stubs: {
GlSprintf,
'gl-modal': '<div><slot name="modal-footer"></slot><slot></slot></div>', 'gl-modal': '<div><slot name="modal-footer"></slot><slot></slot></div>',
'gl-dropdown': true,
'gl-dropdown-item': true,
GlSprintf,
}, },
}); });
}; };
...@@ -34,7 +39,7 @@ describe('InviteMembersModal', () => { ...@@ -34,7 +39,7 @@ describe('InviteMembersModal', () => {
}); });
const findDropdown = () => wrapper.find(GlDropdown); const findDropdown = () => wrapper.find(GlDropdown);
const findDropdownItems = () => wrapper.findAll(GlDropdownItem); const findDropdownItems = () => findDropdown().findAll(GlDropdownItem);
const findDatepicker = () => wrapper.find(GlDatepicker); const findDatepicker = () => wrapper.find(GlDatepicker);
const findLink = () => wrapper.find(GlLink); const findLink = () => wrapper.find(GlLink);
const findCancelButton = () => wrapper.find({ ref: 'cancelButton' }); const findCancelButton = () => wrapper.find({ ref: 'cancelButton' });
...@@ -88,25 +93,69 @@ describe('InviteMembersModal', () => { ...@@ -88,25 +93,69 @@ describe('InviteMembersModal', () => {
format: 'json', format: 'json',
}; };
describe('when the invite was sent successfully', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent(); wrapper = createComponent();
jest.spyOn(Api, 'inviteGroupMember').mockResolvedValue({ data: postData });
wrapper.vm.$toast = { show: jest.fn() }; wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'inviteGroupMember').mockResolvedValue({ data: postData });
wrapper.vm.submitForm(postData); wrapper.vm.submitForm(postData);
}); });
it('displays the successful toastMessage', () => {
const toastMessageSuccessful = 'Members were successfully added';
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
toastMessageSuccessful,
wrapper.vm.toastOptions,
);
});
it('calls Api inviteGroupMember with the correct params', () => { it('calls Api inviteGroupMember with the correct params', () => {
expect(Api.inviteGroupMember).toHaveBeenCalledWith(groupId, postData); expect(Api.inviteGroupMember).toHaveBeenCalledWith(groupId, postData);
}); });
});
describe('when the invite was sent successfully', () => { describe('when sending the invite for a single member returned an api error', () => {
const toastMessageSuccessful = 'Users were succesfully added'; const apiErrorMessage = 'Members already exists';
it('displays the successful toastMessage', () => { beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: '123' });
wrapper.vm.$toast = { show: jest.fn() };
jest
.spyOn(Api, 'inviteGroupMember')
.mockRejectedValue({ response: { data: { message: apiErrorMessage } } });
findInviteButton().vm.$emit('click');
});
it('displays the api error message for the toastMessage', () => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith( expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
toastMessageSuccessful, apiErrorMessage,
wrapper.vm.toastOptions,
);
});
});
describe('when sending the invite for multiple members returned any error', () => {
const genericErrorMessage = 'Some of the members could not be added';
beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: '123' });
wrapper.vm.$toast = { show: jest.fn() };
jest
.spyOn(Api, 'inviteGroupMember')
.mockRejectedValue({ response: { data: { success: false } } });
findInviteButton().vm.$emit('click');
});
it('displays the expected toastMessage', () => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
genericErrorMessage,
wrapper.vm.toastOptions, wrapper.vm.toastOptions,
); );
}); });
......
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { GlTokenSelector } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import Api from '~/api';
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
const label = 'testgroup';
const placeholder = 'Search for a member';
const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' };
const user2 = { id: 2, name: 'Name Two', username: 'two_2', avatar_url: '' };
const allUsers = [user1, user2];
const createComponent = () => {
return shallowMount(MembersTokenSelect, {
propsData: {
ariaLabelledby: label,
placeholder,
},
});
};
describe('MembersTokenSelect', () => {
let wrapper;
beforeEach(() => {
jest.spyOn(Api, 'users').mockResolvedValue({ data: allUsers });
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findTokenSelector = () => wrapper.find(GlTokenSelector);
describe('rendering the token-selector component', () => {
it('renders with the correct props', () => {
const expectedProps = {
ariaLabelledby: label,
placeholder,
};
expect(findTokenSelector().props()).toEqual(expect.objectContaining(expectedProps));
});
});
describe('users', () => {
describe('when input is focused for the first time (modal auto-focus)', () => {
it('does not call the API', async () => {
findTokenSelector().vm.$emit('focus');
await waitForPromises();
expect(Api.users).not.toHaveBeenCalled();
});
});
describe('when input is manually focused', () => {
it('calls the API and sets dropdown items as request result', async () => {
const tokenSelector = findTokenSelector();
tokenSelector.vm.$emit('focus');
tokenSelector.vm.$emit('blur');
tokenSelector.vm.$emit('focus');
await waitForPromises();
expect(tokenSelector.props('dropdownItems')).toMatchObject(allUsers);
expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false);
});
});
describe('when text input is typed in', () => {
it('calls the API with search parameter', async () => {
const searchParam = 'One';
const tokenSelector = findTokenSelector();
tokenSelector.vm.$emit('text-input', searchParam);
await waitForPromises();
expect(Api.users).toHaveBeenCalledWith(searchParam, wrapper.vm.$options.queryOptions);
expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false);
});
});
describe('when user is selected', () => {
it('emits `input` event with selected users', () => {
findTokenSelector().vm.$emit('input', [
{ id: 1, name: 'John Smith' },
{ id: 2, name: 'Jane Doe' },
]);
expect(wrapper.emitted().input[0][0]).toBe('1,2');
});
});
});
describe('when text input is blurred', () => {
it('clears text input', async () => {
const tokenSelector = findTokenSelector();
tokenSelector.vm.$emit('blur');
await nextTick();
expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false);
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment