summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/issuables_list
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2019-11-06 21:06:44 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2019-11-06 21:06:44 +0000
commitcf85de264d049f1f8ff14b23f38f8331ae4c60fa (patch)
tree73188488af9f2a6d40df4f62b3d84dc6536e2ffc /app/assets/javascripts/issuables_list
parentbcdcff749598f4275f7c250c07cbfe632cfe7fdb (diff)
downloadgitlab-ce-cf85de264d049f1f8ff14b23f38f8331ae4c60fa.tar.gz
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/issuables_list')
-rw-r--r--app/assets/javascripts/issuables_list/components/issuable.vue327
-rw-r--r--app/assets/javascripts/issuables_list/components/issuables_list_app.vue277
-rw-r--r--app/assets/javascripts/issuables_list/constants.js33
-rw-r--r--app/assets/javascripts/issuables_list/eventhub.js5
-rw-r--r--app/assets/javascripts/issuables_list/index.js24
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">
+ &middot;
+ <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 });
+ },
+ });
+ });
+}