summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/work_items/components/work_item_milestone.vue
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/work_items/components/work_item_milestone.vue')
-rw-r--r--app/assets/javascripts/work_items/components/work_item_milestone.vue248
1 files changed, 248 insertions, 0 deletions
diff --git a/app/assets/javascripts/work_items/components/work_item_milestone.vue b/app/assets/javascripts/work_items/components/work_item_milestone.vue
new file mode 100644
index 00000000000..c4a36e36555
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_milestone.vue
@@ -0,0 +1,248 @@
+<script>
+import {
+ GlFormGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlSkeletonLoader,
+ GlSearchBoxByType,
+ GlDropdownText,
+} from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import { debounce } from 'lodash';
+import Tracking from '~/tracking';
+import { s__ } from '~/locale';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql';
+import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql';
+import {
+ I18N_WORK_ITEM_ERROR_UPDATING,
+ sprintfWorkItem,
+ TRACKING_CATEGORY_SHOW,
+} from '../constants';
+
+const noMilestoneId = 'no-milestone-id';
+
+export default {
+ i18n: {
+ MILESTONE: s__('WorkItem|Milestone'),
+ NONE: s__('WorkItem|None'),
+ MILESTONE_PLACEHOLDER: s__('WorkItem|Add to milestone'),
+ NO_MATCHING_RESULTS: s__('WorkItem|No matching results'),
+ NO_MILESTONE: s__('WorkItem|No milestone'),
+ MILESTONE_FETCH_ERROR: s__(
+ 'WorkItem|Something went wrong while fetching milestones. Please try again.',
+ ),
+ },
+ components: {
+ GlFormGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlSkeletonLoader,
+ GlSearchBoxByType,
+ GlDropdownText,
+ },
+ mixins: [Tracking.mixin()],
+ props: {
+ workItemId: {
+ type: String,
+ required: true,
+ },
+ workItemMilestone: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ workItemType: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ canUpdate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ localMilestone: this.workItemMilestone,
+ searchTerm: '',
+ shouldFetch: false,
+ updateInProgress: false,
+ isFocused: false,
+ milestones: [],
+ };
+ },
+ computed: {
+ tracking() {
+ return {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'item_milestone',
+ property: `type_${this.workItemType}`,
+ };
+ },
+ emptyPlaceholder() {
+ return this.canUpdate ? this.$options.i18n.MILESTONE_PLACEHOLDER : this.$options.i18n.NONE;
+ },
+ dropdownText() {
+ return this.localMilestone?.title || this.emptyPlaceholder;
+ },
+ isLoadingMilestones() {
+ return this.$apollo.queries.milestones.loading;
+ },
+ isNoMilestone() {
+ return this.localMilestone?.id === noMilestoneId || !this.localMilestone?.id;
+ },
+ dropdownClasses() {
+ return {
+ 'gl-text-gray-500!': this.canUpdate && this.isNoMilestone,
+ 'is-not-focused': !this.isFocused,
+ };
+ },
+ },
+ created() {
+ this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ },
+ apollo: {
+ milestones: {
+ query: projectMilestonesQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ title: this.searchTerm,
+ first: 20,
+ };
+ },
+ skip() {
+ return !this.shouldFetch;
+ },
+ update(data) {
+ return data?.workspace?.attributes?.nodes || [];
+ },
+ error() {
+ this.$emit('error', this.i18n.MILESTONE_FETCH_ERROR);
+ },
+ },
+ },
+ methods: {
+ handleMilestoneClick(milestone) {
+ this.localMilestone = milestone;
+ },
+ onDropdownShown() {
+ this.$refs.search.focusInput();
+ this.shouldFetch = true;
+ this.isFocused = true;
+ },
+ onDropdownHide() {
+ this.isFocused = false;
+ this.searchTerm = '';
+ this.shouldFetch = false;
+ this.updateMilestone();
+ },
+ setSearchKey(value) {
+ this.searchTerm = value;
+ },
+ isMilestoneChecked(milestone) {
+ return this.localMilestone?.id === milestone?.id;
+ },
+ updateMilestone() {
+ if (this.workItemMilestone?.id === this.localMilestone?.id) {
+ return;
+ }
+
+ this.track('updated_milestone');
+ this.updateInProgress = true;
+ this.$apollo
+ .mutate({
+ mutation: localUpdateWorkItemMutation,
+ variables: {
+ input: {
+ id: this.workItemId,
+ milestone: {
+ milestoneId: this.localMilestone?.id,
+ },
+ },
+ },
+ })
+ .then(({ data }) => {
+ if (data.workItemUpdate.errors.length) {
+ throw new Error(data.workItemUpdate.errors.join('\n'));
+ }
+ })
+ .catch((error) => {
+ const msg = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType);
+ this.$emit('error', msg);
+ Sentry.captureException(error);
+ })
+ .finally(() => {
+ this.updateInProgress = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form-group
+ class="work-item-dropdown"
+ :label="$options.i18n.MILESTONE"
+ label-class="gl-pb-0! gl-overflow-wrap-break gl-mt-3"
+ label-cols="3"
+ label-cols-lg="2"
+ >
+ <span
+ v-if="!canUpdate"
+ class="gl-text-secondary gl-ml-4 gl-mt-3 gl-display-inline-block gl-line-height-normal"
+ data-testid="disabled-text"
+ >
+ {{ dropdownText }}
+ </span>
+ <gl-dropdown
+ v-else
+ :toggle-class="dropdownClasses"
+ :text="dropdownText"
+ :loading="updateInProgress"
+ @shown="onDropdownShown"
+ @hide="onDropdownHide"
+ >
+ <template #header>
+ <gl-search-box-by-type ref="search" :value="searchTerm" @input="debouncedSearchKeyUpdate" />
+ </template>
+ <gl-dropdown-item
+ data-testid="no-milestone"
+ is-check-item
+ :is-checked="isNoMilestone"
+ @click="handleMilestoneClick({ id: 'no-milestone-id' })"
+ >
+ {{ $options.i18n.NO_MILESTONE }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ <gl-dropdown-text v-if="isLoadingMilestones">
+ <gl-skeleton-loader :height="90">
+ <rect width="380" height="10" x="10" y="15" rx="4" />
+ <rect width="280" height="10" x="10" y="30" rx="4" />
+ <rect width="380" height="10" x="10" y="50" rx="4" />
+ <rect width="280" height="10" x="10" y="65" rx="4" />
+ </gl-skeleton-loader>
+ </gl-dropdown-text>
+ <template v-else-if="milestones.length">
+ <gl-dropdown-item
+ v-for="milestone in milestones"
+ :key="milestone.id"
+ is-check-item
+ :is-checked="isMilestoneChecked(milestone)"
+ @click="handleMilestoneClick(milestone)"
+ >
+ {{ milestone.title }}
+ </gl-dropdown-item>
+ </template>
+ <gl-dropdown-text v-else>{{ $options.i18n.NO_MATCHING_RESULTS }}</gl-dropdown-text>
+ </gl-dropdown>
+ </gl-form-group>
+</template>