diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-04-13 12:11:10 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-04-13 12:11:10 +0300 |
commit | 37974ac0b196b06ffcc6cbea44385eaac1cc57bd (patch) | |
tree | 98450a46516f93a71018ec6b8d718fc023744575 /app | |
parent | fcbd3db20f5dfb13ae33ddfee98be8d92cade72f (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
48 files changed, 966 insertions, 380 deletions
diff --git a/app/assets/javascripts/boards/components/filtered_search.vue b/app/assets/javascripts/boards/components/filtered_search.vue deleted file mode 100644 index 8505ea39a6b..00000000000 --- a/app/assets/javascripts/boards/components/filtered_search.vue +++ /dev/null @@ -1,54 +0,0 @@ -<script> -import { mapActions } from 'vuex'; -import { historyPushState } from '~/lib/utils/common_utils'; -import { setUrlParams } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; -import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; - -export default { - i18n: { - search: __('Search'), - }, - components: { FilteredSearch }, - props: { - search: { - type: String, - required: false, - default: '', - }, - }, - computed: { - initialSearch() { - return [{ type: 'filtered-search-term', value: { data: this.search } }]; - }, - }, - methods: { - ...mapActions(['performSearch']), - handleSearch(filters) { - let itemValue = ''; - const [item] = filters; - - if (filters.length === 0) { - itemValue = ''; - } else { - itemValue = item?.value?.data; - } - - historyPushState(setUrlParams({ search: itemValue }, window.location.href)); - - this.performSearch(); - }, - }, -}; -</script> - -<template> - <filtered-search - class="gl-w-full" - namespace="" - :tokens="[]" - :search-input-placeholder="$options.i18n.search" - :initial-filter-value="initialSearch" - @onFilter="handleSearch" - /> -</template> diff --git a/app/assets/javascripts/boards/filtered_search.js b/app/assets/javascripts/boards/filtered_search.js deleted file mode 100644 index 182a2cf3724..00000000000 --- a/app/assets/javascripts/boards/filtered_search.js +++ /dev/null @@ -1,25 +0,0 @@ -import Vue from 'vue'; -import store from '~/boards/stores'; -import { queryToObject } from '~/lib/utils/url_utility'; -import FilteredSearch from './components/filtered_search.vue'; - -export default () => { - const queryParams = queryToObject(window.location.search); - const el = document.getElementById('js-board-filtered-search'); - - /* - When https://github.com/vuejs/vue-apollo/pull/1153 is merged and deployed - we can remove apolloProvider option from here. Currently without it its causing - an error - */ - - return new Vue({ - el, - store, - apolloProvider: {}, - render: (createElement) => - createElement(FilteredSearch, { - props: { search: queryParams.search }, - }), - }); -}; diff --git a/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql new file mode 100644 index 00000000000..0b451262b5a --- /dev/null +++ b/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql @@ -0,0 +1,5 @@ +fragment UserAvailability on User { + status { + availability + } +} diff --git a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql index 3397a2529f1..e18eea33041 100644 --- a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql @@ -1,4 +1,5 @@ #import "../fragments/user.fragment.graphql" +#import "~/graphql_shared/fragments/user_availability.fragment.graphql" query usersSearch($search: String!, $fullPath: ID!) { workspace: project(fullPath: $fullPath) { @@ -6,6 +7,7 @@ query usersSearch($search: String!, $fullPath: ID!) { nodes { user { ...User + ...UserAvailability } } } diff --git a/app/assets/javascripts/invite_member/components/invite_member_modal.vue b/app/assets/javascripts/invite_member/components/invite_member_modal.vue index 144c1a2c22a..ec77e49ae53 100644 --- a/app/assets/javascripts/invite_member/components/invite_member_modal.vue +++ b/app/assets/javascripts/invite_member/components/invite_member_modal.vue @@ -19,8 +19,10 @@ export default { GlLink, GlModal, }, - inject: { + props: { membersPath: { + type: String, + required: false, default: '', }, }, diff --git a/app/assets/javascripts/invite_member/components/invite_member_trigger.vue b/app/assets/javascripts/invite_member/components/invite_member_trigger.vue index 56cf1ab2fc2..ee89e0bbf71 100644 --- a/app/assets/javascripts/invite_member/components/invite_member_trigger.vue +++ b/app/assets/javascripts/invite_member/components/invite_member_trigger.vue @@ -7,14 +7,20 @@ export default { components: { GlLink, }, - inject: { + props: { displayText: { + type: String, + required: false, default: '', }, event: { + type: String, + required: false, default: '', }, label: { + type: String, + required: false, default: '', }, }, diff --git a/app/assets/javascripts/invite_member/init_invite_member_modal.js b/app/assets/javascripts/invite_member/init_invite_member_modal.js index c292bda1931..108f636ee3e 100644 --- a/app/assets/javascripts/invite_member/init_invite_member_modal.js +++ b/app/assets/javascripts/invite_member/init_invite_member_modal.js @@ -1,5 +1,6 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; +import { isInIssuePage, isInDesignPage } from '~/lib/utils/common_utils'; import InviteMemberModal from './components/invite_member_modal.vue'; Vue.use(GlToast); @@ -7,7 +8,7 @@ Vue.use(GlToast); export default function initInviteMembersModal() { const el = document.querySelector('.js-invite-member-modal'); - if (!el) { + if (!el || isInDesignPage() || isInIssuePage()) { return false; } @@ -15,7 +16,9 @@ export default function initInviteMembersModal() { return new Vue({ el, - provide: { membersPath }, - render: (createElement) => createElement(InviteMemberModal), + render: (createElement) => + createElement(InviteMemberModal, { + props: { membersPath }, + }), }); } diff --git a/app/assets/javascripts/invite_member/init_invite_member_trigger.js b/app/assets/javascripts/invite_member/init_invite_member_trigger.js index 5e763e4f47d..eb765ae83b0 100644 --- a/app/assets/javascripts/invite_member/init_invite_member_trigger.js +++ b/app/assets/javascripts/invite_member/init_invite_member_trigger.js @@ -10,7 +10,9 @@ export default function initInviteMembersTrigger() { return new Vue({ el, - provide: { ...el.dataset }, - render: (createElement) => createElement(InviteMemberTrigger), + render: (createElement) => + createElement(InviteMemberTrigger, { + props: { ...el.dataset }, + }), }); } diff --git a/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql b/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql deleted file mode 100644 index 42e646391a8..00000000000 --- a/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql +++ /dev/null @@ -1,16 +0,0 @@ -#import "~/graphql_shared/fragments/author.fragment.graphql" - -query getProjectIssue($iid: String!, $fullPath: ID!) { - project(fullPath: $fullPath) { - issue(iid: $iid) { - id - assignees { - nodes { - ...Author - id - state - } - } - } - } -} diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index da8dc527d79..91f376060f8 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -123,10 +123,19 @@ export default class Project { const loc = window.location.href; if (loc.includes('/-/')) { - const refs = this.fullData.Branches.concat(this.fullData.Tags); - const currentRef = refs.find((ref) => loc.indexOf(ref) > -1); - if (currentRef) { - const targetPath = loc.split(currentRef)[1].slice(1).split('#')[0]; + // Since the current ref in renderRow is outdated on page changes + // (To be addressed in: https://gitlab.com/gitlab-org/gitlab/-/issues/327085) + // We are deciphering the current ref from the dropdown data instead + const currentRef = $dropdown.data('ref'); + // The split and startWith is to ensure an exact word match + // and avoid partial match ie. currentRef is "dev" and loc is "development" + const splitPathAfterRefPortion = loc.split(currentRef)[1]; + const doesPathContainRef = splitPathAfterRefPortion?.startsWith('/'); + + if (doesPathContainRef) { + // We are ignoring the url containing the ref portion + // and plucking the thereafter portion to reconstructure the url that is correct + const targetPath = splitPathAfterRefPortion?.slice(1).split('#')[0]; selectedUrl.searchParams.set('path', targetPath); selectedUrl.hash = window.location.hash; } diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 849f4c27988..cc53532b554 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -6,6 +6,8 @@ import PipelineGraphLegacy from './components/graph/graph_component_legacy.vue'; import TestReports from './components/test_reports/test_reports.vue'; import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; import createDagApp from './pipeline_details_dag'; +import { createPipelinesDetailApp } from './pipeline_details_graph'; +import { createPipelineHeaderApp } from './pipeline_details_header'; import { apolloProvider } from './pipeline_shared_client'; import createTestReportsStore from './stores/test_reports'; import { reportToSentry } from './utils'; @@ -80,20 +82,19 @@ const createTestDetails = () => { }; export default async function initPipelineDetailsBundle() { - createTestDetails(); - createDagApp(apolloProvider); - const canShowNewPipelineDetails = gon.features.graphqlPipelineDetails || gon.features.graphqlPipelineDetailsUsers; const { dataset } = document.querySelector(SELECTORS.PIPELINE_DETAILS); + try { + createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER, apolloProvider, dataset.graphqlResourceEtag); + } catch { + Flash(__('An error occurred while loading a section of this page.')); + } + if (canShowNewPipelineDetails) { try { - const { createPipelinesDetailApp } = await import( - /* webpackChunkName: 'createPipelinesDetailApp' */ './pipeline_details_graph' - ); - createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, apolloProvider, dataset); } catch { Flash(__('An error occurred while loading the pipeline.')); @@ -108,12 +109,6 @@ export default async function initPipelineDetailsBundle() { createLegacyPipelinesDetailApp(mediator); } - try { - const { createPipelineHeaderApp } = await import( - /* webpackChunkName: 'createPipelineHeaderApp' */ './pipeline_details_header' - ); - createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER, apolloProvider, dataset.graphqlResourceEtag); - } catch { - Flash(__('An error occurred while loading a section of this page.')); - } + createDagApp(apolloProvider); + createTestDetails(); } diff --git a/app/assets/javascripts/projects/compare/components/repo_dropdown.vue b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue index 822dfc09d81..cb9d8b64b33 100644 --- a/app/assets/javascripts/projects/compare/components/repo_dropdown.vue +++ b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue @@ -46,14 +46,7 @@ export default { this.emitTargetProject(repo.name); }, setDefaultRepo() { - if (this.isSourceRevision) { - this.selectedRepo = this.projectTo; - return; - } - - const [defaultTargetProject] = this.projectsFrom; - this.emitTargetProject(defaultTargetProject.name); - this.selectedRepo = defaultTargetProject; + this.selectedRepo = this.projectTo; }, emitTargetProject(name) { if (!this.isSourceRevision) { diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue index d0a65b48522..98fc0b0a783 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue @@ -103,10 +103,10 @@ export default { v-gl-tooltip="tooltipOption" :href="assigneeUrl" :title="tooltipTitle" - class="d-inline-block" + class="gl-display-inline-block" > <!-- use d-flex so that slot can be appropriately styled --> - <span class="d-flex"> + <span class="gl-display-flex"> <assignee-avatar :user="user" :img-size="32" :issuable-type="issuableType" /> <slot></slot> </span> diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue index ca86d6c6c3e..f98798582c1 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue @@ -1,7 +1,7 @@ <script> import actionCable from '~/actioncable_consumer'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import query from '~/issuable_sidebar/queries/issue_sidebar.query.graphql'; +import { assigneesQueries } from '~/sidebar/constants'; export default { subscription: null, @@ -9,7 +9,8 @@ export default { props: { mediator: { type: Object, - required: true, + required: false, + default: null, }, issuableIid: { type: String, @@ -19,10 +20,16 @@ export default { type: String, required: true, }, + issuableType: { + type: String, + required: true, + }, }, apollo: { - project: { - query, + workspace: { + query() { + return assigneesQueries[this.issuableType].query; + }, variables() { return { iid: this.issuableIid, @@ -30,7 +37,9 @@ export default { }; }, result(data) { - this.handleFetchResult(data); + if (this.mediator) { + this.handleFetchResult(data); + } }, }, }, @@ -43,7 +52,7 @@ export default { methods: { received(data) { if (data.event === 'updated') { - this.$apollo.queries.project.refetch(); + this.$apollo.queries.workspace.refetch(); } }, initActionCablePolling() { @@ -57,7 +66,7 @@ export default { ); }, handleFetchResult({ data }) { - const { nodes } = data.project.issue.assignees; + const { nodes } = data.workspace.issuable.assignees; const assignees = nodes.map((n) => ({ ...n, @@ -69,7 +78,7 @@ export default { }, }, render() { - return this.$slots.default; + return null; }, }; </script> diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue index b53b7039018..e93aced12f3 100644 --- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue @@ -18,6 +18,11 @@ export default { required: false, default: 'issue', }, + signedIn: { + type: Boolean, + required: false, + default: false, + }, }, computed: { assigneesText() { @@ -34,20 +39,28 @@ export default { <div class="gl-display-flex gl-flex-direction-column issuable-assignees"> <div v-if="emptyUsers" - class="gl-display-flex gl-align-items-center gl-text-gray-500" + class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-2 hide-collapsed" data-testid="none" > - <span> {{ __('None') }} -</span> - <gl-button - data-testid="assign-yourself" - category="tertiary" - variant="link" - class="gl-ml-2" - @click="$emit('assign-self')" - > - <span class="gl-text-gray-500 gl-hover-text-blue-800">{{ __('assign yourself') }}</span> - </gl-button> + <span> {{ __('None') }}</span> + <template v-if="signedIn"> + <span class="gl-ml-2">-</span> + <gl-button + data-testid="assign-yourself" + category="tertiary" + variant="link" + class="gl-ml-2" + @click="$emit('assign-self')" + > + <span class="gl-text-gray-500 gl-hover-text-blue-800">{{ __('assign yourself') }}</span> + </gl-button> + </template> </div> - <uncollapsed-assignee-list v-else :users="users" :issuable-type="issuableType" /> + <uncollapsed-assignee-list + v-else + :users="users" + :issuable-type="issuableType" + class="gl-mt-2 hide-collapsed" + /> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index 6595debf9a5..e15ea595190 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -123,6 +123,7 @@ export default { v-if="shouldEnableRealtime" :issuable-iid="issuableIid" :project-path="projectPath" + :issuable-type="issuableType" :mediator="mediator" /> <assignee-title diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue index cc2201ad359..34bc5cf5c2b 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue @@ -1,26 +1,28 @@ <script> -import { - GlDropdownItem, - GlDropdownDivider, - GlAvatarLabeled, - GlAvatarLink, - GlSearchBoxByType, - GlLoadingIcon, -} from '@gitlab/ui'; +import { GlDropdownItem, GlDropdownDivider, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui'; import { cloneDeep } from 'lodash'; import Vue from 'vue'; import createFlash from '~/flash'; import searchUsers from '~/graphql_shared/queries/users_search.query.graphql'; import { IssuableType } from '~/issue_show/constants'; import { __, n__ } from '~/locale'; +import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import { assigneesQueries, ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants'; import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import SidebarInviteMembers from './sidebar_invite_members.vue'; +import SidebarParticipant from './sidebar_participant.vue'; export const assigneesWidget = Vue.observable({ updateAssignees: null, }); + +const hideDropdownEvent = new CustomEvent('hiddenGlDropdown', { + bubbles: true, +}); + export default { i18n: { unassigned: __('Unassigned'), @@ -28,17 +30,26 @@ export default { assignees: __('Assignees'), assignTo: __('Assign to'), }, - assigneesQueries, components: { SidebarEditableItem, IssuableAssignees, MultiSelectDropdown, GlDropdownItem, GlDropdownDivider, - GlAvatarLabeled, - GlAvatarLink, GlSearchBoxByType, GlLoadingIcon, + SidebarInviteMembers, + SidebarParticipant, + SidebarAssigneesRealtime, + }, + mixins: [glFeatureFlagsMixin()], + inject: { + directlyInviteMembers: { + default: false, + }, + indirectlyInviteMembers: { + default: false, + }, }, props: { iid: { @@ -76,12 +87,13 @@ export default { selected: [], isSettingAssignees: false, isSearching: false, + isDirty: false, }; }, apollo: { issuable: { query() { - return this.$options.assigneesQueries[this.issuableType].query; + return assigneesQueries[this.issuableType].query; }, variables() { return this.queryVariables; @@ -134,6 +146,10 @@ export default { }, }, computed: { + shouldEnableRealtime() { + // Note: Realtime is only available on issues right now, future support for MR wil be built later. + return this.glFeatures.realTimeIssueSidebar && this.issuableType === IssuableType.Issue; + }, queryVariables() { return { iid: this.iid, @@ -155,6 +171,9 @@ export default { }, assigneeText() { const items = this.$apollo.queries.issuable.loading ? this.initialAssignees : this.selected; + if (!items) { + return __('Assignee'); + } return n__('Assignee', '%d Assignees', items.length); }, selectedFiltered() { @@ -197,8 +216,15 @@ export default { noUsersFound() { return !this.isSearchEmpty && this.searchUsers.length === 0; }, + signedIn() { + return this.currentUser.username !== undefined; + }, showCurrentUser() { - return !this.isCurrentUserInParticipants && (this.isSearchEmpty || this.isSearching); + return ( + this.signedIn && + !this.isCurrentUserInParticipants && + (this.isSearchEmpty || this.isSearching) + ); }, }, watch: { @@ -221,7 +247,7 @@ export default { this.isSettingAssignees = true; return this.$apollo .mutate({ - mutation: this.$options.assigneesQueries[this.issuableType].mutation, + mutation: assigneesQueries[this.issuableType].mutation, variables: { ...this.queryVariables, assigneeUsernames, @@ -239,20 +265,22 @@ export default { }); }, selectAssignee(name) { - if (name === undefined) { - this.clearSelected(); - return; - } + this.isDirty = true; if (!this.multipleAssignees) { - this.selected = [name]; + this.selected = name ? [name] : []; this.collapseWidget(); - } else { - this.selected = this.selected.concat(name); + return; } + if (name === undefined) { + this.clearSelected(); + return; + } + this.selected = this.selected.concat(name); }, unselect(name) { this.selected = this.selected.filter((user) => user.username !== name); + this.isDirty = true; if (!this.multipleAssignees) { this.collapseWidget(); @@ -265,7 +293,9 @@ export default { this.selected = []; }, saveAssignees() { + this.isDirty = false; this.updateAssignees(this.selectedUserNames); + this.$el.dispatchEvent(hideDropdownEvent); }, isChecked(id) { return this.selectedUserNames.includes(id); @@ -291,6 +321,9 @@ export default { collapseWidget() { this.$refs.toggle.collapse(); }, + expandWidget() { + this.$refs.toggle.expand(); + }, showDivider(list) { return list.length > 0 && this.isSearchEmpty; }, @@ -299,121 +332,113 @@ export default { </script> <template> - <div - v-if="isAssigneesLoading" - class="gl-display-flex gl-align-items-center assignee" - data-testid="loading-assignees" - > - {{ __('Assignee') }} - <gl-loading-icon size="sm" class="gl-ml-2" /> - </div> - <sidebar-editable-item - v-else - ref="toggle" - :loading="isSettingAssignees" - :title="assigneeText" - @open="focusSearch" - @close="saveAssignees" - > - <template #collapsed> - <issuable-assignees - :users="assignees" - :issuable-type="issuableType" - class="gl-mt-2" - @assign-self="assignSelf" - /> - </template> + <div data-testid="assignees-widget"> + <sidebar-assignees-realtime + v-if="shouldEnableRealtime" + :project-path="fullPath" + :issuable-iid="iid" + :issuable-type="issuableType" + /> + <sidebar-editable-item + ref="toggle" + :loading="isSettingAssignees" + :initial-loading="isAssigneesLoading" + :title="assigneeText" + :is-dirty="isDirty" + @open="focusSearch" + @close="saveAssignees" + > + <template #collapsed> + <slot name="collapsed" :users="assignees" :on-click="expandWidget"></slot> + <issuable-assignees + :users="assignees" + :issuable-type="issuableType" + :signed-in="signedIn" + @assign-self="assignSelf" + @expand-widget="expandWidget" + /> + </template> - <template #default> - <multi-select-dropdown - class="gl-w-full dropdown-menu-user" - :text="$options.i18n.assignees" - :header-text="$options.i18n.assignTo" - @toggle="collapseWidget" - > - <template #search> - <gl-search-box-by-type ref="search" v-model.trim="search" /> - </template> - <template #items> - <gl-loading-icon - v-if="$apollo.queries.searchUsers.loading || $apollo.queries.issuable.loading" - data-testid="loading-participants" - size="lg" - /> - <template v-else> - <template v-if="isSearchEmpty || isSearching"> + <template #default> + <multi-select-dropdown + class="gl-w-full dropdown-menu-user" + :text="$options.i18n.assignees" + :header-text="$options.i18n.assignTo" + @toggle="collapseWidget" + > + <template #search> + <gl-search-box-by-type + ref="search" + v-model.trim="search" + class="js-dropdown-input-field" + /> + </template> + <template #items> + <gl-loading-icon + v-if="$apollo.queries.searchUsers.loading || $apollo.queries.issuable.loading" + data-testid="loading-participants" + size="lg" + /> + <template v-else> + <template v-if="isSearchEmpty || isSearching"> + <gl-dropdown-item + :is-checked="selectedIsEmpty" + :is-check-centered="true" + data-testid="unassign" + @click="selectAssignee()" + > + <span + :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" + class="gl-font-weight-bold" + >{{ $options.i18n.unassigned }}</span + ></gl-dropdown-item + > + </template> + <gl-dropdown-divider v-if="showDivider(selectedFiltered)" /> <gl-dropdown-item - :is-checked="selectedIsEmpty" + v-for="item in selectedFiltered" + :key="item.id" + :is-checked="isChecked(item.username)" :is-check-centered="true" - data-testid="unassign" - @click="selectAssignee()" + data-testid="selected-participant" + @click.stop="unselect(item.username)" > - <span - :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" - class="gl-font-weight-bold" - >{{ $options.i18n.unassigned }}</span - ></gl-dropdown-item + <sidebar-participant :user="item" /> + </gl-dropdown-item> + <template v-if="showCurrentUser"> + <gl-dropdown-divider /> + <gl-dropdown-item + data-testid="current-user" + @click.stop="selectAssignee(currentUser)" + > + <sidebar-participant :user="currentUser" class="gl-pl-6!" /> + </gl-dropdown-item> + </template> + <gl-dropdown-divider v-if="showDivider(unselectedFiltered)" /> + <gl-dropdown-item + v-for="unselectedUser in unselectedFiltered" + :key="unselectedUser.id" + data-testid="unselected-participant" + @click="selectAssignee(unselectedUser)" > - </template> - <gl-dropdown-divider v-if="showDivider(selectedFiltered)" /> - <gl-dropdown-item - v-for="item in selectedFiltered" - :key="item.id" - :is-checked="isChecked(item.username)" - :is-check-centered="true" - data-testid="selected-participant" - @click.stop="unselect(item.username)" - > - <gl-avatar-link> - <gl-avatar-labeled - :size="32" - :label="item.name" - :sub-label="item.username" - :src="item.avatarUrl || item.avatar || item.avatar_url" - class="gl-align-items-center" - /> - </gl-avatar-link> - </gl-dropdown-item> - <template v-if="showCurrentUser"> - <gl-dropdown-divider /> + <sidebar-participant :user="unselectedUser" class="gl-pl-6!" /> + </gl-dropdown-item> <gl-dropdown-item - data-testid="current-user" - @click.stop="selectAssignee(currentUser)" + v-if="noUsersFound && !isSearching" + data-testid="empty-results" + class="gl-pl-6!" > - <gl-avatar-link> - <gl-avatar-labeled - :size="32" - :label="currentUser.name" - :sub-label="currentUser.username" - :src="currentUser.avatarUrl" - class="gl-align-items-center gl-pl-6!" - /> - </gl-avatar-link> + {{ __('No matching results') }} </gl-dropdown-item> </template> - <gl-dropdown-divider v-if="showDivider(unselectedFiltered)" /> - <gl-dropdown-item - v-for="unselectedUser in unselectedFiltered" - :key="unselectedUser.id" - data-testid="unselected-participant" - @click="selectAssignee(unselectedUser)" - > - <gl-avatar-link class="gl-pl-6!"> - <gl-avatar-labeled - :size="32" - :label="unselectedUser.name" - :sub-label="unselectedUser.username" - :src="unselectedUser.avatarUrl || unselectedUser.avatar" - class="gl-align-items-center" - /> - </gl-avatar-link> - </gl-dropdown-item> - <gl-dropdown-item v-if="noUsersFound && !isSearching" data-testid="empty-results"> - {{ __('No matching results') }} + </template> + <template #footer> + <gl-dropdown-item> + <sidebar-invite-members v-if="directlyInviteMembers || indirectlyInviteMembers" /> </gl-dropdown-item> </template> - </template> - </multi-select-dropdown> - </template> - </sidebar-editable-item> + </multi-select-dropdown> + </template> + </sidebar-editable-item> + </div> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue new file mode 100644 index 00000000000..9952c6db582 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue @@ -0,0 +1,51 @@ +<script> +import InviteMemberModal from '~/invite_member/components/invite_member_modal.vue'; +import InviteMemberTrigger from '~/invite_member/components/invite_member_trigger.vue'; +import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; +import { __ } from '~/locale'; + +export default { + displayText: __('Invite members'), + dataTrackLabel: 'edit_assignee', + components: { + InviteMemberTrigger, + InviteMemberModal, + InviteMembersTrigger, + }, + inject: { + projectMembersPath: { + default: '', + }, + directlyInviteMembers: { + default: false, + }, + }, + computed: { + trackEvent() { + return this.directlyInviteMembers ? 'click_invite_members' : 'click_invite_members_version_b'; + }, + }, +}; +</script> + +<template> + <div> + <invite-members-trigger + v-if="directlyInviteMembers" + trigger-element="anchor" + :display-text="$options.displayText" + :event="trackEvent" + :label="$options.dataTrackLabel" + classes="gl-display-block gl-pl-6 gl-hover-text-decoration-none gl-hover-text-blue-800!" + /> + <template v-else> + <invite-member-trigger + :display-text="$options.displayText" + :event="trackEvent" + :label="$options.dataTrackLabel" + class="gl-display-block gl-pl-6 gl-hover-text-decoration-none gl-hover-text-blue-800!" + /> + <invite-member-modal :members-path="projectMembersPath" /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue new file mode 100644 index 00000000000..e2a38a100b9 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue @@ -0,0 +1,39 @@ +<script> +import { GlAvatarLabeled, GlAvatarLink } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; + +export default { + components: { + GlAvatarLabeled, + GlAvatarLink, + }, + props: { + user: { + type: Object, + required: true, + }, + }, + computed: { + userLabel() { + if (!this.user.status) { + return this.user.name; + } + return sprintf(s__('UserAvailability|%{author} (Busy)'), { + author: this.user.name, + }); + }, + }, +}; +</script> + +<template> + <gl-avatar-link> + <gl-avatar-labeled + :size="32" + :label="userLabel" + :sub-label="user.username" + :src="user.avatarUrl || user.avatar || user.avatar_url" + class="gl-align-items-center" + /> + </gl-avatar-link> +</template> diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue index d0da4a9c75a..b7080bb05b8 100644 --- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue @@ -1,4 +1,5 @@ <script> +import { IssuableType } from '~/issue_show/constants'; import { __, sprintf } from '~/locale'; import AssigneeAvatarLink from './assignee_avatar_link.vue'; import UserNameWithStatus from './user_name_with_status.vue'; @@ -58,7 +59,10 @@ export default { this.showLess = !this.showLess; }, userAvailability(u) { - return u?.availability || ''; + if (this.issuableType === IssuableType.MergeRequest) { + return u?.availability || ''; + } + return u?.status?.availability || ''; }, }, }; diff --git a/app/assets/javascripts/sidebar/components/due_date/sidebar_due_date_widget.vue b/app/assets/javascripts/sidebar/components/due_date/sidebar_due_date_widget.vue new file mode 100644 index 00000000000..141c2b3aae9 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/due_date/sidebar_due_date_widget.vue @@ -0,0 +1,203 @@ +<script> +import { GlButton, GlIcon, GlDatepicker, GlTooltipDirective } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { IssuableType } from '~/issue_show/constants'; +import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility'; +import { __, sprintf } from '~/locale'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import { dueDateQueries } from '~/sidebar/constants'; + +const hideDropdownEvent = new CustomEvent('hiddenGlDropdown', { + bubbles: true, +}); + +export default { + tracking: { + event: 'click_edit_button', + label: 'right_sidebar', + property: 'dueDate', + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlButton, + GlIcon, + GlDatepicker, + SidebarEditableItem, + }, + inject: ['fullPath', 'iid', 'canUpdate'], + props: { + issuableType: { + required: true, + type: String, + }, + }, + data() { + return { + dueDate: null, + loading: false, + }; + }, + apollo: { + dueDate: { + query() { + return dueDateQueries[this.issuableType].query; + }, + variables() { + return { + fullPath: this.fullPath, + iid: String(this.iid), + }; + }, + update(data) { + return data.workspace?.issuable?.dueDate || null; + }, + result({ data }) { + this.$emit('dueDateUpdated', data.workspace?.issuable?.dueDate); + }, + error() { + createFlash({ + message: sprintf(__('Something went wrong while setting %{issuableType} due date.'), { + issuableType: this.issuableType, + }), + }); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.dueDate.loading || this.loading; + }, + hasDueDate() { + return this.dueDate !== null; + }, + parsedDueDate() { + if (!this.hasDueDate) { + return null; + } + + return parsePikadayDate(this.dueDate); + }, + formattedDueDate() { + if (!this.hasDueDate) { + return this.$options.i18n.noDueDate; + } + + return dateInWords(this.parsedDueDate, true); + }, + workspacePath() { + return this.issuableType === IssuableType.Issue + ? { + projectPath: this.fullPath, + } + : { + groupPath: this.fullPath, + }; + }, + }, + methods: { + closeForm() { + this.$refs.editable.collapse(); + this.$el.dispatchEvent(hideDropdownEvent); + this.$emit('closeForm'); + }, + openDatePicker() { + this.$refs.datePicker.calendar.show(); + }, + setDueDate(date) { + this.loading = true; + this.$refs.editable.collapse(); + this.$apollo + .mutate({ + mutation: dueDateQueries[this.issuableType].mutation, + variables: { + input: { + ...this.workspacePath, + iid: this.iid, + dueDate: date ? formatDate(date, 'yyyy-mm-dd') : null, + }, + }, + }) + .then( + ({ + data: { + issuableSetDueDate: { errors }, + }, + }) => { + if (errors.length) { + createFlash({ + message: errors[0], + }); + } else { + this.$emit('closeForm'); + } + }, + ) + .catch(() => { + createFlash({ + message: sprintf(__('Something went wrong while setting %{issuableType} due date.'), { + issuableType: this.issuableType, + }), + }); + }) + .finally(() => { + this.loading = false; + }); + }, + }, + i18n: { + dueDate: __('Due date'), + noDueDate: __('None'), + removeDueDate: __('remove due date'), + }, +}; +</script> + +<template> + <sidebar-editable-item + ref="editable" + :title="$options.i18n.dueDate" + :tracking="$options.tracking" + :loading="isLoading" + class="block" + data-testid="due-date" + @open="openDatePicker" + > + <template #collapsed> + <div v-gl-tooltip :title="$options.i18n.dueDate" class="sidebar-collapsed-icon"> + <gl-icon :size="16" name="calendar" /> + <span class="collapse-truncated-title">{{ formattedDueDate }}</span> + </div> + <div class="gl-display-flex gl-align-items-center hide-collapsed"> + <span + :class="hasDueDate ? 'gl-text-gray-900 gl-font-weight-bold' : 'gl-text-gray-500'" + data-testid="sidebar-duedate-value" + > + {{ formattedDueDate }} + </span> + <div v-if="hasDueDate && canUpdate" class="gl-display-flex"> + <span class="gl-px-2">-</span> + <gl-button + variant="link" + class="gl-text-gray-500!" + data-testid="reset-button" + :disabled="isLoading" + @click="setDueDate(null)" + > + {{ $options.i18n.removeDueDate }} + </gl-button> + </div> + </div> + </template> + <template #default> + <gl-datepicker + ref="datePicker" + :value="parsedDueDate" + show-clear-button + @input="setDueDate" + @clear="setDueDate(null)" + /> + </template> + </sidebar-editable-item> +</template> diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue index 4ab4606ac1c..caf1c92c28a 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlLoadingIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; export default { components: { GlButton, GlLoadingIcon }, @@ -20,6 +21,16 @@ export default { required: false, default: false, }, + initialLoading: { + type: Boolean, + required: false, + default: false, + }, + isDirty: { + type: Boolean, + required: false, + default: false, + }, tracking: { type: Object, required: false, @@ -35,6 +46,11 @@ export default { edit: false, }; }, + computed: { + editButtonText() { + return this.isDirty ? __('Apply') : __('Edit'); + }, + }, destroyed() { window.removeEventListener('click', this.collapseWhenOffClick); window.removeEventListener('keyup', this.collapseOnEscape); @@ -86,15 +102,15 @@ export default { <template> <div> <div class="gl-display-flex gl-align-items-center" @click.self="collapse"> - <span class="hide-collapsed" data-testid="title">{{ title }}</span> - <gl-loading-icon v-if="loading" inline class="gl-ml-2 hide-collapsed" /> + <span class="hide-collapsed" data-testid="title" @click="collapse">{{ title }}</span> + <gl-loading-icon v-if="loading || initialLoading" inline class="gl-ml-2 hide-collapsed" /> <gl-loading-icon v-if="loading && isClassicSidebar" inline class="gl-mx-auto gl-my-0 hide-expanded" /> <gl-button - v-if="canUpdate" + v-if="canUpdate && !initialLoading" variant="link" class="gl-text-gray-900! gl-hover-text-blue-800! gl-ml-auto hide-collapsed" data-testid="edit-button" @@ -105,14 +121,16 @@ export default { @keyup.esc="toggle" @click="toggle" > - {{ __('Edit') }} + {{ editButtonText }} </gl-button> </div> - <div v-show="!edit" data-testid="collapsed-content"> - <slot name="collapsed">{{ __('None') }}</slot> - </div> - <div v-show="edit" data-testid="expanded-content" :class="{ 'gl-mt-3': !isClassicSidebar }"> - <slot :edit="edit"></slot> - </div> + <template v-if="!initialLoading"> + <div v-show="!edit" data-testid="collapsed-content"> + <slot name="collapsed">{{ __('None') }}</slot> + </div> + <div v-show="edit" data-testid="expanded-content" :class="{ 'gl-mt-3': !isClassicSidebar }"> + <slot :edit="edit"></slot> + </div> + </template> </div> </template> diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index a0e636488f4..80e07d556bf 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -1,10 +1,12 @@ import { IssuableType } from '~/issue_show/constants'; import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql'; import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql'; +import issueDueDateQuery from '~/sidebar/queries/issue_due_date.query.graphql'; import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql'; import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql'; import updateEpicMutation from '~/sidebar/queries/update_epic_confidential.mutation.graphql'; import updateIssueConfidentialMutation from '~/sidebar/queries/update_issue_confidential.mutation.graphql'; +import updateIssueDueDateMutation from '~/sidebar/queries/update_issue_due_date.mutation.graphql'; import getIssueParticipants from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql'; import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql'; import updateAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql'; @@ -42,3 +44,10 @@ export const referenceQueries = { query: mergeRequestReferenceQuery, }, }; + +export const dueDateQueries = { + [IssuableType.Issue]: { + query: issueDueDateQuery, + mutation: updateIssueDueDateMutation, + }, +}; diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index b98c8a8c5dc..f5c8ed6bd61 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -10,7 +10,10 @@ import { parseBoolean, } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; +import CollapsedAssigneeList from '~/sidebar/components/assignees/collapsed_assignee_list.vue'; +import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; +import SidebarDueDateWidget from '~/sidebar/components/due_date/sidebar_due_date_widget.vue'; import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue'; import { apolloProvider } from '~/sidebar/graphql'; import Translate from '../vue_shared/translate'; @@ -32,15 +35,6 @@ function getSidebarOptions(sidebarOptEl = document.querySelector('.js-sidebar-op return JSON.parse(sidebarOptEl.innerHTML); } -/** - * Extracts the list of assignees with availability information from a hidden input - * field and converts to a key:value pair for use in the sidebar assignees component. - * The assignee username is used as the key and their busy status is the value - * - * e.g { root: 'busy', admin: '' } - * - * @returns {Object} - */ function getSidebarAssigneeAvailabilityData() { const sidebarAssigneeEl = document.querySelectorAll('.js-sidebar-assignee-data input'); return Array.from(sidebarAssigneeEl) @@ -54,7 +48,7 @@ function getSidebarAssigneeAvailabilityData() { ); } -function mountAssigneesComponent(mediator) { +function mountAssigneesComponentDeprecated(mediator) { const el = document.getElementById('js-vue-sidebar-assignees'); if (!el) return; @@ -86,6 +80,51 @@ function mountAssigneesComponent(mediator) { }); } +function mountAssigneesComponent() { + const el = document.getElementById('js-vue-sidebar-assignees'); + + if (!el) return; + + const { iid, fullPath, editable, projectMembersPath } = getSidebarOptions(); + // eslint-disable-next-line no-new + new Vue({ + el, + apolloProvider, + components: { + SidebarAssigneesWidget, + }, + provide: { + canUpdate: editable, + projectMembersPath, + directlyInviteMembers: el.hasAttribute('data-directly-invite-members'), + indirectlyInviteMembers: el.hasAttribute('data-indirectly-invite-members'), + }, + render: (createElement) => + createElement('sidebar-assignees-widget', { + props: { + iid: String(iid), + fullPath, + issuableType: + isInIssuePage() || isInIncidentPage() || isInDesignPage() + ? IssuableType.Issue + : IssuableType.MergeRequest, + multipleAssignees: !el.dataset.maxAssignees, + }, + scopedSlots: { + collapsed: ({ users, onClick }) => + createElement(CollapsedAssigneeList, { + props: { + users, + }, + nativeOn: { + click: onClick, + }, + }), + }, + }), + }); +} + function mountReviewersComponent(mediator) { const el = document.getElementById('js-vue-sidebar-reviewers'); @@ -168,6 +207,36 @@ function mountConfidentialComponent() { }); } +function mountDueDateComponent() { + const el = document.getElementById('js-due-date-entry-point'); + if (!el) { + return; + } + + const { fullPath, iid, editable } = getSidebarOptions(); + + // eslint-disable-next-line no-new + new Vue({ + el, + apolloProvider, + components: { + SidebarDueDateWidget, + }, + provide: { + iid: String(iid), + fullPath, + canUpdate: editable, + }, + + render: (createElement) => + createElement('sidebar-due-date-widget', { + props: { + issuableType: IssuableType.Issue, + }, + }), + }); +} + function mountReferenceComponent() { const el = document.getElementById('js-reference-entry-point'); if (!el) { @@ -342,9 +411,14 @@ function mountCopyEmailComponent() { } export function mountSidebar(mediator) { - mountAssigneesComponent(mediator); + if (isInIssuePage() || isInDesignPage()) { + mountAssigneesComponent(); + } else { + mountAssigneesComponentDeprecated(mediator); + } mountReviewersComponent(mediator); mountConfidentialComponent(mediator); + mountDueDateComponent(mediator); mountReferenceComponent(mediator); mountLockComponent(); mountParticipantsComponent(mediator); diff --git a/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql b/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql new file mode 100644 index 00000000000..6d3f782bd0a --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql @@ -0,0 +1,10 @@ +query issueDueDate($fullPath: ID!, $iid: String) { + workspace: project(fullPath: $fullPath) { + __typename + issuable: issue(iid: $iid) { + __typename + id + dueDate + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/update_issue_due_date.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_issue_due_date.mutation.graphql new file mode 100644 index 00000000000..cf7eccd61c7 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/update_issue_due_date.mutation.graphql @@ -0,0 +1,9 @@ +mutation updateIssueDueDate($input: UpdateIssueInput!) { + issuableSetDueDate: updateIssue(input: $input) { + issuable: issue { + id + dueDate + } + errors + } +} diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue index d53c829a48e..aeb698a3adb 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue @@ -45,6 +45,9 @@ export default { activeAuthor() { return this.authors.find((author) => author.username.toLowerCase() === this.currentValue); }, + activeAuthorAvatar() { + return this.avatarUrl(this.activeAuthor); + }, }, watch: { active: { @@ -74,6 +77,9 @@ export default { this.loading = false; }); }, + avatarUrl(author) { + return author.avatarUrl || author.avatar_url; + }, searchAuthors: debounce(function debouncedSearch({ data }) { this.fetchAuthorBySearchTerm(data); }, DEBOUNCE_DELAY), @@ -92,7 +98,7 @@ export default { <gl-avatar v-if="activeAuthor" :size="16" - :src="activeAuthor.avatar_url" + :src="activeAuthorAvatar" shape="circle" class="gl-mr-2" /> @@ -115,7 +121,7 @@ export default { :value="author.username" > <div class="d-flex"> - <gl-avatar :size="32" :src="author.avatar_url" /> + <gl-avatar :size="32" :src="avatarUrl(author)" /> <div> <div>{{ author.name }}</div> <div>@{{ author.username }}</div> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue index ef5f052527b..17904f20341 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue @@ -30,5 +30,8 @@ export default { <gl-dropdown-form> <slot name="items"></slot> </gl-dropdown-form> + <template #footer> + <slot name="footer"></slot> + </template> </gl-dropdown> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql index 459ea27e9cd..3885127fa8e 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql @@ -1,4 +1,5 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" +#import "~/graphql_shared/fragments/user_availability.fragment.graphql" query issueParticipants($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { @@ -9,11 +10,13 @@ query issueParticipants($fullPath: ID!, $iid: String!) { participants { nodes { ...User + ...UserAvailability } } assignees { nodes { ...User + ...UserAvailability } } } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql index 43bd9f17e9a..63482873b69 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql @@ -1,4 +1,5 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" +#import "~/graphql_shared/fragments/user_availability.fragment.graphql" query getMrParticipants($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { @@ -7,11 +8,13 @@ query getMrParticipants($fullPath: ID!, $iid: String!) { participants { nodes { ...User + ...UserAvailability } } assignees { nodes { ...User + ...UserAvailability } } } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql index 8ee8de2cb5c..3f40c0368d7 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql @@ -1,4 +1,5 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" +#import "~/graphql_shared/fragments/user_availability.fragment.graphql" mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) { issuableSetAssignees: issueSetAssignees( @@ -9,11 +10,13 @@ mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullP assignees { nodes { ...User + ...UserAvailability } } participants { nodes { ...User + ...UserAvailability } } } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql index a0f15a07692..77140ea36d8 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql @@ -1,4 +1,5 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" +#import "~/graphql_shared/fragments/user_availability.fragment.graphql" mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) { mergeRequestSetAssignees( @@ -9,11 +10,13 @@ mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!, assignees { nodes { ...User + ...UserAvailability } } participants { nodes { ...User + ...UserAvailability } } } diff --git a/app/controllers/admin/services_controller.rb b/app/controllers/admin/services_controller.rb index bcfffc7b889..9f951e838c8 100644 --- a/app/controllers/admin/services_controller.rb +++ b/app/controllers/admin/services_controller.rb @@ -9,7 +9,7 @@ class Admin::ServicesController < Admin::ApplicationController feature_category :integrations def index - @services = Service.find_or_create_templates.sort_by(&:title) + @activated_services = Service.for_template.active.sort_by(&:title) @existing_instance_types = Service.for_instance.pluck(:type) # rubocop: disable CodeReuse/ActiveRecord end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 6153cad693c..8ebc773bb25 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -389,7 +389,8 @@ module IssuablesHelper severity: issuable[:severity], timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours, createNoteEmail: issuable[:create_note_email], - issuableType: issuable[:type] + issuableType: issuable[:type], + projectMembersPath: project_project_members_path(@project, sort: :access_level_desc) } end diff --git a/app/models/bulk_imports/stage.rb b/app/models/bulk_imports/stage.rb new file mode 100644 index 00000000000..050c2c76ce8 --- /dev/null +++ b/app/models/bulk_imports/stage.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module BulkImports + class Stage + include Singleton + + CONFIG = { + group: { + pipeline: BulkImports::Groups::Pipelines::GroupPipeline, + stage: 0 + }, + subgroups: { + pipeline: BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline, + stage: 1 + }, + members: { + pipeline: BulkImports::Groups::Pipelines::MembersPipeline, + stage: 1 + }, + labels: { + pipeline: BulkImports::Groups::Pipelines::LabelsPipeline, + stage: 1 + }, + milestones: { + pipeline: BulkImports::Groups::Pipelines::MilestonesPipeline, + stage: 1 + }, + badges: { + pipeline: BulkImports::Groups::Pipelines::BadgesPipeline, + stage: 1 + }, + finisher: { + pipeline: BulkImports::Groups::Pipelines::EntityFinisher, + stage: 2 + } + }.freeze + + def self.pipelines + instance.pipelines + end + + def self.pipeline_exists?(name) + pipelines.any? do |(_, pipeline)| + pipeline.to_s == name.to_s + end + end + + def pipelines + @pipelines ||= config + .values + .sort_by { |entry| entry[:stage] } + .map do |entry| + [entry[:stage], entry[:pipeline]] + end + end + + private + + def config + @config ||= CONFIG + end + end +end + +::BulkImports::Stage.prepend_if_ee('::EE::BulkImports::Stage') diff --git a/app/models/bulk_imports/tracker.rb b/app/models/bulk_imports/tracker.rb index cf84414f9cf..282ba9e19ac 100644 --- a/app/models/bulk_imports/tracker.rb +++ b/app/models/bulk_imports/tracker.rb @@ -20,6 +20,27 @@ class BulkImports::Tracker < ApplicationRecord DEFAULT_PAGE_SIZE = 500 + scope :next_pipeline_trackers_for, -> (entity_id) { + entity_scope = where(bulk_import_entity_id: entity_id) + next_stage_scope = entity_scope.with_status(:created).select('MIN(stage)') + + entity_scope.where(stage: next_stage_scope) + } + + def self.stage_running?(entity_id, stage) + where(stage: stage, bulk_import_entity_id: entity_id) + .with_status(:created, :started) + .exists? + end + + def pipeline_class + unless BulkImports::Stage.pipeline_exists?(pipeline_name) + raise NameError.new("'#{pipeline_name}' is not a valid BulkImport Pipeline") + end + + pipeline_name.constantize + end + state_machine :status, initial: :created do state :created, value: 0 state :started, value: 1 @@ -32,10 +53,6 @@ class BulkImports::Tracker < ApplicationRecord end event :finish do - # When applying the concurrent model, - # remove the created => finished transaction - # https://gitlab.com/gitlab-org/gitlab/-/issues/323384 - transition created: :finished transition started: :finished transition failed: :failed transition skipped: :skipped diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 478c7cd156f..1e44321e148 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -137,6 +137,14 @@ module Issuable scope :references_project, -> { references(:project) } scope :non_archived, -> { join_project.where(projects: { archived: false }) } + scope :includes_for_bulk_update, -> do + associations = %i[author assignees epic group labels metrics project source_project target_project].select do |association| + reflect_on_association(association) + end + + includes(*associations) + end + attr_mentionable :title, pipeline: :single_line attr_mentionable :description diff --git a/app/models/concerns/milestoneable.rb b/app/models/concerns/milestoneable.rb index ccb334343ff..d42417bb6c1 100644 --- a/app/models/concerns/milestoneable.rb +++ b/app/models/concerns/milestoneable.rb @@ -39,11 +39,13 @@ module Milestoneable private def milestone_is_valid - errors.add(:milestone_id, 'is invalid') if respond_to?(:milestone_id) && milestone_id.present? && !milestone_available? + errors.add(:milestone_id, 'is invalid') if respond_to?(:milestone_id) && !milestone_available? end end def milestone_available? + return true if milestone_id.blank? + project_id == milestone&.project_id || project.ancestors_upto.compact.include?(milestone&.group) end diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb index d3d543edcd7..13e289716ef 100644 --- a/app/services/issuable/bulk_update_service.rb +++ b/app/services/issuable/bulk_update_service.rb @@ -15,7 +15,7 @@ module Issuable set_update_params(type) items = update_issuables(type, ids) - response_success(payload: { count: items.count }) + response_success(payload: { count: items.size }) rescue ArgumentError => e response_error(e.message, 422) end @@ -59,10 +59,17 @@ module Issuable def find_issuables(parent, model_class, ids) if parent.is_a?(Project) - model_class.id_in(ids).of_projects(parent) + projects = parent elsif parent.is_a?(Group) - model_class.id_in(ids).of_projects(parent.all_projects) + projects = parent.all_projects + else + return end + + model_class + .id_in(ids) + .of_projects(projects) + .includes_for_bulk_update end def response_success(message: nil, payload: nil) diff --git a/app/views/admin/services/_form.html.haml b/app/views/admin/services/_form.html.haml index c17ab5e08a7..4d9fa6d3d57 100644 --- a/app/views/admin/services/_form.html.haml +++ b/app/views/admin/services/_form.html.haml @@ -1,7 +1,9 @@ += render "service_templates_deprecated_alert" + %h3.page-title = @service.title -%p #{@service.description} template. +%p= @service.description = form_for :service, url: admin_application_settings_service_path, method: :put, html: { class: 'fieldset-form js-integration-settings-form' } do |form| = render 'shared/service_settings', form: form, integration: @service diff --git a/app/views/admin/services/_service_templates_deprecated_alert.html.haml b/app/views/admin/services/_service_templates_deprecated_alert.html.haml new file mode 100644 index 00000000000..0cc44099049 --- /dev/null +++ b/app/views/admin/services/_service_templates_deprecated_alert.html.haml @@ -0,0 +1,8 @@ +- doc_link_start = "<a href=\"#{integrations_help_page_path}\" target='_blank' rel='noopener noreferrer'>".html_safe +- settings_link_start = "<a href=\"#{integrations_admin_application_settings_path}\">".html_safe + +.gl-alert.gl-alert-danger.gl-mt-5{ role: 'alert' } + = sprite_icon('error', css_class: 'gl-alert-icon gl-alert-icon-no-title') + %h4.gl-alert-title= s_('AdminSettings|Service templates are deprecated and will be removed in GitLab 14.0.') + .gl-alert-body + = html_escape_once(s_("AdminSettings|You can't add new templates. To migrate or remove a Service template, create a new integration at %{settings_link_start}Settings > Integrations%{link_end}. Learn more about %{doc_link_start}Project integration management%{link_end}.")).html_safe % { settings_link_start: settings_link_start, doc_link_start: doc_link_start, link_end: '</a>'.html_safe } diff --git a/app/views/admin/services/index.html.haml b/app/views/admin/services/index.html.haml index a1b485e888d..91706452402 100644 --- a/app/views/admin/services/index.html.haml +++ b/app/views/admin/services/index.html.haml @@ -1,23 +1,13 @@ - page_title _("Service Templates") - @content_class = 'limit-container-width' unless fluid_layout -- if show_service_templates_deprecated? - .gl-alert.gl-alert-tip.js-service-templates-deprecated.gl-mt-5{ role: 'alert', data: { feature_id: UserCalloutsHelper::SERVICE_TEMPLATES_DEPRECATED, dismiss_endpoint: user_callouts_path } } - = sprite_icon('bulb', css_class: 'gl-alert-icon gl-alert-icon-no-title') - %button.js-close.gl-alert-dismiss{ type: 'button', aria: { label: _('Dismiss') } } - = sprite_icon('close') - %h4.gl-alert-title= s_('AdminSettings|Service Templates will soon be deprecated.') - .gl-alert-body - = s_('AdminSettings|Try using the latest version of Integrations instead.') - .gl-alert-actions - = link_to _('Go to Integrations'), integrations_admin_application_settings_path, class: 'btn btn-info gl-alert-action gl-button' - = link_to _('Learn more'), help_page_path('user/admin_area/settings/project_integration_management'), class: 'btn btn-default gl-alert-action btn-secondary gl-button', target: '_blank', rel: 'noopener noreferrer' += render "service_templates_deprecated_alert" -%h3.page-title Service templates -%p.light= s_('AdminSettings|Service template allows you to set default values for integrations') +- if @activated_services.any? + %h3.page-title Service templates + %p= s_('AdminSettings|Service template allows you to set default values for integrations') -.table-holder - %table.table + %table.table.b-table.gl-table %colgroup %col %col @@ -29,7 +19,7 @@ %th= _('Service') %th= _('Description') %th= _('Last edit') - - @services.each do |service| + - @activated_services.each do |service| - if service.type.in?(@existing_instance_types) %tr %td diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index b555e73c808..fb2019bef15 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -70,41 +70,7 @@ = _('Time tracking') = loading_icon(css_class: 'gl-vertical-align-text-bottom') - if issuable_sidebar.has_key?(:due_date) - .block.due_date - .sidebar-collapsed-icon.has-tooltip{ data: { placement: 'left', container: 'body', html: 'true', boundary: 'viewport' }, title: sidebar_due_date_tooltip_label(issuable_sidebar[:due_date]) } - = sprite_icon('calendar') - %span.js-due-date-sidebar-value - = issuable_sidebar[:due_date].try(:to_s, :medium) || _('None') - .title.hide-collapsed - = _('Due date') - = loading_icon(css_class: 'gl-vertical-align-text-bottom hidden block-loading') - - if can_edit_issuable - = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { track_label: "right_sidebar", track_property: "due_date", track_event: "click_edit_button", track_value: "" } - .value.hide-collapsed - %span.value-content - - if issuable_sidebar[:due_date] - %span.bold= issuable_sidebar[:due_date].to_s(:medium) - - else - %span.no-value - = _('None') - - if can_edit_issuable - %span.no-value.js-remove-due-date-holder{ class: ("hidden" if issuable_sidebar[:due_date].nil?) } - \- - %a.js-remove-due-date{ href: "#", role: "button" } - = _('remove due date') - - if can_edit_issuable - .selectbox.hide-collapsed - = f.hidden_field :due_date, value: issuable_sidebar[:due_date].try(:strftime, 'yy-mm-dd') - .dropdown - %button.dropdown-menu-toggle.js-due-date-select{ type: 'button', data: { toggle: 'dropdown', field_name: "#{issuable_type}[due_date]", ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], display: 'static' } } - %span.dropdown-toggle-text - = _('Due date') - = sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3") - .dropdown-menu.dropdown-menu-due-date - = dropdown_title(_('Due date')) - = dropdown_content do - .js-due-date-calendar - + #js-due-date-entry-point .js-sidebar-labels{ data: sidebar_labels_data(issuable_sidebar, @project) } diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index 0f1b77eea6e..47e7ff0e4bc 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -1,6 +1,7 @@ - issuable_type = issuable_sidebar[:type] +- dropdown_options = assignees_dropdown_options(issuable_type) -#js-vue-sidebar-assignees{ data: { field: issuable_type, signed_in: signed_in } } +#js-vue-sidebar-assignees{ data: { field: issuable_type, signed_in: signed_in, max_assignees: dropdown_options[:data][:"max-select"], directly_invite_members: directly_invite_members?, indirectly_invite_members: indirectly_invite_members? } } .title.hide-collapsed = _('Assignee') = loading_icon(css_class: 'gl-vertical-align-text-bottom') @@ -29,7 +30,6 @@ null_user: true, display: 'static' } } - - dropdown_options = assignees_dropdown_options(issuable_type) - title = dropdown_options[:title] - options[:toggle_class] += ' js-multiselect js-save-user-data' - data = { field_name: "#{issuable_type}[assignee_ids][]" } diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 00f33eb08b8..ff3d3740ed6 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -1539,6 +1539,14 @@ :weight: 1 :idempotent: :tags: [] +- :name: bulk_imports_pipeline + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: + :tags: [] - :name: chat_notification :feature_category: :chatops :has_external_dependencies: true diff --git a/app/workers/bulk_import_worker.rb b/app/workers/bulk_import_worker.rb index e6bc54895a7..b4b9d9b05c1 100644 --- a/app/workers/bulk_import_worker.rb +++ b/app/workers/bulk_import_worker.rb @@ -21,9 +21,11 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker @bulk_import.start! if @bulk_import.created? created_entities.first(next_batch_size).each do |entity| - entity.start! + create_pipeline_tracker_for(entity) BulkImports::EntityWorker.perform_async(entity.id) + + entity.start! end re_enqueue @@ -65,4 +67,13 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker def re_enqueue BulkImportWorker.perform_in(PERFORM_DELAY, @bulk_import.id) end + + def create_pipeline_tracker_for(entity) + BulkImports::Stage.pipelines.each do |stage, pipeline| + entity.trackers.create!( + stage: stage, + pipeline_name: pipeline + ) + end + end end diff --git a/app/workers/bulk_imports/entity_worker.rb b/app/workers/bulk_imports/entity_worker.rb index 5b41ccbdea1..7f173b738cf 100644 --- a/app/workers/bulk_imports/entity_worker.rb +++ b/app/workers/bulk_imports/entity_worker.rb @@ -10,24 +10,47 @@ module BulkImports worker_has_external_dependencies! - def perform(entity_id) - entity = BulkImports::Entity.with_status(:started).find_by_id(entity_id) + def perform(entity_id, current_stage = nil) + return if stage_running?(entity_id, current_stage) + + logger.info( + worker: self.class.name, + entity_id: entity_id, + current_stage: current_stage + ) + + next_pipeline_trackers_for(entity_id).each do |pipeline_tracker| + BulkImports::PipelineWorker.perform_async( + pipeline_tracker.id, + pipeline_tracker.stage, + entity_id + ) + end + rescue => e + logger.error( + worker: self.class.name, + entity_id: entity_id, + current_stage: current_stage, + error_message: e.message + ) + + Gitlab::ErrorTracking.track_exception(e, entity_id: entity_id) + end - if entity - entity.update!(jid: jid) + private - BulkImports::Importers::GroupImporter.new(entity).execute - end + def stage_running?(entity_id, stage) + return unless stage - rescue => e - extra = { - bulk_import_id: entity&.bulk_import&.id, - entity_id: entity&.id - } + BulkImports::Tracker.stage_running?(entity_id, stage) + end - Gitlab::ErrorTracking.track_exception(e, extra) + def next_pipeline_trackers_for(entity_id) + BulkImports::Tracker.next_pipeline_trackers_for(entity_id) + end - entity&.fail_op + def logger + @logger ||= Gitlab::Import::Logger.build end end end diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb new file mode 100644 index 00000000000..a6de3c36205 --- /dev/null +++ b/app/workers/bulk_imports/pipeline_worker.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module BulkImports + class PipelineWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + + feature_category :importers + + sidekiq_options retry: false, dead: false + + worker_has_external_dependencies! + + def perform(pipeline_tracker_id, stage, entity_id) + pipeline_tracker = ::BulkImports::Tracker + .with_status(:created) + .find_by_id(pipeline_tracker_id) + + if pipeline_tracker.present? + logger.info( + worker: self.class.name, + entity_id: pipeline_tracker.entity.id, + pipeline_name: pipeline_tracker.pipeline_name + ) + + run(pipeline_tracker) + else + logger.error( + worker: self.class.name, + entity_id: entity_id, + pipeline_tracker_id: pipeline_tracker_id, + message: 'Unstarted pipeline not found' + ) + end + + ensure + ::BulkImports::EntityWorker.perform_async(entity_id, stage) + end + + private + + def run(pipeline_tracker) + pipeline_tracker.update!(status_event: 'start', jid: jid) + + context = ::BulkImports::Pipeline::Context.new(pipeline_tracker) + + pipeline_tracker.pipeline_class.new(context).run + + pipeline_tracker.finish! + rescue => e + pipeline_tracker.fail_op! + + logger.error( + worker: self.class.name, + entity_id: pipeline_tracker.entity.id, + pipeline_name: pipeline_tracker.pipeline_name, + message: e.message + ) + + Gitlab::ErrorTracking.track_exception( + e, + entity_id: pipeline_tracker.entity.id, + pipeline_name: pipeline_tracker.pipeline_name + ) + end + + def logger + @logger ||= Gitlab::Import::Logger.build + end + end +end |