diff options
Diffstat (limited to 'app/assets/javascripts/issues_list/components/issues_list_app.vue')
-rw-r--r-- | app/assets/javascripts/issues_list/components/issues_list_app.vue | 363 |
1 files changed, 344 insertions, 19 deletions
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 c57fa5a82fa..57c5107fcbb 100644 --- a/app/assets/javascripts/issues_list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue @@ -1,19 +1,63 @@ <script> -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlEmptyState, GlIcon, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import { toNumber } from 'lodash'; import createFlash from '~/flash'; +import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import IssuableList from '~/issuable_list/components/issuable_list_root.vue'; -import { IssuableStatus } from '~/issue_show/constants'; -import { PAGE_SIZE } from '~/issues_list/constants'; +import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants'; +import { + CREATED_DESC, + PAGE_SIZE, + RELATIVE_POSITION_ASC, + sortOptions, + sortParams, +} from '~/issues_list/constants'; import axios from '~/lib/utils/axios_utils'; import { convertObjectPropsToCamelCase, getParameterByName } from '~/lib/utils/common_utils'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; +import eventHub from '../eventhub'; import IssueCardTimeInfo from './issue_card_time_info.vue'; export default { + CREATED_DESC, + IssuableListTabs, PAGE_SIZE, + sortOptions, + sortParams, + i18n: { + calendarLabel: __('Subscribe to calendar'), + jiraIntegrationMessage: s__( + 'JiraService|%{jiraDocsLinkStart}Enable the Jira integration%{jiraDocsLinkEnd} to view your Jira issues in GitLab.', + ), + jiraIntegrationSecondaryMessage: s__('JiraService|This feature requires a Premium plan.'), + jiraIntegrationTitle: s__('JiraService|Using Jira for issue tracking?'), + newIssueLabel: __('New issue'), + noClosedIssuesTitle: __('There are no closed issues'), + noOpenIssuesDescription: __('To keep this project going, create a new issue'), + noOpenIssuesTitle: __('There are no open issues'), + noIssuesSignedInDescription: __( + 'Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable.', + ), + noIssuesSignedInTitle: __( + 'The Issue Tracker is the place to add things that need to be improved or solved in a project', + ), + noIssuesSignedOutButtonText: __('Register / Sign In'), + noIssuesSignedOutDescription: __( + '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.', + ), + noIssuesSignedOutTitle: __('There are no issues to show'), + noSearchResultsDescription: __('To widen your search, change or remove filters above'), + noSearchResultsTitle: __('Sorry, your filter produced no results'), + reorderError: __('An error occurred while reordering issues.'), + rssLabel: __('Subscribe to RSS feed'), + }, components: { + CsvImportExportButtons, + GlButton, + GlEmptyState, GlIcon, + GlLink, + GlSprintf, IssuableList, IssueCardTimeInfo, BlockingIssuesCount: () => import('ee_component/issues/components/blocking_issues_count.vue'), @@ -22,49 +66,147 @@ export default { GlTooltip: GlTooltipDirective, }, inject: { + calendarPath: { + default: '', + }, + canBulkUpdate: { + default: false, + }, + emptyStateSvgPath: { + default: '', + }, endpoint: { default: '', }, + exportCsvPath: { + default: '', + }, fullPath: { default: '', }, + hasIssues: { + default: false, + }, + isSignedIn: { + default: false, + }, + issuesPath: { + default: '', + }, + jiraIntegrationPath: { + default: '', + }, + newIssuePath: { + default: '', + }, + rssPath: { + default: '', + }, + showNewIssueLink: { + default: false, + }, + signInPath: { + default: '', + }, }, data() { + const orderBy = getParameterByName('order_by'); + const sort = getParameterByName('sort'); + const sortKey = Object.keys(sortParams).find( + (key) => sortParams[key].order_by === orderBy && sortParams[key].sort === sort, + ); + + const search = getParameterByName('search') || ''; + const tokens = search.split(' ').map((searchWord) => ({ + type: 'filtered-search-term', + value: { + data: searchWord, + }, + })); + return { - currentPage: toNumber(getParameterByName('page')) || 1, + exportCsvPathWithQuery: this.getExportCsvPathWithQuery(), + filters: sortParams[sortKey] || {}, + filterTokens: tokens, isLoading: false, issues: [], + page: toNumber(getParameterByName('page')) || 1, + showBulkEditSidebar: false, + sortKey: sortKey || CREATED_DESC, + state: getParameterByName('state') || IssuableStates.Opened, totalIssues: 0, }; }, computed: { + isManualOrdering() { + return this.sortKey === RELATIVE_POSITION_ASC; + }, + isOpenTab() { + return this.state === IssuableStates.Opened; + }, + searchQuery() { + return ( + this.filterTokens + .map((searchTerm) => searchTerm.value.data) + .filter((searchWord) => Boolean(searchWord)) + .join(' ') || undefined + ); + }, + showPaginationControls() { + return this.issues.length > 0; + }, + tabCounts() { + return Object.values(IssuableStates).reduce( + (acc, state) => ({ + ...acc, + [state]: this.state === state ? this.totalIssues : undefined, + }), + {}, + ); + }, urlParams() { return { - page: this.currentPage, - state: IssuableStatus.Open, + page: this.page, + search: this.searchQuery, + state: this.state, + ...this.filters, }; }, }, mounted() { + eventHub.$on('issuables:toggleBulkEdit', (showBulkEditSidebar) => { + this.showBulkEditSidebar = showBulkEditSidebar; + }); this.fetchIssues(); }, + beforeDestroy() { + // eslint-disable-next-line @gitlab/no-global-event-off + eventHub.$off('issuables:toggleBulkEdit'); + }, methods: { - fetchIssues(pageToFetch) { + fetchIssues() { + if (!this.hasIssues) { + return undefined; + } + this.isLoading = true; return axios .get(this.endpoint, { params: { - page: pageToFetch || this.currentPage, + page: this.page, per_page: this.$options.PAGE_SIZE, - state: IssuableStatus.Open, + search: this.searchQuery, + state: this.state, with_labels_details: true, + ...this.filters, }, }) .then(({ data, headers }) => { - this.currentPage = Number(headers['x-page']); + 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: __('An error occurred while loading issues') }); @@ -73,8 +215,71 @@ export default { this.isLoading = false; }); }, + getExportCsvPathWithQuery() { + return `${this.exportCsvPath}${window.location.search}`; + }, + handleUpdateLegacyBulkEdit() { + // If "select all" checkbox was checked, wait for all checkboxes + // to be checked before updating IssuableBulkUpdateSidebar class + this.$nextTick(() => { + eventHub.$emit('issuables:updateBulkEdit'); + }); + }, + handleBulkUpdateClick() { + eventHub.$emit('issuables:enableBulkEdit'); + }, + handleClickTab(state) { + if (this.state !== state) { + this.page = 1; + } + this.state = state; + this.fetchIssues(); + }, + handleFilter(filter) { + this.filterTokens = filter; + this.fetchIssues(); + }, handlePageChange(page) { - this.fetchIssues(page); + this.page = page; + this.fetchIssues(); + }, + handleReorder({ newIndex, oldIndex }) { + const issueToMove = this.issues[oldIndex]; + const isDragDropDownwards = newIndex > oldIndex; + const isMovingToBeginning = newIndex === 0; + const isMovingToEnd = newIndex === this.issues.length - 1; + + let moveBeforeId; + let moveAfterId; + + if (isDragDropDownwards) { + const afterIndex = isMovingToEnd ? newIndex : newIndex + 1; + moveBeforeId = this.issues[newIndex].id; + moveAfterId = this.issues[afterIndex].id; + } else { + const beforeIndex = isMovingToBeginning ? newIndex : newIndex - 1; + moveBeforeId = this.issues[beforeIndex].id; + moveAfterId = this.issues[newIndex].id; + } + + return axios + .put(`${this.issuesPath}/${issueToMove.iid}/reorder`, { + move_before_id: isMovingToBeginning ? null : moveBeforeId, + move_after_id: isMovingToEnd ? null : moveAfterId, + }) + .then(() => { + // Move issue to new position in list + this.issues.splice(oldIndex, 1); + this.issues.splice(newIndex, 0, issueToMove); + }) + .catch(() => { + createFlash({ message: this.$options.i18n.reorderError }); + }); + }, + handleSort(value) { + this.sortKey = value; + this.filters = sortParams[value]; + this.fetchIssues(); }, }, }; @@ -82,26 +287,70 @@ export default { <template> <issuable-list + v-if="hasIssues" :namespace="fullPath" recent-searches-storage-key="issues" :search-input-placeholder="__('Search or filter results…')" :search-tokens="[]" - :sort-options="[]" + :initial-filter-value="filterTokens" + :sort-options="$options.sortOptions" + :initial-sort-by="sortKey" :issuables="issues" - :tabs="[]" - current-tab="" + :tabs="$options.IssuableListTabs" + :current-tab="state" + :tab-counts="tabCounts" :issuables-loading="isLoading" - :show-pagination-controls="true" + :is-manual-ordering="isManualOrdering" + :show-bulk-edit-sidebar="showBulkEditSidebar" + :show-pagination-controls="showPaginationControls" :total-items="totalIssues" - :current-page="currentPage" - :previous-page="currentPage - 1" - :next-page="currentPage + 1" + :current-page="page" + :previous-page="page - 1" + :next-page="page + 1" :url-params="urlParams" + @click-tab="handleClickTab" + @filter="handleFilter" @page-change="handlePageChange" + @reorder="handleReorder" + @sort="handleSort" + @update-legacy-bulk-edit="handleUpdateLegacyBulkEdit" > + <template #nav-actions> + <gl-button + v-gl-tooltip + :href="rssPath" + icon="rss" + :title="$options.i18n.rssLabel" + :aria-label="$options.i18n.rssLabel" + /> + <gl-button + v-gl-tooltip + :href="calendarPath" + icon="calendar" + :title="$options.i18n.calendarLabel" + :aria-label="$options.i18n.calendarLabel" + /> + <csv-import-export-buttons + class="gl-mr-3" + :export-csv-path="exportCsvPathWithQuery" + :issuable-count="totalIssues" + /> + <gl-button + v-if="canBulkUpdate" + :disabled="showBulkEditSidebar" + @click="handleBulkUpdateClick" + > + {{ __('Edit issues') }} + </gl-button> + <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + {{ $options.i18n.newIssueLabel }} + </gl-button> + </template> + <template #timeframe="{ issuable = {} }"> <issue-card-time-info :issue="issuable" /> </template> + <template #statistics="{ issuable = {} }"> <li v-if="issuable.mergeRequestsCount" @@ -139,5 +388,81 @@ export default { :is-list-item="true" /> </template> + + <template #empty-state> + <gl-empty-state + v-if="searchQuery" + :description="$options.i18n.noSearchResultsDescription" + :title="$options.i18n.noSearchResultsTitle" + :svg-path="emptyStateSvgPath" + > + <template #actions> + <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + {{ $options.i18n.newIssueLabel }} + </gl-button> + </template> + </gl-empty-state> + + <gl-empty-state + v-else-if="isOpenTab" + :description="$options.i18n.noOpenIssuesDescription" + :title="$options.i18n.noOpenIssuesTitle" + :svg-path="emptyStateSvgPath" + > + <template #actions> + <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + {{ $options.i18n.newIssueLabel }} + </gl-button> + </template> + </gl-empty-state> + + <gl-empty-state + v-else + :title="$options.i18n.noClosedIssuesTitle" + :svg-path="emptyStateSvgPath" + /> + </template> </issuable-list> + + <div v-else-if="isSignedIn"> + <gl-empty-state + :description="$options.i18n.noIssuesSignedInDescription" + :title="$options.i18n.noIssuesSignedInTitle" + :svg-path="emptyStateSvgPath" + > + <template #actions> + <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + {{ $options.i18n.newIssueLabel }} + </gl-button> + <csv-import-export-buttons + class="gl-mr-3" + :export-csv-path="exportCsvPathWithQuery" + :issuable-count="totalIssues" + /> + </template> + </gl-empty-state> + <hr /> + <p class="gl-text-center gl-font-weight-bold gl-mb-0"> + {{ $options.i18n.jiraIntegrationTitle }} + </p> + <p class="gl-text-center gl-mb-0"> + <gl-sprintf :message="$options.i18n.jiraIntegrationMessage"> + <template #jiraDocsLink="{ content }"> + <gl-link :href="jiraIntegrationPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + <p class="gl-text-center gl-text-gray-500"> + {{ $options.i18n.jiraIntegrationSecondaryMessage }} + </p> + </div> + + <gl-empty-state + v-else + :description="$options.i18n.noIssuesSignedOutDescription" + :title="$options.i18n.noIssuesSignedOutTitle" + :svg-path="emptyStateSvgPath" + :primary-button-text="$options.i18n.noIssuesSignedOutButtonText" + :primary-button-link="signInPath" + /> </template> |