diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue index 348dc054f576a983a31995aeb859d59d3a5bc93f..20d1dce3905bd8bf59cf9b459c5a536ae9a20e89 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_item.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue @@ -50,6 +50,9 @@ export default { }, }, computed: { + issuableId() { + return getIdFromGraphQLId(this.issuable.id); + }, createdInPastDay() { const createdSecondsAgo = differenceInSeconds(new Date(this.issuable.createdAt), new Date()); return createdSecondsAgo < SECONDS_IN_DAY; @@ -61,7 +64,7 @@ export default { return this.issuable.gitlabWebUrl || this.issuable.webUrl; }, authorId() { - return getIdFromGraphQLId(`${this.author.id}`); + return getIdFromGraphQLId(this.author.id); }, isIssuableUrlExternal() { return isExternal(this.webUrl); @@ -70,10 +73,10 @@ export default { return this.issuable.labels?.nodes || this.issuable.labels || []; }, labelIdsString() { - return JSON.stringify(this.labels.map((label) => label.id)); + return JSON.stringify(this.labels.map((label) => getIdFromGraphQLId(label.id))); }, assignees() { - return this.issuable.assignees || []; + return this.issuable.assignees?.nodes || this.issuable.assignees || []; }, createdAt() { return sprintf(__('created %{timeAgo}'), { @@ -157,7 +160,7 @@ export default { <template> <li - :id="`issuable_${issuable.id}`" + :id="`issuable_${issuableId}`" class="issue gl-px-5!" :class="{ closed: issuable.closedAt, today: createdInPastDay }" :data-labels="labelIdsString" @@ -167,7 +170,7 @@ export default { <gl-form-checkbox class="gl-mr-0" :checked="checked" - :data-id="issuable.id" + :data-id="issuableId" @input="$emit('checked-input', $event)" > <span class="gl-sr-only">{{ issuable.title }}</span> diff --git a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue index 45584205be0237281cdd11681565b1b8678d2205..a19c76cfe3f6d0f45309f9ed31e9caa049b6e4fc 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue @@ -1,7 +1,7 @@ <script> -import { GlSkeletonLoading, GlPagination } from '@gitlab/ui'; +import { GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui'; import { uniqueId } from 'lodash'; - +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; @@ -19,6 +19,7 @@ export default { tag: 'ul', }, components: { + GlKeysetPagination, GlSkeletonLoading, IssuableTabs, FilteredSearchBar, @@ -140,6 +141,21 @@ export default { required: false, default: false, }, + useKeysetPagination: { + type: Boolean, + required: false, + default: false, + }, + hasNextPage: { + type: Boolean, + required: false, + default: false, + }, + hasPreviousPage: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -211,7 +227,7 @@ export default { }, methods: { issuableId(issuable) { - return issuable.id || issuable.iid || uniqueId(); + return getIdFromGraphQLId(issuable.id) || issuable.iid || uniqueId(); }, issuableChecked(issuable) { return this.checkedIssuables[this.issuableId(issuable)]?.checked; @@ -315,8 +331,16 @@ export default { <slot v-else name="empty-state"></slot> </template> + <div v-if="showPaginationControls && useKeysetPagination" class="gl-text-center gl-mt-3"> + <gl-keyset-pagination + :has-next-page="hasNextPage" + :has-previous-page="hasPreviousPage" + @next="$emit('next-page')" + @prev="$emit('previous-page')" + /> + </div> <gl-pagination - v-if="showPaginationControls" + v-else-if="showPaginationControls" :per-page="defaultPageSize" :total-items="totalItems" :value="currentPage" diff --git a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue b/app/assets/javascripts/issues_list/components/issue_card_time_info.vue index 8d00d337baca333a57c0a816f19d80095f6defe8..70d73aca9257d3416a67f216f45e7502bb916613 100644 --- a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue +++ b/app/assets/javascripts/issues_list/components/issue_card_time_info.vue @@ -42,6 +42,9 @@ export default { } return __('Milestone'); }, + milestoneLink() { + return this.issue.milestone.webPath || this.issue.milestone.webUrl; + }, dueDate() { return this.issue.dueDate && dateInWords(new Date(this.issue.dueDate), true); }, @@ -49,7 +52,7 @@ export default { return isInPast(new Date(this.issue.dueDate)); }, timeEstimate() { - return this.issue.timeStats?.humanTimeEstimate; + return this.issue.humanTimeEstimate || this.issue.timeStats?.humanTimeEstimate; }, showHealthStatus() { return this.hasIssuableHealthStatusFeature && this.issue.healthStatus; @@ -85,7 +88,7 @@ export default { class="issuable-milestone gl-display-none gl-sm-display-inline-block! gl-mr-3" data-testid="issuable-milestone" > - <gl-link v-gl-tooltip :href="issue.milestone.webUrl" :title="milestoneDate"> + <gl-link v-gl-tooltip :href="milestoneLink" :title="milestoneDate"> <gl-icon name="clock" /> {{ issue.milestone.title }} </gl-link> diff --git a/app/assets/javascripts/issues_list/components/issues_list_app.vue b/app/assets/javascripts/issues_list/components/issues_list_app.vue index d5cab77f26c6dc0f0211b94fa10bc5bd4123b230..dbf7717b248ad8cf63fe84768e65b7d162292266 100644 --- a/app/assets/javascripts/issues_list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue @@ -9,7 +9,7 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; -import { toNumber } from 'lodash'; +import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql'; import createFlash from '~/flash'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import IssuableByEmail from '~/issuable/components/issuable_by_email.vue'; @@ -17,13 +17,12 @@ import IssuableList from '~/issuable_list/components/issuable_list_root.vue'; import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants'; import { API_PARAM, - apiSortParams, CREATED_DESC, i18n, + initialPageParams, MAX_LIST_SIZE, PAGE_SIZE, PARAM_DUE_DATE, - PARAM_PAGE, PARAM_SORT, PARAM_STATE, RELATIVE_POSITION_DESC, @@ -49,7 +48,8 @@ import { getSortOptions, } from '~/issues_list/utils'; import axios from '~/lib/utils/axios_utils'; -import { convertObjectPropsToCamelCase, getParameterByName } from '~/lib/utils/common_utils'; +import { getParameterByName } from '~/lib/utils/common_utils'; +import { scrollUp } from '~/lib/utils/scroll_utils'; import { DEFAULT_NONE_ANY, OPERATOR_IS_ONLY, @@ -107,9 +107,6 @@ export default { emptyStateSvgPath: { default: '', }, - endpoint: { - default: '', - }, exportCsvPath: { default: '', }, @@ -173,15 +170,43 @@ export default { dueDateFilter: getDueDateValue(getParameterByName(PARAM_DUE_DATE)), exportCsvPathWithQuery: this.getExportCsvPathWithQuery(), filterTokens: getFilterTokens(window.location.search), - isLoading: false, issues: [], - page: toNumber(getParameterByName(PARAM_PAGE)) || 1, + pageInfo: {}, + pageParams: initialPageParams, showBulkEditSidebar: false, sortKey: getSortKey(getParameterByName(PARAM_SORT)) || defaultSortKey, state: state || IssuableStates.Opened, totalIssues: 0, }; }, + apollo: { + issues: { + query: getIssuesQuery, + variables() { + return { + projectPath: this.projectPath, + search: this.searchQuery, + sort: this.sortKey, + state: this.state, + ...this.pageParams, + ...this.apiFilterParams, + }; + }, + update: ({ project }) => project.issues.nodes, + result({ data }) { + this.pageInfo = data.project.issues.pageInfo; + this.totalIssues = data.project.issues.count; + this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); + }, + error(error) { + createFlash({ message: this.$options.i18n.errorFetchingIssues, captureError: true, error }); + }, + skip() { + return !this.hasProjectIssues; + }, + debounce: 200, + }, + }, computed: { hasSearch() { return this.searchQuery || Object.keys(this.urlFilterParams).length; @@ -348,7 +373,6 @@ export default { return { due_date: this.dueDateFilter, - page: this.page, search: this.searchQuery, state: this.state, ...urlSortParams[this.sortKey], @@ -361,7 +385,6 @@ export default { }, mounted() { eventHub.$on('issuables:toggleBulkEdit', this.toggleBulkEditSidebar); - this.fetchIssues(); }, beforeDestroy() { eventHub.$off('issuables:toggleBulkEdit', this.toggleBulkEditSidebar); @@ -406,54 +429,11 @@ export default { fetchUsers(search) { return axios.get(this.autocompleteUsersPath, { params: { search } }); }, - fetchIssues() { - if (!this.hasProjectIssues) { - return undefined; - } - - this.isLoading = true; - - const filterParams = { - ...this.apiFilterParams, - }; - - if (filterParams.epic_id) { - filterParams.epic_id = filterParams.epic_id.split('::&').pop(); - } else if (filterParams['not[epic_id]']) { - filterParams['not[epic_id]'] = filterParams['not[epic_id]'].split('::&').pop(); - } - - return axios - .get(this.endpoint, { - params: { - due_date: this.dueDateFilter, - page: this.page, - per_page: PAGE_SIZE, - search: this.searchQuery, - state: this.state, - with_labels_details: true, - ...apiSortParams[this.sortKey], - ...filterParams, - }, - }) - .then(({ data, headers }) => { - this.page = Number(headers['x-page']); - this.totalIssues = Number(headers['x-total']); - this.issues = data.map((issue) => convertObjectPropsToCamelCase(issue, { deep: true })); - this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); - }) - .catch(() => { - createFlash({ message: this.$options.i18n.errorFetchingIssues }); - }) - .finally(() => { - this.isLoading = false; - }); - }, getExportCsvPathWithQuery() { return `${this.exportCsvPath}${window.location.search}`; }, getStatus(issue) { - if (issue.closedAt && issue.movedToId) { + if (issue.closedAt && issue.moved) { return this.$options.i18n.closedMoved; } if (issue.closedAt) { @@ -484,18 +464,26 @@ export default { }, handleClickTab(state) { if (this.state !== state) { - this.page = 1; + this.pageParams = initialPageParams; } this.state = state; - this.fetchIssues(); }, handleFilter(filter) { this.filterTokens = filter; - this.fetchIssues(); }, - handlePageChange(page) { - this.page = page; - this.fetchIssues(); + handleNextPage() { + this.pageParams = { + afterCursor: this.pageInfo.endCursor, + firstPageSize: PAGE_SIZE, + }; + scrollUp(); + }, + handlePreviousPage() { + this.pageParams = { + beforeCursor: this.pageInfo.startCursor, + lastPageSize: PAGE_SIZE, + }; + scrollUp(); }, handleReorder({ newIndex, oldIndex }) { const issueToMove = this.issues[oldIndex]; @@ -530,9 +518,11 @@ export default { createFlash({ message: this.$options.i18n.reorderError }); }); }, - handleSort(value) { - this.sortKey = value; - this.fetchIssues(); + handleSort(sortKey) { + if (this.sortKey !== sortKey) { + this.pageParams = initialPageParams; + } + this.sortKey = sortKey; }, toggleBulkEditSidebar(showBulkEditSidebar) { this.showBulkEditSidebar = showBulkEditSidebar; @@ -556,18 +546,18 @@ export default { :tabs="$options.IssuableListTabs" :current-tab="state" :tab-counts="tabCounts" - :issuables-loading="isLoading" + :issuables-loading="$apollo.queries.issues.loading" :is-manual-ordering="isManualOrdering" :show-bulk-edit-sidebar="showBulkEditSidebar" :show-pagination-controls="showPaginationControls" - :total-items="totalIssues" - :current-page="page" - :previous-page="page - 1" - :next-page="page + 1" + :use-keyset-pagination="true" + :has-next-page="pageInfo.hasNextPage" + :has-previous-page="pageInfo.hasPreviousPage" :url-params="urlParams" @click-tab="handleClickTab" @filter="handleFilter" - @page-change="handlePageChange" + @next-page="handleNextPage" + @previous-page="handlePreviousPage" @reorder="handleReorder" @sort="handleSort" @update-legacy-bulk-edit="handleUpdateLegacyBulkEdit" @@ -646,7 +636,7 @@ export default { </li> <blocking-issues-count class="gl-display-none gl-sm-display-block" - :blocking-issues-count="issuable.blockingIssuesCount" + :blocking-issues-count="issuable.blockedByCount" :is-list-item="true" /> </template> diff --git a/app/assets/javascripts/issues_list/constants.js b/app/assets/javascripts/issues_list/constants.js index 06e140d642037c3db4fc3ea202030a42ec54c349..76006f9081dc3d33d93b011808a2bf413ec1af5d 100644 --- a/app/assets/javascripts/issues_list/constants.js +++ b/app/assets/javascripts/issues_list/constants.js @@ -101,10 +101,13 @@ export const i18n = { export const JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY = 'jira-import-success-alert-hide-map'; export const PARAM_DUE_DATE = 'due_date'; -export const PARAM_PAGE = 'page'; export const PARAM_SORT = 'sort'; export const PARAM_STATE = 'state'; +export const initialPageParams = { + firstPageSize: PAGE_SIZE, +}; + export const DUE_DATE_NONE = '0'; export const DUE_DATE_ANY = ''; export const DUE_DATE_OVERDUE = 'overdue'; diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js index d0c9462a3d75677e6850990af8d3bb0f965497bb..97b9a9a115de92f8d0561bd8500af224f7c44439 100644 --- a/app/assets/javascripts/issues_list/index.js +++ b/app/assets/javascripts/issues_list/index.js @@ -73,6 +73,13 @@ export function mountIssuesListApp() { return false; } + Vue.use(VueApollo); + + const defaultClient = createDefaultClient({}, { assumeImmutableResults: true }); + const apolloProvider = new VueApollo({ + defaultClient, + }); + const { autocompleteAwardEmojisPath, autocompleteUsersPath, @@ -83,7 +90,6 @@ export function mountIssuesListApp() { email, emailsHelpPagePath, emptyStateSvgPath, - endpoint, exportCsvPath, groupEpicsPath, hasBlockedIssuesFeature, @@ -113,16 +119,13 @@ export function mountIssuesListApp() { return new Vue({ el, - // Currently does not use Vue Apollo, but need to provide {} for now until the - // issue is fixed upstream in https://github.com/vuejs/vue-apollo/pull/1153 - apolloProvider: {}, + apolloProvider, provide: { autocompleteAwardEmojisPath, autocompleteUsersPath, calendarPath, canBulkUpdate: parseBoolean(canBulkUpdate), emptyStateSvgPath, - endpoint, groupEpicsPath, hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature), hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), diff --git a/app/assets/javascripts/issues_list/queries/get_issues.query.graphql b/app/assets/javascripts/issues_list/queries/get_issues.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..afd53084ca04e1f34c2f56812df6507db5468acf --- /dev/null +++ b/app/assets/javascripts/issues_list/queries/get_issues.query.graphql @@ -0,0 +1,45 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "./issue.fragment.graphql" + +query getProjectIssues( + $projectPath: ID! + $search: String + $sort: IssueSort + $state: IssuableState + $assigneeId: String + $assigneeUsernames: [String!] + $authorUsername: String + $labelName: [String] + $milestoneTitle: [String] + $not: NegatedIssueFilterInput + $beforeCursor: String + $afterCursor: String + $firstPageSize: Int + $lastPageSize: Int +) { + project(fullPath: $projectPath) { + issues( + search: $search + sort: $sort + state: $state + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + labelName: $labelName + milestoneTitle: $milestoneTitle + not: $not + before: $beforeCursor + after: $afterCursor + first: $firstPageSize + last: $lastPageSize + ) { + count + pageInfo { + ...PageInfo + } + nodes { + ...IssueFragment + } + } + } +} diff --git a/app/assets/javascripts/issues_list/queries/issue.fragment.graphql b/app/assets/javascripts/issues_list/queries/issue.fragment.graphql new file mode 100644 index 0000000000000000000000000000000000000000..de30d8b4bf669ca369b2dc711a16f9133ec6398d --- /dev/null +++ b/app/assets/javascripts/issues_list/queries/issue.fragment.graphql @@ -0,0 +1,51 @@ +fragment IssueFragment on Issue { + id + iid + closedAt + confidential + createdAt + downvotes + dueDate + humanTimeEstimate + moved + title + updatedAt + upvotes + userDiscussionsCount + webUrl + assignees { + nodes { + id + avatarUrl + name + username + webUrl + } + } + author { + id + avatarUrl + name + username + webUrl + } + labels { + nodes { + id + color + title + description + } + } + milestone { + id + dueDate + startDate + webPath + title + } + taskCompletionStatus { + completedCount + count + } +} diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 91920277c509ae9823500c3f6589b60f04ca758c..7690773354fe12c490a073a1d615a77ddce43c5d 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -190,7 +190,6 @@ def issues_list_data(project, current_user, finder) email: current_user&.notification_email, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), empty_state_svg_path: image_path('illustrations/issues.svg'), - endpoint: expose_path(api_v4_projects_issues_path(id: project.id)), export_csv_path: export_csv_project_issues_path(project), has_project_issues: project_issues(project).exists?.to_s, import_csv_issues_path: import_csv_namespace_project_issues_path, diff --git a/ee/app/assets/javascripts/issues_list/queries/get_issues.query.graphql b/ee/app/assets/javascripts/issues_list/queries/get_issues.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..d61fbd1c75ff72a88e2f653c47de059ee2528e1f --- /dev/null +++ b/ee/app/assets/javascripts/issues_list/queries/get_issues.query.graphql @@ -0,0 +1,56 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "~/issues_list/queries/issue.fragment.graphql" + +query getProjectIssues( + $projectPath: ID! + $search: String + $sort: IssueSort + $state: IssuableState + $assigneeId: String + $assigneeUsernames: [String!] + $authorUsername: String + $labelName: [String] + $milestoneTitle: [String] + $epicId: String + $iterationId: [ID] + $iterationWildcardId: IterationWildcardId + $weight: String + $not: NegatedIssueFilterInput + $beforeCursor: String + $afterCursor: String + $firstPageSize: Int + $lastPageSize: Int +) { + project(fullPath: $projectPath) { + issues( + search: $search + sort: $sort + state: $state + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + labelName: $labelName + milestoneTitle: $milestoneTitle + epicId: $epicId + iterationId: $iterationId + iterationWildcardId: $iterationWildcardId + weight: $weight + not: $not + before: $beforeCursor + after: $afterCursor + first: $firstPageSize + last: $lastPageSize + ) { + count + pageInfo { + ...PageInfo + } + nodes { + ...IssueFragment + blockedByCount + healthStatus + weight + } + } + } +} diff --git a/ee/spec/frontend/integrations/jira/issues_list/components/__snapshots__/jira_issues_list_root_spec.js.snap b/ee/spec/frontend/integrations/jira/issues_list/components/__snapshots__/jira_issues_list_root_spec.js.snap index ff91d7ec30d0889e90357f11e100282c5d340a35..8ef1102e3368832cc5232b5b14521dd4249a5590 100644 --- a/ee/spec/frontend/integrations/jira/issues_list/components/__snapshots__/jira_issues_list_root_spec.js.snap +++ b/ee/spec/frontend/integrations/jira/issues_list/components/__snapshots__/jira_issues_list_root_spec.js.snap @@ -6,6 +6,8 @@ Object { "currentTab": "opened", "defaultPageSize": 2, "enableLabelPermalinks": true, + "hasNextPage": false, + "hasPreviousPage": false, "initialFilterValue": Array [ Object { "type": "filtered-search-term", @@ -75,5 +77,6 @@ Object { "sort": "created_desc", "state": "opened", }, + "useKeysetPagination": false, } `; diff --git a/spec/frontend/issuable_list/components/issuable_list_root_spec.js b/spec/frontend/issuable_list/components/issuable_list_root_spec.js index 38d6d6d86bc035d4edd2dd9746d9672a862b483b..7dddd2c3405bc8581b4a91f7cfa68f35085abc23 100644 --- a/spec/frontend/issuable_list/components/issuable_list_root_spec.js +++ b/spec/frontend/issuable_list/components/issuable_list_root_spec.js @@ -1,4 +1,4 @@ -import { GlSkeletonLoading, GlPagination } from '@gitlab/ui'; +import { GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import VueDraggable from 'vuedraggable'; @@ -11,9 +11,12 @@ import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filte import { mockIssuableListProps, mockIssuables } from '../mock_data'; -const createComponent = ({ props = mockIssuableListProps, data = {} } = {}) => +const createComponent = ({ props = {}, data = {} } = {}) => shallowMount(IssuableListRoot, { - propsData: props, + propsData: { + ...mockIssuableListProps, + ...props, + }, data() { return data; }, @@ -34,6 +37,7 @@ describe('IssuableListRoot', () => { let wrapper; const findFilteredSearchBar = () => wrapper.findComponent(FilteredSearchBar); + const findGlKeysetPagination = () => wrapper.findComponent(GlKeysetPagination); const findGlPagination = () => wrapper.findComponent(GlPagination); const findIssuableItem = () => wrapper.findComponent(IssuableItem); const findIssuableTabs = () => wrapper.findComponent(IssuableTabs); @@ -189,15 +193,15 @@ describe('IssuableListRoot', () => { }); describe('template', () => { - beforeEach(() => { + it('renders component container element with class "issuable-list-container"', () => { wrapper = createComponent(); - }); - it('renders component container element with class "issuable-list-container"', () => { expect(wrapper.classes()).toContain('issuable-list-container'); }); it('renders issuable-tabs component', () => { + wrapper = createComponent(); + const tabsEl = findIssuableTabs(); expect(tabsEl.exists()).toBe(true); @@ -209,6 +213,8 @@ describe('IssuableListRoot', () => { }); it('renders contents for slot "nav-actions" within issuable-tab component', () => { + wrapper = createComponent(); + const buttonEl = findIssuableTabs().find('button.js-new-issuable'); expect(buttonEl.exists()).toBe(true); @@ -216,6 +222,8 @@ describe('IssuableListRoot', () => { }); it('renders filtered-search-bar component', () => { + wrapper = createComponent(); + const searchEl = findFilteredSearchBar(); const { namespace, @@ -239,12 +247,8 @@ describe('IssuableListRoot', () => { }); }); - it('renders gl-loading-icon when `issuablesLoading` prop is true', async () => { - wrapper.setProps({ - issuablesLoading: true, - }); - - await wrapper.vm.$nextTick(); + it('renders gl-loading-icon when `issuablesLoading` prop is true', () => { + wrapper = createComponent({ props: { issuablesLoading: true } }); expect(wrapper.findAllComponents(GlSkeletonLoading)).toHaveLength( wrapper.vm.skeletonItemCount, @@ -252,6 +256,8 @@ describe('IssuableListRoot', () => { }); it('renders issuable-item component for each item within `issuables` array', () => { + wrapper = createComponent(); + const itemsEl = wrapper.findAllComponents(IssuableItem); const mockIssuable = mockIssuableListProps.issuables[0]; @@ -262,28 +268,23 @@ describe('IssuableListRoot', () => { }); }); - it('renders contents for slot "empty-state" when `issuablesLoading` is false and `issuables` is empty', async () => { - wrapper.setProps({ - issuables: [], - }); - - await wrapper.vm.$nextTick(); + it('renders contents for slot "empty-state" when `issuablesLoading` is false and `issuables` is empty', () => { + wrapper = createComponent({ props: { issuables: [] } }); expect(wrapper.find('p.js-issuable-empty-state').exists()).toBe(true); expect(wrapper.find('p.js-issuable-empty-state').text()).toBe('Issuable empty state'); }); - it('renders gl-pagination when `showPaginationControls` prop is true', async () => { - wrapper.setProps({ - showPaginationControls: true, - totalItems: 10, + it('renders only gl-pagination when `showPaginationControls` prop is true', () => { + wrapper = createComponent({ + props: { + showPaginationControls: true, + totalItems: 10, + }, }); - await wrapper.vm.$nextTick(); - - const paginationEl = findGlPagination(); - expect(paginationEl.exists()).toBe(true); - expect(paginationEl.props()).toMatchObject({ + expect(findGlKeysetPagination().exists()).toBe(false); + expect(findGlPagination().props()).toMatchObject({ perPage: 20, value: 1, prevPage: 0, @@ -292,32 +293,47 @@ describe('IssuableListRoot', () => { align: 'center', }); }); - }); - describe('events', () => { - beforeEach(() => { + it('renders only gl-keyset-pagination when `showPaginationControls` and `useKeysetPagination` props are true', () => { wrapper = createComponent({ - data: { - checkedIssuables: { - [mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] }, - }, + props: { + hasNextPage: true, + hasPreviousPage: true, + showPaginationControls: true, + useKeysetPagination: true, }, }); + + expect(findGlPagination().exists()).toBe(false); + expect(findGlKeysetPagination().props()).toMatchObject({ + hasNextPage: true, + hasPreviousPage: true, + }); }); + }); + + describe('events', () => { + const data = { + checkedIssuables: { + [mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] }, + }, + }; it('issuable-tabs component emits `click-tab` event on `click-tab` event', () => { + wrapper = createComponent({ data }); + findIssuableTabs().vm.$emit('click'); expect(wrapper.emitted('click-tab')).toBeTruthy(); }); - it('sets all issuables as checked when filtered-search-bar component emits `checked-input` event', async () => { + it('sets all issuables as checked when filtered-search-bar component emits `checked-input` event', () => { + wrapper = createComponent({ data }); + const searchEl = findFilteredSearchBar(); searchEl.vm.$emit('checked-input', true); - await wrapper.vm.$nextTick(); - expect(searchEl.emitted('checked-input')).toBeTruthy(); expect(searchEl.emitted('checked-input').length).toBe(1); @@ -328,6 +344,8 @@ describe('IssuableListRoot', () => { }); it('filtered-search-bar component emits `filter` event on `onFilter` & `sort` event on `onSort` events', () => { + wrapper = createComponent({ data }); + const searchEl = findFilteredSearchBar(); searchEl.vm.$emit('onFilter'); @@ -336,13 +354,13 @@ describe('IssuableListRoot', () => { expect(wrapper.emitted('sort')).toBeTruthy(); }); - it('sets an issuable as checked when issuable-item component emits `checked-input` event', async () => { + it('sets an issuable as checked when issuable-item component emits `checked-input` event', () => { + wrapper = createComponent({ data }); + const issuableItem = wrapper.findAllComponents(IssuableItem).at(0); issuableItem.vm.$emit('checked-input', true); - await wrapper.vm.$nextTick(); - expect(issuableItem.emitted('checked-input')).toBeTruthy(); expect(issuableItem.emitted('checked-input').length).toBe(1); @@ -353,27 +371,45 @@ describe('IssuableListRoot', () => { }); it('emits `update-legacy-bulk-edit` when filtered-search-bar checkbox is checked', () => { + wrapper = createComponent({ data }); + findFilteredSearchBar().vm.$emit('checked-input'); expect(wrapper.emitted('update-legacy-bulk-edit')).toEqual([[]]); }); it('emits `update-legacy-bulk-edit` when issuable-item checkbox is checked', () => { + wrapper = createComponent({ data }); + findIssuableItem().vm.$emit('checked-input'); expect(wrapper.emitted('update-legacy-bulk-edit')).toEqual([[]]); }); - it('gl-pagination component emits `page-change` event on `input` event', async () => { - wrapper.setProps({ - showPaginationControls: true, - }); - - await wrapper.vm.$nextTick(); + it('gl-pagination component emits `page-change` event on `input` event', () => { + wrapper = createComponent({ data, props: { showPaginationControls: true } }); findGlPagination().vm.$emit('input'); expect(wrapper.emitted('page-change')).toBeTruthy(); }); + + it.each` + event | glKeysetPaginationEvent + ${'next-page'} | ${'next'} + ${'previous-page'} | ${'prev'} + `( + 'emits `$event` event when gl-keyset-pagination emits `$glKeysetPaginationEvent` event', + ({ event, glKeysetPaginationEvent }) => { + wrapper = createComponent({ + data, + props: { showPaginationControls: true, useKeysetPagination: true }, + }); + + findGlKeysetPagination().vm.$emit(glKeysetPaginationEvent); + + expect(wrapper.emitted(event)).toEqual([[]]); + }, + ); }); describe('manual sorting', () => { diff --git a/spec/frontend/issues_list/components/issue_card_time_info_spec.js b/spec/frontend/issues_list/components/issue_card_time_info_spec.js index 614ad586ec9bd1eacc5839a7de35212733b95c0d..634687e77abc58cef252a31a1bcfbabde01ed98d 100644 --- a/spec/frontend/issues_list/components/issue_card_time_info_spec.js +++ b/spec/frontend/issues_list/components/issue_card_time_info_spec.js @@ -13,12 +13,10 @@ describe('IssuesListApp component', () => { dueDate: '2020-12-17', startDate: '2020-12-10', title: 'My milestone', - webUrl: '/milestone/webUrl', + webPath: '/milestone/webPath', }, dueDate: '2020-12-12', - timeStats: { - humanTimeEstimate: '1w', - }, + humanTimeEstimate: '1w', }; const findMilestone = () => wrapper.find('[data-testid="issuable-milestone"]'); @@ -56,7 +54,7 @@ describe('IssuesListApp component', () => { expect(milestone.text()).toBe(issue.milestone.title); expect(milestone.find(GlIcon).props('name')).toBe('clock'); - expect(milestone.find(GlLink).attributes('href')).toBe(issue.milestone.webUrl); + expect(milestone.find(GlLink).attributes('href')).toBe(issue.milestone.webPath); }); describe.each` @@ -102,7 +100,7 @@ describe('IssuesListApp component', () => { const timeEstimate = wrapper.find('[data-testid="time-estimate"]'); - expect(timeEstimate.text()).toBe(issue.timeStats.humanTimeEstimate); + expect(timeEstimate.text()).toBe(issue.humanTimeEstimate); expect(timeEstimate.attributes('title')).toBe('Estimate'); expect(timeEstimate.find(GlIcon).props('name')).toBe('timer'); }); diff --git a/spec/frontend/issues_list/components/issues_list_app_spec.js b/spec/frontend/issues_list/components/issues_list_app_spec.js index d78a436c618b9bc86132f5a36d1d35cb887be346..a3ac57ee1bbb934421e53a2eb89517ea38eba5e3 100644 --- a/spec/frontend/issues_list/components/issues_list_app_spec.js +++ b/spec/frontend/issues_list/components/issues_list_app_spec.js @@ -1,9 +1,19 @@ import { GlButton, GlEmptyState, GlLink } from '@gitlab/ui'; -import { mount, shallowMount } from '@vue/test-utils'; +import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; +import { cloneDeep } from 'lodash'; +import { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; -import { apiParams, filteredTokens, locationSearch, urlParams } from 'jest/issues_list/mock_data'; +import { + getIssuesQueryResponse, + filteredTokens, + locationSearch, + urlParams, +} from 'jest/issues_list/mock_data'; import createFlash from '~/flash'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import IssuableByEmail from '~/issuable/components/issuable_by_email.vue'; @@ -14,10 +24,7 @@ import { apiSortParams, CREATED_DESC, DUE_DATE_OVERDUE, - PAGE_SIZE, - PAGE_SIZE_MANUAL, PARAM_DUE_DATE, - RELATIVE_POSITION_DESC, TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, TOKEN_TYPE_CONFIDENTIAL, @@ -32,20 +39,26 @@ import { import eventHub from '~/issues_list/eventhub'; import { getSortOptions } from '~/issues_list/utils'; import axios from '~/lib/utils/axios_utils'; +import { scrollUp } from '~/lib/utils/scroll_utils'; import { setUrlParams } from '~/lib/utils/url_utility'; jest.mock('~/flash'); +jest.mock('~/lib/utils/scroll_utils', () => ({ + scrollUp: jest.fn().mockName('scrollUpMock'), +})); describe('IssuesListApp component', () => { let axiosMock; let wrapper; + const localVue = createLocalVue(); + localVue.use(VueApollo); + const defaultProvide = { autocompleteUsersPath: 'autocomplete/users/path', calendarPath: 'calendar/path', canBulkUpdate: false, emptyStateSvgPath: 'empty-state.svg', - endpoint: 'api/endpoint', exportCsvPath: 'export/csv/path', hasBlockedIssuesFeature: true, hasIssueWeightsFeature: true, @@ -61,21 +74,13 @@ describe('IssuesListApp component', () => { signInPath: 'sign/in/path', }; - const state = 'opened'; - const xPage = 1; - const xTotal = 25; - const tabCounts = { - opened: xTotal, - closed: undefined, - all: undefined, - }; - const fetchIssuesResponse = { - data: [], - headers: { - 'x-page': xPage, - 'x-total': xTotal, - }, - }; + let defaultQueryResponse = getIssuesQueryResponse; + if (IS_EE) { + defaultQueryResponse = cloneDeep(getIssuesQueryResponse); + defaultQueryResponse.data.project.issues.nodes[0].blockedByCount = 1; + defaultQueryResponse.data.project.issues.nodes[0].healthStatus = null; + defaultQueryResponse.data.project.issues.nodes[0].weight = 5; + } const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons); const findIssuableByEmail = () => wrapper.findComponent(IssuableByEmail); @@ -86,19 +91,26 @@ describe('IssuesListApp component', () => { const findGlLink = () => wrapper.findComponent(GlLink); const findIssuableList = () => wrapper.findComponent(IssuableList); - const mountComponent = ({ provide = {}, mountFn = shallowMount } = {}) => - mountFn(IssuesListApp, { + const mountComponent = ({ + provide = {}, + response = defaultQueryResponse, + mountFn = shallowMount, + } = {}) => { + const requestHandlers = [[getIssuesQuery, jest.fn().mockResolvedValue(response)]]; + const apolloProvider = createMockApollo(requestHandlers); + + return mountFn(IssuesListApp, { + localVue, + apolloProvider, provide: { ...defaultProvide, ...provide, }, }); + }; beforeEach(() => { axiosMock = new AxiosMockAdapter(axios); - axiosMock - .onGet(defaultProvide.endpoint) - .reply(200, fetchIssuesResponse.data, fetchIssuesResponse.headers); }); afterEach(() => { @@ -108,28 +120,37 @@ describe('IssuesListApp component', () => { }); describe('IssuableList', () => { - beforeEach(async () => { + beforeEach(() => { wrapper = mountComponent(); - await waitForPromises(); + jest.runOnlyPendingTimers(); }); it('renders', () => { expect(findIssuableList().props()).toMatchObject({ namespace: defaultProvide.projectPath, recentSearchesStorageKey: 'issues', - searchInputPlaceholder: 'Search or filter results…', + searchInputPlaceholder: IssuesListApp.i18n.searchPlaceholder, sortOptions: getSortOptions(true, true), initialSortBy: CREATED_DESC, + issuables: getIssuesQueryResponse.data.project.issues.nodes, tabs: IssuableListTabs, currentTab: IssuableStates.Opened, - tabCounts, - showPaginationControls: false, - issuables: [], - totalItems: xTotal, - currentPage: xPage, - previousPage: xPage - 1, - nextPage: xPage + 1, - urlParams: { page: xPage, state }, + tabCounts: { + opened: 1, + closed: undefined, + all: undefined, + }, + issuablesLoading: false, + isManualOrdering: false, + showBulkEditSidebar: false, + showPaginationControls: true, + useKeysetPagination: true, + hasPreviousPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasPreviousPage, + hasNextPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasNextPage, + urlParams: { + state: IssuableStates.Opened, + ...urlSortParams[CREATED_DESC], + }, }); }); }); @@ -157,9 +178,9 @@ describe('IssuesListApp component', () => { describe('csv import/export component', () => { describe('when user is signed in', () => { - it('renders', async () => { - const search = '?page=1&search=refactor&state=opened&sort=created_date'; + const search = '?search=refactor&state=opened&sort=created_date'; + beforeEach(() => { global.jsdom.reconfigure({ url: `${TEST_HOST}${search}` }); wrapper = mountComponent({ @@ -167,11 +188,13 @@ describe('IssuesListApp component', () => { mountFn: mount, }); - await waitForPromises(); + jest.runOnlyPendingTimers(); + }); + it('renders', () => { expect(findCsvImportExportButtons().props()).toMatchObject({ exportCsvPath: `${defaultProvide.exportCsvPath}${search}`, - issuableCount: xTotal, + issuableCount: 1, }); }); }); @@ -238,18 +261,6 @@ describe('IssuesListApp component', () => { }); }); - describe('page', () => { - it('is set from the url params', () => { - const page = 5; - - global.jsdom.reconfigure({ url: setUrlParams({ page }, TEST_HOST) }); - - wrapper = mountComponent(); - - expect(findIssuableList().props('currentPage')).toBe(page); - }); - }); - describe('search', () => { it('is set from the url params', () => { global.jsdom.reconfigure({ url: `${TEST_HOST}${locationSearch}` }); @@ -326,12 +337,10 @@ describe('IssuesListApp component', () => { describe('empty states', () => { describe('when there are issues', () => { describe('when search returns no results', () => { - beforeEach(async () => { + beforeEach(() => { global.jsdom.reconfigure({ url: `${TEST_HOST}?search=no+results` }); wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount }); - - await waitForPromises(); }); it('shows empty state', () => { @@ -344,10 +353,8 @@ describe('IssuesListApp component', () => { }); describe('when "Open" tab has no issues', () => { - beforeEach(async () => { + beforeEach(() => { wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount }); - - await waitForPromises(); }); it('shows empty state', () => { @@ -360,14 +367,12 @@ describe('IssuesListApp component', () => { }); describe('when "Closed" tab has no issues', () => { - beforeEach(async () => { + beforeEach(() => { global.jsdom.reconfigure({ url: setUrlParams({ state: IssuableStates.Closed }, TEST_HOST), }); wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount }); - - await waitForPromises(); }); it('shows empty state', () => { @@ -555,98 +560,70 @@ describe('IssuesListApp component', () => { describe('events', () => { describe('when "click-tab" event is emitted by IssuableList', () => { beforeEach(() => { - axiosMock.onGet(defaultProvide.endpoint).reply(200, fetchIssuesResponse.data, { - 'x-page': 2, - 'x-total': xTotal, - }); - wrapper = mountComponent(); findIssuableList().vm.$emit('click-tab', IssuableStates.Closed); }); - it('makes API call to filter the list by the new state and resets the page to 1', () => { - expect(axiosMock.history.get[1].params).toMatchObject({ - page: 1, - state: IssuableStates.Closed, - }); + it('updates to the new tab', () => { + expect(findIssuableList().props('currentTab')).toBe(IssuableStates.Closed); }); }); - describe('when "page-change" event is emitted by IssuableList', () => { - const data = [{ id: 10, title: 'title', state }]; - const page = 2; - const totalItems = 21; - - beforeEach(async () => { - axiosMock.onGet(defaultProvide.endpoint).reply(200, data, { - 'x-page': page, - 'x-total': totalItems, - }); - - wrapper = mountComponent(); - - findIssuableList().vm.$emit('page-change', page); - - await waitForPromises(); - }); + describe.each(['next-page', 'previous-page'])( + 'when "%s" event is emitted by IssuableList', + (event) => { + beforeEach(() => { + wrapper = mountComponent(); - it('fetches issues with expected params', () => { - expect(axiosMock.history.get[1].params).toMatchObject({ - page, - per_page: PAGE_SIZE, - state, - with_labels_details: true, + findIssuableList().vm.$emit(event); }); - }); - it('updates IssuableList with response data', () => { - expect(findIssuableList().props()).toMatchObject({ - issuables: data, - totalItems, - currentPage: page, - previousPage: page - 1, - nextPage: page + 1, - urlParams: { page, state }, + it('scrolls to the top', () => { + expect(scrollUp).toHaveBeenCalled(); }); - }); - }); + }, + ); describe('when "reorder" event is emitted by IssuableList', () => { - const issueOne = { id: 1, iid: 101, title: 'Issue one' }; - const issueTwo = { id: 2, iid: 102, title: 'Issue two' }; - const issueThree = { id: 3, iid: 103, title: 'Issue three' }; - const issueFour = { id: 4, iid: 104, title: 'Issue four' }; - const issues = [issueOne, issueTwo, issueThree, issueFour]; - - beforeEach(async () => { - axiosMock.onGet(defaultProvide.endpoint).reply(200, issues, fetchIssuesResponse.headers); - wrapper = mountComponent(); - await waitForPromises(); - }); - - describe('when successful', () => { - describe.each` - description | issueToMove | oldIndex | newIndex | moveBeforeId | moveAfterId - ${'to the beginning of the list'} | ${issueThree} | ${2} | ${0} | ${null} | ${issueOne.id} - ${'down the list'} | ${issueOne} | ${0} | ${1} | ${issueTwo.id} | ${issueThree.id} - ${'up the list'} | ${issueThree} | ${2} | ${1} | ${issueOne.id} | ${issueTwo.id} - ${'to the end of the list'} | ${issueTwo} | ${1} | ${3} | ${issueFour.id} | ${null} - `( - 'when moving issue $description', - ({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => { - it('makes API call to reorder the issue', async () => { - findIssuableList().vm.$emit('reorder', { oldIndex, newIndex }); - - await waitForPromises(); - - expect(axiosMock.history.put[0]).toMatchObject({ - url: `${defaultProvide.issuesPath}/${issueToMove.iid}/reorder`, - data: JSON.stringify({ move_before_id: moveBeforeId, move_after_id: moveAfterId }), - }); - }); + const issueOne = { + ...defaultQueryResponse.data.project.issues.nodes[0], + id: 'gid://gitlab/Issue/1', + iid: 101, + title: 'Issue one', + }; + const issueTwo = { + ...defaultQueryResponse.data.project.issues.nodes[0], + id: 'gid://gitlab/Issue/2', + iid: 102, + title: 'Issue two', + }; + const issueThree = { + ...defaultQueryResponse.data.project.issues.nodes[0], + id: 'gid://gitlab/Issue/3', + iid: 103, + title: 'Issue three', + }; + const issueFour = { + ...defaultQueryResponse.data.project.issues.nodes[0], + id: 'gid://gitlab/Issue/4', + iid: 104, + title: 'Issue four', + }; + const response = { + data: { + project: { + issues: { + ...defaultQueryResponse.data.project.issues, + nodes: [issueOne, issueTwo, issueThree, issueFour], + }, }, - ); + }, + }; + + beforeEach(() => { + wrapper = mountComponent({ response }); + jest.runOnlyPendingTimers(); }); describe('when unsuccessful', () => { @@ -664,21 +641,16 @@ describe('IssuesListApp component', () => { describe('when "sort" event is emitted by IssuableList', () => { it.each(Object.keys(apiSortParams))( - 'fetches issues with correct params with payload `%s`', + 'updates to the new sort when payload is `%s`', async (sortKey) => { wrapper = mountComponent(); findIssuableList().vm.$emit('sort', sortKey); - await waitForPromises(); + jest.runOnlyPendingTimers(); + await nextTick(); - expect(axiosMock.history.get[1].params).toEqual({ - page: xPage, - per_page: sortKey === RELATIVE_POSITION_DESC ? PAGE_SIZE_MANUAL : PAGE_SIZE, - state, - with_labels_details: true, - ...apiSortParams[sortKey], - }); + expect(findIssuableList().props('urlParams')).toMatchObject(urlSortParams[sortKey]); }, ); }); @@ -687,13 +659,11 @@ describe('IssuesListApp component', () => { beforeEach(() => { wrapper = mountComponent(); jest.spyOn(eventHub, '$emit'); - }); - it('emits an "issuables:updateBulkEdit" event to the legacy bulk edit class', async () => { findIssuableList().vm.$emit('update-legacy-bulk-edit'); + }); - await waitForPromises(); - + it('emits an "issuables:updateBulkEdit" event to the legacy bulk edit class', () => { expect(eventHub.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit'); }); }); @@ -705,10 +675,6 @@ describe('IssuesListApp component', () => { findIssuableList().vm.$emit('filter', filteredTokens); }); - it('makes an API call to search for issues with the search term', () => { - expect(axiosMock.history.get[1].params).toMatchObject(apiParams); - }); - it('updates IssuableList with url params', () => { expect(findIssuableList().props('urlParams')).toMatchObject(urlParams); }); diff --git a/spec/frontend/issues_list/mock_data.js b/spec/frontend/issues_list/mock_data.js index 99267fb6e3102429784d1762326b47f63fdaf536..6c669e02070bac8b2ce1710aebf1b341af134eab 100644 --- a/spec/frontend/issues_list/mock_data.js +++ b/spec/frontend/issues_list/mock_data.js @@ -3,6 +3,73 @@ import { OPERATOR_IS_NOT, } from '~/vue_shared/components/filtered_search_bar/constants'; +export const getIssuesQueryResponse = { + data: { + project: { + issues: { + count: 1, + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'startcursor', + endCursor: 'endcursor', + }, + nodes: [ + { + id: 'gid://gitlab/Issue/123456', + iid: '789', + closedAt: null, + confidential: false, + createdAt: '2021-05-22T04:08:01Z', + downvotes: 2, + dueDate: '2021-05-29', + humanTimeEstimate: null, + moved: false, + title: 'Issue title', + updatedAt: '2021-05-22T04:08:01Z', + upvotes: 3, + userDiscussionsCount: 4, + webUrl: 'project/-/issues/789', + assignees: { + nodes: [ + { + id: 'gid://gitlab/User/234', + avatarUrl: 'avatar/url', + name: 'Marge Simpson', + username: 'msimpson', + webUrl: 'url/msimpson', + }, + ], + }, + author: { + id: 'gid://gitlab/User/456', + avatarUrl: 'avatar/url', + name: 'Homer Simpson', + username: 'hsimpson', + webUrl: 'url/hsimpson', + }, + labels: { + nodes: [ + { + id: 'gid://gitlab/ProjectLabel/456', + color: '#333', + title: 'Label title', + description: 'Label description', + }, + ], + }, + milestone: null, + taskCompletionStatus: { + completedCount: 1, + count: 2, + }, + }, + ], + }, + }, + }, +}; + export const locationSearch = [ '?search=find+issues', 'author_username=homer', diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb index 59b42dfca2094273355e807ac638e4946e6ebc7c..a8a227c8ec4e953248b4cbf7758ee5bdc4289113 100644 --- a/spec/helpers/issues_helper_spec.rb +++ b/spec/helpers/issues_helper_spec.rb @@ -302,7 +302,6 @@ email: current_user&.notification_email, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), empty_state_svg_path: '#', - endpoint: expose_path(api_v4_projects_issues_path(id: project.id)), export_csv_path: export_csv_project_issues_path(project), has_project_issues: project_issues(project).exists?.to_s, import_csv_issues_path: '#',