diff options
25 files changed, 3055 insertions, 16 deletions
diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js index 74150ce3a8b..7a74611acde 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js @@ -1,11 +1,10 @@ -/* eslint-disable class-methods-use-this, no-new */ - import $ from 'jquery'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; import MilestoneSelect from './milestone_select'; import issueStatusSelect from './issue_status_select'; import subscriptionSelect from './subscription_select'; import LabelsSelect from './labels_select'; +import issuesListStore from '~/issues/stores'; const HIDDEN_CLASS = 'hidden'; const DISABLED_CONTENT_CLASS = 'disabled-content'; @@ -14,6 +13,7 @@ const SIDEBAR_COLLAPSED_CLASS = 'right-sidebar-collapsed issuable-bulk-update-si export default class IssuableBulkUpdateSidebar { constructor() { + this.issuableBulkUpdateActions = IssuableBulkUpdateActions; this.initDomElements(); this.bindEvents(); this.initDropdowns(); @@ -44,14 +44,14 @@ export default class IssuableBulkUpdateSidebar { } initDropdowns() { - new LabelsSelect(); - new MilestoneSelect(); - issueStatusSelect(); - subscriptionSelect(); + this.labelSelect = new LabelsSelect(); + this.labelSelect = new MilestoneSelect(); + this.issueStatusSelect = issueStatusSelect(); + this.subscriptionSelect = subscriptionSelect(); } setupBulkUpdateActions() { - IssuableBulkUpdateActions.setOriginalDropdownData(); + this.issuableBulkUpdateActions.setOriginalDropdownData(); } updateFormState() { @@ -60,7 +60,7 @@ export default class IssuableBulkUpdateSidebar { this.toggleSubmitButtonDisabled(noCheckedIssues); this.updateSelectedIssuableIds(); - IssuableBulkUpdateActions.setOriginalDropdownData(); + this.issuableBulkUpdateActions.setOriginalDropdownData(); } prepForSubmit() { @@ -107,7 +107,12 @@ export default class IssuableBulkUpdateSidebar { toggleCheckboxDisplay(show) { this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show); - this.$issueChecks.toggleClass(HIDDEN_CLASS, !show); + + if (gon.features && gon.features.issuesVueComponent) { + issuesListStore.dispatch('issuesList/setBulkUpdateState', show); + } else { + this.$issueChecks.toggleClass(HIDDEN_CLASS, !show); + } } toggleOtherFiltersDisabled(disable) { diff --git a/app/assets/javascripts/issues/components/empty_state.vue b/app/assets/javascripts/issues/components/empty_state.vue new file mode 100644 index 00000000000..e6aa9c6a77b --- /dev/null +++ b/app/assets/javascripts/issues/components/empty_state.vue @@ -0,0 +1,78 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import { ISSUE_STATES } from '../constants'; + +export default { + components: { + GlEmptyState, + }, + props: { + hasFilters: { + type: Boolean, + required: true, + }, + state: { + type: String, + required: true, + }, + buttonPath: { + type: String, + required: true, + }, + loadingDisabled: { + type: Boolean, + required: true, + }, + svgImagePath: { + type: String, + required: true, + }, + svgLoadingDisabledImagePath: { + type: String, + required: true, + }, + }, + data() { + return { + ISSUE_STATES, + }; + }, +}; +</script> + +<template> + <gl-empty-state + v-if="loadingDisabled" + :title="__('Please select at least one filter to see results')" + :svg-path="svgLoadingDisabledImagePath" + /> + <gl-empty-state + v-else-if="hasFilters" + :title="__('Sorry, your filter produced no results')" + :description="__('To widen your search, change or remove filters above')" + :svg-path="svgImagePath" + /> + <gl-empty-state + v-else-if="state === ISSUE_STATES.OPENED" + :title="__('There are no open issues')" + :description="__('To keep this project going, create a new issue')" + :primary-button-link="buttonPath" + :primary-button-text="__('New issue')" + :svg-path="svgImagePath" + /> + <gl-empty-state + v-else-if="state === ISSUE_STATES.CLOSED" + :title="__('There are no closed issues')" + :svg-path="svgImagePath" + /> + <gl-empty-state + v-else + :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.', + ) + " + :svg-path="svgImagePath" + /> +</template> diff --git a/app/assets/javascripts/issues/components/issue.vue b/app/assets/javascripts/issues/components/issue.vue new file mode 100644 index 00000000000..13f16fefa60 --- /dev/null +++ b/app/assets/javascripts/issues/components/issue.vue @@ -0,0 +1,279 @@ +<script> +import dateFormat from 'dateformat'; +import { GlTooltipDirective } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { getDayDifference } from '~/lib/utils/datetime_utility'; +import { ISSUE_STATES, MAX_ASSIGNEES_RENDER } from '../constants'; +import { sprintf, __ } from '~/locale'; + +export default { + components: { + Icon, + UserAvatarLink, + TimeAgoTooltip, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + issue: { + type: Object, + required: true, + }, + isBulkUpdating: { + type: Boolean, + required: true, + }, + canBulkUpdate: { + type: Boolean, + required: true, + }, + }, + computed: { + issueClassesList() { + const classList = ['issue']; + const issueCreatedToday = !getDayDifference(new Date(this.issue.created_at), new Date()); + + if (this.issueIsClosed) { + classList.push('closed'); + } + + if (issueCreatedToday) { + classList.push('today'); + } + + return classList; + }, + formatDueDate() { + return dateFormat(this.issue.due_date, 'mmm d, yyyy'); + }, + isIssueOverDue() { + return getDayDifference(new Date(), new Date(this.issue.due_date)) < 0; + }, + shouldAssigneeRenderCounter() { + return this.issue.assignees.length - MAX_ASSIGNEES_RENDER > 0; + }, + moreAssigneesCount() { + return this.issue.assignees.length - MAX_ASSIGNEES_RENDER; + }, + assigneeCounterTooltip() { + return sprintf('+%{moreAssigneesCount} more assignees', { + moreAssigneesCount: this.moreAssigneesCount, + }); + }, + issueCommentsURL() { + return `${this.issue.web_url}#notes`; + }, + bulkUpdateId() { + return `selected_issue_${this.issue.id}`; + }, + issueIsClosed() { + return this.issue.state === ISSUE_STATES.CLOSED; + }, + assigneesToRender() { + return this.issue.assignees.filter((assignee, index) => index < MAX_ASSIGNEES_RENDER); + }, + hasRelatedMergeRequests() { + return this.issue.merge_requests_count > 0; + }, + hasUpvotes() { + return this.issue.upvotes > 0; + }, + hasDownvotes() { + return this.issue.downvotes > 0; + }, + hasComments() { + return this.issue.note_count < 0; + }, + }, + methods: { + getAvatarTitle(assignee) { + return sprintf('Assigned to %{name}', { name: assignee.name }); + }, + labelStyle(label) { + return { + backgroundColor: label.color, + color: label.text_color, + }; + }, + }, + confidentialText: __('Confidential'), +}; +</script> +<template> + <li :id="`issue_${issue.id}`" :url="issue.web_url" :class="issueClassesList" data-labels="[]"> + <div class="issue-box"> + <div v-if="canBulkUpdate" class="issue-check" :class="{ hidden: !isBulkUpdating }"> + <input + :id="bulkUpdateId" + type="checkbox" + class="selected-issuable" + :name="bulkUpdateId" + :data-id="issue.id" + /> + </div> + <div class="issuable-info-container"> + <div class="issuable-main-info"> + <div class="issue-title title"> + <span class="issue-title-text"> + <span + v-if="issue.confidential" + v-gl-tooltip + :title="$options.confidentialText" + :aria-label="$options.confidentialText" + > + <icon name="eye-slash" class="align-text-bottom js-issue-confidential-icon" /> + </span> + <a :href="issue.web_url">{{ issue.title }}</a> + <span v-if="issue.has_tasks" class="task-status d-none d-sm-inline-block"> + + {{ issue.task_status }} + </span> + </span> + </div> + <div class="issuable-info"> + <span class="issuable-reference">{{ issue.reference_path }}</span> + <span class="issuable-authored d-none d-sm-inline-block"> + · {{ __('opened') }} + <time-ago-tooltip :time="issue.created_at" tooltip-placement="bottom" /> + by + <a + class="author-link js-user-link" + :data-user-id="issue.author.id" + :data-username="issue.author.username" + :data-name="issue.author.name" + :href="issue.author.web_url" + > + <span class="author">{{ issue.author.name }}</span> + </a> + </span> + <span v-if="issue.milestone" class="issuable-milestone d-none d-sm-inline-block"> + + <a :href="issue.milestone.web_url"> + <icon name="clock" class="align-text-bottom" /> + {{ issue.milestone.title }} + </a> + </span> + <span + v-if="issue.due_date" + v-gl-tooltip + class="issuable-due-date d-none d-sm-inline-block" + :title="__('Due date')" + :class="{ cred: isIssueOverDue }" + > + + <icon name="calendar" class="align-text-bottom" /> + {{ formatDueDate }} + </span> + <span v-if="issue.labels"> + <span + v-for="label in issue.labels" + :key="label.id" + ref="button" + class="label-link append-right-4 cursor-pointer" + @click="$emit('issueLabelClicked', label.name)" + > + <span + v-gl-tooltip + class="badge color-label" + :style="labelStyle(label)" + :title="label.description" + > + {{ label.name }} + </span> + </span> + </span> + <span + v-if="issue.weight" + v-gl-tooltip + :title="__('Weight')" + class="issuable-weight d-none d-sm-inline-block" + > + <icon name="weight" class="align-text-bottom issue-weight-icon" /> + {{ issue.weight }} + </span> + </div> + </div> + <div class="issuable-meta"> + <ul class="controls"> + <li v-if="issueIsClosed" class="issuable-status">{{ __('CLOSED') }}</li> + <li v-if="issue.assignees.length"> + <user-avatar-link + v-for="assignee in assigneesToRender" + :key="assignee.id" + :link-href="assignee.web_url" + :img-alt="getAvatarTitle(assignee)" + :img-src="assignee.avatar_url" + :img-size="16" + :tooltip-text="getAvatarTitle(assignee)" + tooltip-placement="bottom" + /> + <span + v-if="shouldAssigneeRenderCounter" + v-gl-tooltip + :title="assigneeCounterTooltip" + class="avatar-counter" + data-placement="bottom" + > + +{{ moreAssigneesCount }} + </span> + </li> + <li + v-if="hasRelatedMergeRequests" + v-gl-tooltip + class="issuable-mr d-none d-sm-block" + :title="__('Related merge requests')" + > + <icon name="merge-request" class="align-text-bottom icon-merge-request-unmerged" /> + {{ issue.merge_requests_count }} + </li> + + <li + v-if="hasUpvotes" + v-gl-tooltip + class="issuable-upvotes d-none d-sm-block" + :title="__('Upvotes')" + > + <icon name="thumb-up" class="align-text-bottom" /> + {{ issue.upvotes }} + </li> + + <li + v-if="hasDownvotes" + v-gl-tooltip + class="issuable-downvotes d-none d-sm-block" + :title="__('Downvotes')" + > + <icon name="thumb-down" class="align-text-bottom" /> + {{ issue.downvotes }} + </li> + + <li class="issuable-comments d-none d-sm-block"> + <a + v-gl-tooltip + :href="issueCommentsURL" + :class="{ 'no-comments': hasComments }" + :title="__('Comments')" + > + <icon name="comments" class="align-text-bottom" /> + {{ issue.note_count || 0 }} + </a> + </li> + </ul> + <div class="float-right issuable-updated-at d-none d-sm-inline-block"> + <span> + updated + <time-ago-tooltip + :time="issue.updated_at" + tooltip-placement="bottom" + css-class="issue_update_ago" + /> + </span> + </div> + </div> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/issues/components/issues_app.vue b/app/assets/javascripts/issues/components/issues_app.vue new file mode 100644 index 00000000000..3272b5da321 --- /dev/null +++ b/app/assets/javascripts/issues/components/issues_app.vue @@ -0,0 +1,184 @@ +<script> +import { mapActions, mapState, mapGetters } from 'vuex'; +import { GlPagination } from '@gitlab/ui'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; +import { + objectToQueryString, + urlParamsToObject, + scrollToElement, + isInProjectPage, + isInGroupsPage, + getPagePath, +} from '~/lib/utils/common_utils'; +import Issue from './issue.vue'; +import IssuesEmptyState from './empty_state.vue'; +import IssuesLoadingState from './loading_state.vue'; +import { ISSUE_STATES, ACTIVE_TAB_CLASS, ISSUES_PER_PAGE, DASHBOARD_PAGE_NAME } from '../constants'; + +export default { + components: { + IssuesLoadingState, + IssuesEmptyState, + GlPagination, + Issue, + }, + props: { + endpoint: { + type: String, + required: true, + }, + canBulkUpdate: { + type: Boolean, + required: true, + }, + createPath: { + type: String, + required: false, + default: '', + }, + issuableIndex: { + type: Object, + required: true, + }, + filteredSearch: { + type: Object, + required: true, + }, + projectSelect: { + type: Function, + required: true, + }, + emptyStateSvgPath: { + type: String, + required: true, + }, + emptyStateLoadingDisabledSvgPath: { + type: String, + required: true, + }, + }, + data() { + return { + ISSUES_PER_PAGE, + isInGroupsPage: isInGroupsPage(), + isInProjectPage: isInProjectPage(), + isInDashboardPage: getPagePath() === DASHBOARD_PAGE_NAME, + isLoadingDisabled: false, + }; + }, + computed: { + ...mapState('issuesList', ['issues', 'loading', 'isBulkUpdating', 'currentPage', 'totalItems']), + ...mapGetters('issuesList', ['hasFilters', 'appliedFilters']), + + hasIssues() { + return !this.isLoadingDisabled && !this.loading && this.issues && this.issues.length > 0; + }, + }, + watch: { + appliedFilters() { + this.loadIssues(); + this.updateIssueStateTabs(); + }, + issues() { + this.setupExternalEvents(); + }, + }, + mounted() { + this.loadIssues(); + this.updateIssueStateTabs(); + this.setupExternalEvents(); + + if (this.isInGroupsPage || this.isInDashboardPage) { + this.projectSelect(); + } + }, + updated() { + this.setupExternalEvents(); + }, + methods: { + ...mapActions('issuesList', ['fetchIssues', 'setCurrentPage']), + getCurrentState() { + const { state } = urlParamsToObject(this.appliedFilters); + return state || ISSUE_STATES.OPENED; + }, + updateIssueStateTabs() { + const activeTabEl = document.querySelector('.issues-state-filters .active'); + const newActiveTabEl = document.querySelector( + `.issues-state-filters [data-state="${this.getCurrentState()}"]`, + ); + + if (activeTabEl && !activeTabEl.querySelector(`[data-state="${this.getCurrentState()}"]`)) { + activeTabEl.classList.remove(ACTIVE_TAB_CLASS); + newActiveTabEl.parentElement.classList.add(ACTIVE_TAB_CLASS); + } else if (newActiveTabEl) { + newActiveTabEl.parentElement.classList.add(ACTIVE_TAB_CLASS); + } + }, + setupExternalEvents() { + if (this.isInProjectPage && this.issuableIndex.bulkUpdateSidebar) { + this.issuableIndex.bulkUpdateSidebar.initDomElements(); + this.issuableIndex.bulkUpdateSidebar.bindEvents(); + } + }, + updatePage(page) { + this.filteredSearch.updateObject(mergeUrlParams({ page }, this.appliedFilters)); + this.setCurrentPage(page); + scrollToElement('#content-body'); + }, + loadIssues() { + if (!this.isInDashboardPage) { + this.isLoadingDisabled = false; + } else { + const assigneeUsername = urlParamsToObject(this.appliedFilters).assignee_username; + this.isLoadingDisabled = assigneeUsername !== gon.current_username; + } + + if (!this.isLoadingDisabled) { + this.fetchIssues(this.endpoint); + } + }, + applyLabelFilter(label) { + this.filteredSearch.clearSearch(); + this.filteredSearch.updateObject( + `?${objectToQueryString({ 'label_name[]': encodeURIComponent(label) })}`, + ); + this.filteredSearch.loadSearchParamsFromURL(); + }, + }, +}; +</script> +<template> + <div v-if="hasIssues"> + <ul class="content-list issues-list issuable-list"> + <issue + v-for="issue in issues" + :key="issue.id" + :issue="issue" + :is-bulk-updating="isBulkUpdating" + :can-bulk-update="canBulkUpdate" + @issueLabelClicked="applyLabelFilter" + /> + </ul> + <div class="gl-pagination prepend-top-default"> + <gl-pagination + :change="updatePage" + :page="currentPage" + :per-page="ISSUES_PER_PAGE" + :total-items="totalItems" + :next-text="__('Next')" + :prev-text="__('Prev')" + class="justify-content-center" + /> + </div> + </div> + <IssuesLoadingState v-else-if="loading" /> + <issues-empty-state + v-else + :state="getCurrentState()" + :button-path="createPath" + :has-filters="hasFilters" + :loading-disabled="isLoadingDisabled" + :svg-image-path="emptyStateSvgPath" + :svg-loading-disabled-image-path="emptyStateLoadingDisabledSvgPath" + /> +</template> diff --git a/app/assets/javascripts/issues/components/loading_state.vue b/app/assets/javascripts/issues/components/loading_state.vue new file mode 100644 index 00000000000..85ccaf75792 --- /dev/null +++ b/app/assets/javascripts/issues/components/loading_state.vue @@ -0,0 +1,17 @@ +<script> +import { GlSkeletonLoading } from '@gitlab/ui'; + +export default { + components: { + GlSkeletonLoading, + }, +}; +</script> + +<template> + <ul class="content-list issues-list issuable-list js-issues-loading"> + <li v-for="n in 8" :key="n" class="issue"> + <gl-skeleton-loading /> + </li> + </ul> +</template> diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js new file mode 100644 index 00000000000..6e7d6e525fb --- /dev/null +++ b/app/assets/javascripts/issues/constants.js @@ -0,0 +1,9 @@ +export const ISSUE_STATES = { + OPENED: 'opened', + CLOSED: 'closed', +}; + +export const ACTIVE_TAB_CLASS = 'active'; +export const ISSUES_PER_PAGE = 20; +export const DASHBOARD_PAGE_NAME = 'dashboard'; +export const MAX_ASSIGNEES_RENDER = 3; diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js new file mode 100644 index 00000000000..fd8c6bca66b --- /dev/null +++ b/app/assets/javascripts/issues/index.js @@ -0,0 +1,53 @@ +import Vue from 'vue'; +import projectSelect from '~/project_select'; +import IssuableIndex from '~/issuable_index'; +import { ISSUABLE_INDEX } from '~/pages/projects/constants'; +import store from './stores'; +import IssuesApp from './components/issues_app.vue'; +import IssuesFilteredSearch from './issues_filtered_search'; + +export default () => { + const el = document.querySelector('#js-issues-list'); + + if (!el) return null; + + const { endpoint, canUpdate, createPath } = el.dataset; + const canBulkUpdate = Boolean(canUpdate); + + // Set default filters from URL + store.dispatch('issuesList/setFilters', window.location.search); + + // Setup filterd search component + const filteredSearch = new IssuesFilteredSearch(store.state.issuesList.filters); + + // Setup issue page handlers + const issuableIndex = new IssuableIndex(ISSUABLE_INDEX.ISSUE); + + return new Vue({ + el, + store, + components: { + IssuesApp, + }, + mounted() { + filteredSearch.setup(); + }, + created() { + this.dataset = this.$options.el.dataset; + }, + render(createElement) { + return createElement('issues-app', { + props: { + endpoint, + createPath, + projectSelect, + canBulkUpdate, + issuableIndex, + filteredSearch, + emptyStateSvgPath: this.dataset.emptyStateSvgPath, + emptyStateLoadingDisabledSvgPath: this.dataset.emptyStateLoadingDisabledSvgPath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/issues/issues_filtered_search.js b/app/assets/javascripts/issues/issues_filtered_search.js new file mode 100644 index 00000000000..aca98f30c84 --- /dev/null +++ b/app/assets/javascripts/issues/issues_filtered_search.js @@ -0,0 +1,32 @@ +import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; +import { FILTERED_SEARCH } from '~/pages/constants'; +import FilteredSearchManager from '~/filtered_search/filtered_search_manager'; +import { historyPushState, getPagePath } from '~/lib/utils/common_utils'; +import { DASHBOARD_PAGE_NAME } from './constants'; +import issuesListStore from './stores'; + +const isInDashboardPage = getPagePath() === DASHBOARD_PAGE_NAME; + +if (!isInDashboardPage) { + IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); +} + +export default class FilteredSearchIssueAnalytics extends FilteredSearchManager { + constructor() { + super({ + page: FILTERED_SEARCH.ISSUES, + isGroup: !isInDashboardPage, + isGroupDecendent: !isInDashboardPage, + IssuableFilteredSearchTokenKeys, + }); + + this.isHandledAsync = true; + } + + updateObject = path => { + historyPushState(path); + + issuesListStore.dispatch('issuesList/setFilters', path); + issuesListStore.dispatch('issuesList/setCurrentPage', 1); + }; +} diff --git a/app/assets/javascripts/issues/services/issues_service.js b/app/assets/javascripts/issues/services/issues_service.js new file mode 100644 index 00000000000..5f272833a7d --- /dev/null +++ b/app/assets/javascripts/issues/services/issues_service.js @@ -0,0 +1,13 @@ +import axios from '~/lib/utils/axios_utils'; + +export default { + fetchIssues(endpoint, filters, state) { + return axios.get(`${endpoint}`, { + params: { + ...filters, + state, + with_labels_details: true, // ensures we more details for labels + }, + }); + }, +}; diff --git a/app/assets/javascripts/issues/stores/index.js b/app/assets/javascripts/issues/stores/index.js new file mode 100644 index 00000000000..4b437f377ba --- /dev/null +++ b/app/assets/javascripts/issues/stores/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import issuesList from './modules/issues_list'; + +Vue.use(Vuex); + +export const createStore = () => + new Vuex.Store({ + modules: { + issuesList: issuesList(), + }, + }); + +export default createStore(); diff --git a/app/assets/javascripts/issues/stores/modules/issues_list/actions.js b/app/assets/javascripts/issues/stores/modules/issues_list/actions.js new file mode 100644 index 00000000000..321e1e8d341 --- /dev/null +++ b/app/assets/javascripts/issues/stores/modules/issues_list/actions.js @@ -0,0 +1,105 @@ +import flash from '~/flash'; +import { __ } from '~/locale'; +import { getParameterValues } from '~/lib/utils/url_utility'; +import _ from 'underscore'; +import { urlParamsToObject } from '~/lib/utils/common_utils'; +import { ISSUE_STATES } from '../../../constants'; +import service from '../../../services/issues_service'; +import * as types from './mutation_types'; + +/** + * Return a object containg order and sort values + * for issues filter. This is necessary to maintain + * URL compatibility for current issues filters while + * keeping backward compatibility for issues API + * @param {String} sort + */ +const transformSortFilter = sort => { + const orderMap = { + created: 'created_at', + updated: 'updated_at', + due_date: 'due_date', + priority: 'priority', + milestone: 'milestone', + popularity: 'popularity', + label_priority: 'label_priority', + }; + + if (!sort) { + return null; + } + + if (sort.lastIndexOf('desc') === -1) { + return { + orderBy: orderMap[sort], + sort: 'asc', + }; + } + + const splitIndex = sort.lastIndexOf('_'); + const orderValue = sort.slice(0, splitIndex); + const sortValue = sort.slice(splitIndex + 1); + + return { + orderBy: orderMap[orderValue], + sort: sortValue, + }; +}; + +export const setFilters = ({ commit }, value) => { + commit(types.SET_FILTERS, value); +}; + +export const setLoadingState = ({ commit }, value) => { + commit(types.SET_LOADING_STATE, value); +}; + +export const setBulkUpdateState = ({ commit }, value) => { + commit(types.SET_BULK_UPDATE_STATE, value); +}; + +export const fetchIssues = ({ commit, dispatch, getters }, endpoint) => { + dispatch('setLoadingState', true); + + // we always update state from the window.location + // as it may not be avaliable in our store + const [currentState] = getParameterValues('state'); + + // only set state if it does not exist + const state = !currentState ? ISSUE_STATES.OPENED : null; + const appliedFilters = urlParamsToObject(getters.appliedFilters); + const filters = _.omit(appliedFilters, ['label_name', 'sort']); + + // map label parameter to supported API value + if (appliedFilters.label_name && Array.isArray(appliedFilters.label_name)) { + filters.labels = appliedFilters.label_name.join(','); + } + + // transform and apply order_by and sort filters + if (appliedFilters.sort) { + const { sort, orderBy } = transformSortFilter(appliedFilters.sort) || {}; + filters.sort = sort; + filters.order_by = orderBy; + } + + return service + .fetchIssues(endpoint, filters, state) + .then(({ data, headers }) => { + dispatch('setTotalItems', headers['x-total']); + dispatch('setCurrentPage', headers['x-page']); + commit(types.SET_ISSUES_DATA, data); + }) + .then(() => dispatch('setLoadingState', false)) + .catch(() => flash(__('An error occurred while loading issues'))); +}; + +export const setCurrentPage = ({ commit }, value) => { + commit(types.SET_CURRENT_PAGE, value); +}; + +export const setTotalItems = ({ commit }, value) => { + commit(types.SET_TOTAL_ITEMS, value); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/issues/stores/modules/issues_list/getters.js b/app/assets/javascripts/issues/stores/modules/issues_list/getters.js new file mode 100644 index 00000000000..31c96913b85 --- /dev/null +++ b/app/assets/javascripts/issues/stores/modules/issues_list/getters.js @@ -0,0 +1,29 @@ +import { tokenKeys } from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; +import { urlParamsToObject } from '~/lib/utils/common_utils'; + +const filterTokenKeys = tokenKeys.map(token => { + const { key, param } = token; + const paramValue = param ? `_${param.replace('[]', '')}` : ''; + + return `${key}${paramValue}`; +}); + +export const hasFilters = state => { + if (!state.filters) { + return false; + } + + const currenFilters = Object.keys(urlParamsToObject(state.filters)); + + return currenFilters.reduce((acc, val) => { + if (!acc) { + return filterTokenKeys.includes(val); + } + return acc; + }, false); +}; +export const appliedFilters = state => state.filters; +export const currentPage = state => state.currentPage; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/issues/stores/modules/issues_list/index.js b/app/assets/javascripts/issues/stores/modules/issues_list/index.js new file mode 100644 index 00000000000..81dab0566c1 --- /dev/null +++ b/app/assets/javascripts/issues/stores/modules/issues_list/index.js @@ -0,0 +1,12 @@ +import state from './state'; +import mutations from './mutations'; +import * as actions from './actions'; +import * as getters from './getters'; + +export default () => ({ + namespaced: true, + state: state(), + mutations, + actions, + getters, +}); diff --git a/app/assets/javascripts/issues/stores/modules/issues_list/mutation_types.js b/app/assets/javascripts/issues/stores/modules/issues_list/mutation_types.js new file mode 100644 index 00000000000..df2f8cf6e37 --- /dev/null +++ b/app/assets/javascripts/issues/stores/modules/issues_list/mutation_types.js @@ -0,0 +1,6 @@ +export const SET_FILTERS = 'SET_FILTERS'; +export const SET_ISSUES_DATA = 'SET_ISSUES_DATA'; +export const SET_TOTAL_ITEMS = 'SET_TOTAL_ITEMS'; +export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE'; +export const SET_LOADING_STATE = 'SET_LOADING_STATE'; +export const SET_BULK_UPDATE_STATE = 'SET_BULK_UPDATE_STATE'; diff --git a/app/assets/javascripts/issues/stores/modules/issues_list/mutations.js b/app/assets/javascripts/issues/stores/modules/issues_list/mutations.js new file mode 100644 index 00000000000..0ff879b7ef7 --- /dev/null +++ b/app/assets/javascripts/issues/stores/modules/issues_list/mutations.js @@ -0,0 +1,24 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_LOADING_STATE](state, value) { + state.loading = value; + }, + [types.SET_ISSUES_DATA](state, issues) { + Object.assign(state, { + issues, + }); + }, + [types.SET_FILTERS](state, value) { + state.filters = value; + }, + [types.SET_BULK_UPDATE_STATE](state, value) { + state.isBulkUpdating = value; + }, + [types.SET_TOTAL_ITEMS](state, value) { + state.totalItems = parseInt(value, 10); + }, + [types.SET_CURRENT_PAGE](state, value) { + state.currentPage = parseInt(value, 10); + }, +}; diff --git a/app/assets/javascripts/issues/stores/modules/issues_list/state.js b/app/assets/javascripts/issues/stores/modules/issues_list/state.js new file mode 100644 index 00000000000..ffa5bd49c26 --- /dev/null +++ b/app/assets/javascripts/issues/stores/modules/issues_list/state.js @@ -0,0 +1,12 @@ +import { getParameterValues } from '~/lib/utils/url_utility'; + +const [page] = getParameterValues('page'); + +export default () => ({ + loading: false, + filters: '', + issues: null, + isBulkUpdating: false, + currentPage: page || 1, + totalItems: 0, +}); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index f0254be2044..0d1acf0544d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1080,6 +1080,9 @@ msgstr "" msgid "An error occurred while loading filenames" msgstr "" +msgid "An error occurred while loading issues" +msgstr "" + msgid "An error occurred while loading the file" msgstr "" @@ -1945,6 +1948,9 @@ msgstr "" msgid "CICD|instance enabled" msgstr "" +msgid "CLOSED" +msgstr "" + msgid "CONTRIBUTING" msgstr "" @@ -8063,6 +8069,9 @@ msgstr "" msgid "Press Enter or click to search" msgstr "" +msgid "Prev" +msgstr "" + msgid "Preview" msgstr "" @@ -12485,6 +12494,9 @@ msgstr "" msgid "Wednesday" msgstr "" +msgid "Weight" +msgstr "" + msgid "Welcome to your Issue Board!" msgstr "" @@ -13601,6 +13613,9 @@ msgstr "" msgid "nounSeries|%{item}, and %{lastItem}" msgstr "" +msgid "opened" +msgstr "" + msgid "or" msgstr "" diff --git a/spec/frontend/helpers/url_util_helper.js b/spec/frontend/helpers/url_util_helper.js new file mode 100644 index 00000000000..d28e9aa7bf9 --- /dev/null +++ b/spec/frontend/helpers/url_util_helper.js @@ -0,0 +1,7 @@ +// eslint-disable-next-line import/prefer-default-export +export const setWindowLocation = value => { + Object.defineProperty(window, 'location', { + writable: true, + value, + }); +}; diff --git a/spec/frontend/issues/components/issue_spec.js b/spec/frontend/issues/components/issue_spec.js new file mode 100644 index 00000000000..66252ad4d97 --- /dev/null +++ b/spec/frontend/issues/components/issue_spec.js @@ -0,0 +1,222 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import IssueComponent from '~/issues/components/issue.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { singleIssueData } from '../mock_data'; + +const localVue = createLocalVue(); + +describe('Issue component', () => { + let wrapper; + + const factory = (props = {}, options = {}) => { + const propsData = { + issue: singleIssueData, + isBulkUpdating: false, + canBulkUpdate: true, + ...props, + }; + + wrapper = shallowMount(localVue.extend(IssueComponent), { + localVue, + propsData, + ...options, + }); + }; + + it('renders the issue title', () => { + factory(); + + const issueTitle = wrapper.find('.issue-title-text > a'); + + expect(issueTitle.exists()).toBe(true); + expect(issueTitle.text()).toBe(singleIssueData.title); + }); + + it('links to the issue details page', () => { + factory(); + + const issueTitle = wrapper.find('.issue-title-text > a'); + + expect(issueTitle.attributes('href')).toBe(singleIssueData.web_url); + }); + + it('renders issue confidential icon', () => { + factory(); + + const confidentialIcon = wrapper.find('.js-issue-confidential-icon'); + + expect(confidentialIcon.exists()).toBe(true); + }); + + it('renders issue tasks', () => { + const issue = { ...singleIssueData }; + issue.has_tasks = true; + issue.task_status = '0 or 2 completed'; + + factory({ issue }); + + const issueTasksWrapper = wrapper.find('.task-status'); + + expect(issueTasksWrapper.exists()).toBe(true); + expect(issueTasksWrapper.text()).toBe(issue.task_status); + }); + + it('renders issue reference path', () => { + factory(); + + const referencePath = wrapper.find('.issuable-reference'); + + expect(referencePath.exists()).toBe(true); + expect(referencePath.text()).toBe(singleIssueData.reference_path); + }); + + it('renders issue created date', () => { + factory(); + + const issueCreatedDate = wrapper.find('.issuable-authored').find(TimeAgoTooltip); + + expect(issueCreatedDate.exists()).toBe(true); + expect(issueCreatedDate.props('time')).toBe(singleIssueData.created_at); + }); + + it('renders issue author', () => { + factory(); + + const issueAuthor = wrapper.find('.issuable-authored .author-link'); + + expect(issueAuthor.exists()).toBe(true); + expect(issueAuthor.text()).toBe(singleIssueData.author.name); + expect(issueAuthor.attributes('href')).toBe(singleIssueData.author.web_url); + }); + + it('renders issue milestone', () => { + const issue = { ...singleIssueData }; + issue.milestone = { + web_url: 'http://hello.world', + title: 'Hello milestone', + }; + + factory({ issue }); + + const issueMilestone = wrapper.find('.issuable-milestone'); + + expect(issueMilestone.exists()).toBe(true); + expect(issueMilestone.text()).toBe(issue.milestone.title); + expect(issueMilestone.find('a').attributes('href')).toBe(issue.milestone.web_url); + }); + + it('renders issue weight', () => { + factory(); + + const issueWeight = wrapper.find('.issuable-weight'); + + expect(issueWeight.exists()).toBe(true); + expect(issueWeight.text()).toBe(`${singleIssueData.weight}`); + }); + + it('renders issue "CLOSED" if issue is closed', () => { + const issue = { ...singleIssueData }; + issue.state = 'closed'; + + factory({ issue }); + + const issueStateWrapper = wrapper.find('.issuable-status'); + + expect(issueStateWrapper.exists()).toBe(true); + expect(issueStateWrapper.text()).toBe('CLOSED'); + }); + + it('renders issue merge request count', () => { + const issue = { ...singleIssueData }; + issue.merge_requests_count = '20'; + + factory({ issue }); + + const issueMRCount = wrapper.find('.issuable-mr'); + + expect(issueMRCount.exists()).toBe(true); + expect(issueMRCount.text()).toBe(issue.merge_requests_count); + }); + + describe('Issue upvotes', () => { + let issue; + + beforeEach(() => { + issue = { ...singleIssueData }; + issue.upvotes = '5'; + }); + + it('does not render if upvotes is 0', () => { + issue.upvotes = 0; + + factory({ issue }); + + const issueUpvotes = wrapper.find('.issuable-upvotes'); + + expect(issueUpvotes.exists()).toBe(false); + }); + + it('renders upvotes count', () => { + factory({ issue }); + + const issueUpvotes = wrapper.find('.issuable-upvotes'); + + expect(issueUpvotes.exists()).toBe(true); + expect(issueUpvotes.text()).toBe(issue.upvotes); + }); + }); + + describe('Issue downvotes', () => { + let issue; + + beforeEach(() => { + issue = { ...singleIssueData }; + issue.downvotes = '5'; + }); + + it('does not render if downvotes is 0', () => { + issue.downvotes = 0; + + factory({ issue }); + + const issueDownvotes = wrapper.find('.issuable-downvotes'); + + expect(issueDownvotes.exists()).toBe(false); + }); + + it('renders downvotes count', () => { + factory({ issue }); + + const issueDownvotes = wrapper.find('.issuable-downvotes'); + + expect(issueDownvotes.exists()).toBe(true); + expect(issueDownvotes.text()).toBe(issue.downvotes); + }); + }); + + describe('Issue comments', () => { + let issue; + + beforeEach(() => { + issue = { ...singleIssueData }; + issue.note_count = '5'; + }); + + it('renders 0 if the issue has no comments', () => { + issue.note_count = null; + + factory({ issue }); + + const issueComments = wrapper.find('.issuable-comments'); + + expect(issueComments.text()).toBe('0'); + }); + + it('renders issue comments count', () => { + factory({ issue }); + + const issueComments = wrapper.find('.issuable-comments'); + expect(issueComments.text()).toBe(issue.note_count); + }); + }); +}); diff --git a/spec/frontend/issues/components/issues_app_spec.js b/spec/frontend/issues/components/issues_app_spec.js new file mode 100644 index 00000000000..b8dcf72fa43 --- /dev/null +++ b/spec/frontend/issues/components/issues_app_spec.js @@ -0,0 +1,234 @@ +import Vuex from 'vuex'; +import { mount, createLocalVue } from '@vue/test-utils'; +import { TEST_HOST } from 'helpers/test_constants'; +import { GlPagination } from '@gitlab/ui'; +import IssuesApp from '~/issues/components/issues_app.vue'; +import IssueComponent from '~/issues/components/issue.vue'; +import IssuesEmptyState from '~/issues/components/empty_state.vue'; +import IssuesLoadingState from '~/issues/components/loading_state.vue'; +import * as getters from '~/issues/stores/modules/issues_list/getters'; +import { issuesResponseData } from '../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Issues app', () => { + let store; + let state; + let actions; + let wrapper; + let mockedProjectSelect; + let mockedIssuableIndex; + let mockedFilteredSearch; + + const factory = (props = {}, options = {}) => { + const propsData = { + endpoint: TEST_HOST, + createPath: '/', + projectSelect: mockedProjectSelect, + canBulkUpdate: true, + issuableIndex: mockedIssuableIndex, + filteredSearch: mockedFilteredSearch, + emptyStateSvgPath: '/', + emptyStateLoadingDisabledSvgPath: '/', + ...props, + }; + + store = new Vuex.Store({ + modules: { + issuesList: { + namespaced: true, + state, + actions, + getters, + }, + }, + }); + + wrapper = mount(localVue.extend(IssuesApp), { + localVue, + store, + sync: false, + propsData, + ...options, + }); + }; + + beforeEach(() => { + state = { + loading: false, + filters: '', + issues: null, + isBulkUpdating: false, + currentPage: 1, + totalItems: 1, + }; + + actions = { + fetchIssues: jest.fn(), + setCurrentPage: jest.fn(), + }; + + mockedFilteredSearch = { + updateObject: jest.fn(), + clearSearch: jest.fn(), + loadSearchParamsFromURL: jest.fn(), + }; + + mockedIssuableIndex = { + bulkUpdateSidebar: { + bindEvents: jest.fn(), + initDomElements: jest.fn(), + }, + }; + + mockedProjectSelect = jest.fn(); + + document.body.innerHTML = ''; + window.gon = {}; + }); + + it('fetches issues when mounted', () => { + factory(); + expect(actions.fetchIssues).toHaveBeenCalled(); + expect(actions.fetchIssues.mock.calls[0]).toContain(TEST_HOST); + }); + + it('renders loading state when fetching issues', () => { + state.loading = true; + factory(); + + const loadingState = wrapper.find(IssuesLoadingState); + + expect(loadingState.exists()).toBe(true); + expect(loadingState.classes()).toContain('js-issues-loading'); + }); + + it('renders empty state if no issues', () => { + state.issues = []; + factory(); + + const emptyState = wrapper.find(IssuesEmptyState); + + expect(emptyState.exists()).toBe(true); + }); + + it('renders issues list when issues are present', () => { + state.issues = issuesResponseData.slice(0, 10); + factory(); + + const issueComponent = wrapper.find(IssueComponent); + + expect(issueComponent.exists()).toBe(true); + expect(wrapper.findAll(IssueComponent).length).toEqual(10); + }); + + it('renders issues list pagination', () => { + state.issues = issuesResponseData; + factory(); + expect(wrapper.find(GlPagination).exists()).toBe(true); + }); + + it('initializes issues page events', () => { + state.issues = issuesResponseData; + factory(); + + wrapper.setData({ isInProjectPage: true }); + wrapper.vm.setupExternalEvents(); + + expect(mockedIssuableIndex.bulkUpdateSidebar.bindEvents).toHaveBeenCalled(); + expect(mockedIssuableIndex.bulkUpdateSidebar.initDomElements).toHaveBeenCalled(); + }); + + it('initializes project selector in issues dashboard page', () => { + state.issues = issuesResponseData; + factory( + {}, + { + data() { + return { + ISSUES_PER_PAGE: 20, + isInGroupsPage: false, + isInProjectPage: false, + isInDashboardPage: true, + isLoadingDisabled: false, + }; + }, + }, + ); + + expect(mockedProjectSelect).toHaveBeenCalled(); + }); + + it('initializes project selector in groups issues page', () => { + state.issues = issuesResponseData; + factory( + {}, + { + data() { + return { + ISSUES_PER_PAGE: 20, + isInGroupsPage: true, + isInProjectPage: false, + isInDashboardPage: false, + isLoadingDisabled: false, + }; + }, + }, + ); + + expect(mockedProjectSelect).toHaveBeenCalled(); + }); + + describe('Issues filters', () => { + it('updates filters when the current page changes', () => { + const page = 2; + + document.body.innerHTML = '<div id="content-body"></div>'; + state.issues = issuesResponseData; + factory(); + + wrapper.vm.updatePage(page); + + expect(mockedFilteredSearch.updateObject).toHaveBeenCalled(); + expect(actions.setCurrentPage.mock.calls[0]).toContain(page); + }); + + it('update filters when a label is clicked', () => { + const label = 'Hello'; + state.issues = issuesResponseData; + factory(); + + wrapper.vm.applyLabelFilter(label); + + expect(mockedFilteredSearch.clearSearch).toHaveBeenCalled(); + expect(mockedFilteredSearch.updateObject).toHaveBeenCalled(); + expect(mockedFilteredSearch.loadSearchParamsFromURL).toHaveBeenCalled(); + }); + + it('disables loading in issues dashboard page if assignee filter is incorrect', () => { + window.gon = { + current_username: 'not_root', + }; + + state.issues = []; + state.filters = '?assignee_username=root'; + factory( + {}, + { + data() { + return { + ISSUES_PER_PAGE: 20, + isInGroupsPage: false, + isInProjectPage: false, + isInDashboardPage: true, + isLoadingDisabled: false, + }; + }, + }, + ); + + expect(wrapper.vm.isLoadingDisabled).toBe(true); + }); + }); +}); diff --git a/spec/frontend/issues/mock_data.js b/spec/frontend/issues/mock_data.js new file mode 100644 index 00000000000..2a0f209336d --- /dev/null +++ b/spec/frontend/issues/mock_data.js @@ -0,0 +1,1491 @@ +export const issuesResponseData = [ + { + id: 389, + iid: 1, + project_id: 15, + title: 'Add Content', + description: 'Add content to this repo', + state: 'opened', + created_at: '2019-04-06T11:06:27.510Z', + updated_at: '2019-04-06T11:06:27.510Z', + closed_at: null, + closed_by: null, + labels: [], + milestone: null, + assignees: [ + { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'http://test.host/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + web_url: 'http://test.host/root', + }, + ], + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: 'http://test.host/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + web_url: 'http://test.host/root', + }, + assignee: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: 'http://test.host/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + web_url: 'http://test.host/root', + }, + user_notes_count: 0, + merge_requests_count: 0, + upvotes: 0, + downvotes: 0, + due_date: '2019-07-25', + confidential: true, + discussion_locked: null, + has_tasks: false, + web_url: 'http://test.host/gitlab-org/awesome-jekyll/issues/1', + reference_path: '#1', + real_path: '/gitlab-org/awesome-jekyll/issues/1', + time_stats: { + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + }, + weight: 20, + }, + { + id: 246, + iid: 33, + project_id: 1, + title: 'Dismiss Cipher with no integrity', + description: null, + state: 'opened', + created_at: '2018-11-05T09:26:15.164Z', + updated_at: '2019-04-11T20:07:02.662Z', + closed_at: null, + closed_by: null, + labels: [ + { id: 36, name: 'To Do', color: '#F0AD4E', description: null, text_color: '#FFFFFF' }, + { id: 37, name: 'Doing', color: '#5CB85C', description: null, text_color: '#FFFFFF' }, + ], + milestone: null, + assignees: [], + author: { + id: 2, + name: 'Wai Hamill', + username: 'felicia_tremblay', + state: 'active', + avatar_url: 'http://test.host/avatar/b53d4d33fdc1d0f9296e3440e71d1bde?s=80\u0026d=identicon', + web_url: 'http://test.host/felicia_tremblay', + }, + assignee: null, + user_notes_count: 2, + merge_requests_count: 0, + upvotes: 0, + downvotes: 0, + due_date: null, + confidential: false, + discussion_locked: null, + has_tasks: false, + web_url: 'http://test.host/gitlab-org/gitlab-test/issues/33', + reference_path: '#33', + real_path: '/gitlab-org/gitlab-test/issues/33', + time_stats: { + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + }, + weight: null, + }, + { + id: 245, + iid: 32, + project_id: 1, + title: 'Dismiss Cipher with no integrity', + description: + '* [ ] Do the first thing\n* [ ] Do the next thing\n* [ ] Do the third thing\n* [ ] Do the last thing', + state: 'opened', + created_at: '2018-11-05T09:26:15.103Z', + updated_at: '2019-04-12T05:43:47.971Z', + closed_at: null, + closed_by: null, + labels: [ + { + id: 38, + name: 'priority::1', + color: '#FF0000', + description: 'This is for top level issues that should we worked on first', + text_color: '#FFFFFF', + }, + ], + milestone: null, + assignees: [ + { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'http://test.host/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + web_url: 'http://test.host/root', + }, + { + id: 2, + name: 'Wai Hamill', + username: 'felicia_tremblay', + state: 'active', + avatar_url: + 'http://test.host/avatar/b53d4d33fdc1d0f9296e3440e71d1bde?s=80\u0026d=identicon', + web_url: 'http://test.host/felicia_tremblay', + }, + { + id: 7, + name: 'Minta Friesen', + username: 'irene_hickle', + state: 'active', + avatar_url: + 'http://test.host/avatar/9231b409b04d59788caa3f1b69cab29e?s=80\u0026d=identicon', + web_url: 'http://test.host/irene_hickle', + }, + { + id: 10, + name: 'George Bartell', + username: 'jaimee', + state: 'active', + avatar_url: + 'http://test.host/avatar/b8b798fad1dd688ceab20efff7624a74?s=80\u0026d=identicon', + web_url: 'http://test.host/jaimee', + }, + { + id: 13, + name: 'Shannan Quigley', + username: 'alva_cassin', + state: 'active', + avatar_url: + 'http://test.host/avatar/8456544bf25a412a9c08f7b6c4e7fc62?s=80\u0026d=identicon', + web_url: 'http://test.host/alva_cassin', + }, + { + id: 20, + name: 'Sharee Gerhold', + username: 'geri_waelchi', + state: 'active', + avatar_url: + 'http://test.host/avatar/7cc8a4b30b2071494223297b9aadeaec?s=80\u0026d=identicon', + web_url: 'http://test.host/geri_waelchi', + }, + { + id: 23, + name: 'User 1', + username: 'user1', + state: 'active', + avatar_url: + 'http://test.host/avatar/111d68d06e2d317b5a59c2c6c5bad808?s=80\u0026d=identicon', + web_url: 'http://test.host/user1', + }, + ], + author: { + id: 2, + name: 'Wai Hamill', + username: 'felicia_tremblay', + state: 'active', + avatar_url: 'http://test.host/avatar/b53d4d33fdc1d0f9296e3440e71d1bde?s=80\u0026d=identicon', + web_url: 'http://test.host/felicia_tremblay', + }, + assignee: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: 'http://test.host/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + web_url: 'http://test.host/root', + }, + user_notes_count: 3, + merge_requests_count: 0, + upvotes: 0, + downvotes: 0, + due_date: null, + confidential: true, + discussion_locked: null, + has_tasks: true, + task_status: '0 of 4 tasks completed', + web_url: 'http://test.host/gitlab-org/gitlab-test/issues/32', + reference_path: '#32', + real_path: '/gitlab-org/gitlab-test/issues/32', + time_stats: { + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + }, + weight: null, + }, + { + id: 244, + iid: 31, + project_id: 1, + title: 'Dismiss Cipher with no integrity', + description: null, + state: 'opened', + created_at: '2018-11-05T09:26:15.047Z', + updated_at: '2019-04-11T20:07:02.542Z', + closed_at: null, + closed_by: null, + labels: [ + { id: 36, name: 'To Do', color: '#F0AD4E', description: null, text_color: '#FFFFFF' }, + { id: 37, name: 'Doing', color: '#5CB85C', description: null, text_color: '#FFFFFF' }, + ], + milestone: null, + assignees: [], + author: { + id: 2, + name: 'Wai Hamill', + username: 'felicia_tremblay', + state: 'active', + avatar_url: 'http://test.host/avatar/b53d4d33fdc1d0f9296e3440e71d1bde?s=80\u0026d=identicon', + web_url: 'http://test.host/felicia_tremblay', + }, + assignee: null, + user_notes_count: 0, + merge_requests_count: 0, + upvotes: 0, + downvotes: 0, + due_date: null, + confidential: false, + discussion_locked: null, + has_tasks: false, + web_url: 'http://test.host/gitlab-org/gitlab-test/issues/31', + reference_path: '#31', + real_path: '/gitlab-org/gitlab-test/issues/31', + time_stats: { + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + }, + weight: null, + }, + { + id: 20, + iid: 10, + project_id: 2, + title: 'Quo beatae ullam laborum est quisquam laudantium ut et autem mollitia.', + description: 'Eveniet voluptatem molestiae dolor expedita.', + state: 'opened', + created_at: '2018-11-05T09:21:13.533Z', + updated_at: '2019-03-29T08:32:36.294Z', + closed_at: null, + closed_by: null, + labels: [], + milestone: { + id: 6, + iid: 1, + project_id: 2, + title: 'v0.0', + description: 'Non nemo aut consequatur et recusandae.', + state: 'closed', + created_at: '2018-11-05T09:21:09.485Z', + updated_at: '2018-11-05T09:21:09.485Z', + due_date: null, + start_date: null, + web_url: 'http://test.host/gitlab-org/gitlab-shell/milestones/1', + }, + assignees: [ + { + id: 20, + name: 'Sharee Gerhold', + username: 'geri_waelchi', + state: 'active', + avatar_url: + 'http://test.host/avatar/7cc8a4b30b2071494223297b9aadeaec?s=80\u0026d=identicon', + web_url: 'http://test.host/geri_waelchi', + }, + ], + author: { + id: 10, + name: 'George Bartell', + username: 'jaimee', + state: 'active', + avatar_url: 'http://test.host/avatar/b8b798fad1dd688ceab20efff7624a74?s=80\u0026d=identicon', + web_url: 'http://test.host/jaimee', + }, + assignee: { + id: 20, + name: 'Sharee Gerhold', + username: 'geri_waelchi', + state: 'active', + avatar_url: 'http://test.host/avatar/7cc8a4b30b2071494223297b9aadeaec?s=80\u0026d=identicon', + web_url: 'http://test.host/geri_waelchi', + }, + user_notes_count: 12, + merge_requests_count: 0, + upvotes: 0, + downvotes: 0, + due_date: null, + confidential: false, + discussion_locked: null, + has_tasks: false, + web_url: 'http://test.host/gitlab-org/gitlab-shell/issues/10', + reference_path: '#10', + real_path: '/gitlab-org/gitlab-shell/issues/10', + time_stats: { + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + }, + weight: null, + }, + { + id: 19, + iid: 9, + project_id: 2, + title: 'Aliquam earum sit dolore voluptates assumenda autem omnis eum necessitatibus minus.', + description: 'Et qui atque deleniti nulla non sed provident ut.', + state: 'opened', + created_at: '2018-11-05T09:21:13.428Z', + updated_at: '2019-01-30T07:17:54.796Z', + closed_at: null, + closed_by: null, + labels: [], + milestone: { + id: 8, + iid: 3, + project_id: 2, + title: 'v2.0', + description: 'Debitis sit aut rerum voluptatem provident corporis sint.', + state: 'closed', + created_at: '2018-11-05T09:21:09.524Z', + updated_at: '2018-11-05T09:21:09.524Z', + due_date: null, + start_date: null, + web_url: 'http://test.host/gitlab-org/gitlab-shell/milestones/3', + }, + assignees: [ + { + id: 7, + name: 'Minta Friesen', + username: 'irene_hickle', + state: 'active', + avatar_url: + 'http://test.host/avatar/9231b409b04d59788caa3f1b69cab29e?s=80\u0026d=identicon', + web_url: 'http://test.host/irene_hickle', + }, + ], + author: { + id: 2, + name: 'Wai Hamill', + username: 'felicia_tremblay', + state: 'active', + avatar_url: 'http://test.host/avatar/b53d4d33fdc1d0f9296e3440e71d1bde?s=80\u0026d=identicon', + web_url: 'http://test.host/felicia_tremblay', + }, + assignee: { + id: 7, + name: 'Minta Friesen', + username: 'irene_hickle', + state: 'active', + avatar_url: 'http://test.host/avatar/9231b409b04d59788caa3f1b69cab29e?s=80\u0026d=identicon', + web_url: 'http://test.host/irene_hickle', + }, + user_notes_count: 8, + merge_requests_count: 0, + upvotes: 0, + downvotes: 0, + due_date: null, + confidential: false, + discussion_locked: null, + has_tasks: false, + web_url: 'http://test.host/gitlab-org/gitlab-shell/issues/9', + reference_path: '#9', + real_path: '/gitlab-org/gitlab-shell/issues/9', + time_stats: { + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + }, + weight: null, + }, + { + id: 17, + iid: 7, + project_id: 2, + title: 'Est nihil ut aut et excepturi ut.', + description: 'Est nesciunt dolor et similique porro ipsam libero.', + state: 'opened', + created_at: '2018-11-05T09:21:13.207Z', + updated_at: '2019-01-22T16:04:41.645Z', + closed_at: null, + closed_by: null, + labels: [], + milestone: { + id: 10, + iid: 5, + project_id: 2, + title: 'v4.0', + description: 'Ipsam amet possimus cupiditate fugit perferendis non enim eligendi.', + state: 'active', + created_at: '2018-11-05T09:21:09.562Z', + updated_at: '2018-11-05T09:21:09.562Z', + due_date: null, + start_date: null, + web_url: 'http://test.host/gitlab-org/gitlab-shell/milestones/5', + }, + assignees: [ + { + id: 20, + name: 'Sharee Gerhold', + username: 'geri_waelchi', + state: 'active', + avatar_url: + 'http://test.host/avatar/7cc8a4b30b2071494223297b9aadeaec?s=80\u0026d=identicon', + web_url: 'http://test.host/geri_waelchi', + }, + ], + author: { + id: 23, + name: 'User 1', + username: 'user1', + state: 'active', + avatar_url: 'http://test.host/avatar/111d68d06e2d317b5a59c2c6c5bad808?s=80\u0026d=identicon', + web_url: 'http://test.host/user1', + }, + assignee: { + id: 20, + name: 'Sharee Gerhold', + username: 'geri_waelchi', + state: 'active', + avatar_url: 'http://test.host/avatar/7cc8a4b30b2071494223297b9aadeaec?s=80\u0026d=identicon', + web_url: 'http://test.host/geri_waelchi', + }, + user_notes_count: 7, + merge_requests_count: 1, + upvotes: 0, + downvotes: 0, + due_date: '2018-11-09', + confidential: false, + discussion_locked: null, + has_tasks: false, + web_url: 'http://test.host/gitlab-org/gitlab-shell/issues/7', + reference_path: '#7', + real_path: '/gitlab-org/gitlab-shell/issues/7', + time_stats: { + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + }, + weight: null, + }, + { + id: 16, + iid: 6, + project_id: 2, + title: 'Qui fuga esse explicabo facilis velit ad eius error mollitia sed.', + description: 'Architecto sed alias cum non incidunt et nemo minus.', + state: 'opened', + created_at: '2018-11-05T09:21:13.105Z', + updated_at: '2018-11-05T09:22:22.510Z', + closed_at: null, + closed_by: null, + labels: [], + milestone: { + id: 9, + iid: 4, + project_id: 2, + title: 'v3.0', + description: 'Omnis et non explicabo nostrum velit nihil.', + state: 'closed', + created_at: '2018-11-05T09:21:09.545Z', + updated_at: '2018-11-05T09:21:09.545Z', + due_date: null, + start_date: null, + web_url: 'http://test.host/gitlab-org/gitlab-shell/milestones/4', + }, + assignees: [ + { + id: 14, + name: 'Shantell Stokes', + username: 'chi', + state: 'active', + avatar_url: + 'http://test.host/avatar/a21b34070d1abbae545f302caeaca174?s=80\u0026d=identicon', + web_url: 'http://test.host/chi', + }, + ], + author: { + id: 23, + name: 'User 1', + username: 'user1', + state: 'active', + avatar_url: 'http://test.host/avatar/111d68d06e2d317b5a59c2c6c5bad808?s=80\u0026d=identicon', + web_url: 'http://test.host/user1', + }, + assignee: { + id: 14, + name: 'Shantell Stokes', + username: 'chi', + state: 'active', + avatar_url: 'http://test.host/avatar/a21b34070d1abbae545f302caeaca174?s=80\u0026d=identicon', + web_url: 'http://test.host/chi', + }, + user_notes_count: 7, + merge_requests_count: 0, + upvotes: 0, + downvotes: 0, + due_date: null, + confidential: false, + discussion_locked: null, + has_tasks: false, + web_url: 'http://test.host/gitlab-org/gitlab-shell/issues/6', + reference_path: '#6', + real_path: '/gitlab-org/gitlab-shell/issues/6', + time_stats: { + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + }, + weight: null, + }, + { + id: 15, + iid: 5, + project_id: 2, + title: 'Corrupti numquam in quibusdam sunt quia autem quam suscipit itaque.', + description: 'Vel officia esse quasi sunt.', + state: 'opened', + created_at: '2018-11-05T09:21:13.000Z', + updated_at: '2018-11-05T09:22:22.250Z', + closed_at: null, + closed_by: null, + labels: [], + milestone: { + id: 9, + iid: 4, + project_id: 2, + title: 'v3.0', + description: 'Omnis et non explicabo nostrum velit nihil.', + state: 'closed', + created_at: '2018-11-05T09:21:09.545Z', + updated_at: '2018-11-05T09:21:09.545Z', + due_date: null, + start_date: null, + web_url: 'http://test.host/gitlab-org/gitlab-shell/milestones/4', + }, + assignees: [ + { + id: 10, + name: 'George Bartell', + username: 'jaimee', + state: 'active', + avatar_url: + 'http://test.host/avatar/b8b798fad1dd688ceab20efff7624a74?s=80\u0026d=identicon', + web_url: 'http://test.host/jaimee', + }, + ], + author: { + id: 2, + name: 'Wai Hamill', + username: 'felicia_tremblay', + state: 'active', + avatar_url: 'http://test.host/avatar/b53d4d33fdc1d0f9296e3440e71d1bde?s=80\u0026d=identicon', + web_url: 'http://test.host/felicia_tremblay', + }, + assignee: { + id: 10, + name: 'George Bartell', + username: 'jaimee', + state: 'active', + avatar_url: 'http://test.host/avatar/b8b798fad1dd688ceab20efff7624a74?s=80\u0026d=identicon', + web_url: 'http://test.host/jaimee', + }, + user_notes_count: 7, + merge_requests_count: 0, + upvotes: 0, + downvotes: 0, + due_date: null, + confidential: false, + discussion_locked: null, + has_tasks: false, + web_url: 'http://test.host/gitlab-org/gitlab-shell/issues/5', + reference_path: '#5', + real_path: '/gitlab-org/gitlab-shell/issues/5', + time_stats: { + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + }, + weight: null, + }, + { + id: 12, + iid: 2, + project_id: 2, + title: 'Consequatur autem dolor facere architecto esse et delectus.', + description: 'Corporis esse ea iste incidunt porro ex qui.', + state: 'opened', + created_at: '2018-11-05T09:21:12.702Z', + updated_at: '2018-11-05T09:22:21.414Z', + closed_at: null, + closed_by: null, + labels: [], + milestone: { + id: 6, + iid: 1, + project_id: 2, + title: 'v0.0', + description: 'Non nemo aut consequatur et recusandae.', + state: 'closed', + created_at: '2018-11-05T09:21:09.485Z', + updated_at: '2018-11-05T09:21:09.485Z', + due_date: null, + start_date: null, + web_url: 'http://test.host/gitlab-org/gitlab-shell/milestones/1', + }, + assignees: [ + { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'http://test.host/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + web_url: 'http://test.host/root', + }, + ], + author: { + id: 7, + name: 'Minta Friesen', + username: 'irene_hickle', + state: 'active', + avatar_url: 'http://test.host/avatar/9231b409b04d59788caa3f1b69cab29e?s=80\u0026d=identicon', + web_url: 'http://test.host/irene_hickle', + }, + assignee: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: 'http://test.host/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + web_url: 'http://test.host/root', + }, + user_notes_count: 7, + merge_requests_count: 0, + upvotes: 0, + downvotes: 0, + due_date: null, + confidential: false, + discussion_locked: null, + has_tasks: false, + web_url: 'http://test.host/gitlab-org/gitlab-shell/issues/2', + reference_path: '#2', + real_path: '/gitlab-org/gitlab-shell/issues/2', + time_stats: { + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + }, + weight: null, + }, + { + id: 10, + iid: 10, + project_id: 1, + title: 'Asperiores rerum a ea ipsam cum explicabo aperiam deleniti et officia.', + description: 'Aperiam eum explicabo doloribus molestias nostrum ex labore consequatur.', + state: 'opened', + created_at: '2018-11-05T09:21:12.439Z', + updated_at: '2018-11-05T09:22:20.837Z', + closed_at: null, + closed_by: null, + labels: [], + milestone: { + id: 1, + iid: 1, + project_id: 1, + title: 'v0.0', + description: 'Eum laborum aut facilis qui voluptas est.', + state: 'closed', + created_at: '2018-11-05T09:21:09.381Z', + updated_at: '2018-11-05T09:21:09.381Z', + due_date: null, + start_date: null, + web_url: 'http://test.host/gitlab-org/gitlab-test/milestones/1', + }, + assignees: [ + { + id: 2, + name: 'Wai Hamill', + username: 'felicia_tremblay', + state: 'active', + avatar_url: + 'http://test.host/avatar/b53d4d33fdc1d0f9296e3440e71d1bde?s=80\u0026d=identicon', + web_url: 'http://test.host/felicia_tremblay', + }, + ], + author: { + id: 7, + name: 'Minta Friesen', + username: 'irene_hickle', + state: 'active', + avatar_url: 'http://test.host/avatar/9231b409b04d59788caa3f1b69cab29e?s=80\u0026d=identicon', + web_url: 'http://test.host/irene_hickle', + }, + assignee: { + id: 2, + name: 'Wai Hamill', + username: 'felicia_tremblay', + state: 'active', + avatar_url: 'http://test.host/avatar/b53d4d33fdc1d0f9296e3440e71d1bde?s=80\u0026d=identicon', + web_url: 'http://test.host/felicia_tremblay', + }, + user_notes_count: 8, + merge_requests_count: 0, + upvotes: 0, + downvotes: 0, + due_date: null, + confidential: false, + discussion_locked: null, + has_tasks: false, + web_url: 'http://test.host/gitlab-org/gitlab-test/issues/10', + reference_path: '#10', + real_path: '/gitlab-org/gitlab-test/issues/10', + time_stats: { + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + }, + weight: null, + }, + { + id: 8, + iid: 8, + project_id: 1, + title: 'Nihil inventore fugiat quibusdam dignissimos aperiam nemo ea dolor consequatur.', + description: 'Maxime doloremque quasi sapiente quos ex eligendi expedita id.', + state: 'opened', + created_at: '2018-11-05T09:21:12.240Z', + updated_at: '2019-04-11T20:07:02.265Z', + closed_at: null, + closed_by: null, + labels: [ + { id: 36, name: 'To Do', color: '#F0AD4E', description: null, text_color: '#FFFFFF' }, + { id: 37, name: 'Doing', color: '#5CB85C', description: null, text_color: '#FFFFFF' }, + ], + milestone: { + id: 2, + iid: 2, + project_id: 1, + title: 'v1.0', + description: 'Ut libero impedit perferendis et voluptate eos.', + state: 'active', + created_at: '2018-11-05T09:21:09.411Z', + updated_at: '2018-11-05T09:21:09.411Z', + due_date: null, + start_date: null, + web_url: 'http://test.host/gitlab-org/gitlab-test/milestones/2', + }, + assignees: [ + { + id: 23, + name: 'User 1', + username: 'user1', + state: 'active', + avatar_url: + 'http://test.host/avatar/111d68d06e2d317b5a59c2c6c5bad808?s=80\u0026d=identicon', + web_url: 'http://test.host/user1', + }, + ], + author: { + id: 10, + name: 'George Bartell', + username: 'jaimee', + state: 'active', + avatar_url: 'http://test.host/avatar/b8b798fad1dd688ceab20efff7624a74?s=80\u0026d=identicon', + web_url: 'http://test.host/jaimee', + }, + assignee: { + id: 23, + name: 'User 1', + username: 'user1', + state: 'active', + avatar_url: 'http://test.host/avatar/111d68d06e2d317b5a59c2c6c5bad808?s=80\u0026d=identicon', + web_url: 'http://test.host/user1', + }, + user_notes_count: 8, + merge_requests_count: 0, + upvotes: 0, + downvotes: 0, + due_date: null, + confidential: false, + discussion_locked: null, + has_tasks: false, + web_url: 'http://test.host/gitlab-org/gitlab-test/issues/8', + reference_path: '#8', + real_path: '/gitlab-org/gitlab-test/issues/8', + time_stats: { + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + }, + weight: null, + }, + { + id: 7, + iid: 7, + project_id: 1, + title: 'Sit ut ab commodi aut nulla veritatis corrupti velit pariatur sint.', + description: 'Reiciendis ab assumenda consequatur magni consequatur.', + state: 'opened', + created_at: '2018-11-05T09:21:12.109Z', + updated_at: '2019-04-11T20:07:02.115Z', + closed_at: null, + closed_by: null, + labels: [ + { id: 36, name: 'To Do', color: '#F0AD4E', description: null, text_color: '#FFFFFF' }, + { id: 37, name: 'Doing', color: '#5CB85C', description: null, text_color: '#FFFFFF' }, + ], + milestone: { + id: 2, + iid: 2, + project_id: 1, + title: 'v1.0', + description: 'Ut libero impedit perferendis et voluptate eos.', + state: 'active', + created_at: '2018-11-05T09:21:09.411Z', + updated_at: '2018-11-05T09:21:09.411Z', + due_date: null, + start_date: null, + web_url: 'http://test.host/gitlab-org/gitlab-test/milestones/2', + }, + assignees: [ + { + id: 10, + name: 'George Bartell', + username: 'jaimee', + state: 'active', + avatar_url: + 'http://test.host/avatar/b8b798fad1dd688ceab20efff7624a74?s=80\u0026d=identicon', + web_url: 'http://test.host/jaimee', + }, + ], + author: { + id: 7, + name: 'Minta Friesen', + username: 'irene_hickle', + state: 'active', + avatar_url: 'http://test.host/avatar/9231b409b04d59788caa3f1b69cab29e?s=80\u0026d=identicon', + web_url: 'http://test.host/irene_hickle', + }, + assignee: { + id: 10, + name: 'George Bartell', + username: 'jaimee', + state: 'active', + avatar_url: 'http://test.host/avatar/b8b798fad1dd688ceab20efff7624a74?s=80\u0026d=identicon', + web_url: 'http://test.host/jaimee', + }, + user_notes_count: 8, + merge_requests_count: 0, + upvotes: 0, + downvotes: 0, + due_date: null, + confidential: false, + discussion_locked: null, + has_tasks: false, + web_url: 'http://test.host/gitlab-org/gitlab-test/issues/7', + reference_path: '#7', + real_path: '/gitlab-org/gitlab-test/issues/7', + time_stats: { + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + }, + weight: null, + }, + { + id: 6, + iid: 6, + project_id: 1, + title: 'Sed autem non aut consequatur odio eveniet quidem omnis ea.', + description: 'Rerum eligendi sunt qui ducimus nihil modi quia deserunt.', + state: 'opened', + created_at: '2018-11-05T09:21:11.994Z', + updated_at: '2019-04-11T20:07:01.956Z', + closed_at: null, + closed_by: null, + labels: [ + { id: 36, name: 'To Do', color: '#F0AD4E', description: null, text_color: '#FFFFFF' }, + { id: 37, name: 'Doing', color: '#5CB85C', description: null, text_color: '#FFFFFF' }, + ], + milestone: { + id: 2, + iid: 2, + project_id: 1, + title: 'v1.0', + description: 'Ut libero impedit perferendis et voluptate eos.', + state: 'active', + created_at: '2018-11-05T09:21:09.411Z', + updated_at: '2018-11-05T09:21:09.411Z', + due_date: null, + start_date: null, + web_url: 'http://test.host/gitlab-org/gitlab-test/milestones/2', + }, + assignees: [ + { + id: 23, + name: 'User 1', + username: 'user1', + state: 'active', + avatar_url: + 'http://test.host/avatar/111d68d06e2d317b5a59c2c6c5bad808?s=80\u0026d=identicon', + web_url: 'http://test.host/user1', + }, + ], + author: { + id: 23, + name: 'User 1', + username: 'user1', + state: 'active', + avatar_url: 'http://test.host/avatar/111d68d06e2d317b5a59c2c6c5bad808?s=80\u0026d=identicon', + web_url: 'http://test.host/user1', + }, + assignee: { + id: 23, + name: 'User 1', + username: 'user1', + state: 'active', + avatar_url: 'http://test.host/avatar/111d68d06e2d317b5a59c2c6c5bad808?s=80\u0026d=identicon', + web_url: 'http://test.host/user1', + }, + user_notes_count: 8, + merge_requests_count: 0, + upvotes: 0, + downvotes: 0, + due_date: null, + confidential: false, + discussion_locked: null, + has_tasks: false, + web_url: 'http://test.host/gitlab-org/gitlab-test/issues/6', + reference_path: '#6', + real_path: '/gitlab-org/gitlab-test/issues/6', + time_stats: { + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + }, + weight: null, + }, + { + id: 5, + iid: 5, + project_id: 1, + title: 'Voluptas accusamus ut officiis ut voluptatem perspiciatis in eius dicta doloribus.', + description: 'Quibusdam cupiditate qui molestias autem aut voluptatem voluptatem mollitia.', + state: 'opened', + created_at: '2018-11-05T09:21:11.877Z', + updated_at: '2019-04-11T20:07:01.823Z', + closed_at: null, + closed_by: null, + labels: [ + { id: 36, name: 'To Do', color: '#F0AD4E', description: null, text_color: '#FFFFFF' }, + { id: 37, name: 'Doing', color: '#5CB85C', description: null, text_color: '#FFFFFF' }, + ], + milestone: { + id: 4, + iid: 4, + project_id: 1, + title: 'v3.0', + description: 'Tempore voluptatem corrupti a perspiciatis autem.', + state: 'active', + created_at: '2018-11-05T09:21:09.446Z', + updated_at: '2018-11-05T09:21:09.446Z', + due_date: null, + start_date: null, + web_url: 'http://test.host/gitlab-org/gitlab-test/milestones/4', + }, + assignees: [ + { + id: 10, + name: 'George Bartell', + username: 'jaimee', + state: 'active', + avatar_url: + 'http://test.host/avatar/b8b798fad1dd688ceab20efff7624a74?s=80\u0026d=identicon', + web_url: 'http://test.host/jaimee', + }, + ], + author: { + id: 13, + name: 'Shannan Quigley', + username: 'alva_cassin', + state: 'active', + avatar_url: 'http://test.host/avatar/8456544bf25a412a9c08f7b6c4e7fc62?s=80\u0026d=identicon', + web_url: 'http://test.host/alva_cassin', + }, + assignee: { + id: 10, + name: 'George Bartell', + username: 'jaimee', + state: 'active', + avatar_url: 'http://test.host/avatar/b8b798fad1dd688ceab20efff7624a74?s=80\u0026d=identicon', + web_url: 'http://test.host/jaimee', + }, + user_notes_count: 8, + merge_requests_count: 0, + upvotes: 0, + downvotes: 0, + due_date: null, + confidential: false, + discussion_locked: null, + has_tasks: false, + web_url: 'http://test.host/gitlab-org/gitlab-test/issues/5', + reference_path: '#5', + real_path: '/gitlab-org/gitlab-test/issues/5', + time_stats: { + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + }, + weight: null, + }, + { + id: 4, + iid: 4, + project_id: 1, + title: 'Maxime ut repudiandae voluptatem libero nostrum iste aut deleniti consectetur.', + description: 'Non dolorem odio sed asperiores in.', + state: 'opened', + created_at: '2018-11-05T09:21:11.768Z', + updated_at: '2018-11-05T09:22:19.054Z', + closed_at: null, + closed_by: null, + labels: [], + milestone: { + id: 2, + iid: 2, + project_id: 1, + title: 'v1.0', + description: 'Ut libero impedit perferendis et voluptate eos.', + state: 'active', + created_at: '2018-11-05T09:21:09.411Z', + updated_at: '2018-11-05T09:21:09.411Z', + due_date: null, + start_date: null, + web_url: 'http://test.host/gitlab-org/gitlab-test/milestones/2', + }, + assignees: [ + { + id: 13, + name: 'Shannan Quigley', + username: 'alva_cassin', + state: 'active', + avatar_url: + 'http://test.host/avatar/8456544bf25a412a9c08f7b6c4e7fc62?s=80\u0026d=identicon', + web_url: 'http://test.host/alva_cassin', + }, + ], + author: { + id: 20, + name: 'Sharee Gerhold', + username: 'geri_waelchi', + state: 'active', + avatar_url: 'http://test.host/avatar/7cc8a4b30b2071494223297b9aadeaec?s=80\u0026d=identicon', + web_url: 'http://test.host/geri_waelchi', + }, + assignee: { + id: 13, + name: 'Shannan Quigley', + username: 'alva_cassin', + state: 'active', + avatar_url: 'http://test.host/avatar/8456544bf25a412a9c08f7b6c4e7fc62?s=80\u0026d=identicon', + web_url: 'http://test.host/alva_cassin', + }, + user_notes_count: 8, + merge_requests_count: 0, + upvotes: 0, + downvotes: 0, + due_date: null, + confidential: false, + discussion_locked: null, + has_tasks: false, + web_url: 'http://test.host/gitlab-org/gitlab-test/issues/4', + reference_path: '#4', + real_path: '/gitlab-org/gitlab-test/issues/4', + time_stats: { + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + }, + weight: null, + }, + { + id: 1, + iid: 1, + project_id: 1, + title: 'Corrupti unde non aperiam id eligendi aut totam sit temporibus.', + description: 'Quia quasi dicta omnis qui.', + state: 'opened', + created_at: '2018-11-05T09:21:10.842Z', + updated_at: '2019-04-14T06:52:03.837Z', + closed_at: null, + closed_by: null, + labels: [ + { + id: 38, + name: 'priority::1', + color: '#FF0000', + description: 'This is for top level issues that should we worked on first', + text_color: '#FFFFFF', + }, + { id: 36, name: 'To Do', color: '#F0AD4E', description: null, text_color: '#FFFFFF' }, + { id: 37, name: 'Doing', color: '#5CB85C', description: null, text_color: '#FFFFFF' }, + ], + milestone: { + id: 2, + iid: 2, + project_id: 1, + title: 'v1.0', + description: 'Ut libero impedit perferendis et voluptate eos.', + state: 'active', + created_at: '2018-11-05T09:21:09.411Z', + updated_at: '2018-11-05T09:21:09.411Z', + due_date: null, + start_date: null, + web_url: 'http://test.host/gitlab-org/gitlab-test/milestones/2', + }, + assignees: [ + { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'http://test.host/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + web_url: 'http://test.host/root', + }, + ], + author: { + id: 7, + name: 'Minta Friesen', + username: 'irene_hickle', + state: 'active', + avatar_url: 'http://test.host/avatar/9231b409b04d59788caa3f1b69cab29e?s=80\u0026d=identicon', + web_url: 'http://test.host/irene_hickle', + }, + assignee: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: 'http://test.host/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + web_url: 'http://test.host/root', + }, + user_notes_count: 8, + merge_requests_count: 0, + upvotes: 0, + downvotes: 0, + due_date: null, + confidential: false, + discussion_locked: null, + has_tasks: false, + web_url: 'http://test.host/gitlab-org/gitlab-test/issues/1', + reference_path: '#1', + real_path: '/gitlab-org/gitlab-test/issues/1', + time_stats: { + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + }, + weight: null, + }, + { + id: 152, + iid: 22, + project_id: 2, + title: 'Quas saepe laboriosam cupiditate incidunt eos occaecati.', + description: 'Quo magni blanditiis et sunt omnis dolorem enim ipsa.', + state: 'opened', + created_at: '2018-10-26T09:25:35.917Z', + updated_at: '2019-01-16T12:36:49.591Z', + closed_at: null, + closed_by: null, + labels: [], + milestone: { + id: 44, + iid: 6, + project_id: 2, + title: 'Sprint - Animi necessitatibus sit laboriosam sequi eum voluptates id illum.', + description: 'Architecto quod animi officia quis quas minus quidem saepe.', + state: 'active', + created_at: '2018-10-26T09:25:34.584Z', + updated_at: '2018-10-26T09:25:34.584Z', + due_date: '2018-11-03', + start_date: '2018-10-26', + web_url: 'http://test.host/gitlab-org/gitlab-shell/milestones/6', + }, + assignees: [ + { + id: 20, + name: 'Sharee Gerhold', + username: 'geri_waelchi', + state: 'active', + avatar_url: + 'http://test.host/avatar/7cc8a4b30b2071494223297b9aadeaec?s=80\u0026d=identicon', + web_url: 'http://test.host/geri_waelchi', + }, + ], + author: { + id: 10, + name: 'George Bartell', + username: 'jaimee', + state: 'active', + avatar_url: 'http://test.host/avatar/b8b798fad1dd688ceab20efff7624a74?s=80\u0026d=identicon', + web_url: 'http://test.host/jaimee', + }, + assignee: { + id: 20, + name: 'Sharee Gerhold', + username: 'geri_waelchi', + state: 'active', + avatar_url: 'http://test.host/avatar/7cc8a4b30b2071494223297b9aadeaec?s=80\u0026d=identicon', + web_url: 'http://test.host/geri_waelchi', + }, + user_notes_count: 0, + merge_requests_count: 0, + upvotes: 0, + downvotes: 0, + due_date: null, + confidential: false, + discussion_locked: null, + has_tasks: false, + web_url: 'http://test.host/gitlab-org/gitlab-shell/issues/22', + reference_path: '#22', + real_path: '/gitlab-org/gitlab-shell/issues/22', + time_stats: { + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + }, + weight: null, + }, + { + id: 151, + iid: 21, + project_id: 2, + title: 'Ad dolorem nobis et nulla tempore qui itaque provident eos et.', + description: 'Laboriosam enim et ipsam explicabo.', + state: 'opened', + created_at: '2018-10-26T09:25:35.807Z', + updated_at: '2019-01-16T12:36:49.644Z', + closed_at: null, + closed_by: null, + labels: [], + milestone: { + id: 44, + iid: 6, + project_id: 2, + title: 'Sprint - Animi necessitatibus sit laboriosam sequi eum voluptates id illum.', + description: 'Architecto quod animi officia quis quas minus quidem saepe.', + state: 'active', + created_at: '2018-10-26T09:25:34.584Z', + updated_at: '2018-10-26T09:25:34.584Z', + due_date: '2018-11-03', + start_date: '2018-10-26', + web_url: 'http://test.host/gitlab-org/gitlab-shell/milestones/6', + }, + assignees: [ + { + id: 10, + name: 'George Bartell', + username: 'jaimee', + state: 'active', + avatar_url: + 'http://test.host/avatar/b8b798fad1dd688ceab20efff7624a74?s=80\u0026d=identicon', + web_url: 'http://test.host/jaimee', + }, + ], + author: { + id: 2, + name: 'Wai Hamill', + username: 'felicia_tremblay', + state: 'active', + avatar_url: 'http://test.host/avatar/b53d4d33fdc1d0f9296e3440e71d1bde?s=80\u0026d=identicon', + web_url: 'http://test.host/felicia_tremblay', + }, + assignee: { + id: 10, + name: 'George Bartell', + username: 'jaimee', + state: 'active', + avatar_url: 'http://test.host/avatar/b8b798fad1dd688ceab20efff7624a74?s=80\u0026d=identicon', + web_url: 'http://test.host/jaimee', + }, + user_notes_count: 0, + merge_requests_count: 0, + upvotes: 0, + downvotes: 0, + due_date: null, + confidential: false, + discussion_locked: null, + has_tasks: false, + web_url: 'http://test.host/gitlab-org/gitlab-shell/issues/21', + reference_path: '#21', + real_path: '/gitlab-org/gitlab-shell/issues/21', + time_stats: { + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + }, + weight: null, + }, + { + id: 119, + iid: 29, + project_id: 1, + title: 'Eaque minima culpa illo explicabo porro aut.', + description: 'Accusantium necessitatibus molestiae et quia animi.', + state: 'opened', + created_at: '2018-10-26T09:25:27.248Z', + updated_at: '2018-10-26T09:25:27.248Z', + closed_at: null, + closed_by: null, + labels: [], + milestone: { + id: 42, + iid: 6, + project_id: 1, + title: 'Sprint - Delectus quasi accusamus dicta exercitationem.', + description: 'Corrupti recusandae in molestiae porro quae qui.', + state: 'active', + created_at: '2018-10-26T09:25:25.177Z', + updated_at: '2018-10-26T09:25:25.177Z', + due_date: '2018-11-03', + start_date: '2018-10-26', + web_url: 'http://test.host/gitlab-org/gitlab-test/milestones/6', + }, + assignees: [ + { + id: 2, + name: 'Wai Hamill', + username: 'felicia_tremblay', + state: 'active', + avatar_url: + 'http://test.host/avatar/b53d4d33fdc1d0f9296e3440e71d1bde?s=80\u0026d=identicon', + web_url: 'http://test.host/felicia_tremblay', + }, + ], + author: { + id: 2, + name: 'Wai Hamill', + username: 'felicia_tremblay', + state: 'active', + avatar_url: 'http://test.host/avatar/b53d4d33fdc1d0f9296e3440e71d1bde?s=80\u0026d=identicon', + web_url: 'http://test.host/felicia_tremblay', + }, + assignee: { + id: 2, + name: 'Wai Hamill', + username: 'felicia_tremblay', + state: 'active', + avatar_url: 'http://test.host/avatar/b53d4d33fdc1d0f9296e3440e71d1bde?s=80\u0026d=identicon', + web_url: 'http://test.host/felicia_tremblay', + }, + user_notes_count: 0, + merge_requests_count: 0, + upvotes: 0, + downvotes: 0, + due_date: null, + confidential: false, + discussion_locked: null, + has_tasks: false, + web_url: 'http://test.host/gitlab-org/gitlab-test/issues/29', + reference_path: '#29', + real_path: '/gitlab-org/gitlab-test/issues/29', + time_stats: { + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + }, + weight: null, + }, +]; + +export const singleIssueData = { + id: 389, + iid: 1, + project_id: 15, + title: 'Add Content', + description: 'Add content to this repo', + state: 'opened', + created_at: '2019-04-06T11:06:27.510Z', + updated_at: '2019-04-06T11:06:27.510Z', + closed_at: null, + closed_by: null, + labels: [], + milestone: null, + assignees: [ + { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: 'http://test.host/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + web_url: 'http://test.host/root', + }, + ], + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: 'http://test.host/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + web_url: 'http://test.host/root', + }, + assignee: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: 'http://test.host/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + web_url: 'http://test.host/root', + }, + user_notes_count: 0, + merge_requests_count: 0, + upvotes: 0, + downvotes: 0, + due_date: '2019-07-25', + confidential: true, + discussion_locked: null, + has_tasks: false, + web_url: 'http://test.host/gitlab-org/awesome-jekyll/issues/1', + reference_path: '#1', + real_path: '/gitlab-org/awesome-jekyll/issues/1', + time_stats: { + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + }, + weight: 20, +}; diff --git a/spec/frontend/issues/stores/modules/issues_list/actions_spec.js b/spec/frontend/issues/stores/modules/issues_list/actions_spec.js new file mode 100644 index 00000000000..9c5cee9d955 --- /dev/null +++ b/spec/frontend/issues/stores/modules/issues_list/actions_spec.js @@ -0,0 +1,111 @@ +import MockAdapter from 'axios-mock-adapter'; +import statusCodes from '~/lib/utils/http_status'; +import axios from '~/lib/utils/axios_utils'; +import * as issuesActions from '~/issues/stores/modules/issues_list/actions'; +import * as types from '~/issues/stores/modules/issues_list/mutation_types'; +import testAction from '../../../../helpers/vuex_action_helper'; +import { issuesResponseData } from '../../../mock_data'; +import { setWindowLocation } from '../../../../helpers/url_util_helper'; + +describe('Issues List Actions', () => { + it('Should set filter value', done => { + const issueFilter = 'hello=world'; + + testAction( + issuesActions.setFilters, + issueFilter, + {}, + [{ type: types.SET_FILTERS, payload: issueFilter }], + [], + done, + ); + }); + + it('Should set loading state', done => { + const loadingState = 'loading'; + + testAction( + issuesActions.setLoadingState, + loadingState, + {}, + [{ type: types.SET_LOADING_STATE, payload: loadingState }], + [], + done, + ); + }); + + it('Should set bulk update state', done => { + const bulkUpdateState = 'updating'; + + testAction( + issuesActions.setBulkUpdateState, + bulkUpdateState, + {}, + [{ type: types.SET_BULK_UPDATE_STATE, payload: bulkUpdateState }], + [], + done, + ); + }); + + it('Should set current page', done => { + const currentPage = 1; + + testAction( + issuesActions.setCurrentPage, + currentPage, + {}, + [{ type: types.SET_CURRENT_PAGE, payload: currentPage }], + [], + done, + ); + }); + + it('Should set total Items', done => { + const totalItems = 200; + + testAction( + issuesActions.setTotalItems, + totalItems, + {}, + [{ type: types.SET_TOTAL_ITEMS, payload: totalItems }], + [], + done, + ); + }); + + it('should fetch issues', done => { + const totalIssues = 1000; + const currentPage = 1; + const issuesEndpoint = '/issues'; + const appliedFilters = 'scope=all&utf8=%E2%9C%93&state=opened&page=2'; + const mock = new MockAdapter(axios); + const { search } = window.location; + + mock.onGet(issuesEndpoint).reply(statusCodes.OK, JSON.stringify({ ...issuesResponseData }), { + 'x-total': totalIssues, + 'x-page': currentPage, + }); + + setWindowLocation({ + search: `?${appliedFilters}`, + }); + + testAction( + issuesActions.fetchIssues, + issuesEndpoint, + { appliedFilters }, + [{ type: types.SET_ISSUES_DATA, payload: { ...issuesResponseData } }], + [ + { type: 'setLoadingState', payload: true }, + { type: 'setTotalItems', payload: totalIssues }, + { type: 'setCurrentPage', payload: currentPage }, + { type: 'setLoadingState', payload: false }, + ], + () => { + mock.restore(); + window.location.search = search; + done(); + }, + ); + }); +}); diff --git a/spec/frontend/issues/stores/modules/issues_list/getters_spec.js b/spec/frontend/issues/stores/modules/issues_list/getters_spec.js new file mode 100644 index 00000000000..f68615fdbe8 --- /dev/null +++ b/spec/frontend/issues/stores/modules/issues_list/getters_spec.js @@ -0,0 +1,27 @@ +import state from '~/issues/stores/modules/issues_list/state'; +import { hasFilters } from '~/issues/stores/modules/issues_list/getters'; + +describe('Issue List Getters', () => { + let mockedState; + + beforeEach(() => { + mockedState = state(); + }); + + describe('hasFilters', () => { + it('should return "true" if current filters match issues filtered search tokens', () => { + mockedState.filters = '?state=opened&label_name[]=Doing'; + expect(hasFilters(mockedState)).toEqual(true); + }); + + it('should return "false" if current filters dont match issues filtered search tokens', () => { + mockedState.filters = '?scope=all&utf8=✓&state=opened'; + expect(hasFilters(mockedState)).toEqual(false); + }); + + it('should return "false" if there are no filters', () => { + mockedState.filters = ''; + expect(hasFilters(mockedState)).toEqual(false); + }); + }); +}); diff --git a/spec/frontend/issues/stores/modules/issues_list/mutations_spec.js b/spec/frontend/issues/stores/modules/issues_list/mutations_spec.js new file mode 100644 index 00000000000..15f9b07d1b1 --- /dev/null +++ b/spec/frontend/issues/stores/modules/issues_list/mutations_spec.js @@ -0,0 +1,66 @@ +import state from '~/issues/stores/modules/issues_list/state'; +import mutations from '~/issues/stores/modules/issues_list/mutations'; +import * as types from '~/issues/stores/modules/issues_list/mutation_types'; + +describe('Issues List Mutataions', () => { + let mockedState; + + beforeEach(() => { + mockedState = state(); + }); + + describe('SET_LOADING_STATE', () => { + it('sets the current loading state', () => { + mockedState.loading = false; + mutations[types.SET_LOADING_STATE](mockedState, true); + + expect(mockedState.loading).toEqual(true); + }); + }); + + describe('SET_FILTERS', () => { + it('sets the current filters', () => { + const mokedFilter = '?hello=worls'; + mockedState.filters = 'none'; + mutations[types.SET_FILTERS](mockedState, mokedFilter); + + expect(mockedState.filters).toEqual(mokedFilter); + }); + }); + + describe('SET_BULK_UPDATE_STATE', () => { + it('updates bulk update status', () => { + mockedState.isBulkUpdating = false; + mutations[types.SET_BULK_UPDATE_STATE](mockedState, true); + + expect(mockedState.isBulkUpdating).toEqual(true); + }); + }); + + describe('SET_TOTAL_ITEMS', () => { + it('sets the count of issues for pagination', () => { + mockedState.totalItems = 0; + mutations[types.SET_TOTAL_ITEMS](mockedState, '10'); + + expect(mockedState.totalItems).toEqual(10); + }); + }); + + describe('SET_CURRENT_PAGE', () => { + it('sets the current page value', () => { + mockedState.currentPage = 0; + mutations[types.SET_CURRENT_PAGE](mockedState, '1'); + + expect(mockedState.currentPage).toEqual(1); + }); + }); + + describe('SET_ISSUES_DATA', () => { + it('sets the current page value', () => { + mockedState.issues = null; + mutations[types.SET_ISSUES_DATA](mockedState, []); + + expect(mockedState.issues).toEqual([]); + }); + }); +}); diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index a986bc49f28..dbab2822a0e 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -1,11 +1,5 @@ import * as urlUtils from '~/lib/utils/url_utility'; - -const setWindowLocation = value => { - Object.defineProperty(window, 'location', { - writable: true, - value, - }); -}; +import { setWindowLocation } from '../../helpers/url_util_helper'; describe('URL utility', () => { describe('webIDEUrl', () => { |