Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2024-01-22 18:10:29 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2024-01-22 18:10:29 +0300
commit917d93d86da4dffd96abcfcf3aa83b0d6fa45286 (patch)
tree481ea258782443f5eaf2bd5e7dd5c1a7f900e3dd
parente5c31c104e19a08546b17b34b7f1563cce3f89e6 (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--.gitlab/ci/review-apps/main.gitlab-ci.yml2
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js1
-rw-r--r--app/assets/javascripts/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit.vue71
-rw-r--r--app/assets/javascripts/work_items/components/work_item_assignees_inline.vue (renamed from app/assets/javascripts/work_items/components/work_item_assignees.vue)0
-rw-r--r--app/assets/javascripts/work_items/components/work_item_assignees_with_edit.vue292
-rw-r--r--app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue42
-rw-r--r--app/assets/javascripts/work_items/components/work_item_milestone_with_edit.vue5
-rw-r--r--app/helpers/application_helper.rb2
-rw-r--r--app/models/users/callout.rb3
-rw-r--r--app/views/dashboard/projects/_zero_authorized_projects.html.haml1
-rw-r--r--app/views/layouts/devise_empty.html.haml5
-rw-r--r--app/views/layouts/minimal.html.haml1
-rw-r--r--db/docs/analytics_dashboards_pointers.yml15
-rw-r--r--db/post_migrate/20240118190758_remove_ignored_columns_from_geo_node_statuses.rb34
-rw-r--r--db/schema_migrations/202401181907581
-rw-r--r--db/structure.sql12
-rw-r--r--doc/api/graphql/reference/index.md1
-rw-r--r--doc/ci/components/index.md9
-rw-r--r--doc/subscriptions/self_managed/index.md1
-rw-r--r--locale/gitlab.pot12
-rw-r--r--spec/features/projects/work_items/work_item_spec.rb95
-rw-r--r--spec/frontend/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit_spec.js43
-rw-r--r--spec/frontend/work_items/components/work_item_assignees_inline_spec.js (renamed from spec/frontend/work_items/components/work_item_assignees_spec.js)6
-rw-r--r--spec/frontend/work_items/components/work_item_assignees_with_edit_spec.js300
-rw-r--r--spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js4
-rw-r--r--spec/frontend/work_items/components/work_item_milestone_with_edit_spec.js2
-rw-r--r--spec/helpers/application_helper_spec.rb24
-rw-r--r--spec/lib/gitlab/auth/current_user_mode_spec.rb2
-rw-r--r--spec/support/shared_examples/features/work_items_shared_examples.rb180
29 files changed, 1029 insertions, 137 deletions
diff --git a/.gitlab/ci/review-apps/main.gitlab-ci.yml b/.gitlab/ci/review-apps/main.gitlab-ci.yml
index a4d8d8672bb..42897cfc0a1 100644
--- a/.gitlab/ci/review-apps/main.gitlab-ci.yml
+++ b/.gitlab/ci/review-apps/main.gitlab-ci.yml
@@ -67,7 +67,7 @@ review-build-cng:
GITLAB_IMAGE_REPOSITORY: "registry.gitlab.com/gitlab-org/build/cng-mirror"
GITLAB_IMAGE_SUFFIX: "ee"
GITLAB_REVIEW_APP_BASE_CONFIG_FILE: "scripts/review_apps/base-config.yaml"
- GITLAB_HELM_CHART_REF: "eace227d3465e17e37b1a2e3764dd244c8e2d716" # 7.6.1: https://gitlab.com/gitlab-org/charts/gitlab/-/commit/eace227d3465e17e37b1a2e3764dd244c8e2d716
+ GITLAB_HELM_CHART_REF: "c91feed6983b24a1b0dbacaf5050ca5c59af3d46" # 7.8.0: https://gitlab.com/gitlab-org/charts/gitlab/-/commit/c91feed6983b24a1b0dbacaf5050ca5c59af3d46
environment:
name: review/${CI_COMMIT_REF_SLUG}${SCHEDULE_TYPE} # No separator for SCHEDULE_TYPE so it's compatible as before and looks nice without it
url: https://gitlab-${CI_ENVIRONMENT_SLUG}.${REVIEW_APPS_DOMAIN}
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index 95ef04ceb30..90f2c11dddc 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -25,6 +25,7 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-new-nav-for-everyone-callout',
'.js-namespace-over-storage-users-combined-alert',
'.js-code-suggestions-ga-alert',
+ '.js-joining-a-project-alert',
];
const initCallouts = () => {
diff --git a/app/assets/javascripts/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit.vue b/app/assets/javascripts/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit.vue
index d94d0494ad9..21512ba6066 100644
--- a/app/assets/javascripts/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit.vue
+++ b/app/assets/javascripts/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit.vue
@@ -39,7 +39,7 @@ export default {
default: () => [],
},
itemValue: {
- type: Object,
+ type: [Array, String],
required: false,
default: null,
},
@@ -68,30 +68,40 @@ export default {
required: false,
default: '',
},
+ multiSelect: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showFooter: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ infiniteScroll: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ infiniteScrollLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
isEditing: false,
- localSelectedItem: this.itemValue?.id,
+ localSelectedItem: this.itemValue,
};
},
computed: {
hasValue() {
- return this.itemValue != null || !isEmpty(this.item);
- },
- listboxText() {
- return (
- this.listItems.find(({ value }) => this.localSelectedItem === value)?.text ||
- this.itemValue?.title ||
- this.$options.i18n.none
- );
+ return this.multiSelect ? !isEmpty(this.itemValue) : this.itemValue !== null;
},
inputId() {
return `work-item-dropdown-listbox-value-${this.dropdownName}`;
},
- toggleText() {
- return this.toggleDropdownText || this.listboxText;
- },
resetButton() {
return this.resetButtonLabel || this.$options.i18n.resetButtonText;
},
@@ -100,7 +110,7 @@ export default {
itemValue: {
handler(newVal) {
if (!this.isEditing) {
- this.localSelectedItem = newVal?.id;
+ this.localSelectedItem = newVal;
}
},
},
@@ -114,18 +124,25 @@ export default {
},
handleItemClick(item) {
this.localSelectedItem = item;
- this.$emit('updateValue', item);
+ if (!this.multiSelect) {
+ this.$emit('updateValue', item);
+ } else {
+ this.$emit('updateSelected', this.localSelectedItem);
+ }
},
onListboxShown() {
this.$emit('dropdownShown');
},
onListboxHide() {
this.isEditing = false;
+ if (this.multiSelect) {
+ this.$emit('updateValue', this.localSelectedItem);
+ }
},
unassignValue() {
- this.localSelectedItem = null;
+ this.localSelectedItem = this.multiSelect ? [] : null;
this.isEditing = false;
- this.$emit('updateValue', null);
+ this.$emit('updateValue', this.localSelectedItem);
},
},
};
@@ -165,34 +182,42 @@ export default {
</div>
<gl-collapsible-listbox
:id="inputId"
+ :multiple="multiSelect"
block
searchable
start-opened
is-check-centered
fluid-width
+ :infinite-scroll="infiniteScroll"
:searching="loading"
:header-text="headerText"
- :toggle-text="toggleText"
+ :toggle-text="toggleDropdownText"
:no-results-text="$options.i18n.noMatchingResults"
:items="listItems"
:selected="localSelectedItem"
:reset-button-label="resetButton"
+ :infinite-scroll-loading="infiniteScrollLoading"
@reset="unassignValue"
@search="debouncedSearchKeyUpdate"
@select="handleItemClick"
@shown="onListboxShown"
@hidden="onListboxHide"
+ @bottom-reached="$emit('bottomReached')"
>
<template #list-item="{ item }">
<slot name="list-item" :item="item">{{ item.text }}</slot>
</template>
+ <template v-if="showFooter" #footer>
+ <div class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-200 gl-p-2!">
+ <slot name="footer"></slot>
+ </div>
+ </template>
</gl-collapsible-listbox>
+ {{ hasValue }}
</gl-form>
- <slot v-else-if="hasValue" name="readonly">
- {{ listboxText }}
- </slot>
- <div v-else class="gl-text-secondary">
+ <slot v-else-if="hasValue" name="readonly"></slot>
+ <slot v-else class="gl-text-secondary" name="none">
{{ $options.i18n.none }}
- </div>
+ </slot>
</div>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees_inline.vue
index a9aafbb3d84..a9aafbb3d84 100644
--- a/app/assets/javascripts/work_items/components/work_item_assignees.vue
+++ b/app/assets/javascripts/work_items/components/work_item_assignees_inline.vue
diff --git a/app/assets/javascripts/work_items/components/work_item_assignees_with_edit.vue b/app/assets/javascripts/work_items/components/work_item_assignees_with_edit.vue
new file mode 100644
index 00000000000..bb7baed29f6
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_assignees_with_edit.vue
@@ -0,0 +1,292 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { isEmpty } from 'lodash';
+import currentUserQuery from '~/graphql_shared/queries/current_user.query.graphql';
+import groupUsersSearchQuery from '~/graphql_shared/queries/group_users_search.query.graphql';
+import usersSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
+import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
+import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue';
+import UncollapsedAssigneeList from '~/sidebar/components/assignees/uncollapsed_assignee_list.vue';
+import WorkItemSidebarDropdownWidgetWithEdit from '~/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit.vue';
+import { s__, sprintf, __ } from '~/locale';
+import Tracking from '~/tracking';
+import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
+import { i18n, TRACKING_CATEGORY_SHOW, DEFAULT_PAGE_SIZE_ASSIGNEES } from '../constants';
+
+export default {
+ components: {
+ WorkItemSidebarDropdownWidgetWithEdit,
+ InviteMembersTrigger,
+ SidebarParticipant,
+ GlButton,
+ UncollapsedAssigneeList,
+ },
+ mixins: [Tracking.mixin()],
+ inject: ['isGroup'],
+ props: {
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ workItemId: {
+ type: String,
+ required: true,
+ },
+ assignees: {
+ type: Array,
+ required: true,
+ },
+ allowsMultipleAssignees: {
+ type: Boolean,
+ required: true,
+ },
+ workItemType: {
+ type: String,
+ required: true,
+ },
+ canUpdate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ canInviteMembers: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ localAssigneeIds: this.assignees.map(({ id }) => id),
+ searchStarted: false,
+ searchKey: '',
+ users: {
+ nodes: [],
+ },
+ currentUser: null,
+ isLoadingMore: false,
+ updateInProgress: false,
+ };
+ },
+ apollo: {
+ users: {
+ query() {
+ return this.isGroup ? groupUsersSearchQuery : usersSearchQuery;
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ search: this.searchKey,
+ first: DEFAULT_PAGE_SIZE_ASSIGNEES,
+ };
+ },
+ skip() {
+ return !this.searchStarted;
+ },
+ update(data) {
+ return data.workspace?.users;
+ },
+ error() {
+ this.$emit('error', i18n.fetchError);
+ },
+ },
+ currentUser: {
+ query: currentUserQuery,
+ },
+ },
+ computed: {
+ searchUsers() {
+ return this.users.nodes.map(({ user }) => ({
+ ...user,
+ value: user.id,
+ text: user.name,
+ }));
+ },
+ pageInfo() {
+ return this.users.pageInfo;
+ },
+ tracking() {
+ return {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'item_assignees',
+ property: `type_${this.workItemType}`,
+ };
+ },
+ isLoadingUsers() {
+ return this.$apollo.queries.users.loading && !this.isLoadingMore;
+ },
+ hasNextPage() {
+ return this.pageInfo?.hasNextPage;
+ },
+ selectedAssigneeIds() {
+ return this.allowsMultipleAssignees ? this.localAssigneeIds : this.localAssigneeIds[0];
+ },
+ dropdownText() {
+ if (this.localAssigneeIds.length === 0) {
+ return s__('WorkItem|No assignees');
+ }
+
+ return this.localAssigneeIds.length === 1
+ ? this.localAssignees.map(({ name }) => name).join(', ')
+ : sprintf(s__('WorkItem|%{usersLength} assignees'), {
+ usersLength: this.localAssigneeIds.length,
+ });
+ },
+ dropdownLabel() {
+ return this.allowsMultipleAssignees ? __('Assignees') : __('Assignee');
+ },
+ headerText() {
+ return this.allowsMultipleAssignees ? __('Select assignees') : __('Select assignee');
+ },
+ filteredAssignees() {
+ return isEmpty(this.searchUsers)
+ ? this.assignees
+ : this.searchUsers.filter(({ id }) => this.localAssigneeIds.includes(id));
+ },
+ localAssignees() {
+ return this.filteredAssignees || [];
+ },
+ },
+ watch: {
+ assignees: {
+ handler(newVal) {
+ this.localAssigneeIds = newVal.map(({ id }) => id);
+ },
+ deep: true,
+ },
+ },
+ methods: {
+ handleAssigneesInput(assignees) {
+ this.setLocalAssigneeIdsOnEvent(assignees);
+ this.setAssignees();
+ },
+ handleAssigneeClick(assignees) {
+ this.setLocalAssigneeIdsOnEvent(assignees);
+ },
+ async setAssignees() {
+ this.updateInProgress = true;
+ const { localAssigneeIds } = this;
+
+ try {
+ const {
+ data: {
+ workItemUpdate: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: updateWorkItemMutation,
+ variables: {
+ input: {
+ id: this.workItemId,
+ assigneesWidget: {
+ assigneeIds: localAssigneeIds,
+ },
+ },
+ },
+ });
+ if (errors.length > 0) {
+ this.throwUpdateError();
+ return;
+ }
+ this.track('updated_assignees');
+ } catch {
+ this.throwUpdateError();
+ } finally {
+ this.updateInProgress = false;
+ }
+ },
+ setLocalAssigneeIdsOnEvent(assignees) {
+ const singleSelectAssignee = assignees === null ? [] : [assignees];
+ this.localAssigneeIds = this.allowsMultipleAssignees ? assignees : singleSelectAssignee;
+ },
+ async fetchMoreAssignees() {
+ if (this.isLoadingMore && !this.hasNextPage) return;
+
+ this.isLoadingMore = true;
+ await this.$apollo.queries.users.fetchMore({
+ variables: {
+ after: this.pageInfo.endCursor,
+ first: DEFAULT_PAGE_SIZE_ASSIGNEES,
+ },
+ });
+ this.isLoadingMore = false;
+ },
+ setSearchKey(value) {
+ this.searchKey = value;
+ this.searchStarted = true;
+ },
+ assignToCurrentUser() {
+ const assignees = this.allowsMultipleAssignees ? [this.currentUser.id] : this.currentUser.id;
+ this.setLocalAssigneeIdsOnEvent(assignees);
+ this.setAssignees();
+ },
+ throwUpdateError() {
+ this.$emit('error', i18n.updateError);
+ // If mutation is rejected, we're rolling back to initial state
+ this.localAssigneeIds = this.assignees.map(({ id }) => id);
+ },
+ onDropdownShown() {
+ this.searchStarted = true;
+ },
+ },
+};
+</script>
+
+<template>
+ <work-item-sidebar-dropdown-widget-with-edit
+ :multi-select="allowsMultipleAssignees"
+ class="issuable-assignees gl-mt-2"
+ :dropdown-label="dropdownLabel"
+ :can-update="canUpdate"
+ dropdown-name="assignees"
+ show-footer
+ :infinite-scroll="hasNextPage"
+ :infinite-scroll-loading="isLoadingMore"
+ :loading="isLoadingUsers"
+ :list-items="searchUsers"
+ :item-value="selectedAssigneeIds"
+ :toggle-dropdown-text="dropdownText"
+ :header-text="headerText"
+ :update-in-progress="updateInProgress"
+ :reset-button-label="__('Clear')"
+ data-testid="work-item-assignees-with-edit"
+ @dropdownShown="onDropdownShown"
+ @searchStarted="setSearchKey"
+ @updateValue="handleAssigneesInput"
+ @updateSelected="handleAssigneeClick"
+ @bottomReached="fetchMoreAssignees"
+ >
+ <template #list-item="{ item }">
+ <sidebar-participant :user="item" />
+ </template>
+ <template v-if="canInviteMembers" #footer>
+ <gl-button category="tertiary" block class="gl-justify-content-start!">
+ <invite-members-trigger
+ :display-text="__('Invite members')"
+ trigger-element="side-nav"
+ icon="plus"
+ trigger-source="work-item-assignees-with-edit"
+ classes="gl-hover-text-decoration-none! gl-pb-2"
+ />
+ </gl-button>
+ </template>
+ <template #none>
+ <div class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-gap-2">
+ <span>{{ __('None') }}</span>
+ <template v-if="currentUser && canUpdate">
+ <span>-</span>
+ <gl-button variant="link" data-testid="assign-self" @click.stop="assignToCurrentUser"
+ ><span class="gl-text-gray-500 gl-hover-text-blue-800">{{
+ __('assign yourself')
+ }}</span></gl-button
+ >
+ </template>
+ </div>
+ </template>
+ <template #readonly>
+ <uncollapsed-assignee-list
+ :users="localAssignees"
+ show-less-assignees-class="gl-hover-bg-transparent!"
+ />
+ </template>
+ </work-item-sidebar-dropdown-widget-with-edit>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue
index 79f0fdca061..76f1938c3ed 100644
--- a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue
+++ b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue
@@ -15,7 +15,8 @@ import {
WORK_ITEM_TYPE_VALUE_TASK,
} from '../constants';
import WorkItemDueDate from './work_item_due_date.vue';
-import WorkItemAssignees from './work_item_assignees.vue';
+import WorkItemAssigneesInline from './work_item_assignees_inline.vue';
+import WorkItemAssigneesWithEdit from './work_item_assignees_with_edit.vue';
import WorkItemLabels from './work_item_labels.vue';
import WorkItemMilestoneInline from './work_item_milestone_inline.vue';
import WorkItemMilestoneWithEdit from './work_item_milestone_with_edit.vue';
@@ -27,7 +28,8 @@ export default {
WorkItemLabels,
WorkItemMilestoneInline,
WorkItemMilestoneWithEdit,
- WorkItemAssignees,
+ WorkItemAssigneesInline,
+ WorkItemAssigneesWithEdit,
WorkItemDueDate,
WorkItemParent,
WorkItemParentInline,
@@ -114,17 +116,31 @@ export default {
<template>
<div class="work-item-attributes-wrapper">
- <work-item-assignees
- v-if="workItemAssignees"
- :can-update="canUpdate"
- :full-path="fullPath"
- :work-item-id="workItem.id"
- :assignees="workItemAssignees.assignees.nodes"
- :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees"
- :work-item-type="workItemType"
- :can-invite-members="workItemAssignees.canInviteMembers"
- @error="$emit('error', $event)"
- />
+ <template v-if="workItemAssignees">
+ <work-item-assignees-with-edit
+ v-if="glFeatures.workItemsMvc2"
+ class="gl-mb-5"
+ :can-update="canUpdate"
+ :full-path="fullPath"
+ :work-item-id="workItem.id"
+ :assignees="workItemAssignees.assignees.nodes"
+ :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees"
+ :work-item-type="workItemType"
+ :can-invite-members="workItemAssignees.canInviteMembers"
+ @error="$emit('error', $event)"
+ />
+ <work-item-assignees-inline
+ v-else
+ :can-update="canUpdate"
+ :full-path="fullPath"
+ :work-item-id="workItem.id"
+ :assignees="workItemAssignees.assignees.nodes"
+ :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees"
+ :work-item-type="workItemType"
+ :can-invite-members="workItemAssignees.canInviteMembers"
+ @error="$emit('error', $event)"
+ />
+ </template>
<work-item-labels
v-if="workItemLabels"
:can-update="canUpdate"
diff --git a/app/assets/javascripts/work_items/components/work_item_milestone_with_edit.vue b/app/assets/javascripts/work_items/components/work_item_milestone_with_edit.vue
index 87b41c9d9ea..26d98f87464 100644
--- a/app/assets/javascripts/work_items/components/work_item_milestone_with_edit.vue
+++ b/app/assets/javascripts/work_items/components/work_item_milestone_with_edit.vue
@@ -91,6 +91,9 @@ export default {
expired,
}));
},
+ localMilestoneId() {
+ return this.localMilestone?.id;
+ },
},
watch: {
workItemMilestone(newVal) {
@@ -184,7 +187,7 @@ export default {
dropdown-name="milestone"
:loading="isLoadingMilestones"
:list-items="milestonesList"
- :item-value="localMilestone"
+ :item-value="localMilestoneId"
:update-in-progress="updateInProgress"
:toggle-dropdown-text="dropdownText"
:header-text="__('Select milestone')"
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 892b046e410..62eb83a77b6 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -311,7 +311,7 @@ module ApplicationHelper
class_names << 'issue-boards-page gl-overflow-auto' if current_controller?(:boards)
class_names << 'epic-boards-page gl-overflow-auto' if current_controller?(:epic_boards)
class_names << 'with-performance-bar' if performance_bar_enabled?
- class_names << 'with-header' unless current_user
+ class_names << 'with-header' if @with_header || !current_user
class_names << 'with-top-bar' unless @hide_top_bar_padding
class_names << system_message_class
diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb
index 8d330e4eb6e..d377a370988 100644
--- a/app/models/users/callout.rb
+++ b/app/models/users/callout.rb
@@ -81,7 +81,8 @@ module Users
code_suggestions_ga_non_owner_alert: 79, # EE-only
duo_chat_callout: 80, # EE-only
code_suggestions_ga_owner_alert: 81, # EE-only
- product_analytics_dashboard_feedback: 82 # EE-only
+ product_analytics_dashboard_feedback: 82, # EE-only
+ joining_a_project_alert: 83 # EE-only
}
validates :feature_name,
diff --git a/app/views/dashboard/projects/_zero_authorized_projects.html.haml b/app/views/dashboard/projects/_zero_authorized_projects.html.haml
index da25dee1e88..ba4e8961f28 100644
--- a/app/views/dashboard/projects/_zero_authorized_projects.html.haml
+++ b/app/views/dashboard/projects/_zero_authorized_projects.html.haml
@@ -1,4 +1,5 @@
.container
+ = render_if_exists 'dashboard/projects/joining_a_project_alert'
.gl-text-center.gl-pt-6.gl-pb-7
%h2.gl-font-size-h1{ data: { testid: 'welcome-title-content' } }
= _('Welcome to GitLab')
diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml
index faf45ae78ef..d7449ea5be7 100644
--- a/app/views/layouts/devise_empty.html.haml
+++ b/app/views/layouts/devise_empty.html.haml
@@ -1,6 +1,9 @@
- add_page_specific_style 'page_bundles/login'
+- @with_header = true
+- page_classes = [user_application_theme, page_class.flatten.compact]
+
!!! 5
-%html.html-devise-layout{ class: user_application_theme, lang: I18n.locale }
+%html.html-devise-layout{ class: page_classes, lang: I18n.locale }
= render "layouts/head"
%body.gl-h-full.login-page.navless{ class: "#{system_message_class} #{client_class_list}" }
= header_message
diff --git a/app/views/layouts/minimal.html.haml b/app/views/layouts/minimal.html.haml
index e499b9ae240..fa39b6d45ba 100644
--- a/app/views/layouts/minimal.html.haml
+++ b/app/views/layouts/minimal.html.haml
@@ -1,3 +1,4 @@
+- @with_header = true
- page_classes = page_class.push(@html_class).flatten.compact
!!! 5
diff --git a/db/docs/analytics_dashboards_pointers.yml b/db/docs/analytics_dashboards_pointers.yml
index b554911d3ad..37cb7e82805 100644
--- a/db/docs/analytics_dashboards_pointers.yml
+++ b/db/docs/analytics_dashboards_pointers.yml
@@ -3,8 +3,17 @@ table_name: analytics_dashboards_pointers
classes:
- Analytics::DashboardsPointer
feature_categories:
- - devops_reports
-description: Stores project link with configuration files for Analytics Dashboards group feature.
+- devops_reports
+description: Stores project link with configuration files for Analytics Dashboards
+ group feature.
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/107673
milestone: '15.8'
-gitlab_schema: gitlab_main
+gitlab_schema: gitlab_main_cell
+allow_cross_joins:
+- gitlab_main_clusterwide
+allow_cross_transactions:
+- gitlab_main_clusterwide
+allow_cross_foreign_keys:
+- gitlab_main_clusterwide
+sharding_key:
+ target_project_id: projects
diff --git a/db/post_migrate/20240118190758_remove_ignored_columns_from_geo_node_statuses.rb b/db/post_migrate/20240118190758_remove_ignored_columns_from_geo_node_statuses.rb
new file mode 100644
index 00000000000..0a5f2f5d274
--- /dev/null
+++ b/db/post_migrate/20240118190758_remove_ignored_columns_from_geo_node_statuses.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class RemoveIgnoredColumnsFromGeoNodeStatuses < Gitlab::Database::Migration[2.2]
+ disable_ddl_transaction!
+
+ milestone '16.9'
+
+ IGNORED_COLLUMNS = [
+ :container_repositories_count,
+ :container_repositories_failed_count,
+ :container_repositories_registry_count,
+ :container_repositories_synced_count,
+ :job_artifacts_count,
+ :job_artifacts_failed_count,
+ :job_artifacts_synced_count,
+ :job_artifacts_synced_missing_on_primary_count,
+ :lfs_objects_count,
+ :lfs_objects_failed_count,
+ :lfs_objects_synced_count,
+ :lfs_objects_synced_missing_on_primary_count
+ ]
+
+ def up
+ IGNORED_COLLUMNS.each do |column_name|
+ remove_column :geo_node_statuses, column_name, if_exists: true
+ end
+ end
+
+ def down
+ IGNORED_COLLUMNS.each do |column_name|
+ add_column :geo_node_statuses, column_name, :integer, if_not_exists: true
+ end
+ end
+end
diff --git a/db/schema_migrations/20240118190758 b/db/schema_migrations/20240118190758
new file mode 100644
index 00000000000..076999958ff
--- /dev/null
+++ b/db/schema_migrations/20240118190758
@@ -0,0 +1 @@
+78738644f53046494ba1f4a8e49ed9effc8147c8563e311bf3744a31a33449c6 \ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index a4efb7a734c..d9ceb1e6a9a 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -17301,9 +17301,6 @@ CREATE TABLE geo_node_statuses (
id integer NOT NULL,
geo_node_id integer NOT NULL,
db_replication_lag_seconds integer,
- lfs_objects_count integer,
- lfs_objects_synced_count integer,
- lfs_objects_failed_count integer,
last_event_id bigint,
last_event_date timestamp without time zone,
cursor_last_event_id bigint,
@@ -17315,19 +17312,10 @@ CREATE TABLE geo_node_statuses (
replication_slots_count integer,
replication_slots_used_count integer,
replication_slots_max_retained_wal_bytes bigint,
- job_artifacts_count integer,
- job_artifacts_synced_count integer,
- job_artifacts_failed_count integer,
version character varying,
revision character varying,
- lfs_objects_synced_missing_on_primary_count integer,
- job_artifacts_synced_missing_on_primary_count integer,
storage_configuration_digest bytea,
projects_count integer,
- container_repositories_count integer,
- container_repositories_synced_count integer,
- container_repositories_failed_count integer,
- container_repositories_registry_count integer,
status jsonb DEFAULT '{}'::jsonb NOT NULL
);
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index c63b1cf0352..0799c002a73 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -31865,6 +31865,7 @@ Name of the feature that the callout is for.
| <a id="usercalloutfeaturenameenumgeo_migrate_hashed_storage"></a>`GEO_MIGRATE_HASHED_STORAGE` | Callout feature name for geo_migrate_hashed_storage. |
| <a id="usercalloutfeaturenameenumgke_cluster_integration"></a>`GKE_CLUSTER_INTEGRATION` | Callout feature name for gke_cluster_integration. |
| <a id="usercalloutfeaturenameenumgold_trial_billings"></a>`GOLD_TRIAL_BILLINGS` | Callout feature name for gold_trial_billings. |
+| <a id="usercalloutfeaturenameenumjoining_a_project_alert"></a>`JOINING_A_PROJECT_ALERT` | Callout feature name for joining_a_project_alert. |
| <a id="usercalloutfeaturenameenummerge_request_settings_moved_callout"></a>`MERGE_REQUEST_SETTINGS_MOVED_CALLOUT` | Callout feature name for merge_request_settings_moved_callout. |
| <a id="usercalloutfeaturenameenummr_experience_survey"></a>`MR_EXPERIENCE_SURVEY` | Callout feature name for mr_experience_survey. |
| <a id="usercalloutfeaturenameenumnamespace_over_storage_users_combined_alert"></a>`NAMESPACE_OVER_STORAGE_USERS_COMBINED_ALERT` | Callout feature name for namespace_over_storage_users_combined_alert. |
diff --git a/doc/ci/components/index.md b/doc/ci/components/index.md
index 830c91616f7..727baa9be18 100644
--- a/doc/ci/components/index.md
+++ b/doc/ci/components/index.md
@@ -337,6 +337,15 @@ create-release:
After committing and pushing changes, the pipeline tests the component, then creates
a release if the earlier jobs pass.
+#### Test a component against sample files
+
+In some cases, components require source files to interact with. For example, a component
+that builds Go source code likely needs some samples of Go to test against. Alternatively,
+a component that builds Docker images likely needs some sample Dockerfiles to test against.
+
+You can include sample files like these directly in the component project, to be used
+during component testing. For example, you can see the [code-quality CI/CD component's testing samples](https://gitlab.com/components/code-quality/-/tree/main/src).
+
### Avoid using global keywords
Avoid using [global keywords](../yaml/index.md#global-keywords) in a component.
diff --git a/doc/subscriptions/self_managed/index.md b/doc/subscriptions/self_managed/index.md
index 797a59dded6..6cc50103c75 100644
--- a/doc/subscriptions/self_managed/index.md
+++ b/doc/subscriptions/self_managed/index.md
@@ -275,7 +275,6 @@ NOTES:
- A custom format is used for [dates](https://gitlab.com/gitlab-org/gitlab/blob/3be39f19ac3412c089be28553e6f91b681e5d739/config/initializers/date_time_formats.rb#L7) and [times](https://gitlab.com/gitlab-org/gitlab/blob/3be39f19ac3412c089be28553e6f91b681e5d739/config/initializers/date_time_formats.rb#L13) in CSV files.
WARNING:
-
Do not open the license usage file. If you open the file, failures might occur when [you submit your license usage data](../../administration/license_file.md#submit-license-usage-data).
## Renew your subscription
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index b025513cce9..697a2af7ba7 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -33944,6 +33944,12 @@ msgstr ""
msgid "OnDemandScans|at"
msgstr ""
+msgid "Onboarding|If you can't find your organization, request an invite from your company's GitLab administrator."
+msgstr ""
+
+msgid "Onboarding|Looking for your team?"
+msgstr ""
+
msgid "Once imported, repositories can be mirrored over SSH. Read more %{link_start}here%{link_end}."
msgstr ""
@@ -55627,6 +55633,9 @@ msgstr ""
msgid "WorkItem|%{invalidWorkItemsList} cannot be added: Cannot assign a non-confidential %{childWorkItemType} to a confidential parent %{parentWorkItemType}. Make the selected %{childWorkItemType} confidential and try again."
msgstr ""
+msgid "WorkItem|%{usersLength} assignees"
+msgstr ""
+
msgid "WorkItem|%{workItemType} deleted"
msgstr ""
@@ -55797,6 +55806,9 @@ msgstr ""
msgid "WorkItem|New task"
msgstr ""
+msgid "WorkItem|No assignees"
+msgstr ""
+
msgid "WorkItem|No child items are currently assigned. Use child items to break down this issue into smaller parts."
msgstr ""
diff --git a/spec/features/projects/work_items/work_item_spec.rb b/spec/features/projects/work_items/work_item_spec.rb
index 33153d21575..9ce068c2ea1 100644
--- a/spec/features/projects/work_items/work_item_spec.rb
+++ b/spec/features/projects/work_items/work_item_spec.rb
@@ -41,23 +41,62 @@ RSpec.describe 'Work item', :js, feature_category: :team_planning do
expect(page).to have_button _('More actions')
end
- it 'reassigns to another user',
- quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/413074' do
- find('[data-testid="work-item-assignees-input"]').fill_in(with: user.username)
- wait_for_requests
+ context 'when work_items_mvc_2 is disabled' do
+ before do
+ stub_feature_flags(work_items_mvc_2: false)
- send_keys(:enter)
- find("body").click
- wait_for_requests
+ page.refresh
+ wait_for_all_requests
+ end
+
+ it 'reassigns to another user',
+ quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/413074' do
+ find('[data-testid="work-item-assignees-input"]').fill_in(with: user.username)
+ wait_for_requests
+
+ send_keys(:enter)
+ find("body").click
+ wait_for_requests
+
+ find('[data-testid="work-item-assignees-input"]').fill_in(with: user2.username)
+ wait_for_requests
+
+ send_keys(:enter)
+ find("body").click
+ wait_for_requests
+
+ expect(work_item.reload.assignees).to include(user2)
+ end
+ end
+
+ context 'when work_items_mvc_2 is enabled' do
+ before do
+ stub_feature_flags(work_items_mvc_2: true)
+
+ page.refresh
+ wait_for_all_requests
+ end
+
+ it 'reassigns to another user',
+ quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/413074' do
+ within('[data-testid="work-item-assignees-with-edit"]') do
+ click_button 'Edit'
+ end
+
+ select_listbox_item(user.username)
- find('[data-testid="work-item-assignees-input"]').fill_in(with: user2.username)
- wait_for_requests
+ wait_for_requests
- send_keys(:enter)
- find("body").click
- wait_for_requests
+ within('[data-testid="work-item-assignees-with-edit"]') do
+ click_button 'Edit'
+ end
- expect(work_item.reload.assignees).to include(user2)
+ select_listbox_item(user2.username)
+
+ wait_for_requests
+
+ expect(work_item.reload.assignees).to include(user2)
+ end
end
it_behaves_like 'work items title'
@@ -118,9 +157,33 @@ RSpec.describe 'Work item', :js, feature_category: :team_planning do
expect(page).to have_selector('[data-testid="award-button"].disabled')
end
- it 'assignees input field is disabled' do
- within('[data-testid="work-item-assignees-input"]') do
- expect(page).to have_field(type: 'text', disabled: true)
+ context 'when work_items_mvc_2 is disabled' do
+ before do
+ stub_feature_flags(work_items_mvc_2: false)
+
+ page.refresh
+ wait_for_all_requests
+ end
+
+ it 'assignees input field is disabled' do
+ within('[data-testid="work-item-assignees-input"]') do
+ expect(page).to have_field(type: 'text', disabled: true)
+ end
+ end
+ end
+
+ context 'when work_items_mvc_2 is enabled' do
+ before do
+ stub_feature_flags(work_items_mvc_2: true)
+
+ page.refresh
+ wait_for_all_requests
+ end
+
+ it 'assignees edit button is not visible' do
+ within('[data-testid="work-item-assignees-with-edit"]') do
+ expect(page).not_to have_button('Edit')
+ end
end
end
diff --git a/spec/frontend/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit_spec.js b/spec/frontend/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit_spec.js
index 171493e87f8..702c0ab1a6d 100644
--- a/spec/frontend/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit_spec.js
+++ b/spec/frontend/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit_spec.js
@@ -21,6 +21,11 @@ describe('WorkItemSidebarDropdownWidgetWithEdit component', () => {
canUpdate = true,
isEditing = false,
updateInProgress = false,
+ showFooter = false,
+ slots = {},
+ multiSelect = false,
+ infiniteScroll = false,
+ infiniteScrollLoading = false,
} = {}) => {
wrapper = mountExtended(WorkItemSidebarDropdownWidgetWithEdit, {
propsData: {
@@ -31,7 +36,12 @@ describe('WorkItemSidebarDropdownWidgetWithEdit component', () => {
canUpdate,
updateInProgress,
headerText: __('Select iteration'),
+ showFooter,
+ multiSelect,
+ infiniteScroll,
+ infiniteScrollLoading,
},
+ slots,
});
if (isEditing) {
@@ -152,10 +162,41 @@ describe('WorkItemSidebarDropdownWidgetWithEdit component', () => {
searching: false,
infiniteScroll: false,
noResultsText: 'No matching results',
- toggleText: 'None',
searchPlaceholder: 'Search',
resetButtonLabel: 'Clear',
});
});
+
+ it('renders the footer when enabled', async () => {
+ const FOOTER_SLOT_HTML = 'Test message';
+ createComponent({ isEditing: true, showFooter: true, slots: { footer: FOOTER_SLOT_HTML } });
+
+ await nextTick();
+ expect(wrapper.text()).toContain(FOOTER_SLOT_HTML);
+ });
+
+ it('supports multiselect', async () => {
+ createComponent({ isEditing: true, multiSelect: true });
+
+ await nextTick();
+
+ expect(findCollapsibleListbox().props('multiple')).toBe(true);
+ });
+
+ it('supports infinite scrolling', async () => {
+ createComponent({ isEditing: true, infiniteScroll: true });
+
+ await nextTick();
+
+ expect(findCollapsibleListbox().props('infiniteScroll')).toBe(true);
+ });
+
+ it('shows loader when bottom reached', async () => {
+ createComponent({ isEditing: true, infiniteScroll: true, infiniteScrollLoading: true });
+
+ await nextTick();
+
+ expect(findCollapsibleListbox().props('infiniteScrollLoading')).toBe(true);
+ });
});
});
diff --git a/spec/frontend/work_items/components/work_item_assignees_spec.js b/spec/frontend/work_items/components/work_item_assignees_inline_spec.js
index b34eed21c60..ae7828b6c48 100644
--- a/spec/frontend/work_items/components/work_item_assignees_spec.js
+++ b/spec/frontend/work_items/components/work_item_assignees_inline_spec.js
@@ -11,7 +11,7 @@ import usersSearchQuery from '~/graphql_shared/queries/users_search.query.graphq
import currentUserQuery from '~/graphql_shared/queries/current_user.query.graphql';
import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
-import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue';
+import WorkItemAssigneesInline from '~/work_items/components/work_item_assignees_inline.vue';
import {
i18n,
DEFAULT_PAGE_SIZE_ASSIGNEES,
@@ -35,7 +35,7 @@ Vue.use(VueApollo);
const workItemId = 'gid://gitlab/WorkItem/1';
const dropdownItems = projectMembersResponseWithCurrentUser.data.workspace.users.nodes;
-describe('WorkItemAssignees component', () => {
+describe('WorkItemAssigneesInline component', () => {
let wrapper;
const findAssigneeLinks = () => wrapper.findAllComponents(GlLink);
@@ -88,7 +88,7 @@ describe('WorkItemAssignees component', () => {
[updateWorkItemMutation, updateWorkItemMutationHandler],
]);
- wrapper = mountExtended(WorkItemAssignees, {
+ wrapper = mountExtended(WorkItemAssigneesInline, {
provide: {
isGroup,
},
diff --git a/spec/frontend/work_items/components/work_item_assignees_with_edit_spec.js b/spec/frontend/work_items/components/work_item_assignees_with_edit_spec.js
new file mode 100644
index 00000000000..f9c875fa124
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_assignees_with_edit_spec.js
@@ -0,0 +1,300 @@
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import WorkItemAssignees from '~/work_items/components/work_item_assignees_with_edit.vue';
+import WorkItemSidebarDropdownWidgetWithEdit from '~/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit.vue';
+import groupUsersSearchQuery from '~/graphql_shared/queries/group_users_search.query.graphql';
+import usersSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
+import currentUserQuery from '~/graphql_shared/queries/current_user.query.graphql';
+import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { mockTracking } from 'helpers/tracking_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import {
+ projectMembersResponseWithCurrentUser,
+ mockAssignees,
+ currentUserResponse,
+ currentUserNullResponse,
+ updateWorkItemMutationResponse,
+ projectMembersResponseWithCurrentUserWithNextPage,
+ projectMembersResponseWithNoMatchingUsers,
+} from 'jest/work_items/mock_data';
+import { DEFAULT_PAGE_SIZE_ASSIGNEES, i18n, TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
+
+const workItemId = 'gid://gitlab/WorkItem/1';
+
+describe('WorkItemAssigneesWithEdit component', () => {
+ Vue.use(VueApollo);
+
+ let wrapper;
+
+ const findInviteMembersTrigger = () => wrapper.findComponent(InviteMembersTrigger);
+ const findAssignSelfButton = () => wrapper.findByTestId('assign-self');
+ const findSidebarDropdownWidget = () =>
+ wrapper.findComponent(WorkItemSidebarDropdownWidgetWithEdit);
+
+ const successSearchQueryHandler = jest
+ .fn()
+ .mockResolvedValue(projectMembersResponseWithCurrentUser);
+ const successGroupSearchQueryHandler = jest
+ .fn()
+ .mockResolvedValue(projectMembersResponseWithCurrentUser);
+ const successSearchQueryHandlerWithMoreAssignees = jest
+ .fn()
+ .mockResolvedValue(projectMembersResponseWithCurrentUserWithNextPage);
+ const successCurrentUserQueryHandler = jest.fn().mockResolvedValue(currentUserResponse);
+ const noCurrentUserQueryHandler = jest.fn().mockResolvedValue(currentUserNullResponse);
+ const successUpdateWorkItemMutationHandler = jest
+ .fn()
+ .mockResolvedValue(updateWorkItemMutationResponse);
+ const successSearchWithNoMatchingUsers = jest
+ .fn()
+ .mockResolvedValue(projectMembersResponseWithNoMatchingUsers);
+
+ const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem');
+
+ const showDropdown = () => {
+ findSidebarDropdownWidget().vm.$emit('dropdownShown');
+ };
+
+ const createComponent = ({
+ assignees = mockAssignees,
+ searchQueryHandler = successSearchQueryHandler,
+ currentUserQueryHandler = successCurrentUserQueryHandler,
+ allowsMultipleAssignees = false,
+ canInviteMembers = false,
+ canUpdate = true,
+ } = {}) => {
+ const apolloProvider = createMockApollo([
+ [usersSearchQuery, searchQueryHandler],
+ [groupUsersSearchQuery, successGroupSearchQueryHandler],
+ [currentUserQuery, currentUserQueryHandler],
+ [updateWorkItemMutation, successUpdateWorkItemMutationHandler],
+ ]);
+
+ wrapper = shallowMountExtended(WorkItemAssignees, {
+ provide: {
+ isGroup: false,
+ },
+ propsData: {
+ assignees,
+ fullPath: 'test-project-path',
+ workItemId,
+ allowsMultipleAssignees,
+ workItemType: 'Task',
+ canUpdate,
+ canInviteMembers,
+ },
+ apolloProvider,
+ });
+ };
+
+ it('has "Assignee" label for single select', () => {
+ createComponent();
+
+ expect(findSidebarDropdownWidget().props('dropdownLabel')).toBe('Assignee');
+ });
+
+ describe('Dropdown search', () => {
+ it('shows no items in the dropdown when no results matching', async () => {
+ createComponent({ searchQueryHandler: successSearchWithNoMatchingUsers });
+ showDropdown();
+ await waitForPromises();
+
+ expect(findSidebarDropdownWidget().props('listItems')).toHaveLength(0);
+ });
+
+ it('emits error event if search users query fails', async () => {
+ createComponent({ searchQueryHandler: errorHandler });
+ showDropdown();
+ await waitForPromises();
+
+ expect(wrapper.emitted('error')).toEqual([[i18n.fetchError]]);
+ });
+ });
+
+ describe('when assigning to current user', () => {
+ it('does not show `Assign yourself` button if current user is loading', () => {
+ createComponent();
+
+ expect(findAssignSelfButton().exists()).toBe(false);
+ });
+
+ it('does now show `Assign yourself` button if user is not logged in', async () => {
+ createComponent({ currentUserQueryHandler: noCurrentUserQueryHandler, assignees: [] });
+ await waitForPromises();
+
+ expect(findAssignSelfButton().exists()).toBe(false);
+ });
+ });
+
+ describe('Dropdown options', () => {
+ beforeEach(() => {
+ createComponent({ canUpdate: true });
+ });
+
+ it('calls successSearchQueryHandler with variables when dropdown is opened', async () => {
+ showDropdown();
+
+ await waitForPromises();
+
+ expect(successSearchQueryHandler).toHaveBeenCalledWith({
+ first: DEFAULT_PAGE_SIZE_ASSIGNEES,
+ fullPath: 'test-project-path',
+ search: '',
+ });
+ });
+
+ it('shows the skeleton loader when the items are being fetched on click', async () => {
+ showDropdown();
+
+ await nextTick();
+
+ expect(findSidebarDropdownWidget().props('loading')).toBe(true);
+ });
+
+ it('shows the iterations in dropdown when the items have finished fetching', async () => {
+ showDropdown();
+
+ await waitForPromises();
+
+ expect(findSidebarDropdownWidget().props('loading')).toBe(false);
+ expect(findSidebarDropdownWidget().props('listItems')).toHaveLength(
+ projectMembersResponseWithCurrentUser.data.workspace.users.nodes.length,
+ );
+ });
+ });
+
+ describe('when user is logged in and there are no assignees', () => {
+ beforeEach(() => {
+ createComponent({ assignees: [] });
+ return waitForPromises();
+ });
+
+ it('renders `Assign yourself` button', () => {
+ expect(findAssignSelfButton().exists()).toBe(true);
+ });
+
+ it('calls update work item assignees mutation with current user as a variable on button click', async () => {
+ const { currentUser } = currentUserResponse.data;
+ findAssignSelfButton().vm.$emit('click', new MouseEvent('click'));
+ await nextTick();
+
+ expect(successUpdateWorkItemMutationHandler).toHaveBeenCalledWith({
+ input: {
+ id: workItemId,
+ assigneesWidget: {
+ assigneeIds: [currentUser.id],
+ },
+ },
+ });
+ });
+ });
+
+ describe('when multiple assignees are allowed', () => {
+ beforeEach(() => {
+ createComponent({ allowsMultipleAssignees: true, assignees: [] });
+ return waitForPromises();
+ });
+
+ it('renders `Assignees` as label and `Select assignees` as dropdown button header', () => {
+ expect(findSidebarDropdownWidget().props()).toMatchObject({
+ dropdownLabel: 'Assignees',
+ headerText: 'Select assignees',
+ });
+ });
+
+ it('adds multiple assignees when collapsible listbox provides multiple values', async () => {
+ showDropdown();
+ await waitForPromises();
+
+ findSidebarDropdownWidget().vm.$emit('updateValue', [
+ 'gid://gitlab/User/5',
+ 'gid://gitlab/User/6',
+ ]);
+ await nextTick();
+
+ expect(findSidebarDropdownWidget().props('itemValue')).toHaveLength(2);
+ });
+ });
+
+ describe('tracking', () => {
+ let trackingSpy;
+
+ beforeEach(() => {
+ createComponent();
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ afterEach(() => {
+ trackingSpy = null;
+ });
+
+ it('tracks editing the assignees on dropdown widget updateValue', async () => {
+ showDropdown();
+ await waitForPromises();
+
+ findSidebarDropdownWidget().vm.$emit('updateValue', mockAssignees[0].id);
+ await waitForPromises();
+
+ expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_assignees', {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'item_assignees',
+ property: 'type_Task',
+ });
+ });
+ });
+
+ describe('invite members', () => {
+ it('does not render `Invite members` link if user has no permission to invite members', () => {
+ createComponent();
+
+ expect(findInviteMembersTrigger().exists()).toBe(false);
+ });
+
+ it('renders `Invite members` link if user has a permission to invite members', () => {
+ createComponent({ canInviteMembers: true });
+
+ expect(findInviteMembersTrigger().exists()).toBe(true);
+ });
+ });
+
+ describe('load more assignees', () => {
+ it('does not have infinite scroll when no matching users', async () => {
+ createComponent({ searchQueryHandler: successSearchWithNoMatchingUsers });
+
+ showDropdown();
+ await waitForPromises();
+
+ expect(findSidebarDropdownWidget().props('infiniteScroll')).toBe(false);
+ });
+
+ it('does not trigger load more when does not have next page', async () => {
+ createComponent();
+
+ showDropdown();
+ await waitForPromises();
+
+ expect(findSidebarDropdownWidget().props('infiniteScroll')).toBe(false);
+ });
+
+ it('triggers load more when there are more users', async () => {
+ createComponent({ searchQueryHandler: successSearchQueryHandlerWithMoreAssignees });
+
+ showDropdown();
+ await waitForPromises();
+
+ findSidebarDropdownWidget().vm.$emit('bottomReached');
+ await waitForPromises();
+
+ expect(successSearchQueryHandlerWithMoreAssignees).toHaveBeenCalledWith({
+ first: DEFAULT_PAGE_SIZE_ASSIGNEES,
+ after:
+ projectMembersResponseWithCurrentUserWithNextPage.data.workspace.users.pageInfo.endCursor,
+ search: '',
+ fullPath: 'test-project-path',
+ });
+ });
+ });
+});
diff --git a/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js b/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js
index 43f7027406f..438b975b120 100644
--- a/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js
+++ b/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js
@@ -1,6 +1,6 @@
import { nextTick } from 'vue';
import { shallowMount } from '@vue/test-utils';
-import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue';
+import WorkItemAssigneesWithEdit from '~/work_items/components/work_item_assignees_with_edit.vue';
import WorkItemDueDate from '~/work_items/components/work_item_due_date.vue';
import WorkItemLabels from '~/work_items/components/work_item_labels.vue';
import WorkItemMilestoneInline from '~/work_items/components/work_item_milestone_inline.vue';
@@ -23,7 +23,7 @@ describe('WorkItemAttributesWrapper component', () => {
const workItemQueryResponse = workItemResponseFactory({ canUpdate: true, canDelete: true });
const findWorkItemDueDate = () => wrapper.findComponent(WorkItemDueDate);
- const findWorkItemAssignees = () => wrapper.findComponent(WorkItemAssignees);
+ const findWorkItemAssignees = () => wrapper.findComponent(WorkItemAssigneesWithEdit);
const findWorkItemLabels = () => wrapper.findComponent(WorkItemLabels);
const findWorkItemMilestone = () => wrapper.findComponent(WorkItemMilestoneWithEdit);
const findWorkItemMilestoneInline = () => wrapper.findComponent(WorkItemMilestoneInline);
diff --git a/spec/frontend/work_items/components/work_item_milestone_with_edit_spec.js b/spec/frontend/work_items/components/work_item_milestone_with_edit_spec.js
index d6ad7b6887b..3efc04719e0 100644
--- a/spec/frontend/work_items/components/work_item_milestone_with_edit_spec.js
+++ b/spec/frontend/work_items/components/work_item_milestone_with_edit_spec.js
@@ -150,7 +150,7 @@ describe('WorkItemMilestoneWithEdit component', () => {
await nextTick();
- expect(findSidebarDropdownWidget().props('itemValue').title).toBe(milestoneAtIndex.title);
+ expect(findSidebarDropdownWidget().props('itemValue')).toBe(milestoneAtIndex.id);
});
});
diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb
index 2445689bf9f..3d9996616d8 100644
--- a/spec/helpers/application_helper_spec.rb
+++ b/spec/helpers/application_helper_spec.rb
@@ -701,17 +701,31 @@ RSpec.describe ApplicationHelper do
end
describe 'with-header' do
- context 'when current_user' do
+ context 'when @with_header is falsey' do
before do
- allow(helper).to receive(:current_user).and_return(user)
+ helper.instance_variable_set(:@with_header, nil)
+ end
+
+ context 'when current_user' do
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ end
+
+ it { is_expected.not_to include('with-header') }
end
- it { is_expected.not_to include('with-header') }
+ context 'when no current_user' do
+ before do
+ allow(helper).to receive(:current_user).and_return(nil)
+ end
+
+ it { is_expected.to include('with-header') }
+ end
end
- context 'when no current_user' do
+ context 'when @with_header is true' do
before do
- allow(helper).to receive(:current_user).and_return(nil)
+ helper.instance_variable_set(:@with_header, true)
end
it { is_expected.to include('with-header') }
diff --git a/spec/lib/gitlab/auth/current_user_mode_spec.rb b/spec/lib/gitlab/auth/current_user_mode_spec.rb
index 0a68a4a0ae2..650af6af229 100644
--- a/spec/lib/gitlab/auth/current_user_mode_spec.rb
+++ b/spec/lib/gitlab/auth/current_user_mode_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Auth::CurrentUserMode, :request_store do
+RSpec.describe Gitlab::Auth::CurrentUserMode, :request_store, feature_category: :system_access do
let(:user) { build_stubbed(:user) }
subject { described_class.new(user) }
diff --git a/spec/support/shared_examples/features/work_items_shared_examples.rb b/spec/support/shared_examples/features/work_items_shared_examples.rb
index 0f35681ca7d..4f36d8a046c 100644
--- a/spec/support/shared_examples/features/work_items_shared_examples.rb
+++ b/spec/support/shared_examples/features/work_items_shared_examples.rb
@@ -167,69 +167,125 @@ RSpec.shared_examples 'work items comments' do |type|
end
RSpec.shared_examples 'work items assignees' do
- it 'successfully assigns the current user by searching',
- quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/413074' do
- # The button is only when the mouse is over the input
- find('[data-testid="work-item-assignees-input"]').fill_in(with: user.username)
- wait_for_requests
- # submit and simulate blur to save
- send_keys(:enter)
- find("body").click
- wait_for_requests
+ context 'when the work_items_mvc_2 FF is disabled' do
+ include_context 'with work_items_mvc_2', false
- expect(work_item.reload.assignees).to include(user)
- end
+ it 'successfully assigns the current user by searching',
+ quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/413074' do
+ # The button is only when the mouse is over the input
+ find('[data-testid="work-item-assignees-input"]').fill_in(with: user.username)
+ wait_for_requests
+ # submit and simulate blur to save
+ send_keys(:enter)
+ find("body").click
+ wait_for_requests
- it 'successfully assigns the current user by clicking `Assign myself` button' do
- find('[data-testid="work-item-assignees-input"]').hover
- click_button _('Assign yourself')
+ expect(work_item.reload.assignees).to include(user)
+ end
- expect(work_item.reload.assignees).to include(user)
- end
+ it 'successfully assigns the current user by clicking `Assign myself` button' do
+ find('[data-testid="work-item-assignees-input"]').hover
+ click_button _('Assign yourself')
- it 'successfully removes all users on clear all button click' do
- find('[data-testid="work-item-assignees-input"]').hover
- click_button _('Assign yourself')
+ expect(work_item.reload.assignees).to include(user)
+ end
- expect(work_item.reload.assignees).to include(user)
+ it 'successfully removes all users on clear all button click' do
+ find('[data-testid="work-item-assignees-input"]').hover
+ click_button _('Assign yourself')
- find('[data-testid="work-item-assignees-input"]').click
- click_button 'Clear all'
- find("body").click
- wait_for_requests
+ expect(work_item.reload.assignees).to include(user)
- expect(work_item.reload.assignees).not_to include(user)
- end
+ find('[data-testid="work-item-assignees-input"]').click
+ click_button 'Clear all'
+ find("body").click
+ wait_for_requests
- it 'successfully removes user on clicking badge cross button' do
- find('[data-testid="work-item-assignees-input"]').hover
- click_button _('Assign yourself')
+ expect(work_item.reload.assignees).not_to include(user)
+ end
+
+ it 'successfully removes user on clicking badge cross button' do
+ find('[data-testid="work-item-assignees-input"]').hover
+ click_button _('Assign yourself')
+
+ expect(work_item.reload.assignees).to include(user)
- expect(work_item.reload.assignees).to include(user)
+ within('[data-testid="work-item-assignees-input"]') do
+ click_button 'Close'
+ end
+ find("body").click
+ wait_for_requests
- within('[data-testid="work-item-assignees-input"]') do
- click_button 'Close'
+ expect(work_item.reload.assignees).not_to include(user)
end
- find("body").click
- wait_for_requests
- expect(work_item.reload.assignees).not_to include(user)
+ it 'updates the assignee in real-time' do
+ Capybara::Session.new(:other_session)
+
+ using_session :other_session do
+ visit work_items_path
+ expect(work_item.reload.assignees).not_to include(user)
+ end
+
+ find('[data-testid="work-item-assignees-input"]').hover
+ click_button _('Assign yourself')
+
+ expect(work_item.reload.assignees).to include(user)
+ using_session :other_session do
+ expect(work_item.reload.assignees).to include(user)
+ end
+ end
end
- it 'updates the assignee in real-time' do
- Capybara::Session.new(:other_session)
+ context 'when the work_items_mvc_2 FF is enabled' do
+ let(:work_item_assignees_selector) { '[data-testid="work-item-assignees-with-edit"]' }
- using_session :other_session do
- visit work_items_path
- expect(work_item.reload.assignees).not_to include(user)
+ include_context 'with work_items_mvc_2', true
+
+ it 'successfully assigns the current user by searching',
+ quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/413074' do
+ # The button is only when the mouse is over the input
+ find_and_click_edit(work_item_assignees_selector)
+
+ select_listbox_item(user.username)
+
+ find("body").click
+ wait_for_all_requests
+
+ expect(work_item.assignees).to include(user)
end
- find('[data-testid="work-item-assignees-input"]').hover
- click_button _('Assign yourself')
+ it 'successfully removes all users on clear all button click' do
+ find_and_click_edit(work_item_assignees_selector)
+
+ select_listbox_item(user.username)
+
+ find("body").click
+ wait_for_requests
+
+ find_and_click_edit(work_item_assignees_selector)
+
+ find_and_click_clear(work_item_assignees_selector)
+ wait_for_all_requests
+
+ expect(work_item.assignees).not_to include(user)
+ end
+
+ it 'updates the assignee in real-time' do
+ Capybara::Session.new(:other_session)
+
+ using_session :other_session do
+ visit work_items_path
+ expect(work_item.reload.assignees).not_to include(user)
+ end
+
+ click_button 'assign yourself'
+ wait_for_all_requests
- expect(work_item.reload.assignees).to include(user)
- using_session :other_session do
expect(work_item.reload.assignees).to include(user)
+ using_session :other_session do
+ expect(work_item.reload.assignees).to include(user)
+ end
end
end
end
@@ -391,15 +447,37 @@ end
RSpec.shared_examples 'work items invite members' do
include Features::InviteMembersModalHelpers
- it 'successfully assigns the current user by searching' do
- # The button is only when the mouse is over the input
- find('[data-testid="work-item-assignees-input"]').fill_in(with: 'Invite members')
- wait_for_requests
+ context 'when the work_items_mvc_2 FF is disabled' do
+ include_context 'with work_items_mvc_2', false
+
+ it 'successfully assigns the current user by searching' do
+ # The button is only when the mouse is over the input
+ find('[data-testid="work-item-assignees-input"]').fill_in(with: 'Invite members')
+ wait_for_requests
+
+ click_button('Invite members')
+
+ page.within invite_modal_selector do
+ expect(page).to have_text("You're inviting members to the #{work_item.project.name} project")
+ end
+ end
+ end
- click_button('Invite members')
+ context 'when the work_items_mvc_2 FF is enabled' do
+ let(:work_item_assignees_selector) { '[data-testid="work-item-assignees-with-edit"]' }
+
+ include_context 'with work_items_mvc_2', true
- page.within invite_modal_selector do
- expect(page).to have_text("You're inviting members to the #{work_item.project.name} project")
+ it 'successfully assigns the current user by searching' do
+ # The button is only when the mouse is over the input
+ find_and_click_edit(work_item_assignees_selector)
+ wait_for_requests
+
+ click_link('Invite members')
+
+ page.within invite_modal_selector do
+ expect(page).to have_text("You're inviting members to the #{work_item.project.name} project")
+ end
end
end
end