diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-05-19 15:44:42 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-05-19 15:44:42 +0000 |
commit | 4555e1b21c365ed8303ffb7a3325d773c9b8bf31 (patch) | |
tree | 5423a1c7516cffe36384133ade12572cf709398d /app/assets/javascripts/sidebar | |
parent | e570267f2f6b326480d284e0164a6464ba4081bc (diff) | |
download | gitlab-ce-4555e1b21c365ed8303ffb7a3325d773c9b8bf31.tar.gz |
Add latest changes from gitlab-org/gitlab@13-12-stable-eev13.12.0-rc42
Diffstat (limited to 'app/assets/javascripts/sidebar')
33 files changed, 1268 insertions, 598 deletions
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue index f98798582c1..e7ef731eed8 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue @@ -1,6 +1,7 @@ <script> -import actionCable from '~/actioncable_consumer'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import produce from 'immer'; +import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { IssuableType } from '~/issue_show/constants'; import { assigneesQueries } from '~/sidebar/constants'; export default { @@ -12,60 +13,62 @@ export default { required: false, default: null, }, - issuableIid: { + issuableType: { type: String, required: true, }, - projectPath: { - type: String, + issuableId: { + type: Number, required: true, }, - issuableType: { - type: String, + queryVariables: { + type: Object, required: true, }, }, + computed: { + issuableClass() { + return Object.keys(IssuableType).find((key) => IssuableType[key] === this.issuableType); + }, + }, apollo: { - workspace: { + issuable: { query() { return assigneesQueries[this.issuableType].query; }, variables() { - return { - iid: this.issuableIid, - fullPath: this.projectPath, - }; + return this.queryVariables; + }, + update(data) { + return data.workspace?.issuable; }, - result(data) { - if (this.mediator) { - this.handleFetchResult(data); - } + subscribeToMore: { + document() { + return assigneesQueries[this.issuableType].subscription; + }, + variables() { + return { + issuableId: convertToGraphQLId(this.issuableClass, this.issuableId), + }; + }, + updateQuery(prev, { subscriptionData }) { + if (prev && subscriptionData?.data?.issuableAssigneesUpdated) { + const data = produce(prev, (draftData) => { + draftData.workspace.issuable.assignees.nodes = + subscriptionData.data.issuableAssigneesUpdated.assignees.nodes; + }); + if (this.mediator) { + this.handleFetchResult(data); + } + return data; + } + return prev; + }, }, }, }, - mounted() { - this.initActionCablePolling(); - }, - beforeDestroy() { - this.$options.subscription.unsubscribe(); - }, methods: { - received(data) { - if (data.event === 'updated') { - this.$apollo.queries.workspace.refetch(); - } - }, - initActionCablePolling() { - this.$options.subscription = actionCable.subscriptions.create( - { - channel: 'IssuesChannel', - project_path: this.projectPath, - iid: this.issuableIid, - }, - { received: this.received }, - ); - }, - handleFetchResult({ data }) { + handleFetchResult(data) { const { nodes } = data.workspace.issuable.assignees; const assignees = nodes.map((n) => ({ diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue index e93aced12f3..80caebad39d 100644 --- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue @@ -60,7 +60,7 @@ export default { v-else :users="users" :issuable-type="issuableType" - class="gl-mt-2 hide-collapsed" + class="gl-text-gray-800 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 e15ea595190..ca95599742a 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -44,6 +44,10 @@ export default { type: String, required: true, }, + issuableId: { + type: Number, + required: true, + }, assigneeAvailabilityStatus: { type: Object, required: false, @@ -61,6 +65,12 @@ export default { // Note: Realtime is only available on issues right now, future support for MR wil be built later. return this.glFeatures.realTimeIssueSidebar && this.issuableType === 'issue'; }, + queryVariables() { + return { + iid: this.issuableIid, + fullPath: this.projectPath, + }; + }, relativeUrlRoot() { return gon.relative_url_root ?? ''; }, @@ -121,9 +131,9 @@ export default { <div> <assignees-realtime v-if="shouldEnableRealtime" - :issuable-iid="issuableIid" - :project-path="projectPath" :issuable-type="issuableType" + :issuable-id="issuableId" + :query-variables="queryVariables" :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 78cac989850..932be7addc0 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue @@ -1,19 +1,17 @@ <script> -import { GlDropdownItem, GlDropdownDivider, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui'; +import { GlDropdownItem } 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 { assigneesQueries } from '~/sidebar/constants'; +import UserSelect from '~/vue_shared/components/user_select/user_select.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, @@ -33,23 +31,16 @@ export default { components: { SidebarEditableItem, IssuableAssignees, - MultiSelectDropdown, GlDropdownItem, - GlDropdownDivider, - GlSearchBoxByType, - GlLoadingIcon, SidebarInviteMembers, - SidebarParticipant, SidebarAssigneesRealtime, + UserSelect, }, mixins: [glFeatureFlagsMixin()], inject: { directlyInviteMembers: { default: false, }, - indirectlyInviteMembers: { - default: false, - }, }, props: { iid: { @@ -73,20 +64,21 @@ export default { return [IssuableType.Issue, IssuableType.MergeRequest].includes(value); }, }, - multipleAssignees: { - type: Boolean, + issuableId: { + type: Number, required: false, - default: true, + default: null, + }, + allowMultipleAssignees: { + type: Boolean, + required: true, }, }, data() { return { - search: '', issuable: {}, - searchUsers: [], selected: [], isSettingAssignees: false, - isSearching: false, isDirty: false, }; }, @@ -104,51 +96,13 @@ export default { result({ data }) { const issuable = data.workspace?.issuable; if (issuable) { - this.selected = this.moveCurrentUserToStart(cloneDeep(issuable.assignees.nodes)); + this.selected = cloneDeep(issuable.assignees.nodes); } }, error() { createFlash({ message: __('An error occurred while fetching participants.') }); }, }, - searchUsers: { - query: searchUsers, - variables() { - return { - fullPath: this.fullPath, - search: this.search, - }; - }, - update(data) { - const searchResults = data.workspace?.users?.nodes.map(({ user }) => user) || []; - const filteredParticipants = this.participants.filter( - (user) => - user.name.toLowerCase().includes(this.search.toLowerCase()) || - user.username.toLowerCase().includes(this.search.toLowerCase()), - ); - const mergedSearchResults = searchResults.reduce((acc, current) => { - // Some users are duplicated in the query result: - // https://gitlab.com/gitlab-org/gitlab/-/issues/327822 - if (!acc.some((user) => current.username === user.username)) { - acc.push(current); - } - return acc; - }, filteredParticipants); - - return mergedSearchResults; - }, - debounce: ASSIGNEES_DEBOUNCE_DELAY, - skip() { - return this.isSearchEmpty; - }, - error() { - createFlash({ message: __('An error occurred while searching users.') }); - this.isSearching = false; - }, - result() { - this.isSearching = false; - }, - }, }, computed: { shouldEnableRealtime() { @@ -167,13 +121,6 @@ export default { : this.issuable?.assignees?.nodes; return currentAssignees || []; }, - participants() { - const users = - this.isSearchEmpty || this.isSearching - ? this.issuable?.participants?.nodes - : this.searchUsers; - return this.moveCurrentUserToStart(users); - }, assigneeText() { const items = this.$apollo.queries.issuable.loading ? this.initialAssignees : this.selected; if (!items) { @@ -181,28 +128,8 @@ export default { } return n__('Assignee', '%d Assignees', items.length); }, - selectedFiltered() { - if (this.isSearchEmpty || this.isSearching) { - return this.selected; - } - - const foundUsernames = this.searchUsers.map(({ username }) => username); - return this.selected.filter(({ username }) => foundUsernames.includes(username)); - }, - unselectedFiltered() { - return ( - this.participants?.filter(({ username }) => !this.selectedUserNames.includes(username)) || - [] - ); - }, - selectedIsEmpty() { - return this.selectedFiltered.length === 0; - }, - selectedUserNames() { - return this.selected.map(({ username }) => username); - }, - isSearchEmpty() { - return this.search === ''; + isAssigneesLoading() { + return !this.initialAssignees && this.$apollo.queries.issuable.loading; }, currentUser() { return { @@ -211,35 +138,9 @@ export default { avatarUrl: gon?.current_user_avatar_url, }; }, - isAssigneesLoading() { - return !this.initialAssignees && this.$apollo.queries.issuable.loading; - }, - isCurrentUserInParticipants() { - const isCurrentUser = (user) => user.username === this.currentUser.username; - return this.selected.some(isCurrentUser) || this.participants.some(isCurrentUser); - }, - noUsersFound() { - return !this.isSearchEmpty && this.searchUsers.length === 0; - }, signedIn() { return this.currentUser.username !== undefined; }, - showCurrentUser() { - return ( - this.signedIn && - !this.isCurrentUserInParticipants && - (this.isSearchEmpty || this.isSearching) - ); - }, - }, - watch: { - // We need to add this watcher to track the moment when user is alredy typing - // but query is still not started due to debounce - search(newVal) { - if (newVal) { - this.isSearching = true; - } - }, }, created() { assigneesWidget.updateAssignees = this.updateAssignees; @@ -269,59 +170,15 @@ export default { this.isSettingAssignees = false; }); }, - selectAssignee(name) { - this.isDirty = true; - - if (!this.multipleAssignees) { - this.selected = name ? [name] : []; - this.collapseWidget(); - 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(); - } - }, assignSelf() { - this.updateAssignees(this.currentUser.username); - }, - clearSelected() { - this.selected = []; + this.updateAssignees([this.currentUser.username]); }, saveAssignees() { - this.isDirty = false; - this.updateAssignees(this.selectedUserNames); - this.$el.dispatchEvent(hideDropdownEvent); - }, - isChecked(id) { - return this.selectedUserNames.includes(id); - }, - async focusSearch() { - await this.$nextTick(); - this.$refs.search.focusInput(); - }, - moveCurrentUserToStart(users) { - if (!users) { - return []; - } - const usersCopy = [...users]; - const currentUser = usersCopy.find((user) => user.username === this.currentUser.username); - - if (currentUser) { - const index = usersCopy.indexOf(currentUser); - usersCopy.splice(0, 0, usersCopy.splice(index, 1)[0]); + if (this.isDirty) { + this.isDirty = false; + this.updateAssignees(this.selected.map(({ username }) => username)); } - - return usersCopy; + this.$el.dispatchEvent(hideDropdownEvent); }, collapseWidget() { this.$refs.toggle.collapse(); @@ -329,8 +186,17 @@ export default { expandWidget() { this.$refs.toggle.expand(); }, - showDivider(list) { - return list.length > 0 && this.isSearchEmpty; + focusSearch() { + this.$refs.userSelect.focusSearch(); + }, + showError() { + createFlash({ message: __('An error occurred while fetching participants.') }); + }, + setDirtyState() { + this.isDirty = true; + if (!this.allowMultipleAssignees) { + this.collapseWidget(); + } }, }, }; @@ -340,9 +206,9 @@ export default { <div data-testid="assignees-widget"> <sidebar-assignees-realtime v-if="shouldEnableRealtime" - :project-path="fullPath" - :issuable-iid="iid" :issuable-type="issuableType" + :issuable-id="issuableId" + :query-variables="queryVariables" /> <sidebar-editable-item ref="toggle" @@ -363,86 +229,27 @@ export default { @expand-widget="expandWidget" /> </template> - <template #default> - <multi-select-dropdown - class="gl-w-full dropdown-menu-user" + <user-select + ref="userSelect" + v-model="selected" :text="$options.i18n.assignees" :header-text="$options.i18n.assignTo" + :iid="iid" + :full-path="fullPath" + :allow-multiple-assignees="allowMultipleAssignees" + :current-user="currentUser" + :issuable-type="issuableType" + class="gl-w-full dropdown-menu-user" @toggle="collapseWidget" + @error="showError" + @input="setDirtyState" > - <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 - 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)" - > - <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)" - > - <sidebar-participant :user="unselectedUser" class="gl-pl-6!" /> - </gl-dropdown-item> - <gl-dropdown-item - v-if="noUsersFound && !isSearching" - data-testid="empty-results" - class="gl-pl-6!" - > - {{ __('No matching results') }} - </gl-dropdown-item> - </template> - </template> <template #footer> - <gl-dropdown-item> - <sidebar-invite-members v-if="directlyInviteMembers || indirectlyInviteMembers" /> - </gl-dropdown-item> - </template> - </multi-select-dropdown> + <gl-dropdown-item v-if="directlyInviteMembers"> + <sidebar-invite-members /> + </gl-dropdown-item> </template + ></user-select> </template> </sidebar-editable-item> </div> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue index 9952c6db582..5c32d03e0d4 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue @@ -1,51 +1,23 @@ <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', + dataTrackEvent: 'click_invite_members', 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> + <invite-members-trigger + trigger-element="anchor" + :display-text="$options.displayText" + :event="$options.dataTrackEvent" + :label="$options.dataTrackLabel" + classes="gl-display-block gl-pl-6 gl-hover-text-decoration-none gl-hover-text-blue-800!" + /> </template> diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue new file mode 100644 index 00000000000..6a68e914b84 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue @@ -0,0 +1,296 @@ +<script> +import { GlIcon, GlDatepicker, GlTooltipDirective, GlLink, GlPopover } 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 { dateFields, dateTypes, dueDateQueries, startDateQueries } from '~/sidebar/constants'; +import SidebarFormattedDate from './sidebar_formatted_date.vue'; +import SidebarInheritDate from './sidebar_inherit_date.vue'; + +const hideDropdownEvent = new CustomEvent('hiddenGlDropdown', { + bubbles: true, +}); + +export default { + tracking: { + event: 'click_edit_button', + label: 'right_sidebar', + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlIcon, + GlDatepicker, + GlLink, + GlPopover, + SidebarEditableItem, + SidebarFormattedDate, + SidebarInheritDate, + }, + inject: ['canUpdate'], + props: { + iid: { + type: String, + required: true, + }, + fullPath: { + type: String, + required: true, + }, + dateType: { + type: String, + required: false, + default: dateTypes.due, + }, + issuableType: { + required: true, + type: String, + }, + canInherit: { + required: false, + type: Boolean, + default: false, + }, + }, + data() { + return { + issuable: {}, + loading: false, + tracking: { + ...this.$options.tracking, + property: this.dateType === dateTypes.start ? 'startDate' : 'dueDate', + }, + }; + }, + apollo: { + issuable: { + query() { + return this.dateQueries[this.issuableType].query; + }, + variables() { + return { + fullPath: this.fullPath, + iid: String(this.iid), + }; + }, + update(data) { + return data.workspace?.issuable || {}; + }, + result({ data }) { + this.$emit(`${this.dateType}Updated`, data.workspace?.issuable?.[this.dateType]); + }, + error() { + createFlash({ + message: sprintf( + __('Something went wrong while setting %{issuableType} %{dateType} date.'), + { + issuableType: this.issuableType, + dateType: this.dateType === dateTypes.start ? 'start' : 'due', + }, + ), + }); + }, + }, + }, + computed: { + dateQueries() { + return this.dateType === dateTypes.start ? startDateQueries : dueDateQueries; + }, + dateLabel() { + return this.dateType === dateTypes.start + ? this.$options.i18n.startDate + : this.$options.i18n.dueDate; + }, + removeDateLabel() { + return this.dateType === dateTypes.start + ? this.$options.i18n.removeStartDate + : this.$options.i18n.removeDueDate; + }, + dateValue() { + return this.issuable?.[this.dateType] || null; + }, + isLoading() { + return this.$apollo.queries.issuable.loading || this.loading; + }, + hasDate() { + return this.dateValue !== null; + }, + parsedDate() { + if (!this.hasDate) { + return null; + } + + return parsePikadayDate(this.dateValue); + }, + formattedDate() { + if (!this.hasDate) { + return this.$options.i18n.noDate; + } + + return dateInWords(this.parsedDate, true); + }, + workspacePath() { + return this.issuableType === IssuableType.Issue + ? { + projectPath: this.fullPath, + } + : { + groupPath: this.fullPath, + }; + }, + dataTestId() { + return this.dateType === dateTypes.start ? 'start-date' : 'due-date'; + }, + }, + methods: { + closeForm() { + this.$refs.editable.collapse(); + this.$el.dispatchEvent(hideDropdownEvent); + this.$emit('closeForm'); + }, + openDatePicker() { + this.$refs.datePicker.calendar.show(); + }, + setFixedDate(isFixed) { + const date = this.issuable[dateFields[this.dateType].dateFixed]; + this.setDate(date, isFixed); + }, + setDate(date, isFixed = true) { + const formattedDate = date ? formatDate(date, 'yyyy-mm-dd') : null; + this.loading = true; + this.$refs.editable.collapse(); + this.$apollo + .mutate({ + mutation: this.dateQueries[this.issuableType].mutation, + variables: { + input: { + ...this.workspacePath, + iid: this.iid, + ...(this.canInherit + ? { + [dateFields[this.dateType].dateFixed]: isFixed ? formattedDate : undefined, + [dateFields[this.dateType].isDateFixed]: isFixed, + } + : { + [this.dateType]: formattedDate, + }), + }, + }, + }) + .then( + ({ + data: { + issuableSetDate: { errors }, + }, + }) => { + if (errors.length) { + createFlash({ + message: errors[0], + }); + } else { + this.$emit('closeForm'); + } + }, + ) + .catch(() => { + createFlash({ + message: sprintf( + __('Something went wrong while setting %{issuableType} %{dateType} date.'), + { + issuableType: this.issuableType, + dateType: this.dateType === dateTypes.start ? 'start' : 'due', + }, + ), + }); + }) + .finally(() => { + this.loading = false; + }); + }, + }, + i18n: { + dueDate: __('Due date'), + startDate: __('Start date'), + noDate: __('None'), + removeDueDate: __('remove due date'), + removeStartDate: __('remove start date'), + dateHelpValidMessage: __( + 'These dates affect how your epics appear in the roadmap. Set a fixed date or one inherited from the milestones assigned to issues in this epic.', + ), + help: __('Help'), + learnMore: __('Learn more'), + }, + dateHelpUrl: '/help/user/group/epics/index.md#start-date-and-due-date', +}; +</script> + +<template> + <sidebar-editable-item + ref="editable" + :title="dateLabel" + :tracking="tracking" + :loading="isLoading" + class="block" + :data-testid="dataTestId" + @open="openDatePicker" + > + <template v-if="canInherit" #title-extra> + <gl-icon + ref="epicDatePopover" + name="question-o" + class="gl-ml-3 gl-cursor-pointer gl-text-blue-600 hide-collapsed" + tabindex="0" + :aria-label="$options.i18n.help" + data-testid="inherit-date-popover" + /> + <gl-popover + :target="() => $refs.epicDatePopover.$el" + triggers="focus" + placement="left" + boundary="viewport" + > + <p>{{ $options.i18n.dateHelpValidMessage }}</p> + <gl-link :href="$options.dateHelpUrl" target="_blank">{{ + $options.i18n.learnMore + }}</gl-link> + </gl-popover> + </template> + <template #collapsed> + <div v-gl-tooltip :title="dateLabel" class="sidebar-collapsed-icon"> + <gl-icon :size="16" name="calendar" /> + <span class="collapse-truncated-title">{{ formattedDate }}</span> + </div> + <sidebar-inherit-date + v-if="canInherit" + :issuable="issuable" + :is-loading="isLoading" + :date-type="dateType" + @reset-date="setDate(null)" + @set-date="setFixedDate" + /> + <sidebar-formatted-date + v-else + :has-date="hasDate" + :formatted-date="formattedDate" + :reset-text="removeDateLabel" + :is-loading="isLoading" + @reset-date="setDate(null)" + /> + </template> + <template #default> + <gl-datepicker + v-if="!isLoading" + ref="datePicker" + class="gl-relative" + :default-date="parsedDate" + show-clear-button + autocomplete="off" + @input="setDate" + @clear="setDate(null)" + /> + </template> + </sidebar-editable-item> +</template> diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_formatted_date.vue b/app/assets/javascripts/sidebar/components/date/sidebar_formatted_date.vue new file mode 100644 index 00000000000..87cf1c29fb0 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/date/sidebar_formatted_date.vue @@ -0,0 +1,56 @@ +<script> +import { GlButton } from '@gitlab/ui'; + +export default { + components: { + GlButton, + }, + inject: ['canUpdate'], + props: { + formattedDate: { + required: true, + type: String, + }, + hasDate: { + required: true, + type: Boolean, + }, + resetText: { + required: true, + type: String, + }, + isLoading: { + required: true, + type: Boolean, + }, + canDelete: { + required: false, + type: Boolean, + default: true, + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center hide-collapsed"> + <span + :class="hasDate ? 'gl-text-gray-900 gl-font-weight-bold' : 'gl-text-gray-500'" + data-testid="sidebar-date-value" + > + {{ formattedDate }} + </span> + <div v-if="hasDate && canUpdate && canDelete" 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="$emit('reset-date', $event)" + > + {{ resetText }} + </gl-button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue b/app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue new file mode 100644 index 00000000000..b6bfacb2e47 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue @@ -0,0 +1,110 @@ +<script> +import { GlFormRadio } from '@gitlab/ui'; +import { dateInWords, parsePikadayDate } from '~/lib/utils/datetime_utility'; +import { __ } from '~/locale'; +import { dateFields } from '../../constants'; +import SidebarFormattedDate from './sidebar_formatted_date.vue'; + +export default { + components: { + GlFormRadio, + SidebarFormattedDate, + }, + inject: ['canUpdate'], + props: { + issuable: { + required: true, + type: Object, + }, + isLoading: { + required: true, + type: Boolean, + }, + dateType: { + type: String, + required: true, + }, + }, + computed: { + dateIsFixed: { + get() { + return this.issuable?.[dateFields[this.dateType].isDateFixed] || false; + }, + set(fixed) { + this.$emit('set-date', fixed); + }, + }, + hasFixedDate() { + return this.issuable[dateFields[this.dateType].dateFixed] !== null; + }, + formattedFixedDate() { + const dateFixed = this.issuable[dateFields[this.dateType].dateFixed]; + if (!dateFixed) { + return this.$options.i18n.noDate; + } + + return dateInWords(parsePikadayDate(dateFixed), true); + }, + formattedInheritedDate() { + const dateFromMilestones = this.issuable[dateFields[this.dateType].dateFromMilestones]; + if (!dateFromMilestones) { + return this.$options.i18n.noDate; + } + + return dateInWords(parsePikadayDate(dateFromMilestones), true); + }, + }, + i18n: { + fixed: __('Fixed:'), + inherited: __('Inherited:'), + remove: __('remove'), + noDate: __('None'), + }, +}; +</script> + +<template> + <div class="hide-collapsed gl-mt-3"> + <div class="gl-display-flex gl-align-items-baseline" data-testid="sidebar-fixed-date"> + <gl-form-radio + v-model="dateIsFixed" + :value="true" + :disabled="!canUpdate || isLoading" + class="gl-pr-2" + > + <span :class="dateIsFixed ? 'gl-text-gray-900 gl-font-weight-bold' : 'gl-text-gray-500'"> + {{ $options.i18n.fixed }} + </span> + </gl-form-radio> + <sidebar-formatted-date + :has-date="dateIsFixed" + :formatted-date="formattedFixedDate" + :reset-text="$options.i18n.remove" + :is-loading="isLoading" + :can-delete="dateIsFixed && hasFixedDate" + class="gl-line-height-normal" + @reset-date="$emit('reset-date', $event)" + /> + </div> + <div class="gl-display-flex gl-align-items-baseline" data-testid="sidebar-inherited-date"> + <gl-form-radio + v-model="dateIsFixed" + :value="false" + :disabled="!canUpdate || isLoading" + class="gl-pr-2" + > + <span :class="!dateIsFixed ? 'gl-text-gray-900 gl-font-weight-bold' : 'gl-text-gray-500'"> + {{ $options.i18n.inherited }} + </span> + </gl-form-radio> + <sidebar-formatted-date + :has-date="!dateIsFixed" + :formatted-date="formattedInheritedDate" + :reset-text="$options.i18n.remove" + :is-loading="isLoading" + :can-delete="false" + class="gl-line-height-normal" + /> + </div> + </div> +</template> 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 deleted file mode 100644 index 141c2b3aae9..00000000000 --- a/app/assets/javascripts/sidebar/components/due_date/sidebar_due_date_widget.vue +++ /dev/null @@ -1,203 +0,0 @@ -<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/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index c3a08f760a0..e85e416881c 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -95,7 +95,7 @@ export default { <gl-loading-icon v-if="loading" /> <span v-else data-testid="collapsed-count"> {{ participantCount }} </span> </div> - <div v-if="showParticipantLabel" class="title hide-collapsed"> + <div v-if="showParticipantLabel" class="title hide-collapsed gl-mb-2"> <gl-loading-icon v-if="loading" :inline="true" /> {{ participantLabel }} </div> @@ -105,10 +105,10 @@ export default { :key="participant.id" class="participants-author" > - <a :href="participant.web_url" class="author-link"> + <a :href="participant.web_url || participant.webUrl" class="author-link"> <user-avatar-image :lazy="true" - :img-src="participant.avatar_url" + :img-src="participant.avatar_url || participant.avatarUrl" :size="24" :tooltip-text="participant.name" css-classes="avatar-inline" diff --git a/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue new file mode 100644 index 00000000000..d3043e6f6aa --- /dev/null +++ b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue @@ -0,0 +1,68 @@ +<script> +import { __ } from '~/locale'; +import { participantsQueries } from '~/sidebar/constants'; +import Participants from './participants.vue'; + +export default { + i18n: { + fetchingError: __('An error occurred while fetching participants'), + }, + components: { + Participants, + }, + props: { + iid: { + type: String, + required: true, + }, + fullPath: { + type: String, + required: true, + }, + issuableType: { + required: true, + type: String, + }, + }, + data() { + return { + participants: [], + }; + }, + apollo: { + participants: { + query() { + return participantsQueries[this.issuableType].query; + }, + variables() { + return { + fullPath: this.fullPath, + iid: this.iid, + }; + }, + update(data) { + return data.workspace?.issuable?.participants.nodes || []; + }, + error(error) { + this.$emit('fetch-error', { + message: this.$options.i18n.fetchingError, + error, + }); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.participants.loading; + }, + }, +}; +</script> + +<template> + <participants + :loading="isLoading" + :participants="participants" + :number-of-less-participants="7" + /> +</template> diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue index caf1c92c28a..0fb8d762c7c 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue @@ -3,6 +3,9 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; export default { + i18n: { + unassigned: __('Unassigned'), + }, components: { GlButton, GlLoadingIcon }, inject: { canUpdate: {}, @@ -40,6 +43,11 @@ export default { property: null, }), }, + canEdit: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -103,14 +111,16 @@ export default { <div> <div class="gl-display-flex gl-align-items-center" @click.self="collapse"> <span class="hide-collapsed" data-testid="title" @click="collapse">{{ title }}</span> + <slot name="title-extra"></slot> <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" /> + <slot name="collapsed-right"></slot> <gl-button - v-if="canUpdate && !initialLoading" + v-if="canUpdate && !initialLoading && canEdit" variant="link" class="gl-text-gray-900! gl-hover-text-blue-800! gl-ml-auto hide-collapsed" data-testid="edit-button" diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue deleted file mode 100644 index 3ad097138a3..00000000000 --- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue +++ /dev/null @@ -1,42 +0,0 @@ -<script> -import { deprecatedCreateFlash as Flash } from '../../../flash'; -import { __ } from '../../../locale'; -import Store from '../../stores/sidebar_store'; -import subscriptions from './subscriptions.vue'; - -export default { - components: { - subscriptions, - }, - props: { - mediator: { - type: Object, - required: true, - }, - }, - data() { - return { - store: new Store(), - }; - }, - methods: { - onToggleSubscription() { - this.mediator.toggleSubscription().catch(() => { - Flash(__('Error occurred when toggling the notification subscription')); - }); - }, - }, -}; -</script> - -<template> - <div class="block subscriptions"> - <subscriptions - :loading="store.isFetching.subscriptions" - :project-emails-disabled="store.projectEmailsDisabled" - :subscribe-disabled-description="store.subscribeDisabledDescription" - :subscribed="store.subscribed" - @toggleSubscription="onToggleSubscription" - /> - </div> -</template> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue new file mode 100644 index 00000000000..ee7502e3457 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue @@ -0,0 +1,202 @@ +<script> +import { GlIcon, GlLoadingIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { IssuableType } from '~/issue_show/constants'; +import { __, sprintf } from '~/locale'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import { subscribedQueries } from '~/sidebar/constants'; + +const ICON_ON = 'notifications'; +const ICON_OFF = 'notifications-off'; + +export default { + tracking: { + event: 'click_edit_button', + label: 'right_sidebar', + property: 'subscriptions', + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlIcon, + GlLoadingIcon, + GlToggle, + SidebarEditableItem, + }, + inject: ['canUpdate'], + props: { + iid: { + type: String, + required: true, + }, + fullPath: { + type: String, + required: true, + }, + issuableType: { + required: true, + type: String, + }, + }, + data() { + return { + subscribed: false, + loading: false, + emailsDisabled: false, + }; + }, + apollo: { + subscribed: { + query() { + return subscribedQueries[this.issuableType].query; + }, + variables() { + return { + fullPath: this.fullPath, + iid: String(this.iid), + }; + }, + update(data) { + return data.workspace?.issuable?.subscribed || false; + }, + result({ data }) { + this.emailsDisabled = this.parentIsGroup + ? data.workspace?.emailsDisabled + : data.workspace?.issuable?.emailsDisabled; + this.$emit('subscribedUpdated', data.workspace?.issuable?.subscribed); + }, + error() { + createFlash({ + message: sprintf( + __('Something went wrong while setting %{issuableType} notifications.'), + { + issuableType: this.issuableType, + }, + ), + }); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries?.subscribed?.loading || this.loading; + }, + notificationTooltip() { + if (this.emailsDisabled) { + return this.subscribeDisabledDescription; + } + return this.subscribed ? this.$options.i18n.labelOn : this.$options.i18n.labelOff; + }, + notificationIcon() { + if (this.emailsDisabled || !this.subscribed) { + return ICON_OFF; + } + return ICON_ON; + }, + parentIsGroup() { + return this.issuableType === IssuableType.Epic; + }, + subscribeDisabledDescription() { + return sprintf(__('Disabled by %{parent} owner'), { + parent: this.parentIsGroup ? 'group' : 'project', + }); + }, + }, + methods: { + setSubscribed(subscribed) { + this.loading = true; + this.$apollo + .mutate({ + mutation: subscribedQueries[this.issuableType].mutation, + variables: { + fullPath: this.fullPath, + iid: this.iid, + subscribedState: subscribed, + }, + }) + .then( + ({ + data: { + updateIssuableSubscription: { errors }, + }, + }) => { + if (errors.length) { + createFlash({ + message: errors[0], + }); + } + }, + ) + .catch(() => { + createFlash({ + message: sprintf( + __('Something went wrong while setting %{issuableType} notifications.'), + { + issuableType: this.issuableType, + }, + ), + }); + }) + .finally(() => { + this.loading = false; + }); + }, + toggleSubscribed() { + if (this.emailsDisabled) { + this.expandSidebar(); + } else { + this.setSubscribed(!this.subscribed); + } + }, + expandSidebar() { + this.$emit('expandSidebar'); + }, + }, + i18n: { + notifications: __('Notifications'), + labelOn: __('Notifications on'), + labelOff: __('Notifications off'), + }, +}; +</script> + +<template> + <sidebar-editable-item + ref="editable" + :title="$options.i18n.notifications" + :tracking="$options.tracking" + :loading="isLoading" + :can-edit="false" + class="block subscriptions" + > + <template #collapsed-right> + <gl-toggle + :value="subscribed" + :is-loading="isLoading" + :disabled="emailsDisabled || !canUpdate" + class="hide-collapsed gl-ml-auto" + data-testid="subscription-toggle" + :label="$options.i18n.notifications" + label-position="hidden" + @change="setSubscribed" + /> + </template> + <template #collapsed> + <span + ref="tooltip" + v-gl-tooltip.viewport.left + :title="notificationTooltip" + class="sidebar-collapsed-icon" + @click="toggleSubscribed" + > + <gl-loading-icon v-if="isLoading" class="sidebar-item-icon is-active" /> + <gl-icon v-else :name="notificationIcon" :size="16" class="sidebar-item-icon is-active" /> + </span> + <div v-show="emailsDisabled" class="gl-mt-3 hide-collapsed gl-text-gray-500"> + {{ subscribeDisabledDescription }} + </div> + </template> + <template #default> </template> + </sidebar-editable-item> +</template> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/report.vue b/app/assets/javascripts/sidebar/components/time_tracking/report.vue new file mode 100644 index 00000000000..67242b3b5b7 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/report.vue @@ -0,0 +1,112 @@ +<script> +import { GlLoadingIcon, GlTable } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { formatDate, parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility'; +import { __ } from '~/locale'; +import { timelogQueries } from '~/sidebar/constants'; + +const TIME_DATE_FORMAT = 'mmmm d, yyyy, HH:MM ("UTC:" o)'; + +export default { + components: { + GlLoadingIcon, + GlTable, + }, + inject: ['issuableId', 'issuableType'], + props: { + limitToHours: { + type: Boolean, + default: false, + required: false, + }, + }, + data() { + return { report: [], isLoading: true }; + }, + apollo: { + report: { + query() { + return timelogQueries[this.issuableType].query; + }, + variables() { + return { + id: convertToGraphQLId(this.getGraphQLEntityType(), this.issuableId), + }; + }, + update(data) { + this.isLoading = false; + return this.extractTimelogs(data); + }, + error() { + createFlash({ message: __('Something went wrong. Please try again.') }); + }, + }, + }, + methods: { + isIssue() { + return this.issuableType === 'issue'; + }, + getGraphQLEntityType() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return this.isIssue() ? 'Issue' : 'MergeRequest'; + }, + extractTimelogs(data) { + const timelogs = data?.issuable?.timelogs?.nodes || []; + return timelogs.slice().sort((a, b) => new Date(a.spentAt) - new Date(b.spentAt)); + }, + formatDate(date) { + return formatDate(date, TIME_DATE_FORMAT); + }, + getNote(note) { + return note?.body; + }, + getTotalTimeSpent() { + const seconds = this.report.reduce((acc, item) => acc + item.timeSpent, 0); + return this.formatTimeSpent(seconds); + }, + formatTimeSpent(seconds) { + const negative = seconds < 0; + return ( + (negative ? '- ' : '') + + stringifyTime(parseSeconds(seconds, { limitToHours: this.limitToHours })) + ); + }, + }, + fields: [ + { key: 'spentAt', label: __('Spent At'), sortable: true }, + { key: 'user', label: __('User'), sortable: true }, + { key: 'timeSpent', label: __('Time Spent'), sortable: true }, + { key: 'note', label: __('Note'), sortable: true }, + ], +}; +</script> + +<template> + <div> + <div v-if="isLoading"><gl-loading-icon size="md" /></div> + <gl-table v-else :items="report" :fields="$options.fields" foot-clone> + <template #cell(spentAt)="{ item: { spentAt } }"> + <div>{{ formatDate(spentAt) }}</div> + </template> + <template #foot(spentAt)> </template> + + <template #cell(user)="{ item: { user } }"> + <div>{{ user.name }}</div> + </template> + <template #foot(user)> </template> + + <template #cell(timeSpent)="{ item: { timeSpent } }"> + <div>{{ formatTimeSpent(timeSpent) }}</div> + </template> + <template #foot(timeSpent)> + <div>{{ getTotalTimeSpent() }}</div> + </template> + + <template #cell(note)="{ item: { note } }"> + <div>{{ getNote(note) }}</div> + </template> + <template #foot(note)> </template> + </gl-table> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index 4c095006dd7..64f2ddc1d16 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -1,10 +1,11 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlLink, GlModal, GlModalDirective } from '@gitlab/ui'; import { s__, __ } from '~/locale'; import eventHub from '../../event_hub'; import TimeTrackingCollapsedState from './collapsed_state.vue'; import TimeTrackingComparisonPane from './comparison_pane.vue'; import TimeTrackingHelpState from './help_state.vue'; +import TimeTrackingReport from './report.vue'; import TimeTrackingSpentOnlyPane from './spent_only_pane.vue'; export default { @@ -15,10 +16,16 @@ export default { }, components: { GlIcon, + GlLink, + GlModal, TimeTrackingCollapsedState, TimeTrackingSpentOnlyPane, TimeTrackingComparisonPane, TimeTrackingHelpState, + TimeTrackingReport, + }, + directives: { + GlModal: GlModalDirective, }, props: { timeEstimate: { @@ -160,6 +167,21 @@ export default { :time-estimate-human-readable="humanTimeEstimate" :limit-to-hours="limitToHours" /> + <gl-link + v-if="hasTimeSpent" + v-gl-modal="'time-tracking-report'" + data-testid="reportLink" + href="#" + class="btn-link" + >{{ __('Time tracking report') }}</gl-link + > + <gl-modal + modal-id="time-tracking-report" + :title="__('Time tracking report')" + :hide-footer="true" + > + <time-tracking-report :limit-to-hours="limitToHours" /> + </gl-modal> <transition name="help-state-toggle"> <time-tracking-help-state v-if="showHelpState" /> </transition> diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index 80e07d556bf..a4e6d8854d1 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -1,27 +1,56 @@ import { IssuableType } from '~/issue_show/constants'; import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql'; +import epicDueDateQuery from '~/sidebar/queries/epic_due_date.query.graphql'; +import epicParticipantsQuery from '~/sidebar/queries/epic_participants.query.graphql'; +import epicStartDateQuery from '~/sidebar/queries/epic_start_date.query.graphql'; +import epicSubscribedQuery from '~/sidebar/queries/epic_subscribed.query.graphql'; +import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.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 issueSubscribedQuery from '~/sidebar/queries/issue_subscribed.query.graphql'; import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql'; -import updateEpicMutation from '~/sidebar/queries/update_epic_confidential.mutation.graphql'; +import mergeRequestSubscribed from '~/sidebar/queries/merge_request_subscribed.query.graphql'; +import updateEpicConfidentialMutation from '~/sidebar/queries/update_epic_confidential.mutation.graphql'; +import updateEpicDueDateMutation from '~/sidebar/queries/update_epic_due_date.mutation.graphql'; +import updateEpicStartDateMutation from '~/sidebar/queries/update_epic_start_date.mutation.graphql'; +import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.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 updateIssueSubscriptionMutation from '~/sidebar/queries/update_issue_subscription.mutation.graphql'; +import updateMergeRequestSubscriptionMutation from '~/sidebar/queries/update_merge_request_subscription.mutation.graphql'; +import getIssueAssignees from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql'; +import issueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql'; +import getIssueTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql'; +import getMergeRequestAssignees from '~/vue_shared/components/sidebar/queries/get_mr_assignees.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'; -import updateMergeRequestParticipantsMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql'; +import getMrTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql'; +import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql'; +import updateMergeRequestAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql'; export const ASSIGNEES_DEBOUNCE_DELAY = 250; export const assigneesQueries = { [IssuableType.Issue]: { - query: getIssueParticipants, - mutation: updateAssigneesMutation, + query: getIssueAssignees, + subscription: issuableAssigneesSubscription, + mutation: updateIssueAssigneesMutation, + }, + [IssuableType.MergeRequest]: { + query: getMergeRequestAssignees, + mutation: updateMergeRequestAssigneesMutation, + }, +}; + +export const participantsQueries = { + [IssuableType.Issue]: { + query: issueParticipantsQuery, }, [IssuableType.MergeRequest]: { query: getMergeRequestParticipants, - mutation: updateMergeRequestParticipantsMutation, + }, + [IssuableType.Epic]: { + query: epicParticipantsQuery, }, }; @@ -32,7 +61,7 @@ export const confidentialityQueries = { }, [IssuableType.Epic]: { query: epicConfidentialQuery, - mutation: updateEpicMutation, + mutation: updateEpicConfidentialMutation, }, }; @@ -45,9 +74,62 @@ export const referenceQueries = { }, }; +export const dateTypes = { + start: 'startDate', + due: 'dueDate', +}; + +export const dateFields = { + [dateTypes.start]: { + isDateFixed: 'startDateIsFixed', + dateFixed: 'startDateFixed', + dateFromMilestones: 'startDateFromMilestones', + }, + [dateTypes.due]: { + isDateFixed: 'dueDateIsFixed', + dateFixed: 'dueDateFixed', + dateFromMilestones: 'dueDateFromMilestones', + }, +}; + +export const subscribedQueries = { + [IssuableType.Issue]: { + query: issueSubscribedQuery, + mutation: updateIssueSubscriptionMutation, + }, + [IssuableType.Epic]: { + query: epicSubscribedQuery, + mutation: updateEpicSubscriptionMutation, + }, + [IssuableType.MergeRequest]: { + query: mergeRequestSubscribed, + mutation: updateMergeRequestSubscriptionMutation, + }, +}; + export const dueDateQueries = { [IssuableType.Issue]: { query: issueDueDateQuery, mutation: updateIssueDueDateMutation, }, + [IssuableType.Epic]: { + query: epicDueDateQuery, + mutation: updateEpicDueDateMutation, + }, +}; + +export const startDateQueries = { + [IssuableType.Epic]: { + query: epicStartDateQuery, + mutation: updateEpicStartDateMutation, + }, +}; + +export const timelogQueries = { + [IssuableType.Issue]: { + query: getIssueTimelogsQuery, + }, + [IssuableType.MergeRequest]: { + query: getMrTimelogsQuery, + }, }; diff --git a/app/assets/javascripts/sidebar/fragmentTypes.json b/app/assets/javascripts/sidebar/fragmentTypes.json new file mode 100644 index 00000000000..a1c68bba454 --- /dev/null +++ b/app/assets/javascripts/sidebar/fragmentTypes.json @@ -0,0 +1 @@ +{"__schema":{"types":[{"kind":"UNION","name":"Issuable","possibleTypes":[{"name":"Issue"},{"name":"MergeRequest"}]}, {"kind":"INTERFACE","name":"User","possibleTypes":[{"name":"UserCore"}]}]}} diff --git a/app/assets/javascripts/sidebar/graphql.js b/app/assets/javascripts/sidebar/graphql.js index aa139540a51..8615b52f1b8 100644 --- a/app/assets/javascripts/sidebar/graphql.js +++ b/app/assets/javascripts/sidebar/graphql.js @@ -1,7 +1,21 @@ +import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import introspectionQueryResultData from './fragmentTypes.json'; -export const defaultClient = createDefaultClient(); +const fragmentMatcher = new IntrospectionFragmentMatcher({ + introspectionQueryResultData, +}); + +export const defaultClient = createDefaultClient( + {}, + { + cacheConfig: { + fragmentMatcher, + }, + assumeImmutableResults: true, + }, +); export const apolloProvider = new VueApollo({ defaultClient, diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 1304e84814b..3f24fdc75dc 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -13,7 +13,7 @@ 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 SidebarDueDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue'; import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue'; import { apolloProvider } from '~/sidebar/graphql'; import Translate from '../vue_shared/translate'; @@ -24,7 +24,7 @@ import IssuableLockForm from './components/lock/issuable_lock_form.vue'; import sidebarParticipants from './components/participants/sidebar_participants.vue'; import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue'; import SidebarSeverity from './components/severity/sidebar_severity.vue'; -import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptions.vue'; +import SidebarSubscriptionsWidget from './components/subscriptions/sidebar_subscriptions_widget.vue'; import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue'; import SidebarMoveIssue from './lib/sidebar_move_issue'; @@ -53,7 +53,7 @@ function mountAssigneesComponentDeprecated(mediator) { if (!el) return; - const { iid, fullPath } = getSidebarOptions(); + const { id, iid, fullPath } = getSidebarOptions(); const assigneeAvailabilityStatus = getSidebarAssigneeAvailabilityData(); // eslint-disable-next-line no-new new Vue({ @@ -74,6 +74,7 @@ function mountAssigneesComponentDeprecated(mediator) { isInIssuePage() || isInIncidentPage() || isInDesignPage() ? IssuableType.Issue : IssuableType.MergeRequest, + issuableId: id, assigneeAvailabilityStatus, }, }), @@ -85,7 +86,7 @@ function mountAssigneesComponent() { if (!el) return; - const { iid, fullPath, editable, projectMembersPath } = getSidebarOptions(); + const { id, iid, fullPath, editable } = getSidebarOptions(); // eslint-disable-next-line no-new new Vue({ el, @@ -95,9 +96,7 @@ function mountAssigneesComponent() { }, provide: { canUpdate: editable, - projectMembersPath, directlyInviteMembers: el.hasAttribute('data-directly-invite-members'), - indirectlyInviteMembers: el.hasAttribute('data-indirectly-invite-members'), }, render: (createElement) => createElement('sidebar-assignees-widget', { @@ -108,7 +107,8 @@ function mountAssigneesComponent() { isInIssuePage() || isInIncidentPage() || isInDesignPage() ? IssuableType.Issue : IssuableType.MergeRequest, - multipleAssignees: !el.dataset.maxAssignees, + issuableId: id, + allowMultipleAssignees: !el.dataset.maxAssignees, }, scopedSlots: { collapsed: ({ users, onClick }) => @@ -223,14 +223,14 @@ function mountDueDateComponent() { SidebarDueDateWidget, }, provide: { - iid: String(iid), - fullPath, canUpdate: editable, }, render: (createElement) => createElement('sidebar-due-date-widget', { props: { + iid: String(iid), + fullPath, issuableType: IssuableType.Issue, }, }), @@ -334,21 +334,32 @@ function mountParticipantsComponent(mediator) { }); } -function mountSubscriptionsComponent(mediator) { +function mountSubscriptionsComponent() { const el = document.querySelector('.js-sidebar-subscriptions-entry-point'); if (!el) return; + const { fullPath, iid, editable } = getSidebarOptions(); + // eslint-disable-next-line no-new new Vue({ el, + apolloProvider, components: { - sidebarSubscriptions, + SidebarSubscriptionsWidget, + }, + provide: { + canUpdate: editable, }, render: (createElement) => - createElement('sidebar-subscriptions', { + createElement('sidebar-subscriptions-widget', { props: { - mediator, + iid: String(iid), + fullPath, + issuableType: + isInIssuePage() || isInIncidentPage() || isInDesignPage() + ? IssuableType.Issue + : IssuableType.MergeRequest, }, }), }); @@ -356,16 +367,16 @@ function mountSubscriptionsComponent(mediator) { function mountTimeTrackingComponent() { const el = document.getElementById('issuable-time-tracker'); + const { id, issuableType } = getSidebarOptions(); if (!el) return; // eslint-disable-next-line no-new new Vue({ el, - components: { - SidebarTimeTracking, - }, - render: (createElement) => createElement('sidebar-time-tracking', {}), + apolloProvider, + provide: { issuableId: id, issuableType }, + render: (createElement) => createElement(SidebarTimeTracking, {}), }); } @@ -425,7 +436,7 @@ export function mountSidebar(mediator) { mountReferenceComponent(mediator); mountLockComponent(); mountParticipantsComponent(mediator); - mountSubscriptionsComponent(mediator); + mountSubscriptionsComponent(); mountCopyEmailComponent(); new SidebarMoveIssue( diff --git a/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql b/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql new file mode 100644 index 00000000000..f60f44abebd --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql @@ -0,0 +1,13 @@ +query epicDueDate($fullPath: ID!, $iid: ID) { + workspace: group(fullPath: $fullPath) { + __typename + issuable: epic(iid: $iid) { + __typename + id + dueDate + dueDateIsFixed + dueDateFixed + dueDateFromMilestones + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql b/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql new file mode 100644 index 00000000000..fbebc50ab08 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql @@ -0,0 +1,18 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" +#import "~/graphql_shared/fragments/user_availability.fragment.graphql" + +query epicParticipants($fullPath: ID!, $iid: ID) { + workspace: group(fullPath: $fullPath) { + __typename + issuable: epic(iid: $iid) { + __typename + id + participants { + nodes { + ...User + ...UserAvailability + } + } + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql b/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql new file mode 100644 index 00000000000..c6c24fd3d95 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql @@ -0,0 +1,13 @@ +query epicStartDate($fullPath: ID!, $iid: ID) { + workspace: group(fullPath: $fullPath) { + __typename + issuable: epic(iid: $iid) { + __typename + id + startDate + startDateIsFixed + startDateFixed + startDateFromMilestones + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql new file mode 100644 index 00000000000..9f1967e1685 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql @@ -0,0 +1,11 @@ +query epicSubscribed($fullPath: ID!, $iid: ID) { + workspace: group(fullPath: $fullPath) { + __typename + emailsDisabled + issuable: epic(iid: $iid) { + __typename + id + subscribed + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/issuable_assignees.subscription.graphql b/app/assets/javascripts/sidebar/queries/issuable_assignees.subscription.graphql new file mode 100644 index 00000000000..47ce094418c --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/issuable_assignees.subscription.graphql @@ -0,0 +1,16 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" + +subscription issuableAssigneesUpdated($issuableId: IssuableID!) { + issuableAssigneesUpdated(issuableId: $issuableId) { + ... on Issue { + assignees { + nodes { + ...User + status { + availability + } + } + } + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql new file mode 100644 index 00000000000..7d38b5d3bd8 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql @@ -0,0 +1,11 @@ +query issueSubscribed($fullPath: ID!, $iid: String) { + workspace: project(fullPath: $fullPath) { + __typename + issuable: issue(iid: $iid) { + __typename + id + subscribed + emailsDisabled + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql new file mode 100644 index 00000000000..3b54a2e529b --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql @@ -0,0 +1,10 @@ +query mergeRequestSubscribed($fullPath: ID!, $iid: String!) { + workspace: project(fullPath: $fullPath) { + __typename + issuable: mergeRequest(iid: $iid) { + __typename + id + subscribed + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/update_epic_due_date.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_due_date.mutation.graphql new file mode 100644 index 00000000000..9b0a8b4a8f7 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/update_epic_due_date.mutation.graphql @@ -0,0 +1,11 @@ +mutation updateEpicDueDate($input: UpdateEpicInput!) { + issuableSetDate: updateEpic(input: $input) { + issuable: epic { + id + dueDateIsFixed + dueDateFixed + dueDateFromMilestones + } + errors + } +} diff --git a/app/assets/javascripts/sidebar/queries/update_epic_start_date.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_start_date.mutation.graphql new file mode 100644 index 00000000000..9b4bb9159c3 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/update_epic_start_date.mutation.graphql @@ -0,0 +1,11 @@ +mutation updateEpicStartDate($input: UpdateEpicInput!) { + issuableSetDate: updateEpic(input: $input) { + issuable: epic { + id + startDateIsFixed + startDateFixed + startDateFromMilestones + } + errors + } +} diff --git a/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql index f2b806102f4..af43766aed5 100644 --- a/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql @@ -1,6 +1,9 @@ -mutation epicSetSubscription($input: EpicSetSubscriptionInput!) { - updateIssuableSubscription: epicSetSubscription(input: $input) { - epic { +mutation epicSetSubscription($fullPath: ID!, $iid: ID!, $subscribedState: Boolean!) { + updateIssuableSubscription: epicSetSubscription( + input: { groupPath: $fullPath, iid: $iid, subscribedState: $subscribedState } + ) { + issuable: epic { + id subscribed } errors 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 index cf7eccd61c7..4765b0b08cc 100644 --- a/app/assets/javascripts/sidebar/queries/update_issue_due_date.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_issue_due_date.mutation.graphql @@ -1,5 +1,5 @@ mutation updateIssueDueDate($input: UpdateIssueInput!) { - issuableSetDueDate: updateIssue(input: $input) { + issuableSetDate: updateIssue(input: $input) { issuable: issue { id dueDate diff --git a/app/assets/javascripts/sidebar/queries/update_issue_subscription.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_issue_subscription.mutation.graphql new file mode 100644 index 00000000000..81891fb601f --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/update_issue_subscription.mutation.graphql @@ -0,0 +1,11 @@ +mutation issueSetSubscription($fullPath: ID!, $iid: String!, $subscribedState: Boolean!) { + updateIssuableSubscription: issueSetSubscription( + input: { projectPath: $fullPath, iid: $iid, subscribedState: $subscribedState } + ) { + issuable: issue { + id + subscribed + } + errors + } +} diff --git a/app/assets/javascripts/sidebar/queries/update_merge_request_subscription.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_merge_request_subscription.mutation.graphql new file mode 100644 index 00000000000..69944ff9a13 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/update_merge_request_subscription.mutation.graphql @@ -0,0 +1,11 @@ +mutation mergeRequestSetSubscription($fullPath: ID!, $iid: String!, $subscribedState: Boolean!) { + updateIssuableSubscription: mergeRequestSetSubscription( + input: { projectPath: $fullPath, iid: $iid, subscribedState: $subscribedState } + ) { + issuable: mergeRequest { + id + subscribed + } + errors + } +} |