summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/sidebar/components
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-05-19 15:44:42 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-05-19 15:44:42 +0000
commit4555e1b21c365ed8303ffb7a3325d773c9b8bf31 (patch)
tree5423a1c7516cffe36384133ade12572cf709398d /app/assets/javascripts/sidebar/components
parente570267f2f6b326480d284e0164a6464ba4081bc (diff)
downloadgitlab-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/components')
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue79
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue14
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue287
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue44
-rw-r--r--app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue296
-rw-r--r--app/assets/javascripts/sidebar/components/date/sidebar_formatted_date.vue56
-rw-r--r--app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue110
-rw-r--r--app/assets/javascripts/sidebar/components/due_date/sidebar_due_date_widget.vue203
-rw-r--r--app/assets/javascripts/sidebar/components/participants/participants.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue68
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_editable_item.vue12
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue42
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue202
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/report.vue112
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue24
16 files changed, 990 insertions, 567 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)>&nbsp;</template>
+
+ <template #cell(user)="{ item: { user } }">
+ <div>{{ user.name }}</div>
+ </template>
+ <template #foot(user)>&nbsp;</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)>&nbsp;</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>