diff options
Diffstat (limited to 'app/assets/javascripts/issuables_list')
5 files changed, 666 insertions, 0 deletions
diff --git a/app/assets/javascripts/issuables_list/components/issuable.vue b/app/assets/javascripts/issuables_list/components/issuable.vue new file mode 100644 index 00000000000..41b826e0394 --- /dev/null +++ b/app/assets/javascripts/issuables_list/components/issuable.vue @@ -0,0 +1,327 @@ +<script> +/* + * This is tightly coupled to projects/issues/_issue.html.haml, + * any changes done to the haml need to be reflected here. + */ +import { escape, isNumber } from 'underscore'; +import { GlLink, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +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 Icon from '~/vue_shared/components/icon.vue'; +import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; + +const ISSUE_TOKEN = '#'; + +export default { + components: { + Icon, + IssueAssignees, + GlLink, + }, + directives: { + GlTooltip, + }, + 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; + }, + }, + }, + computed: { + hasLabels() { + return Boolean(this.issuable.labels && this.issuable.labels.length); + }, + 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; + }, + hasNoComments() { + return !this.userNotesCount; + }, + isOverdue() { + return this.dueDate ? this.dueDate < new Date() : false; + }, + isClosed() { + return this.issuable.state === 'closed'; + }, + 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'); + }, + openedAgoByString() { + const { author, created_at } = this.issuable; + + return sprintf( + __('opened %{timeAgoString} by %{user}'), + { + timeAgoString: escape(getTimeago().format(created_at)), + user: `<a href="${escape(author.web_url)}" + data-user-id=${escape(author.id)} + data-username=${escape(author.username)} + data-name=${escape(author.name)} + data-avatar-url="${escape(author.avatar_url)}"> + ${escape(author.name)} + </a>`, + }, + false, + ); + }, + referencePath() { + // TODO: The API should return the reference path (it doesn't now) https://gitlab.com/gitlab-org/gitlab/issues/31301 + return `${ISSUE_TOKEN}${this.issuable.iid}`; + }, + 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)), + }); + }, + userNotesCount() { + return this.issuable.user_notes_count; + }, + issuableMeta() { + return [ + { + key: 'merge-requests', + value: this.issuable.merge_requests_count, + title: __('Related merge requests'), + class: 'js-merge-requests', + icon: 'merge-request', + }, + { + key: 'upvotes', + value: this.issuable.upvotes, + title: __('Upvotes'), + class: 'js-upvotes', + faicon: 'fa-thumbs-up', + }, + { + key: 'downvotes', + value: this.issuable.downvotes, + title: __('Downvotes'), + class: 'js-downvotes', + faicon: 'fa-thumbs-down', + }, + ]; + }, + }, + mounted() { + // TODO: Refactor user popover to use its own component instead of + // spawning event listeners on Vue-rendered elements. + initUserPopovers([this.$refs.openedAgoByContainer.querySelector('a')]); + }, + methods: { + labelStyle(label) { + return { + backgroundColor: label.color, + color: label.text_color, + }; + }, + labelHref({ name }) { + return mergeUrlParams({ 'label_name[]': name }, this.baseUrl); + }, + onSelect(ev) { + this.$emit('select', { + issuable: this.issuable, + selected: ev.target.checked, + }); + }, + }, + + 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" + > + <div class="d-flex"> + <!-- Bulk edit checkbox --> + <div v-if="isBulkEditing" class="mr-2"> + <input + :checked="selected" + class="selected-issuable" + type="checkbox" + :data-id="issuable.id" + @input="onSelect" + /> + </div> + + <!-- Issuable info container --> + <!-- Issuable main info --> + <div class="flex-grow-1"> + <div class="title"> + <span class="issue-title-text"> + <i + v-if="issuable.confidential" + v-gl-tooltip + class="fa fa-eye-slash" + :title="$options.confidentialTooltipText" + :aria-label="$options.confidentialTooltipText" + ></i> + <gl-link :href="issuable.web_url">{{ issuable.title }}</gl-link> + </span> + <span v-if="issuable.has_tasks" class="ml-1 task-status d-none d-sm-inline-block"> + {{ issuable.task_status }} + </span> + </div> + + <div class="issuable-info"> + <span>{{ referencePath }}</span> + + <span class="d-none d-sm-inline-block mr-1"> + · + <span ref="openedAgoByContainer" v-html="openedAgoByString"></span> + </span> + + <gl-link + v-if="issuable.milestone" + v-gl-tooltip + class="d-none d-sm-inline-block mr-1 js-milestone" + :href="issuable.milestone.web_url" + :title="milestoneTooltipText" + > + <i class="fa fa-clock-o"></i> + {{ issuable.milestone.title }} + </gl-link> + + <span + v-if="dueDate" + v-gl-tooltip + class="d-none d-sm-inline-block mr-1 js-due-date" + :class="{ cred: isOverdue }" + :title="__('Due date')" + > + <i class="fa fa-calendar"></i> + {{ dueDateWords }} + </span> + + <span v-if="hasLabels" class="js-labels"> + <gl-link + v-for="label in issuable.labels" + :key="label.id" + class="label-link mr-1" + :href="labelHref(label)" + > + <span + v-gl-tooltip + class="badge color-label" + :style="labelStyle(label)" + :title="label.description" + >{{ label.name }}</span + > + </gl-link> + </span> + + <span + v-if="hasWeight" + v-gl-tooltip + :title="__('Weight')" + class="d-none d-sm-inline-block js-weight" + > + <icon name="weight" class="align-text-bottom" /> + {{ issuable.weight }} + </span> + </div> + </div> + + <!-- Issuable meta --> + <div class="flex-shrink-0 d-flex flex-column align-items-end justify-content-center"> + <div class="controls d-flex"> + <span v-if="isClosed" class="issuable-status">{{ __('CLOSED') }}</span> + + <issue-assignees + :assignees="issuable.assignees" + class="align-items-center d-flex ml-2" + :icon-size="16" + img-css-classes="mr-1" + :max-visible="4" + /> + + <template v-for="meta in issuableMeta"> + <span + v-if="meta.value" + :key="meta.key" + v-gl-tooltip + :class="['d-none d-sm-inline-block ml-2', meta.class]" + :title="meta.title" + > + <icon v-if="meta.icon" :name="meta.icon" /> + <i v-else :class="['fa', meta.faicon]"></i> + {{ meta.value }} + </span> + </template> + + <gl-link + v-gl-tooltip + class="ml-2 js-notes" + :href="`${issuable.web_url}#notes`" + :title="__('Comments')" + :class="{ 'no-comments': hasNoComments }" + > + <i class="fa fa-comments"></i> + {{ userNotesCount }} + </gl-link> + </div> + <div v-gl-tooltip class="issuable-updated-at" :title="updatedDateString"> + {{ updatedDateAgo }} + </div> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/issuables_list/components/issuables_list_app.vue b/app/assets/javascripts/issuables_list/components/issuables_list_app.vue new file mode 100644 index 00000000000..6b6a8bd4068 --- /dev/null +++ b/app/assets/javascripts/issuables_list/components/issuables_list_app.vue @@ -0,0 +1,277 @@ +<script> +import { omit } from 'underscore'; +import { GlEmptyState, GlPagination, GlSkeletonLoading } from '@gitlab/ui'; +import flash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { scrollToElement, urlParamsToObject } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; +import initManualOrdering from '~/manual_ordering'; +import Issuable from './issuable.vue'; +import { + sortOrderMap, + RELATIVE_POSITION, + PAGE_SIZE, + PAGE_SIZE_MANUAL, + LOADING_LIST_ITEMS_LENGTH, +} from '../constants'; +import issueableEventHub from '../eventhub'; + +export default { + LOADING_LIST_ITEMS_LENGTH, + components: { + GlEmptyState, + GlPagination, + GlSkeletonLoading, + Issuable, + }, + props: { + canBulkEdit: { + type: Boolean, + required: false, + default: false, + }, + createIssuePath: { + type: String, + required: false, + default: '', + }, + emptySvgPath: { + type: String, + required: false, + default: '', + }, + endpoint: { + type: String, + required: true, + }, + sortKey: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + filters: {}, + isBulkEditing: false, + issuables: [], + loading: false, + 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 + } else if (this.hasFilters) { + return { + title: __('Sorry, your filter produced no results'), + description: __('To widen your search, change or remove filters above'), + }; + } else if (this.filters.state === 'opened') { + return { + title: __('There are no open issues'), + description: __('To keep this project going, create a new issue'), + primaryLink: this.createIssuePath, + primaryText: __('New issue'), + }; + } else if (this.filters.state === 'closed') { + return { + title: __('There are no closed issues'), + }; + } + + return { + title: __('There are no issues to show'), + 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(/(\?.*)?(#.*)?$/, ''); + }, + }, + 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'); + 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, + ...filters + } = this.getQueryObject(); + + if (milestoneTitle) { + filters.milestone = milestoneTitle; + } + if (Array.isArray(labels)) { + filters.labels = labels.join(','); + } + if (!filters.state) { + filters.state = 'opened'; + } + + Object.assign(filters, sortOrderMap[this.sortKey]); + + this.filters = filters; + }, + }, +}; +</script> + +<template> + <ul v-if="loading" class="content-list"> + <li v-for="n in $options.LOADING_LIST_ITEMS_LENGTH" :key="n" class="issue"> + <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 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-if="totalItems" + :value="page" + :per-page="itemsPerPage" + :total-items="totalItems" + class="justify-content-center" + @input="onPaginate" + /> + </div> + </div> + <gl-empty-state + v-else + :title="emptyState.title" + :description="emptyState.description" + :svg-path="emptySvgPath" + :primary-button-link="emptyState.primaryLink" + :primary-button-text="emptyState.primaryText" + /> +</template> diff --git a/app/assets/javascripts/issuables_list/constants.js b/app/assets/javascripts/issuables_list/constants.js new file mode 100644 index 00000000000..71b9c52c703 --- /dev/null +++ b/app/assets/javascripts/issuables_list/constants.js @@ -0,0 +1,33 @@ +// 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 }, +}; diff --git a/app/assets/javascripts/issuables_list/eventhub.js b/app/assets/javascripts/issuables_list/eventhub.js new file mode 100644 index 00000000000..d1601a7d8f3 --- /dev/null +++ b/app/assets/javascripts/issuables_list/eventhub.js @@ -0,0 +1,5 @@ +import Vue from 'vue'; + +const issueablesEventBus = new Vue(); + +export default issueablesEventBus; diff --git a/app/assets/javascripts/issuables_list/index.js b/app/assets/javascripts/issuables_list/index.js new file mode 100644 index 00000000000..9fc7fa837ff --- /dev/null +++ b/app/assets/javascripts/issuables_list/index.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import IssuablesListApp from './components/issuables_list_app.vue'; + +export default function initIssuablesList() { + if (!gon.features || !gon.features.vueIssuablesList) { + return; + } + + document.querySelectorAll('.js-issuables-list').forEach(el => { + const { canBulkEdit, ...data } = el.dataset; + + const props = { + ...data, + canBulkEdit: Boolean(canBulkEdit), + }; + + return new Vue({ + el, + render(createElement) { + return createElement(IssuablesListApp, { props }); + }, + }); + }); +} |