diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-09-19 01:45:44 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-09-19 01:45:44 +0000 |
commit | 85dc423f7090da0a52c73eb66faf22ddb20efff9 (patch) | |
tree | 9160f299afd8c80c038f08e1545be119f5e3f1e1 /app/assets/javascripts/issues_list | |
parent | 15c2c8c66dbe422588e5411eee7e68f1fa440bb8 (diff) | |
download | gitlab-ce-85dc423f7090da0a52c73eb66faf22ddb20efff9.tar.gz |
Add latest changes from gitlab-org/gitlab@13-4-stable-ee
Diffstat (limited to 'app/assets/javascripts/issues_list')
8 files changed, 1226 insertions, 0 deletions
diff --git a/app/assets/javascripts/issues_list/components/issuable.vue b/app/assets/javascripts/issues_list/components/issuable.vue new file mode 100644 index 00000000000..adfb234fe7a --- /dev/null +++ b/app/assets/javascripts/issues_list/components/issuable.vue @@ -0,0 +1,432 @@ +<script> +/* + * This is tightly coupled to projects/issues/_issue.html.haml, + * any changes done to the haml need to be reflected here. + */ + +// TODO: need to move this component to graphql - https://gitlab.com/gitlab-org/gitlab/-/issues/221246 +import { escape, isNumber } from 'lodash'; +import { + GlLink, + GlTooltipDirective as GlTooltip, + GlSprintf, + GlLabel, + GlIcon, + GlSafeHtmlDirective as SafeHtml, +} from '@gitlab/ui'; +import jiraLogo from '@gitlab/svgs/dist/illustrations/logos/jira.svg'; +import { + dateInWords, + formatDate, + getDayDifference, + getTimeago, + timeFor, + newDateAsLocaleTime, +} from '~/lib/utils/datetime_utility'; +import { sprintf, __ } from '~/locale'; +import initUserPopovers from '~/user_popovers'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; +import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; +import { isScopedLabel } from '~/lib/utils/common_utils'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + +import { convertToCamelCase } from '~/lib/utils/text_utility'; + +export default { + i18n: { + openedAgo: __('opened %{timeAgoString} by %{user}'), + openedAgoJira: __('opened %{timeAgoString} by %{user} in Jira'), + }, + components: { + IssueAssignees, + GlLink, + GlLabel, + GlIcon, + GlSprintf, + IssueHealthStatus: () => + import('ee_component/related_items_tree/components/issue_health_status.vue'), + }, + directives: { + GlTooltip, + SafeHtml, + }, + mixins: [glFeatureFlagsMixin()], + props: { + issuable: { + type: Object, + required: true, + }, + isBulkEditing: { + type: Boolean, + required: false, + default: false, + }, + selected: { + type: Boolean, + required: false, + default: false, + }, + baseUrl: { + type: String, + required: false, + default() { + return window.location.href; + }, + }, + }, + data() { + return { + jiraLogo, + }; + }, + computed: { + milestoneLink() { + const { title } = this.issuable.milestone; + + return this.issuableLink({ milestone_title: title }); + }, + scopedLabelsAvailable() { + return this.glFeatures.scopedLabels; + }, + hasWeight() { + return isNumber(this.issuable.weight); + }, + dueDate() { + return this.issuable.due_date ? newDateAsLocaleTime(this.issuable.due_date) : undefined; + }, + dueDateWords() { + return this.dueDate ? dateInWords(this.dueDate, true) : undefined; + }, + isOverdue() { + return this.dueDate ? this.dueDate < new Date() : false; + }, + isClosed() { + return this.issuable.state === 'closed'; + }, + isJiraIssue() { + return this.issuable.external_tracker === 'jira'; + }, + linkTarget() { + return this.isJiraIssue ? '_blank' : null; + }, + issueCreatedToday() { + return getDayDifference(new Date(this.issuable.created_at), new Date()) < 1; + }, + labelIdsString() { + return JSON.stringify(this.issuable.labels.map(l => l.id)); + }, + milestoneDueDate() { + const { due_date: dueDate } = this.issuable.milestone || {}; + + return dueDate ? newDateAsLocaleTime(dueDate) : undefined; + }, + milestoneTooltipText() { + if (this.milestoneDueDate) { + return sprintf(__('%{primary} (%{secondary})'), { + primary: formatDate(this.milestoneDueDate, 'mmm d, yyyy'), + secondary: timeFor(this.milestoneDueDate), + }); + } + return __('Milestone'); + }, + issuableAuthor() { + return this.issuable.author; + }, + issuableCreatedAt() { + return getTimeago().format(this.issuable.created_at); + }, + popoverDataAttrs() { + const { id, username, name, avatar_url } = this.issuableAuthor; + + return { + 'data-user-id': id, + 'data-username': username, + 'data-name': name, + 'data-avatar-url': avatar_url, + }; + }, + referencePath() { + return this.issuable.references.relative; + }, + updatedDateString() { + return formatDate(new Date(this.issuable.updated_at), 'mmm d, yyyy h:MMtt'); + }, + updatedDateAgo() { + // snake_case because it's the same i18n string as the HAML view + return sprintf(__('updated %{time_ago}'), { + time_ago: escape(getTimeago().format(this.issuable.updated_at)), + }); + }, + issuableMeta() { + return [ + { + key: 'merge-requests', + visible: this.issuable.merge_requests_count > 0, + value: this.issuable.merge_requests_count, + title: __('Related merge requests'), + dataTestId: 'merge-requests', + class: 'js-merge-requests', + icon: 'merge-request', + }, + { + key: 'upvotes', + visible: this.issuable.upvotes > 0, + value: this.issuable.upvotes, + title: __('Upvotes'), + dataTestId: 'upvotes', + class: 'js-upvotes issuable-upvotes', + icon: 'thumb-up', + }, + { + key: 'downvotes', + visible: this.issuable.downvotes > 0, + value: this.issuable.downvotes, + title: __('Downvotes'), + dataTestId: 'downvotes', + class: 'js-downvotes issuable-downvotes', + icon: 'thumb-down', + }, + { + key: 'blocking-issues', + visible: this.issuable.blocking_issues_count > 0, + value: this.issuable.blocking_issues_count, + title: __('Blocking issues'), + dataTestId: 'blocking-issues', + href: `${this.issuable.web_url}#related-issues`, + icon: 'issue-block', + }, + { + key: 'comments-count', + visible: !this.isJiraIssue, + value: this.issuable.user_notes_count, + title: __('Comments'), + dataTestId: 'notes-count', + href: `${this.issuable.web_url}#notes`, + class: { 'no-comments': !this.issuable.user_notes_count, 'issuable-comments': true }, + icon: 'comments', + }, + ]; + }, + healthStatus() { + return convertToCamelCase(this.issuable.health_status); + }, + }, + mounted() { + // TODO: Refactor user popover to use its own component instead of + // spawning event listeners on Vue-rendered elements. + initUserPopovers([this.$refs.openedAgoByContainer.$el]); + }, + methods: { + issuableLink(params) { + return mergeUrlParams(params, this.baseUrl); + }, + isScoped({ name }) { + return isScopedLabel({ title: name }) && this.scopedLabelsAvailable; + }, + labelHref({ name }) { + if (this.isJiraIssue) { + return this.issuableLink({ 'labels[]': name }); + } + + return this.issuableLink({ 'label_name[]': name }); + }, + onSelect(ev) { + this.$emit('select', { + issuable: this.issuable, + selected: ev.target.checked, + }); + }, + issuableMetaComponent(href) { + return href ? 'gl-link' : 'span'; + }, + }, + + confidentialTooltipText: __('Confidential'), +}; +</script> +<template> + <li + :id="`issue_${issuable.id}`" + class="issue" + :class="{ today: issueCreatedToday, closed: isClosed }" + :data-id="issuable.id" + :data-labels="labelIdsString" + :data-url="issuable.web_url" + data-qa-selector="issue_container" + :data-qa-issue-title="issuable.title" + > + <div class="gl-display-flex"> + <!-- Bulk edit checkbox --> + <div v-if="isBulkEditing" class="gl-mr-3"> + <input + :id="`selected_issue_${issuable.id}`" + :checked="selected" + class="selected-issuable" + type="checkbox" + :data-id="issuable.id" + @input="onSelect" + /> + </div> + + <!-- Issuable info container --> + <!-- Issuable main info --> + <div class="gl-flex-grow-1"> + <div class="title"> + <span class="issue-title-text"> + <gl-icon + v-if="issuable.confidential" + v-gl-tooltip + name="eye-slash" + class="gl-vertical-align-text-bottom" + :size="16" + :title="$options.confidentialTooltipText" + :aria-label="$options.confidentialTooltipText" + /> + <gl-link + :href="issuable.web_url" + :target="linkTarget" + data-testid="issuable-title" + data-qa-selector="issue_link" + >{{ issuable.title + }}<gl-icon + v-if="isJiraIssue" + name="external-link" + class="gl-vertical-align-text-bottom gl-ml-2" + /> + </gl-link> + </span> + <span + v-if="issuable.has_tasks" + class="gl-ml-2 task-status gl-display-none d-sm-inline-block" + >{{ issuable.task_status }}</span + > + </div> + + <div class="issuable-info"> + <span class="js-ref-path gl-mr-4 mr-sm-0"> + <span + v-if="isJiraIssue" + v-safe-html="jiraLogo" + class="svg-container jira-logo-container" + data-testid="jira-logo" + ></span> + {{ referencePath }} + </span> + + <span data-testid="openedByMessage" class="gl-display-none d-sm-inline-block gl-mr-4"> + · + <gl-sprintf + :message="isJiraIssue ? $options.i18n.openedAgoJira : $options.i18n.openedAgo" + > + <template #timeAgoString> + <span>{{ issuableCreatedAt }}</span> + </template> + <template #user> + <gl-link + ref="openedAgoByContainer" + v-bind="popoverDataAttrs" + :href="issuableAuthor.web_url" + :target="linkTarget" + >{{ issuableAuthor.name }}</gl-link + > + </template> + </gl-sprintf> + </span> + + <gl-link + v-if="issuable.milestone" + v-gl-tooltip + class="gl-display-none d-sm-inline-block gl-mr-4 js-milestone milestone" + :href="milestoneLink" + :title="milestoneTooltipText" + > + <gl-icon name="clock" class="s16 gl-vertical-align-text-bottom" /> + {{ issuable.milestone.title }} + </gl-link> + + <span + v-if="dueDate" + v-gl-tooltip + class="gl-display-none d-sm-inline-block gl-mr-4 js-due-date" + :class="{ cred: isOverdue }" + :title="__('Due date')" + > + <i class="fa fa-calendar"></i> + {{ dueDateWords }} + </span> + + <span + v-if="hasWeight" + v-gl-tooltip + :title="__('Weight')" + class="gl-display-none d-sm-inline-block gl-mr-4" + data-testid="weight" + data-qa-selector="issuable_weight_content" + > + <gl-icon name="weight" class="align-text-bottom" /> + {{ issuable.weight }} + </span> + + <issue-health-status + v-if="issuable.health_status" + :health-status="healthStatus" + class="gl-mr-4 issuable-tag-valign" + /> + + <gl-label + v-for="label in issuable.labels" + :key="label.id" + data-qa-selector="issuable-label" + :target="labelHref(label)" + :background-color="label.color" + :description="label.description" + :color="label.text_color" + :title="label.name" + :scoped="isScoped(label)" + size="sm" + class="gl-mr-2 issuable-tag-valign" + >{{ label.name }}</gl-label + > + </div> + </div> + + <!-- Issuable meta --> + <div + class="gl-flex-shrink-0 gl-display-flex gl-flex-direction-column align-items-end gl-justify-content-center" + > + <div class="controls gl-display-flex"> + <span v-if="isJiraIssue" data-testid="issuable-status">{{ issuable.status }}</span> + <span v-else-if="isClosed" class="issuable-status">{{ __('CLOSED') }}</span> + + <issue-assignees + :assignees="issuable.assignees" + class="gl-align-items-center gl-display-flex gl-ml-3" + :icon-size="16" + img-css-classes="gl-mr-2!" + :max-visible="4" + /> + + <template v-for="meta in issuableMeta"> + <span + v-if="meta.visible" + :key="meta.key" + v-gl-tooltip + class="gl-display-none gl-display-sm-flex gl-align-items-center gl-ml-3" + :class="meta.class" + :data-testid="meta.dataTestId" + :title="meta.title" + > + <component :is="issuableMetaComponent(meta.href)" :href="meta.href"> + <gl-icon v-if="meta.icon" :name="meta.icon" /> + {{ meta.value }} + </component> + </span> + </template> + </div> + <div v-gl-tooltip class="issuable-updated-at" :title="updatedDateString"> + {{ updatedDateAgo }} + </div> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/issues_list/components/issuables_list_app.vue b/app/assets/javascripts/issues_list/components/issuables_list_app.vue new file mode 100644 index 00000000000..0d4f5bce965 --- /dev/null +++ b/app/assets/javascripts/issues_list/components/issuables_list_app.vue @@ -0,0 +1,424 @@ +<script> +import { toNumber, omit } from 'lodash'; +import { + GlEmptyState, + GlPagination, + GlDeprecatedSkeletonLoading as GlSkeletonLoading, + GlSafeHtmlDirective as SafeHtml, +} from '@gitlab/ui'; +import { deprecatedCreateFlash as flash } from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { + scrollToElement, + urlParamsToObject, + historyPushState, + getParameterByName, +} from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; +import initManualOrdering from '~/manual_ordering'; +import Issuable from './issuable.vue'; +import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import { + sortOrderMap, + availableSortOptionsJira, + RELATIVE_POSITION, + PAGE_SIZE, + PAGE_SIZE_MANUAL, + LOADING_LIST_ITEMS_LENGTH, +} from '../constants'; +import { setUrlParams } from '~/lib/utils/url_utility'; +import issueableEventHub from '../eventhub'; +import { emptyStateHelper } from '../service_desk_helper'; + +export default { + LOADING_LIST_ITEMS_LENGTH, + directives: { + SafeHtml, + }, + components: { + GlEmptyState, + GlPagination, + GlSkeletonLoading, + Issuable, + FilteredSearchBar, + }, + props: { + canBulkEdit: { + type: Boolean, + required: false, + default: false, + }, + emptyStateMeta: { + type: Object, + required: true, + }, + endpoint: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: false, + default: '', + }, + sortKey: { + type: String, + required: false, + default: '', + }, + type: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + availableSortOptionsJira, + filters: {}, + isBulkEditing: false, + issuables: [], + loading: false, + page: + getParameterByName('page', window.location.href) !== null + ? toNumber(getParameterByName('page')) + : 1, + selection: {}, + totalItems: 0, + }; + }, + computed: { + allIssuablesSelected() { + // WARNING: Because we are only keeping track of selected values + // this works, we will need to rethink this if we start tracking + // [id]: false for not selected values. + return this.issuables.length === Object.keys(this.selection).length; + }, + emptyState() { + if (this.issuables.length) { + return {}; // Empty state shouldn't be shown here + } + + if (this.isServiceDesk) { + return emptyStateHelper(this.emptyStateMeta); + } + + if (this.hasFilters) { + return { + title: __('Sorry, your filter produced no results'), + svgPath: this.emptyStateMeta.svgPath, + description: __('To widen your search, change or remove filters above'), + primaryLink: this.emptyStateMeta.createIssuePath, + primaryText: __('New issue'), + }; + } + + if (this.filters.state === 'opened') { + return { + title: __('There are no open issues'), + svgPath: this.emptyStateMeta.svgPath, + description: __('To keep this project going, create a new issue'), + primaryLink: this.emptyStateMeta.createIssuePath, + primaryText: __('New issue'), + }; + } else if (this.filters.state === 'closed') { + return { + title: __('There are no closed issues'), + svgPath: this.emptyStateMeta.svgPath, + }; + } + + return { + title: __('There are no issues to show'), + svgPath: this.emptyStateMeta.svgPath, + description: __( + 'The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.', + ), + }; + }, + hasFilters() { + const ignored = ['utf8', 'state', 'scope', 'order_by', 'sort']; + return Object.keys(omit(this.filters, ignored)).length > 0; + }, + isManualOrdering() { + return this.sortKey === RELATIVE_POSITION; + }, + itemsPerPage() { + return this.isManualOrdering ? PAGE_SIZE_MANUAL : PAGE_SIZE; + }, + baseUrl() { + return window.location.href.replace(/(\?.*)?(#.*)?$/, ''); + }, + paginationNext() { + return this.page + 1; + }, + paginationPrev() { + return this.page - 1; + }, + paginationProps() { + const paginationProps = { value: this.page }; + + if (this.totalItems) { + return { + ...paginationProps, + perPage: this.itemsPerPage, + totalItems: this.totalItems, + }; + } + + return { + ...paginationProps, + prevPage: this.paginationPrev, + nextPage: this.paginationNext, + }; + }, + isServiceDesk() { + return this.type === 'service_desk'; + }, + isJira() { + return this.type === 'jira'; + }, + initialFilterValue() { + const value = []; + const { search } = this.getQueryObject(); + + if (search) { + value.push(search); + } + return value; + }, + initialSortBy() { + const { sort } = this.getQueryObject(); + return sort || 'created_desc'; + }, + }, + watch: { + selection() { + // We need to call nextTick here to wait for all of the boxes to be checked and rendered + // before we query the dom in issuable_bulk_update_actions.js. + this.$nextTick(() => { + issueableEventHub.$emit('issuables:updateBulkEdit'); + }); + }, + issuables() { + this.$nextTick(() => { + initManualOrdering(); + }); + }, + }, + mounted() { + if (this.canBulkEdit) { + this.unsubscribeToggleBulkEdit = issueableEventHub.$on('issuables:toggleBulkEdit', val => { + this.isBulkEditing = val; + }); + } + this.fetchIssuables(); + }, + beforeDestroy() { + issueableEventHub.$off('issuables:toggleBulkEdit'); + }, + methods: { + isSelected(issuableId) { + return Boolean(this.selection[issuableId]); + }, + setSelection(ids) { + ids.forEach(id => { + this.select(id, true); + }); + }, + clearSelection() { + this.selection = {}; + }, + select(id, isSelect = true) { + if (isSelect) { + this.$set(this.selection, id, true); + } else { + this.$delete(this.selection, id); + } + }, + fetchIssuables(pageToFetch) { + this.loading = true; + + this.clearSelection(); + + this.setFilters(); + + return axios + .get(this.endpoint, { + params: { + ...this.filters, + + with_labels_details: true, + page: pageToFetch || this.page, + per_page: this.itemsPerPage, + }, + }) + .then(response => { + this.loading = false; + this.issuables = response.data; + this.totalItems = Number(response.headers['x-total']); + this.page = Number(response.headers['x-page']); + }) + .catch(() => { + this.loading = false; + return flash(__('An error occurred while loading issues')); + }); + }, + getQueryObject() { + return urlParamsToObject(window.location.search); + }, + onPaginate(newPage) { + if (newPage === this.page) return; + + scrollToElement('#content-body'); + + // NOTE: This allows for the params to be updated on pagination + historyPushState( + setUrlParams({ ...this.filters, page: newPage }, window.location.href, true), + ); + + this.fetchIssuables(newPage); + }, + onSelectAll() { + if (this.allIssuablesSelected) { + this.selection = {}; + } else { + this.setSelection(this.issuables.map(({ id }) => id)); + } + }, + onSelectIssuable({ issuable, selected }) { + if (!this.canBulkEdit) return; + + this.select(issuable.id, selected); + }, + setFilters() { + const { + label_name: labels, + milestone_title: milestoneTitle, + 'not[label_name]': excludedLabels, + 'not[milestone_title]': excludedMilestone, + ...filters + } = this.getQueryObject(); + + // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/227880 + + if (milestoneTitle) { + filters.milestone = milestoneTitle; + } + if (Array.isArray(labels)) { + filters.labels = labels.join(','); + } + if (!filters.state) { + filters.state = 'opened'; + } + + if (excludedLabels) { + filters['not[labels]'] = excludedLabels; + } + + if (excludedMilestone) { + filters['not[milestone]'] = excludedMilestone; + } + + Object.assign(filters, sortOrderMap[this.sortKey]); + + this.filters = filters; + }, + refetchIssuables() { + const ignored = ['utf8']; + const params = omit(this.filters, ignored); + + historyPushState(setUrlParams(params, window.location.href, true, true)); + this.fetchIssuables(); + }, + handleFilter(filters) { + let search = null; + + filters.forEach(filter => { + if (typeof filter === 'string') { + search = filter; + } + }); + + this.filters.search = search; + this.page = 1; + + this.refetchIssuables(); + }, + handleSort(sort) { + this.filters.sort = sort; + this.page = 1; + + this.refetchIssuables(); + }, + }, +}; +</script> + +<template> + <div> + <filtered-search-bar + v-if="isJira" + :namespace="projectPath" + :search-input-placeholder="__('Search Jira issues')" + :tokens="[]" + :sort-options="availableSortOptionsJira" + :initial-filter-value="initialFilterValue" + :initial-sort-by="initialSortBy" + class="row-content-block" + @onFilter="handleFilter" + @onSort="handleSort" + /> + <ul v-if="loading" class="content-list"> + <li v-for="n in $options.LOADING_LIST_ITEMS_LENGTH" :key="n" class="issue gl-px-5! gl-py-5!"> + <gl-skeleton-loading /> + </li> + </ul> + <div v-else-if="issuables.length"> + <div v-if="isBulkEditing" class="issue px-3 py-3 border-bottom border-light"> + <input + id="check-all-issues" + type="checkbox" + :checked="allIssuablesSelected" + class="mr-2" + @click="onSelectAll" + /> + <strong>{{ __('Select all') }}</strong> + </div> + <ul + class="content-list issuable-list issues-list" + :class="{ 'manual-ordering': isManualOrdering }" + > + <issuable + v-for="issuable in issuables" + :key="issuable.id" + class="pr-3" + :class="{ 'user-can-drag': isManualOrdering }" + :issuable="issuable" + :is-bulk-editing="isBulkEditing" + :selected="isSelected(issuable.id)" + :base-url="baseUrl" + @select="onSelectIssuable" + /> + </ul> + <div class="mt-3"> + <gl-pagination + v-bind="paginationProps" + class="gl-justify-content-center" + @input="onPaginate" + /> + </div> + </div> + <gl-empty-state + v-else + :title="emptyState.title" + :svg-path="emptyState.svgPath" + :primary-button-link="emptyState.primaryLink" + :primary-button-text="emptyState.primaryText" + > + <template #description> + <div v-safe-html="emptyState.description"></div> + </template> + </gl-empty-state> + </div> +</template> diff --git a/app/assets/javascripts/issues_list/components/jira_issues_list_root.vue b/app/assets/javascripts/issues_list/components/jira_issues_list_root.vue new file mode 100644 index 00000000000..61781c576c0 --- /dev/null +++ b/app/assets/javascripts/issues_list/components/jira_issues_list_root.vue @@ -0,0 +1,112 @@ +<script> +import { GlAlert, GlLabel } from '@gitlab/ui'; +import { last } from 'lodash'; +import { n__ } from '~/locale'; +import getIssuesListDetailsQuery from '../queries/get_issues_list_details.query.graphql'; +import { + calculateJiraImportLabel, + isInProgress, + setFinishedAlertHideMap, + shouldShowFinishedAlert, +} from '~/jira_import/utils/jira_import_utils'; + +export default { + name: 'JiraIssuesList', + components: { + GlAlert, + GlLabel, + }, + props: { + canEdit: { + type: Boolean, + required: true, + }, + isJiraConfigured: { + type: Boolean, + required: true, + }, + issuesPath: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + }, + data() { + return { + jiraImport: {}, + }; + }, + apollo: { + jiraImport: { + query: getIssuesListDetailsQuery, + variables() { + return { + fullPath: this.projectPath, + }; + }, + update: ({ project }) => { + const label = calculateJiraImportLabel( + project.jiraImports.nodes, + project.issues.nodes.flatMap(({ labels }) => labels.nodes), + ); + return { + importedIssuesCount: last(project.jiraImports.nodes)?.importedIssuesCount, + label, + shouldShowFinishedAlert: shouldShowFinishedAlert(label.title, project.jiraImportStatus), + shouldShowInProgressAlert: isInProgress(project.jiraImportStatus), + }; + }, + skip() { + return !this.isJiraConfigured || !this.canEdit; + }, + }, + }, + computed: { + finishedMessage() { + return n__( + '%d issue successfully imported with the label', + '%d issues successfully imported with the label', + this.jiraImport.importedIssuesCount, + ); + }, + labelTarget() { + return `${this.issuesPath}?label_name[]=${encodeURIComponent(this.jiraImport.label.title)}`; + }, + }, + methods: { + hideFinishedAlert() { + setFinishedAlertHideMap(this.jiraImport.label.title); + this.jiraImport.shouldShowFinishedAlert = false; + }, + hideInProgressAlert() { + this.jiraImport.shouldShowInProgressAlert = false; + }, + }, +}; +</script> + +<template> + <div class="issuable-list-root"> + <gl-alert v-if="jiraImport.shouldShowInProgressAlert" @dismiss="hideInProgressAlert"> + {{ __('Import in progress. Refresh page to see newly added issues.') }} + </gl-alert> + + <gl-alert + v-if="jiraImport.shouldShowFinishedAlert" + variant="success" + @dismiss="hideFinishedAlert" + > + {{ finishedMessage }} + <gl-label + :background-color="jiraImport.label.color" + scoped + size="sm" + :target="labelTarget" + :title="jiraImport.label.title" + /> + </gl-alert> + </div> +</template> diff --git a/app/assets/javascripts/issues_list/constants.js b/app/assets/javascripts/issues_list/constants.js new file mode 100644 index 00000000000..f008ba1bf4a --- /dev/null +++ b/app/assets/javascripts/issues_list/constants.js @@ -0,0 +1,56 @@ +import { __ } from '~/locale'; + +// Maps sort order as it appears in the URL query to API `order_by` and `sort` params. +const PRIORITY = 'priority'; +const ASC = 'asc'; +const DESC = 'desc'; +const CREATED_AT = 'created_at'; +const UPDATED_AT = 'updated_at'; +const DUE_DATE = 'due_date'; +const MILESTONE_DUE = 'milestone_due'; +const POPULARITY = 'popularity'; +const WEIGHT = 'weight'; +const LABEL_PRIORITY = 'label_priority'; +export const RELATIVE_POSITION = 'relative_position'; +export const LOADING_LIST_ITEMS_LENGTH = 8; +export const PAGE_SIZE = 20; +export const PAGE_SIZE_MANUAL = 100; + +export const sortOrderMap = { + priority: { order_by: PRIORITY, sort: ASC }, // asc and desc are flipped for some reason + created_date: { order_by: CREATED_AT, sort: DESC }, + created_asc: { order_by: CREATED_AT, sort: ASC }, + updated_desc: { order_by: UPDATED_AT, sort: DESC }, + updated_asc: { order_by: UPDATED_AT, sort: ASC }, + milestone_due_desc: { order_by: MILESTONE_DUE, sort: DESC }, + milestone: { order_by: MILESTONE_DUE, sort: ASC }, + due_date_desc: { order_by: DUE_DATE, sort: DESC }, + due_date: { order_by: DUE_DATE, sort: ASC }, + popularity: { order_by: POPULARITY, sort: DESC }, + popularity_asc: { order_by: POPULARITY, sort: ASC }, + label_priority: { order_by: LABEL_PRIORITY, sort: ASC }, // asc and desc are flipped + relative_position: { order_by: RELATIVE_POSITION, sort: ASC }, + weight_desc: { order_by: WEIGHT, sort: DESC }, + weight: { order_by: WEIGHT, sort: ASC }, +}; + +export const availableSortOptionsJira = [ + { + id: 1, + title: __('Created date'), + sortDirection: { + descending: 'created_desc', + ascending: 'created_asc', + }, + }, + { + id: 2, + title: __('Last updated'), + sortDirection: { + descending: 'updated_desc', + ascending: 'updated_asc', + }, + }, +]; + +export const JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY = 'jira-import-success-alert-hide-map'; diff --git a/app/assets/javascripts/issues_list/eventhub.js b/app/assets/javascripts/issues_list/eventhub.js new file mode 100644 index 00000000000..e31806ad199 --- /dev/null +++ b/app/assets/javascripts/issues_list/eventhub.js @@ -0,0 +1,3 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js new file mode 100644 index 00000000000..1ff41c20d08 --- /dev/null +++ b/app/assets/javascripts/issues_list/index.js @@ -0,0 +1,67 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { parseBoolean, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import JiraIssuesListRoot from './components/jira_issues_list_root.vue'; +import IssuablesListApp from './components/issuables_list_app.vue'; + +function mountJiraIssuesListApp() { + const el = document.querySelector('.js-projects-issues-root'); + + if (!el) { + return false; + } + + Vue.use(VueApollo); + + const defaultClient = createDefaultClient(); + const apolloProvider = new VueApollo({ + defaultClient, + }); + + return new Vue({ + el, + apolloProvider, + render(createComponent) { + return createComponent(JiraIssuesListRoot, { + props: { + canEdit: parseBoolean(el.dataset.canEdit), + isJiraConfigured: parseBoolean(el.dataset.isJiraConfigured), + issuesPath: el.dataset.issuesPath, + projectPath: el.dataset.projectPath, + }, + }); + }, + }); +} + +function mountIssuablesListApp() { + if (!gon.features?.vueIssuablesList && !gon.features?.jiraIssuesIntegration) { + return; + } + + document.querySelectorAll('.js-issuables-list').forEach(el => { + const { canBulkEdit, emptyStateMeta = {}, ...data } = el.dataset; + + return new Vue({ + el, + render(createElement) { + return createElement(IssuablesListApp, { + props: { + ...data, + emptyStateMeta: + Object.keys(emptyStateMeta).length !== 0 + ? convertObjectPropsToCamelCase(JSON.parse(emptyStateMeta)) + : {}, + canBulkEdit: Boolean(canBulkEdit), + }, + }); + }, + }); + }); +} + +export default function initIssuablesList() { + mountJiraIssuesListApp(); + mountIssuablesListApp(); +} diff --git a/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql b/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql new file mode 100644 index 00000000000..8f9b888d19b --- /dev/null +++ b/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql @@ -0,0 +1,21 @@ +query($fullPath: ID!) { + project(fullPath: $fullPath) { + issues { + nodes { + labels { + nodes { + title + color + } + } + } + } + jiraImportStatus + jiraImports { + nodes { + importedIssuesCount + jiraProjectKey + } + } + } +} diff --git a/app/assets/javascripts/issues_list/service_desk_helper.js b/app/assets/javascripts/issues_list/service_desk_helper.js new file mode 100644 index 00000000000..0a34b754377 --- /dev/null +++ b/app/assets/javascripts/issues_list/service_desk_helper.js @@ -0,0 +1,111 @@ +import { __ } from '~/locale'; + +/** + * Generates empty state messages for Service Desk issues list. + * + * @param {emptyStateMeta} emptyStateMeta - Meta data used to generate empty state messages + * @returns {Object} Object containing empty state messages generated using the meta data. + */ +export function generateMessages(emptyStateMeta) { + const { + svgPath, + serviceDeskHelpPage, + serviceDeskAddress, + editProjectPage, + incomingEmailHelpPage, + } = emptyStateMeta; + + const serviceDeskSupportedTitle = __( + 'Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab', + ); + + const serviceDeskSupportedMessage = __( + 'Those emails automatically become issues (with the comments becoming the email conversation) listed here.', + ); + + const commonDescription = ` + <span>${serviceDeskSupportedMessage}</span> + <a href="${serviceDeskHelpPage}">${__('Read more')}</a>`; + + return { + serviceDeskEnabledAndCanEditProjectSettings: { + title: serviceDeskSupportedTitle, + svgPath, + description: `<p>${__('Have your users email')} + <code>${serviceDeskAddress}</code> + </p> + ${commonDescription}`, + }, + serviceDeskEnabledAndCannotEditProjectSettings: { + title: serviceDeskSupportedTitle, + svgPath, + description: commonDescription, + }, + serviceDeskDisabledAndCanEditProjectSettings: { + title: serviceDeskSupportedTitle, + svgPath, + description: commonDescription, + primaryLink: editProjectPage, + primaryText: __('Turn on Service Desk'), + }, + serviceDeskDisabledAndCannotEditProjectSettings: { + title: serviceDeskSupportedTitle, + svgPath, + description: commonDescription, + }, + serviceDeskIsNotSupported: { + title: __('Service Desk is not supported'), + svgPath, + description: __( + 'In order to enable Service Desk for your instance, you must first set up incoming email.', + ), + primaryLink: incomingEmailHelpPage, + primaryText: __('More information'), + }, + serviceDeskIsNotEnabled: { + title: __('Service Desk is not enabled'), + svgPath, + description: __( + 'For help setting up the Service Desk for your instance, please contact an administrator.', + ), + }, + }; +} + +/** + * Returns the attributes used for gl-empty-state in the Service Desk issues list. + * + * @param {Object} emptyStateMeta - Meta data used to generate empty state messages + * @returns {Object} + */ +export function emptyStateHelper(emptyStateMeta) { + const messages = generateMessages(emptyStateMeta); + + const { isServiceDeskSupported, canEditProjectSettings, isServiceDeskEnabled } = emptyStateMeta; + + if (isServiceDeskSupported) { + if (isServiceDeskEnabled && canEditProjectSettings) { + return messages.serviceDeskEnabledAndCanEditProjectSettings; + } + + if (isServiceDeskEnabled && !canEditProjectSettings) { + return messages.serviceDeskEnabledAndCannotEditProjectSettings; + } + + // !isServiceDeskEnabled && canEditProjectSettings + if (canEditProjectSettings) { + return messages.serviceDeskDisabledAndCanEditProjectSettings; + } + + // !isServiceDeskEnabled && !canEditProjectSettings + return messages.serviceDeskDisabledAndCannotEditProjectSettings; + } + + // !serviceDeskSupported && canEditProjectSettings + if (canEditProjectSettings) { + return messages.serviceDeskIsNotSupported; + } + + // !serviceDeskSupported && !canEditProjectSettings + return messages.serviceDeskIsNotEnabled; +} |