Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2025-05-24 00:12:13 +00:00
parent fc68a997ca
commit 224076f495
20 changed files with 603 additions and 38 deletions

View File

@ -0,0 +1,195 @@
<script>
import { GlCollapsibleListbox, GlFormGroup } from '@gitlab/ui';
import { debounce, unionBy } from 'lodash';
import { createAlert } from '~/alert';
import currentUserQuery from '~/graphql_shared/queries/current_user.query.graphql';
import usersSearchQuery from '~/graphql_shared/queries/workspace_autocomplete_users.query.graphql';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { __ } from '~/locale';
import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue';
import { BULK_UPDATE_UNASSIGNED } from '../../constants';
import { formatUserForListbox } from '../../utils';
export default {
BULK_UPDATE_UNASSIGNED,
components: {
GlCollapsibleListbox,
GlFormGroup,
SidebarParticipant,
},
props: {
fullPath: {
type: String,
required: true,
},
isGroup: {
type: Boolean,
required: false,
default: false,
},
value: {
type: String,
required: false,
default: undefined,
},
},
data() {
return {
currentUser: undefined,
searchStarted: false,
searchTerm: '',
selectedId: this.value,
users: [],
usersCache: [],
};
},
apollo: {
currentUser: {
query: currentUserQuery,
},
users: {
query: usersSearchQuery,
variables() {
return {
fullPath: this.fullPath,
isProject: !this.isGroup,
search: this.searchTerm,
};
},
skip() {
return !this.searchStarted;
},
update(data) {
return this.isGroup ? data.groupWorkspace?.users : data.workspace?.users ?? [];
},
error(error) {
createAlert({
message: __('Failed to load assignees. Please try again.'),
captureError: true,
error,
});
},
},
},
computed: {
isLoading() {
return this.$apollo.queries.users.loading;
},
listboxItems() {
const listboxItems = [];
if (!this.searchTerm.trim().length) {
listboxItems.push({
text: __('Unassigned'),
textSrOnly: true,
options: [{ text: __('Unassigned'), value: BULK_UPDATE_UNASSIGNED }],
});
}
if (this.selectedAssignee) {
listboxItems.push({
text: __('Selected'),
options: [this.selectedAssignee].map(formatUserForListbox),
});
}
listboxItems.push({
text: __('All'),
textSrOnly: true,
options: this.users
.reduce((acc, user) => {
// If user is the selected user, take them out of the list
if (user.id === this.selectedId) {
return acc;
}
// If user is the current user, move them to the beginning of the list
if (user.id === this.currentUser?.id) {
return [user].concat(acc);
}
return acc.concat(user);
}, [])
.map(formatUserForListbox),
});
return listboxItems;
},
selectedAssignee() {
return this.usersCache.find((user) => this.selectedId === user.id);
},
toggleText() {
if (this.selectedAssignee) {
return this.selectedAssignee.name;
}
if (this.selectedId === BULK_UPDATE_UNASSIGNED) {
return __('Unassigned');
}
return __('Select assignee');
},
},
watch: {
currentUser(currentUser) {
this.updateUsersCache([currentUser]);
},
users(users) {
this.updateUsersCache(users);
},
},
created() {
this.setSearchTermDebounced = debounce(this.setSearchTerm, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
},
methods: {
clearSearch() {
this.searchTerm = '';
this.$refs.listbox.$refs.searchBox.clearInput?.();
},
handleSelect(item) {
this.selectedId = item;
this.$emit('input', item);
this.clearSearch();
},
handleShown() {
this.searchTerm = '';
this.searchStarted = true;
},
reset() {
this.handleSelect(undefined);
this.$refs.listbox.close();
},
setSearchTerm(searchTerm) {
this.searchTerm = searchTerm;
},
updateUsersCache(users) {
// Need to store all users we encounter so we can show "Selected" users
// even if they're not found in the apollo `users` list
this.usersCache = unionBy(this.usersCache, users, 'id');
},
},
};
</script>
<template>
<gl-form-group :label="__('Assignee')">
<gl-collapsible-listbox
ref="listbox"
block
:header-text="__('Select assignee')"
is-check-centered
:items="listboxItems"
:no-results-text="s__('WorkItem|No matching results')"
:reset-button-label="__('Reset')"
searchable
:searching="isLoading"
:selected="selectedId"
:toggle-text="toggleText"
@reset="reset"
@search="setSearchTermDebounced"
@select="handleSelect"
@shown="handleShown"
>
<template #list-item="{ item }">
<template v-if="item.value === $options.BULK_UPDATE_UNASSIGNED">{{ item.text }}</template>
<sidebar-participant v-else-if="item" :user="item" />
</template>
</gl-collapsible-listbox>
</gl-form-group>
</template>

View File

@ -1,9 +1,9 @@
<script>
import { GlButton, GlCollapsibleListbox, GlFormGroup } from '@gitlab/ui';
import { debounce, intersectionBy, unionBy } from 'lodash';
import { createAlert } from '~/alert';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { __, createListFormat, s__, sprintf } from '~/locale';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import groupLabelsQuery from '~/sidebar/components/labels/labels_select_widget/graphql/group_labels.query.graphql';
import projectLabelsQuery from '~/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql';
import { findLabelsWidget, formatLabelForListbox } from '../../utils';
@ -67,11 +67,11 @@ export default {
return data.workspace?.labels?.nodes ?? [];
},
error(error) {
this.$emit(
'error',
s__('WorkItem|Something went wrong when fetching labels. Please try again.'),
);
Sentry.captureException(error);
createAlert({
message: s__('WorkItem|Something went wrong when fetching labels. Please try again.'),
captureError: true,
error,
});
},
},
},
@ -132,9 +132,6 @@ export default {
searchLabels(searchLabels) {
this.updateLabelsCache(searchLabels);
},
selectedLabelsIds(selectedLabelsIds) {
this.selectedIds = selectedLabelsIds;
},
},
created() {
this.setSearchTermDebounced = debounce(this.setSearchTerm, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
@ -146,8 +143,8 @@ export default {
},
handleSelect(items) {
this.selectedIds = items;
this.$emit('select', items);
this.clearSearch();
this.$emit('select', this.selectedIds);
},
handleShown() {
this.searchTerm = '';

View File

@ -4,14 +4,17 @@ import { createAlert } from '~/alert';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
import { BULK_UPDATE_UNASSIGNED } from '../../constants';
import workItemBulkUpdateMutation from '../../graphql/list/work_item_bulk_update.mutation.graphql';
import workItemParent from '../../graphql/list/work_item_parent.query.graphql';
import WorkItemBulkEditAssignee from './work_item_bulk_edit_assignee.vue';
import WorkItemBulkEditLabels from './work_item_bulk_edit_labels.vue';
import WorkItemBulkEditState from './work_item_bulk_edit_state.vue';
export default {
components: {
GlForm,
WorkItemBulkEditAssignee,
WorkItemBulkEditLabels,
WorkItemBulkEditState,
},
@ -38,6 +41,7 @@ export default {
data() {
return {
addLabelIds: [],
assigneeId: undefined,
parentId: undefined,
removeLabelIds: [],
state: undefined,
@ -75,7 +79,6 @@ export default {
try {
await executeBulkEdit();
this.$emit('success', { refetchCounts: Boolean(this.state) });
this.resetData();
} catch (error) {
createAlert({
message: s__('WorkItem|Something went wrong while bulk editing.'),
@ -102,19 +105,23 @@ export default {
});
},
performLegacyBulkEdit() {
let assigneeIds;
if (this.assigneeId === BULK_UPDATE_UNASSIGNED) {
assigneeIds = [0];
} else if (this.assigneeId) {
assigneeIds = [getIdFromGraphQLId(this.assigneeId)];
}
const update = {
add_label_ids: this.addLabelIds.map(getIdFromGraphQLId),
assignee_ids: assigneeIds,
issuable_ids: this.checkedItems.map((item) => getIdFromGraphQLId(item.id)).join(','),
remove_label_ids: this.removeLabelIds.map(getIdFromGraphQLId),
state_event: this.state,
};
return axios.post(this.legacyBulkEditEndpoint, { update });
},
resetData() {
this.addLabelIds = [];
this.removeLabelIds = [];
this.state = undefined;
},
},
};
</script>
@ -122,6 +129,12 @@ export default {
<template>
<gl-form id="work-item-list-bulk-edit" class="gl-p-5" @submit.prevent="handleFormSubmitted">
<work-item-bulk-edit-state v-if="!isEpicsList" v-model="state" />
<work-item-bulk-edit-assignee
v-if="!isEpicsList"
v-model="assigneeId"
:full-path="fullPath"
:is-group="isGroup"
/>
<work-item-bulk-edit-labels
:form-label="__('Add labels')"
:full-path="fullPath"

View File

@ -1,6 +1,8 @@
import { __, s__, sprintf } from '~/locale';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
export const BULK_UPDATE_UNASSIGNED = 'unassigned';
export const STATE_OPEN = 'OPEN';
export const STATE_CLOSED = 'CLOSED';

View File

@ -120,11 +120,17 @@ export const findHierarchyWidgetAncestors = (workItem) =>
findHierarchyWidget(workItem)?.ancestors?.nodes || [];
export const formatLabelForListbox = (label) => ({
text: label.title || label.text,
value: label.id || label.value,
text: label.title,
value: label.id,
color: label.color,
});
export const formatUserForListbox = (user) => ({
...user,
text: user.name,
value: user.id,
});
export const convertTypeEnumToName = (workItemTypeEnum) =>
Object.keys(NAME_TO_ENUM_MAP).find((name) => NAME_TO_ENUM_MAP[name] === workItemTypeEnum);

View File

@ -56,6 +56,7 @@ module UserSettings
:location,
:mastodon,
:name,
:orcid,
:organization,
:private_profile,
:pronouns,

View File

@ -396,6 +396,12 @@ module ApplicationHelper
external_redirect_path(url: "https://bsky.app/profile/#{user.bluesky}")
end
def orcid_url(user)
return '' if user.orcid.blank?
external_redirect_path(url: "https://orcid.org/#{user.orcid}")
end
def mastodon_url(user)
return '' if user.mastodon.blank?

View File

@ -212,7 +212,7 @@ module UsersHelper
end
def has_contact_info?(user)
contact_fields = %i[bluesky discord linkedin mastodon skype twitter website_url github]
contact_fields = %i[bluesky discord linkedin mastodon orcid skype twitter website_url github]
has_contact = contact_fields.any? { |field| user.public_send(field).present? } # rubocop:disable GitlabSecurity/PublicSend -- fields are controlled, it is safe.
has_contact || display_public_email?(user)
end

View File

@ -160,6 +160,9 @@
%li.form-group.gl-form-group
= f.label :github
= f.text_field :github, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|username")
%li.form-group.gl-form-group
= f.label :orcid, s_('Profiles|ORCID')
= f.text_field :orcid, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: "1234-1234-1234-1234"
.gl-pt-6
%fieldset.form-group.gl-form-group
%legend.col-form-label

View File

@ -87,3 +87,7 @@
.gl-flex.gl-gap-2.gl-mb-2
= sprite_icon('github', css_class: 'gl-fill-icon-subtle gl-mt-1 flex-shrink-0')
= link_to @user.github, github_url(@user), class: 'gl-text-default', title: "GitHub", target: '_blank', rel: 'noopener noreferrer nofollow'
- if @user.orcid.present?
.gl-flex.gl-gap-2.gl-mb-2
= sprite_icon('link', css_class: 'gl-fill-icon-subtle gl-mt-1 flex-shrink-0')
= link_to @user.orcid, orcid_url(@user), class: 'gl-text-default', title: "Orcid", target: '_blank', rel: 'noopener noreferrer nofollow'

View File

@ -110,14 +110,14 @@ You might find Agentic Chat particularly helpful when you:
Agentic Chat works best with natural language questions. Here are some examples:
- "Read the project structure and explain it to me", or "Explain the project".
- "Find the API endpoints that handle user authentication in this codebase."
- "Please explain the authorization flow for `<application name>`"
- "How do I add a GraphQL mutation in this repository?"
- "Show me how error handling is implemented across our application."
- "Component `<component name>` has methods for `<x>` and `<y>`. Could you split it up into two components?"
- "Could you add in-line documentation for all Java files in `<directory>`?
- "Do merge request `<MR URL>` and merge request `<MR URL>` fully address this issue `<issue URL>`?"
- `Read the project structure and explain it to me`, or `Explain the project`.
- `Find the API endpoints that handle user authentication in this codebase`.
- `Please explain the authorization flow for <application name>`.
- `How do I add a GraphQL mutation in this repository?`
- `Show me how error handling is implemented across our application`.
- `Component <component name> has methods for <x> and <y>. Could you split it up into two components?`
- `Could you add in-line documentation for all Java files in <directory>?`
- `Do merge request <MR URL> and merge request <MR URL> fully address this issue <issue URL>?`
## Troubleshooting

View File

@ -254,6 +254,7 @@ To add links to other accounts:
- Mastodon handle. In GitLab 17.4 and later, you can use your [GitLab profile](#access-your-user-profile) to verify your Mastodon account.
- Skype username.
- X (formerly Twitter) @username.
- [ORCID](https://orcid.org/).
Your user ID or username must be 500 characters or less.
1. Select **Update profile settings**.

View File

@ -47248,6 +47248,9 @@ msgstr ""
msgid "Profiles|No file chosen."
msgstr ""
msgid "Profiles|ORCID"
msgstr ""
msgid "Profiles|Optional but recommended. If set, key becomes invalid on the specified date."
msgstr ""

View File

@ -159,5 +159,15 @@ RSpec.describe UserSettings::ProfilesController, :request_store, feature_categor
expect(response).to have_gitlab_http_status(:found)
end
end
it 'allows updating user specified ORCID ID', :aggregate_failures do
orcid_id = '1234-1234-1234-1234'
sign_in(user)
put :update, params: { user: { orcid: orcid_id } }
expect(user.reload.orcid).to eq(orcid_id)
expect(response).to have_gitlab_http_status(:found)
end
end
end

View File

@ -0,0 +1,217 @@
import { GlCollapsibleListbox, GlFormGroup } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import {
currentUserResponse,
projectMembersAutocompleteResponseWithCurrentUser,
} from 'jest/work_items/mock_data';
import { createAlert } from '~/alert';
import currentUserQuery from '~/graphql_shared/queries/current_user.query.graphql';
import usersSearchQuery from '~/graphql_shared/queries/workspace_autocomplete_users.query.graphql';
import WorkItemBulkEditAssignee from '~/work_items/components/work_item_bulk_edit/work_item_bulk_edit_assignee.vue';
import { BULK_UPDATE_UNASSIGNED } from '~/work_items/constants';
jest.mock('~/alert');
Vue.use(VueApollo);
describe('WorkItemBulkEditAssignee component', () => {
let wrapper;
const usersSearchQueryHandler = jest
.fn()
.mockResolvedValue(projectMembersAutocompleteResponseWithCurrentUser);
const currentUserQueryHandler = jest.fn().mockResolvedValue(currentUserResponse);
const createComponent = ({ props = {}, searchQueryHandler = usersSearchQueryHandler } = {}) => {
wrapper = mount(WorkItemBulkEditAssignee, {
apolloProvider: createMockApollo([
[usersSearchQuery, searchQueryHandler],
[currentUserQuery, currentUserQueryHandler],
]),
propsData: {
fullPath: 'group/project',
...props,
},
stubs: {
GlCollapsibleListbox,
GlFormGroup: true,
},
});
};
const findFormGroup = () => wrapper.findComponent(GlFormGroup);
const findListbox = () => wrapper.findComponent(GlCollapsibleListbox);
const openListboxAndSelect = async (value) => {
findListbox().vm.$emit('shown');
findListbox().vm.$emit('select', value);
await waitForPromises();
};
it('renders the form group', () => {
createComponent();
expect(findFormGroup().attributes('label')).toBe('Assignee');
});
it('renders a header and reset button', () => {
createComponent();
expect(findListbox().props()).toMatchObject({
headerText: 'Select assignee',
resetButtonLabel: 'Reset',
});
});
it('resets the selected assignee when the Reset button is clicked', async () => {
createComponent();
await openListboxAndSelect('gid://gitlab/User/5');
expect(findListbox().props('selected')).toBe('gid://gitlab/User/5');
findListbox().vm.$emit('reset');
await nextTick();
expect(findListbox().props('selected')).toEqual([]);
});
describe('users query', () => {
it('is not called before dropdown is shown', () => {
createComponent();
expect(usersSearchQueryHandler).not.toHaveBeenCalled();
});
it('is called when dropdown is shown', async () => {
createComponent();
findListbox().vm.$emit('shown');
await nextTick();
expect(usersSearchQueryHandler).toHaveBeenCalled();
});
it('emits an error when there is an error in the call', async () => {
createComponent({ searchQueryHandler: jest.fn().mockRejectedValue(new Error('error!')) });
findListbox().vm.$emit('shown');
await waitForPromises();
expect(createAlert).toHaveBeenCalledWith({
captureError: true,
error: new Error('error!'),
message: 'Failed to load assignees. Please try again.',
});
});
});
describe('listbox items', () => {
describe('with no selected user', () => {
it('renders all users with current user Administrator at the top', async () => {
createComponent();
findListbox().vm.$emit('shown');
await waitForPromises();
expect(findListbox().props('items')).toEqual([
{
text: 'Unassigned',
textSrOnly: true,
options: [{ text: 'Unassigned', value: BULK_UPDATE_UNASSIGNED }],
},
{
text: 'All',
textSrOnly: true,
options: [
expect.objectContaining({ text: 'Administrator', value: 'gid://gitlab/User/1' }),
expect.objectContaining({ text: 'rookie', value: 'gid://gitlab/User/5' }),
],
},
]);
});
});
describe('with selected user', () => {
it('renders a "Selected" group and an "All" group', async () => {
createComponent();
await openListboxAndSelect('gid://gitlab/User/5');
expect(findListbox().props('items')).toEqual([
{
text: 'Unassigned',
textSrOnly: true,
options: [{ text: 'Unassigned', value: BULK_UPDATE_UNASSIGNED }],
},
{
text: 'Selected',
options: [expect.objectContaining({ text: 'rookie', value: 'gid://gitlab/User/5' })],
},
{
text: 'All',
textSrOnly: true,
options: [
expect.objectContaining({ text: 'Administrator', value: 'gid://gitlab/User/1' }),
],
},
]);
});
});
describe('with search', () => {
it('does not show "Unassigned"', async () => {
createComponent();
findListbox().vm.$emit('shown');
findListbox().vm.$emit('search', 'Admin');
await waitForPromises();
expect(findListbox().props('items')).toEqual([
{
text: 'All',
textSrOnly: true,
options: [
expect.objectContaining({ text: 'Administrator', value: 'gid://gitlab/User/1' }),
expect.objectContaining({ text: 'rookie', value: 'gid://gitlab/User/5' }),
],
},
]);
});
});
});
describe('listbox text', () => {
describe('with no selected user', () => {
it('renders "Select assignee"', () => {
createComponent();
expect(findListbox().props('toggleText')).toBe('Select assignee');
});
});
describe('with selected user', () => {
it('renders "rookie"', async () => {
createComponent();
await openListboxAndSelect('gid://gitlab/User/5');
expect(findListbox().props('toggleText')).toBe('rookie');
});
});
describe('with unassigned', () => {
it('renders "Unassigned"', async () => {
createComponent();
await openListboxAndSelect('unassigned');
expect(findListbox().props('toggleText')).toBe('Unassigned');
});
});
});
});

View File

@ -6,12 +6,12 @@ import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { projectLabelsResponse } from 'jest/work_items/mock_data';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import { createAlert } from '~/alert';
import projectLabelsQuery from '~/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql';
import WorkItemBulkEditLabels from '~/work_items/components/work_item_bulk_edit/work_item_bulk_edit_labels.vue';
import { WIDGET_TYPE_LABELS } from '~/work_items/constants';
jest.mock('~/sentry/sentry_browser_wrapper');
jest.mock('~/alert');
Vue.use(VueApollo);
@ -112,10 +112,11 @@ describe('WorkItemBulkEditLabels component', () => {
findListbox().vm.$emit('shown');
await waitForPromises();
expect(wrapper.emitted('error')).toEqual([
['Something went wrong when fetching labels. Please try again.'],
]);
expect(Sentry.captureException).toHaveBeenCalledWith(new Error('error!'));
expect(createAlert).toHaveBeenCalledWith({
captureError: true,
error: new Error('error!'),
message: 'Something went wrong when fetching labels. Please try again.',
});
});
});

View File

@ -8,6 +8,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import WorkItemBulkEditAssignee from '~/work_items/components/work_item_bulk_edit/work_item_bulk_edit_assignee.vue';
import WorkItemBulkEditLabels from '~/work_items/components/work_item_bulk_edit/work_item_bulk_edit_labels.vue';
import WorkItemBulkEditSidebar from '~/work_items/components/work_item_bulk_edit/work_item_bulk_edit_sidebar.vue';
import WorkItemBulkEditState from '~/work_items/components/work_item_bulk_edit/work_item_bulk_edit_state.vue';
@ -50,6 +51,7 @@ describe('WorkItemBulkEditSidebar component', () => {
const findForm = () => wrapper.findComponent(GlForm);
const findStateComponent = () => wrapper.findComponent(WorkItemBulkEditState);
const findAssigneeComponent = () => wrapper.findComponent(WorkItemBulkEditAssignee);
const findAddLabelsComponent = () => wrapper.findAllComponents(WorkItemBulkEditLabels).at(0);
const findRemoveLabelsComponent = () => wrapper.findAllComponents(WorkItemBulkEditLabels).at(1);
@ -114,14 +116,24 @@ describe('WorkItemBulkEditSidebar component', () => {
it('makes POST request to bulk edit', async () => {
const issuable_ids = '11,22'; // eslint-disable-line camelcase
const add_label_ids = [1, 2, 3]; // eslint-disable-line camelcase
const assignee_ids = [5]; // eslint-disable-line camelcase
const remove_label_ids = [4, 5, 6]; // eslint-disable-line camelcase
const state_event = 'reopen'; // eslint-disable-line camelcase
axiosMock.onPost().replyOnce(HTTP_STATUS_OK);
createComponent({ props: { isEpicsList: false } });
findStateComponent().vm.$emit('input', state_event);
findAddLabelsComponent().vm.$emit('select', add_label_ids);
findRemoveLabelsComponent().vm.$emit('select', remove_label_ids);
findAssigneeComponent().vm.$emit('input', 'gid://gitlab/User/5');
findAddLabelsComponent().vm.$emit('select', [
'gid://gitlab/Label/1',
'gid://gitlab/Label/2',
'gid://gitlab/Label/3',
]);
findRemoveLabelsComponent().vm.$emit('select', [
'gid://gitlab/Label/4',
'gid://gitlab/Label/5',
'gid://gitlab/Label/6',
]);
findForm().vm.$emit('submit', { preventDefault: () => {} });
await waitForPromises();
@ -130,15 +142,13 @@ describe('WorkItemBulkEditSidebar component', () => {
JSON.stringify({
update: {
add_label_ids,
assignee_ids,
issuable_ids,
remove_label_ids,
state_event,
},
}),
);
expect(findStateComponent().props('value')).toBeUndefined();
expect(findAddLabelsComponent().props('selectedLabelsIds')).toEqual([]);
expect(findRemoveLabelsComponent().props('selectedLabelsIds')).toEqual([]);
});
it('renders error when there is a response error', async () => {
@ -188,6 +198,23 @@ describe('WorkItemBulkEditSidebar component', () => {
});
});
describe('"Assignee" component', () => {
it.each([true, false])('renders depending on isEpicsList prop', (isEpicsList) => {
createComponent({ props: { isEpicsList } });
expect(findAssigneeComponent().exists()).toBe(!isEpicsList);
});
it('updates assignee when "Assignee" component emits "input" event', async () => {
createComponent();
findAssigneeComponent().vm.$emit('input', 'gid://gitlab/User/5');
await nextTick();
expect(findAssigneeComponent().props('value')).toBe('gid://gitlab/User/5');
});
});
describe('"Add labels" component', () => {
beforeEach(() => {
createComponent();

View File

@ -24,6 +24,8 @@ import {
import {
autocompleteDataSources,
convertTypeEnumToName,
formatLabelForListbox,
formatUserForListbox,
markdownPreviewPath,
newWorkItemPath,
isReference,
@ -42,6 +44,51 @@ import {
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { TYPE_EPIC } from '~/issues/constants';
describe('formatLabelForListbox', () => {
const label = {
__typename: 'Label',
id: 'gid://gitlab/Label/1',
title: 'Label 1',
description: '',
color: '#f00',
textColor: '#00f',
};
it('formats as expected', () => {
expect(formatLabelForListbox(label)).toEqual({
text: 'Label 1',
value: 'gid://gitlab/Label/1',
color: '#f00',
});
});
});
describe('formatUserForListbox', () => {
const user = {
__typename: 'UserCore',
id: 'gid://gitlab/User/1',
avatarUrl: '',
webUrl: '',
webPath: '/doe_I',
name: 'John Doe',
username: 'doe_I',
};
it('formats as expected', () => {
expect(formatUserForListbox(user)).toEqual({
__typename: 'UserCore',
id: 'gid://gitlab/User/1',
avatarUrl: '',
webUrl: '',
webPath: '/doe_I',
name: 'John Doe',
username: 'doe_I',
text: 'John Doe',
value: 'gid://gitlab/User/1',
});
});
});
describe('autocompleteDataSources', () => {
beforeEach(() => {
gon.relative_url_root = '/foobar';

View File

@ -697,6 +697,26 @@ RSpec.describe ApplicationHelper do
end
end
end
describe '#orcid_url' do
let(:user) { build(:user) }
subject(:orcid) { orcid_url(user) }
context 'without ORCID ID' do
it 'returns an empty string' do
expect(orcid).to eq('')
end
end
context 'with ORCID ID' do
it 'returns orcid url' do
user.orcid = '1234-1234-1234-1234'
expect(orcid).to eq(external_redirect_path(url: 'https://orcid.org/1234-1234-1234-1234'))
end
end
end
end
describe '#gitlab_ui_form_for' do

View File

@ -21,6 +21,12 @@ RSpec.describe UsersHelper, feature_category: :user_management do
it { is_expected.to be true }
end
context 'when user has ORCID' do
let_it_be(:user) { create(:user, orcid: '1234-1234-1234-1234') }
it { is_expected.to be true }
end
context 'when user has public email' do
let_it_be(:user) { create(:user, :public_email) }
@ -32,6 +38,12 @@ RSpec.describe UsersHelper, feature_category: :user_management do
it { is_expected.to be false }
end
context 'when user ORCID is blank' do
let_it_be(:user) { create(:user, orcid: '') }
it { is_expected.to be false }
end
end
describe 'display_public_email?' do