diff options
Diffstat (limited to 'app/assets/javascripts/issues/list')
9 files changed, 503 insertions, 327 deletions
diff --git a/app/assets/javascripts/issues/list/components/empty_state_with_any_issues.vue b/app/assets/javascripts/issues/list/components/empty_state_with_any_issues.vue new file mode 100644 index 00000000000..8aece24de0c --- /dev/null +++ b/app/assets/javascripts/issues/list/components/empty_state_with_any_issues.vue @@ -0,0 +1,53 @@ +<script> +import { GlButton, GlEmptyState } from '@gitlab/ui'; +import { i18n } from '../constants'; + +export default { + i18n, + components: { + GlButton, + GlEmptyState, + }, + inject: ['emptyStateSvgPath', 'newIssuePath', 'showNewIssueLink'], + props: { + hasSearch: { + type: Boolean, + required: true, + }, + isOpenTab: { + type: Boolean, + required: true, + }, + }, +}; +</script> + +<template> + <gl-empty-state + v-if="hasSearch" + :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> diff --git a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue new file mode 100644 index 00000000000..5a37751410a --- /dev/null +++ b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue @@ -0,0 +1,110 @@ +<script> +import { GlButton, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; +import { i18n } from '../constants'; +import NewIssueDropdown from './new_issue_dropdown.vue'; + +export default { + i18n, + issuesHelpPagePath: helpPagePath('user/project/issues/index'), + components: { + CsvImportExportButtons, + GlButton, + GlEmptyState, + GlLink, + GlSprintf, + NewIssueDropdown, + }, + inject: [ + 'canCreateProjects', + 'emptyStateSvgPath', + 'isSignedIn', + 'jiraIntegrationPath', + 'newIssuePath', + 'newProjectPath', + 'showNewIssueLink', + 'signInPath', + ], + props: { + currentTabCount: { + type: Number, + required: false, + default: undefined, + }, + exportCsvPathWithQuery: { + type: String, + required: false, + default: '', + }, + showCsvButtons: { + type: Boolean, + required: false, + default: false, + }, + showNewIssueDropdown: { + type: Boolean, + required: false, + default: false, + }, + }, +}; +</script> + +<template> + <div v-if="isSignedIn"> + <gl-empty-state :title="$options.i18n.noIssuesTitle" :svg-path="emptyStateSvgPath"> + <template #description> + <gl-link :href="$options.issuesHelpPagePath"> + {{ $options.i18n.noIssuesDescription }} + </gl-link> + <p v-if="canCreateProjects"> + <strong>{{ $options.i18n.noGroupIssuesSignedInDescription }}</strong> + </p> + </template> + <template #actions> + <gl-button v-if="canCreateProjects" :href="newProjectPath" variant="confirm"> + {{ $options.i18n.newProjectLabel }} + </gl-button> + <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + {{ $options.i18n.newIssueLabel }} + </gl-button> + <csv-import-export-buttons + v-if="showCsvButtons" + class="gl-w-full gl-sm-w-auto gl-sm-mr-3" + :export-csv-path="exportCsvPathWithQuery" + :issuable-count="currentTabCount" + /> + <new-issue-dropdown v-if="showNewIssueDropdown" class="gl-align-self-center" /> + </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-secondary"> + {{ $options.i18n.jiraIntegrationSecondaryMessage }} + </p> + </div> + + <gl-empty-state + v-else + :title="$options.i18n.noIssuesTitle" + :svg-path="emptyStateSvgPath" + :primary-button-text="$options.i18n.noIssuesSignedOutButtonText" + :primary-button-link="signInPath" + > + <template #description> + <gl-link :href="$options.issuesHelpPagePath"> + {{ $options.i18n.noIssuesDescription }} + </gl-link> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/issues/list/components/issue_card_statistics.vue b/app/assets/javascripts/issues/list/components/issue_card_statistics.vue new file mode 100644 index 00000000000..2d00c3e549d --- /dev/null +++ b/app/assets/javascripts/issues/list/components/issue_card_statistics.vue @@ -0,0 +1,56 @@ +<script> +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { i18n } from '../constants'; + +export default { + i18n, + components: { + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + issue: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <ul class="gl-display-contents"> + <li + v-if="issue.mergeRequestsCount" + v-gl-tooltip + class="gl-display-none gl-sm-display-block gl-mr-3" + :title="$options.i18n.relatedMergeRequests" + data-testid="merge-requests" + > + <gl-icon name="merge-request" /> + {{ issue.mergeRequestsCount }} + </li> + <li + v-if="issue.upvotes" + v-gl-tooltip + class="gl-display-none gl-sm-display-block gl-mr-3" + :title="$options.i18n.upvotes" + data-testid="issuable-upvotes" + > + <gl-icon name="thumb-up" /> + {{ issue.upvotes }} + </li> + <li + v-if="issue.downvotes" + v-gl-tooltip + class="gl-display-none gl-sm-display-block gl-mr-3" + :title="$options.i18n.downvotes" + data-testid="issuable-downvotes" + > + <gl-icon name="thumb-down" /> + {{ issue.downvotes }} + </li> + <slot></slot> + </ul> +</template> 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 64de4b1947b..12a83f06453 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,12 @@ <script> -import { - GlButton, - GlEmptyState, - GlFilteredSearchToken, - GlIcon, - GlLink, - GlSprintf, - GlTooltipDirective, -} from '@gitlab/ui'; +import { GlButton, GlFilteredSearchToken, GlTooltipDirective } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_statistics.vue'; import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue'; import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql'; -import createFlash, { FLASH_TYPES } from '~/flash'; +import { createAlert, VARIANT_INFO } from '~/flash'; import { TYPE_USER } from '~/graphql_shared/constants'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { ITEM_TYPE } from '~/groups/constants'; @@ -24,11 +17,11 @@ import axios from '~/lib/utils/axios_utils'; import { isPositiveInteger } from '~/lib/utils/number_utils'; import { scrollUp } from '~/lib/utils/scroll_utils'; import { getParameterByName, joinPaths } from '~/lib/utils/url_utility'; -import { helpPagePath } from '~/helpers/help_page_helper'; import { - DEFAULT_NONE_ANY, FILTERED_SEARCH_TERM, - OPERATOR_IS_ONLY, + OPERATORS_IS, + OPERATORS_IS_NOT, + OPERATORS_IS_NOT_OR, TOKEN_TITLE_ASSIGNEE, TOKEN_TITLE_AUTHOR, TOKEN_TITLE_CONFIDENTIAL, @@ -38,9 +31,8 @@ import { TOKEN_TITLE_MY_REACTION, TOKEN_TITLE_ORGANIZATION, TOKEN_TITLE_RELEASE, + TOKEN_TITLE_SEARCH_WITHIN, TOKEN_TITLE_TYPE, - OPERATOR_IS_NOT_OR, - OPERATOR_IS_AND_IS_NOT, TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, TOKEN_TYPE_CONFIDENTIAL, @@ -50,6 +42,7 @@ import { TOKEN_TYPE_MY_REACTION, TOKEN_TYPE_ORGANIZATION, TOKEN_TYPE_RELEASE, + TOKEN_TYPE_SEARCH_WITHIN, TOKEN_TYPE_TYPE, } from '~/vue_shared/components/filtered_search_bar/constants'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; @@ -70,11 +63,9 @@ import { PARAM_SORT, PARAM_STATE, RELATIVE_POSITION_ASC, - TYPE_TOKEN_TASK_OPTION, UPDATED_DESC, urlSortParams, } from '../constants'; - import eventHub from '../eventhub'; import reorderIssuesMutation from '../queries/reorder_issues.mutation.graphql'; import searchLabelsQuery from '../queries/search_labels.query.graphql'; @@ -91,10 +82,11 @@ import { getSortOptions, isSortKey, } from '../utils'; +import EmptyStateWithAnyIssues from './empty_state_with_any_issues.vue'; +import EmptyStateWithoutAnyIssues from './empty_state_without_any_issues.vue'; import NewIssueDropdown from './new_issue_dropdown.vue'; -const AuthorToken = () => - import('~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'); +const UserToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'); const EmojiToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'); const LabelToken = () => @@ -113,13 +105,12 @@ export default { IssuableListTabs, components: { CsvImportExportButtons, + EmptyStateWithAnyIssues, + EmptyStateWithoutAnyIssues, GlButton, - GlEmptyState, - GlIcon, - GlLink, - GlSprintf, IssuableByEmail, IssuableList, + IssueCardStatistics, IssueCardTimeInfo, NewIssueDropdown, }, @@ -131,15 +122,14 @@ export default { 'autocompleteAwardEmojisPath', 'calendarPath', 'canBulkUpdate', - 'canCreateProjects', 'canReadCrmContact', 'canReadCrmOrganization', - 'emptyStateSvgPath', 'exportCsvPath', 'fullPath', 'hasAnyIssues', 'hasAnyProjects', 'hasBlockedIssuesFeature', + 'hasIssuableHealthStatusFeature', 'hasIssueWeightsFeature', 'hasScopedLabelsFeature', 'initialEmail', @@ -149,13 +139,10 @@ export default { 'isProject', 'isPublicVisibilityRestricted', 'isSignedIn', - 'jiraIntegrationPath', 'newIssuePath', - 'newProjectPath', 'releasesPath', 'rssPath', 'showNewIssueLink', - 'signInPath', ], props: { eeSearchTokens: { @@ -163,6 +150,21 @@ export default { required: false, default: () => [], }, + eeTypeTokenOptions: { + type: Array, + required: false, + default: () => [], + }, + eeWorkItemTypes: { + type: Array, + required: false, + default: () => [], + }, + eeIsOkrsEnabled: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -189,10 +191,7 @@ export default { return data[this.namespace]?.issues.nodes ?? []; }, result({ data }) { - if (!data) { - return; - } - this.pageInfo = data[this.namespace]?.issues.pageInfo ?? {}; + this.pageInfo = data?.[this.namespace]?.issues.pageInfo ?? {}; this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); }, error(error) { @@ -239,24 +238,27 @@ export default { state: this.state, ...this.pageParams, ...this.apiFilterParams, - types: this.apiFilterParams.types || defaultWorkItemTypes, + types: this.apiFilterParams.types || this.defaultWorkItemTypes, }; }, namespace() { return this.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP; }, + defaultWorkItemTypes() { + return [...defaultWorkItemTypes, ...this.eeWorkItemTypes]; + }, typeTokenOptions() { - return defaultTypeTokenOptions.concat(TYPE_TOKEN_TASK_OPTION); + return [...defaultTypeTokenOptions, ...this.eeTypeTokenOptions]; }, hasOrFeature() { return this.glFeatures.orIssuableQueries; }, hasSearch() { - return ( + return Boolean( this.searchQuery || - Object.keys(this.urlFilterParams).length || - this.pageParams.afterCursor || - this.pageParams.beforeCursor + Object.keys(this.urlFilterParams).length || + this.pageParams.afterCursor || + this.pageParams.beforeCursor, ); }, isBulkEditButtonDisabled() { @@ -284,13 +286,13 @@ export default { return convertToUrlParams(this.filterTokens); }, searchQuery() { - return convertToSearchQuery(this.filterTokens) || undefined; + return convertToSearchQuery(this.filterTokens); }, searchTokens() { - const preloadedAuthors = []; + const preloadedUsers = []; if (gon.current_user_id) { - preloadedAuthors.push({ + preloadedUsers.push({ id: convertToGraphQLId(TYPE_USER, gon.current_user_id), name: gon.current_user_fullname, username: gon.current_username, @@ -300,28 +302,41 @@ export default { const tokens = [ { + type: TOKEN_TYPE_SEARCH_WITHIN, + title: TOKEN_TITLE_SEARCH_WITHIN, + icon: 'search', + token: GlFilteredSearchToken, + unique: true, + operators: OPERATORS_IS, + options: [ + { icon: 'title', value: 'TITLE', title: this.$options.i18n.titles }, + { + icon: 'text-description', + value: 'DESCRIPTION', + title: this.$options.i18n.descriptions, + }, + ], + }, + { type: TOKEN_TYPE_AUTHOR, title: TOKEN_TITLE_AUTHOR, icon: 'pencil', - token: AuthorToken, - dataType: 'user', - unique: true, - defaultAuthors: [], - fetchAuthors: this.fetchUsers, + token: UserToken, + defaultUsers: [], + operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT, + fetchUsers: this.fetchUsers, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-author`, - preloadedAuthors, + preloadedUsers, }, { type: TOKEN_TYPE_ASSIGNEE, title: TOKEN_TITLE_ASSIGNEE, icon: 'user', - token: AuthorToken, - dataType: 'user', - defaultAuthors: DEFAULT_NONE_ANY, - operators: this.hasOrFeature ? OPERATOR_IS_NOT_OR : OPERATOR_IS_AND_IS_NOT, - fetchAuthors: this.fetchUsers, + token: UserToken, + operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT, + fetchUsers: this.fetchUsers, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-assignee`, - preloadedAuthors, + preloadedUsers, }, { type: TOKEN_TYPE_MILESTONE, @@ -337,7 +352,6 @@ export default { title: TOKEN_TITLE_LABEL, icon: 'labels', token: LabelToken, - defaultLabels: DEFAULT_NONE_ANY, fetchLabels: this.fetchLabels, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-label`, }, @@ -378,7 +392,7 @@ export default { icon: 'eye-slash', token: GlFilteredSearchToken, unique: true, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, options: [ { icon: 'eye-slash', value: 'yes', title: this.$options.i18n.confidentialYes }, { icon: 'eye', value: 'no', title: this.$options.i18n.confidentialNo }, @@ -394,9 +408,8 @@ export default { token: CrmContactToken, fullPath: this.fullPath, isProject: this.isProject, - defaultContacts: DEFAULT_NONE_ANY, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-crm-contacts`, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, unique: true, }); } @@ -409,9 +422,8 @@ export default { token: CrmOrganizationToken, fullPath: this.fullPath, isProject: this.isProject, - defaultOrganizations: DEFAULT_NONE_ANY, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-crm-organizations`, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, unique: true, }); } @@ -428,11 +440,14 @@ export default { return this.issues.length > 0 && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage); }, showPageSizeControls() { - /** only show page size controls when the tab count is greater than the default/minimum page size control i.e 20 in this case */ return this.currentTabCount > PAGE_SIZE; }, sortOptions() { - return getSortOptions(this.hasIssueWeightsFeature, this.hasBlockedIssuesFeature); + return getSortOptions({ + hasBlockedIssuesFeature: this.hasBlockedIssuesFeature, + hasIssuableHealthStatusFeature: this.hasIssuableHealthStatusFeature, + hasIssueWeightsFeature: this.hasIssueWeightsFeature, + }); }, tabCounts() { const { openedIssues, closedIssues, allIssues } = this.issuesCounts; @@ -457,10 +472,7 @@ export default { page_before: this.pageParams.beforeCursor ?? undefined, }; }, - issuesHelpPagePath() { - return helpPagePath('user/project/issues/index'); - }, - shouldDisableSomeFilters() { + shouldDisableTextSearch() { return this.isAnonymousSearchDisabled && !this.isSignedIn; }, }, @@ -482,18 +494,17 @@ export default { eventHub.$off('issuables:toggleBulkEdit', this.toggleBulkEditSidebar); }, methods: { - fetchWithCache(path, cacheName, searchKey, search, wrapData = false) { + fetchWithCache(path, cacheName, searchKey, search) { if (this.cache[cacheName]) { const data = search ? fuzzaldrinPlus.filter(this.cache[cacheName], search, { key: searchKey }) : this.cache[cacheName].slice(0, MAX_LIST_SIZE); - return wrapData ? Promise.resolve({ data }) : Promise.resolve(data); + return Promise.resolve(data); } return axios.get(path).then(({ data }) => { this.cache[cacheName] = data; - const result = data.slice(0, MAX_LIST_SIZE); - return wrapData ? { data: result } : result; + return data.slice(0, MAX_LIST_SIZE); }); }, fetchEmojis(search) { @@ -554,14 +565,10 @@ export default { }, async handleBulkUpdateClick() { if (!this.hasInitBulkEdit) { - const bulkUpdateSidebar = await import('~/issuable/bulk_update_sidebar'); + const bulkUpdateSidebar = await import('~/issuable'); bulkUpdateSidebar.initBulkUpdateSidebar('issuable_'); - bulkUpdateSidebar.initStatusDropdown(); - bulkUpdateSidebar.initSubscriptionsDropdown(); - bulkUpdateSidebar.initMoveIssuesButton(); - const usersSelect = await import('~/users_select'); - const UsersSelect = usersSelect.default; + const UsersSelect = (await import('~/users_select')).default; new UsersSelect(); // eslint-disable-line no-new this.hasInitBulkEdit = true; @@ -570,19 +577,20 @@ export default { eventHub.$emit('issuables:enableBulkEdit'); }, handleClickTab(state) { - if (this.state !== state) { - this.pageParams = getInitialPageParams(this.pageSize); + if (this.state === state) { + return; } + this.state = state; + this.pageParams = getInitialPageParams(this.pageSize); this.$router.push({ query: this.urlParams }); }, handleDismissAlert() { this.issuesError = null; }, - handleFilter(filter) { - this.setFilterTokens(filter); - + handleFilter(tokens) { + this.setFilterTokens(tokens); this.pageParams = getInitialPageParams(this.pageSize); this.$router.push({ query: this.urlParams }); @@ -642,15 +650,17 @@ export default { }); }, handleSort(sortKey) { + if (this.sortKey === sortKey) { + return; + } + if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) { this.showIssueRepositioningMessage(); return; } - if (this.sortKey !== sortKey) { - this.pageParams = getInitialPageParams(this.pageSize); - } this.sortKey = sortKey; + this.pageParams = getInitialPageParams(this.pageSize); if (this.isSignedIn) { this.saveSortPreference(sortKey); @@ -673,49 +683,36 @@ export default { Sentry.captureException(error); }); }, - setFilterTokens(filtersArg) { - const filters = this.removeDisabledSearchTerms(filtersArg); + setFilterTokens(tokens) { + this.filterTokens = this.removeDisabledSearchTerms(tokens); - this.filterTokens = filters; - - // If we filtered something out, let's show a warning message - if (filters.length < filtersArg.length) { + if (this.filterTokens.length < tokens.length) { this.showAnonymousSearchingMessage(); } }, removeDisabledSearchTerms(filters) { - // If we shouldn't disable anything, let's return the same thing - if (!this.shouldDisableSomeFilters) { - return filters; - } - - const filtersWithoutSearchTerms = filters.filter( - (token) => !(token.type === FILTERED_SEARCH_TERM && token.value?.data), - ); - - return filtersWithoutSearchTerms; + return this.shouldDisableTextSearch + ? filters.filter((token) => !(token.type === FILTERED_SEARCH_TERM && token.value?.data)) + : filters; }, showAnonymousSearchingMessage() { - createFlash({ + createAlert({ message: this.$options.i18n.anonymousSearchingMessage, - type: FLASH_TYPES.NOTICE, + variant: VARIANT_INFO, }); }, showIssueRepositioningMessage() { - createFlash({ + createAlert({ message: this.$options.i18n.issueRepositioningMessage, - type: FLASH_TYPES.NOTICE, + variant: VARIANT_INFO, }); }, toggleBulkEditSidebar(showBulkEditSidebar) { this.showBulkEditSidebar = showBulkEditSidebar; }, handlePageSizeChange(newPageSize) { - /** make sure the page number is preserved so that the current context is not lost* */ - const lastPageSize = getParameterByName(PARAM_LAST_PAGE_SIZE); - const pageNumberSize = lastPageSize ? 'lastPageSize' : 'firstPageSize'; - /** depending upon what page or page size we are dynamically set pageParams * */ - this.pageParams[pageNumberSize] = newPageSize; + const pageParam = getParameterByName(PARAM_LAST_PAGE_SIZE) ? 'lastPageSize' : 'firstPageSize'; + this.pageParams[pageParam] = newPageSize; this.pageSize = newPageSize; scrollUp(); @@ -724,16 +721,14 @@ export default { updateData(sortValue) { const firstPageSize = getParameterByName(PARAM_FIRST_PAGE_SIZE); const lastPageSize = getParameterByName(PARAM_LAST_PAGE_SIZE); - const pageAfter = getParameterByName(PARAM_PAGE_AFTER); - const pageBefore = getParameterByName(PARAM_PAGE_BEFORE); const state = getParameterByName(PARAM_STATE); const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC; const dashboardSortKey = getSortKey(sortValue); const graphQLSortKey = isSortKey(sortValue?.toUpperCase()) && sortValue.toUpperCase(); - // The initial sort is an old enum value when it is saved on the dashboard issues page. - // The initial sort is a GraphQL enum value when it is saved on the Vue issues list page. + // The initial sort is an old enum value when it is saved on the Haml dashboard issues page. + // The initial sort is a GraphQL enum value when it is saved on the Vue group/project issues page. let sortKey = dashboardSortKey || graphQLSortKey || defaultSortKey; if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) { @@ -741,15 +736,15 @@ export default { sortKey = defaultSortKey; } - this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); this.setFilterTokens(getFilterTokens(window.location.search)); + this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); this.pageParams = getInitialPageParams( this.pageSize, isPositiveInteger(firstPageSize) ? parseInt(firstPageSize, 10) : undefined, isPositiveInteger(lastPageSize) ? parseInt(lastPageSize, 10) : undefined, - pageAfter, - pageBefore, + getParameterByName(PARAM_PAGE_AFTER), + getParameterByName(PARAM_PAGE_BEFORE), ); this.sortKey = sortKey; this.state = state || IssuableStates.Opened; @@ -827,9 +822,14 @@ export default { > {{ $options.i18n.editIssues }} </gl-button> - <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + <gl-button + v-if="showNewIssueLink && !eeIsOkrsEnabled" + :href="newIssuePath" + variant="confirm" + > {{ $options.i18n.newIssueLabel }} </gl-button> + <slot name="new-objective-button"></slot> <new-issue-dropdown v-if="showNewIssueDropdown" /> </template> @@ -842,129 +842,25 @@ export default { </template> <template #statistics="{ issuable = {} }"> - <li - v-if="issuable.mergeRequestsCount" - v-gl-tooltip - class="gl-display-none gl-sm-display-block" - :title="$options.i18n.relatedMergeRequests" - data-testid="merge-requests" - > - <gl-icon name="merge-request" /> - {{ issuable.mergeRequestsCount }} - </li> - <li - v-if="issuable.upvotes" - v-gl-tooltip - class="issuable-upvotes gl-display-none gl-sm-display-block" - :title="$options.i18n.upvotes" - data-testid="issuable-upvotes" - > - <gl-icon name="thumb-up" /> - {{ issuable.upvotes }} - </li> - <li - v-if="issuable.downvotes" - v-gl-tooltip - class="issuable-downvotes gl-display-none gl-sm-display-block" - :title="$options.i18n.downvotes" - data-testid="issuable-downvotes" - > - <gl-icon name="thumb-down" /> - {{ issuable.downvotes }} - </li> - <slot :issuable="issuable"></slot> + <issue-card-statistics :issue="issuable" /> </template> <template #empty-state> - <gl-empty-state - v-if="hasSearch" - :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" - /> + <empty-state-with-any-issues :has-search="hasSearch" :is-open-tab="isOpenTab" /> + </template> + + <template #list-body> + <slot name="list-body"></slot> </template> </issuable-list> - <template v-else-if="isSignedIn"> - <gl-empty-state :title="$options.i18n.noIssuesSignedInTitle" :svg-path="emptyStateSvgPath"> - <template #description> - <gl-link :href="issuesHelpPagePath" target="_blank">{{ - $options.i18n.noIssuesSignedInDescription - }}</gl-link> - <p v-if="canCreateProjects"> - <strong>{{ $options.i18n.noGroupIssuesSignedInDescription }}</strong> - </p> - </template> - <template #actions> - <gl-button v-if="canCreateProjects" :href="newProjectPath" variant="confirm"> - {{ $options.i18n.newProjectLabel }} - </gl-button> - <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> - {{ $options.i18n.newIssueLabel }} - </gl-button> - <csv-import-export-buttons - v-if="showCsvButtons" - class="gl-w-full gl-sm-w-auto gl-sm-mr-3" - :export-csv-path="exportCsvPathWithQuery" - :issuable-count="currentTabCount" - /> - <new-issue-dropdown v-if="showNewIssueDropdown" class="gl-align-self-center" /> - </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> - </template> - - <gl-empty-state + <empty-state-without-any-issues v-else - :title="$options.i18n.noIssuesSignedOutTitle" - :svg-path="emptyStateSvgPath" - :primary-button-text="$options.i18n.noIssuesSignedOutButtonText" - :primary-button-link="signInPath" - > - <template #description> - <gl-link :href="issuesHelpPagePath" target="_blank">{{ - $options.i18n.noIssuesSignedOutDescription - }}</gl-link> - </template> - </gl-empty-state> + :current-tab-count="currentTabCount" + :export-csv-path-with-query="exportCsvPathWithQuery" + :show-csv-buttons="showCsvButtons" + :show-new-issue-dropdown="showNewIssueDropdown" + /> <issuable-by-email v-if="showIssuableByEmail" class="gl-text-center gl-pt-5 gl-pb-7" /> </div> diff --git a/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue b/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue index 666e80dfd4b..e420c21a11f 100644 --- a/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue +++ b/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue @@ -6,7 +6,7 @@ import { GlLoadingIcon, GlSearchBoxByType, } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; @@ -45,7 +45,7 @@ export default { }, update: ({ group }) => group.projects.nodes ?? [], error(error) { - createFlash({ + createAlert({ message: __('An error occurred while loading projects.'), captureError: true, error, diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js index 5ed9ceea856..49a953cad43 100644 --- a/app/assets/javascripts/issues/list/constants.js +++ b/app/assets/javascripts/issues/list/constants.js @@ -6,7 +6,7 @@ import { FILTER_STARTED, FILTER_UPCOMING, OPERATOR_IS, - OPERATOR_IS_NOT, + OPERATOR_NOT, OPERATOR_OR, TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, @@ -22,6 +22,7 @@ import { TOKEN_TYPE_RELEASE, TOKEN_TYPE_TYPE, TOKEN_TYPE_WEIGHT, + TOKEN_TYPE_SEARCH_WITHIN, } from '~/vue_shared/components/filtered_search_bar/constants'; import { WORK_ITEM_TYPE_ENUM_INCIDENT, @@ -30,6 +31,50 @@ import { WORK_ITEM_TYPE_ENUM_TASK, } from '~/work_items/constants'; +export const ISSUE_REFERENCE = /^#\d+$/; +export const MAX_LIST_SIZE = 10; +export const PAGE_SIZE = 20; +export const PARAM_ASSIGNEE_ID = 'assignee_id'; +export const PARAM_FIRST_PAGE_SIZE = 'first_page_size'; +export const PARAM_LAST_PAGE_SIZE = 'last_page_size'; +export const PARAM_PAGE_AFTER = 'page_after'; +export const PARAM_PAGE_BEFORE = 'page_before'; +export const PARAM_SORT = 'sort'; +export const PARAM_STATE = 'state'; +export const RELATIVE_POSITION = 'relative_position'; + +export const BLOCKING_ISSUES_ASC = 'BLOCKING_ISSUES_ASC'; +export const BLOCKING_ISSUES_DESC = 'BLOCKING_ISSUES_DESC'; +export const CLOSED_AT_ASC = 'CLOSED_AT_ASC'; +export const CLOSED_AT_DESC = 'CLOSED_AT_DESC'; +export const CREATED_ASC = 'CREATED_ASC'; +export const CREATED_DESC = 'CREATED_DESC'; +export const DUE_DATE_ASC = 'DUE_DATE_ASC'; +export const DUE_DATE_DESC = 'DUE_DATE_DESC'; +export const HEALTH_STATUS_ASC = 'HEALTH_STATUS_ASC'; +export const HEALTH_STATUS_DESC = 'HEALTH_STATUS_DESC'; +export const LABEL_PRIORITY_ASC = 'LABEL_PRIORITY_ASC'; +export const LABEL_PRIORITY_DESC = 'LABEL_PRIORITY_DESC'; +export const MILESTONE_DUE_ASC = 'MILESTONE_DUE_ASC'; +export const MILESTONE_DUE_DESC = 'MILESTONE_DUE_DESC'; +export const POPULARITY_ASC = 'POPULARITY_ASC'; +export const POPULARITY_DESC = 'POPULARITY_DESC'; +export const PRIORITY_ASC = 'PRIORITY_ASC'; +export const PRIORITY_DESC = 'PRIORITY_DESC'; +export const RELATIVE_POSITION_ASC = 'RELATIVE_POSITION_ASC'; +export const TITLE_ASC = 'TITLE_ASC'; +export const TITLE_DESC = 'TITLE_DESC'; +export const UPDATED_ASC = 'UPDATED_ASC'; +export const UPDATED_DESC = 'UPDATED_DESC'; +export const WEIGHT_ASC = 'WEIGHT_ASC'; +export const WEIGHT_DESC = 'WEIGHT_DESC'; + +export const API_PARAM = 'apiParam'; +export const URL_PARAM = 'urlParam'; +export const NORMAL_FILTER = 'normalFilter'; +export const SPECIAL_FILTER = 'specialFilter'; +export const ALTERNATIVE_FILTER = 'alternativeFilter'; + export const i18n = { anonymousSearchingMessage: __('You must sign in to search for specific terms.'), calendarLabel: __('Subscribe to calendar'), @@ -57,11 +102,9 @@ export const i18n = { ), noOpenIssuesDescription: __('To keep this project going, create a new issue'), noOpenIssuesTitle: __('There are no open issues'), - noIssuesSignedInDescription: __('Learn more about issues.'), - noIssuesSignedInTitle: __('Use issues to collaborate on ideas, solve problems, and plan work'), + noIssuesDescription: __('Learn more about issues.'), + noIssuesTitle: __('Use issues to collaborate on ideas, solve problems, and plan work'), noIssuesSignedOutButtonText: __('Register / Sign In'), - noIssuesSignedOutDescription: __('Learn more about issues.'), - noIssuesSignedOutTitle: __('Use issues to collaborate on ideas, solve problems, and plan work'), noSearchResultsDescription: __('To widen your search, change or remove filters above'), noSearchResultsTitle: __('Sorry, your filter produced no results'), relatedMergeRequests: __('Related merge requests'), @@ -69,45 +112,10 @@ export const i18n = { rssLabel: __('Subscribe to RSS feed'), searchPlaceholder: __('Search or filter results...'), upvotes: __('Upvotes'), + titles: __('Titles'), + descriptions: __('Descriptions'), }; -export const ISSUE_REFERENCE = /^#\d+$/; -export const MAX_LIST_SIZE = 10; -export const PAGE_SIZE = 20; -export const PAGE_SIZE_MANUAL = 100; -export const PARAM_ASSIGNEE_ID = 'assignee_id'; -export const PARAM_FIRST_PAGE_SIZE = 'first_page_size'; -export const PARAM_LAST_PAGE_SIZE = 'last_page_size'; -export const PARAM_PAGE_AFTER = 'page_after'; -export const PARAM_PAGE_BEFORE = 'page_before'; -export const PARAM_SORT = 'sort'; -export const PARAM_STATE = 'state'; -export const RELATIVE_POSITION = 'relative_position'; - -export const BLOCKING_ISSUES_ASC = 'BLOCKING_ISSUES_ASC'; -export const BLOCKING_ISSUES_DESC = 'BLOCKING_ISSUES_DESC'; -export const CREATED_ASC = 'CREATED_ASC'; -export const CREATED_DESC = 'CREATED_DESC'; -export const DUE_DATE_ASC = 'DUE_DATE_ASC'; -export const DUE_DATE_DESC = 'DUE_DATE_DESC'; -export const LABEL_PRIORITY_ASC = 'LABEL_PRIORITY_ASC'; -export const LABEL_PRIORITY_DESC = 'LABEL_PRIORITY_DESC'; -export const MILESTONE_DUE_ASC = 'MILESTONE_DUE_ASC'; -export const MILESTONE_DUE_DESC = 'MILESTONE_DUE_DESC'; -export const POPULARITY_ASC = 'POPULARITY_ASC'; -export const POPULARITY_DESC = 'POPULARITY_DESC'; -export const PRIORITY_ASC = 'PRIORITY_ASC'; -export const PRIORITY_DESC = 'PRIORITY_DESC'; -export const RELATIVE_POSITION_ASC = 'RELATIVE_POSITION_ASC'; -export const TITLE_ASC = 'TITLE_ASC'; -export const TITLE_DESC = 'TITLE_DESC'; -export const UPDATED_ASC = 'UPDATED_ASC'; -export const UPDATED_DESC = 'UPDATED_DESC'; -export const WEIGHT_ASC = 'WEIGHT_ASC'; -export const WEIGHT_DESC = 'WEIGHT_DESC'; -export const CLOSED_ASC = 'CLOSED_AT_ASC'; -export const CLOSED_DESC = 'CLOSED_AT_DESC'; - export const urlSortParams = { [PRIORITY_ASC]: 'priority', [PRIORITY_DESC]: 'priority_desc', @@ -115,8 +123,8 @@ export const urlSortParams = { [CREATED_DESC]: 'created_date', [UPDATED_ASC]: 'updated_asc', [UPDATED_DESC]: 'updated_desc', - [CLOSED_ASC]: 'closed_asc', - [CLOSED_DESC]: 'closed_desc', + [CLOSED_AT_ASC]: 'closed_at', + [CLOSED_AT_DESC]: 'closed_at_desc', [MILESTONE_DUE_ASC]: 'milestone', [MILESTONE_DUE_DESC]: 'milestone_due_desc', [DUE_DATE_ASC]: 'due_date', @@ -126,20 +134,16 @@ export const urlSortParams = { [LABEL_PRIORITY_ASC]: 'label_priority', [LABEL_PRIORITY_DESC]: 'label_priority_desc', [RELATIVE_POSITION_ASC]: RELATIVE_POSITION, + [TITLE_ASC]: 'title_asc', + [TITLE_DESC]: 'title_desc', + [HEALTH_STATUS_ASC]: 'health_status_asc', + [HEALTH_STATUS_DESC]: 'health_status_desc', [WEIGHT_ASC]: 'weight', [WEIGHT_DESC]: 'weight_desc', [BLOCKING_ISSUES_ASC]: 'blocking_issues_asc', [BLOCKING_ISSUES_DESC]: 'blocking_issues_desc', - [TITLE_ASC]: 'title_asc', - [TITLE_DESC]: 'title_desc', }; -export const API_PARAM = 'apiParam'; -export const URL_PARAM = 'urlParam'; -export const NORMAL_FILTER = 'normalFilter'; -export const SPECIAL_FILTER = 'specialFilter'; -export const ALTERNATIVE_FILTER = 'alternativeFilter'; - export const specialFilterValues = [ FILTER_NONE, FILTER_ANY, @@ -148,7 +152,17 @@ export const specialFilterValues = [ FILTER_STARTED, ]; -export const TYPE_TOKEN_TASK_OPTION = { icon: 'issue-type-task', title: 'task', value: 'task' }; +export const TYPE_TOKEN_OBJECTIVE_OPTION = { + icon: 'issue-type-objective', + title: 'objective', + value: 'objective', +}; + +export const TYPE_TOKEN_KEY_RESULT_OPTION = { + icon: 'issue-type-key-result', + title: 'key_result', + value: 'key_result', +}; // This should be consistent with Issue::TYPES_FOR_LIST in the backend // https://gitlab.com/gitlab-org/gitlab/-/blob/1379c2d7bffe2a8d809f23ac5ef9b4114f789c07/app/models/issue.rb#L48 @@ -163,20 +177,35 @@ export const defaultTypeTokenOptions = [ { icon: 'issue-type-issue', title: 'issue', value: 'issue' }, { icon: 'issue-type-incident', title: 'incident', value: 'incident' }, { icon: 'issue-type-test-case', title: 'test_case', value: 'test_case' }, + { icon: 'issue-type-task', title: 'task', value: 'task' }, ]; export const filters = { [TOKEN_TYPE_AUTHOR]: { [API_PARAM]: { [NORMAL_FILTER]: 'authorUsername', + [ALTERNATIVE_FILTER]: 'authorUsernames', }, [URL_PARAM]: { [OPERATOR_IS]: { [NORMAL_FILTER]: 'author_username', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[author_username]', }, + [OPERATOR_OR]: { + [ALTERNATIVE_FILTER]: 'or[author_username]', + }, + }, + }, + [TOKEN_TYPE_SEARCH_WITHIN]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'in', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'in', + }, }, }, [TOKEN_TYPE_ASSIGNEE]: { @@ -190,7 +219,7 @@ export const filters = { [SPECIAL_FILTER]: 'assignee_id', [ALTERNATIVE_FILTER]: 'assignee_username', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[assignee_username][]', }, [OPERATOR_OR]: { @@ -208,7 +237,7 @@ export const filters = { [NORMAL_FILTER]: 'milestone_title', [SPECIAL_FILTER]: 'milestone_title', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[milestone_title]', [SPECIAL_FILTER]: 'not[milestone_title]', }, @@ -225,7 +254,7 @@ export const filters = { [SPECIAL_FILTER]: 'label_name[]', [ALTERNATIVE_FILTER]: 'label_name', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[label_name][]', }, }, @@ -238,7 +267,7 @@ export const filters = { [OPERATOR_IS]: { [NORMAL_FILTER]: 'type[]', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[type][]', }, }, @@ -253,7 +282,7 @@ export const filters = { [NORMAL_FILTER]: 'release_tag', [SPECIAL_FILTER]: 'release_tag', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[release_tag]', }, }, @@ -268,7 +297,7 @@ export const filters = { [NORMAL_FILTER]: 'my_reaction_emoji', [SPECIAL_FILTER]: 'my_reaction_emoji', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[my_reaction_emoji]', }, }, @@ -293,7 +322,7 @@ export const filters = { [NORMAL_FILTER]: 'iteration_id', [SPECIAL_FILTER]: 'iteration_id', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[iteration_id]', [SPECIAL_FILTER]: 'not[iteration_id]', }, @@ -309,7 +338,7 @@ export const filters = { [NORMAL_FILTER]: 'epic_id', [SPECIAL_FILTER]: 'epic_id', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[epic_id]', }, }, @@ -324,7 +353,7 @@ export const filters = { [NORMAL_FILTER]: 'weight', [SPECIAL_FILTER]: 'weight', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[weight]', }, }, diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js index 5e04dd1971c..7b68b7432c9 100644 --- a/app/assets/javascripts/issues/list/index.js +++ b/app/assets/javascripts/issues/list/index.js @@ -2,16 +2,15 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import VueRouter from 'vue-router'; import IssuesListApp from 'ee_else_ce/issues/list/components/issues_list_app.vue'; -import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; -import JiraIssuesImportStatusRoot from './components/jira_issues_import_status_app.vue'; +import JiraIssuesImportStatusApp from './components/jira_issues_import_status_app.vue'; import { gqlClient } from './graphql'; export function mountJiraIssuesListApp() { - const el = document.querySelector('.js-jira-issues-import-status'); + const el = document.querySelector('.js-jira-issues-import-status-root'); if (!el) { - return false; + return null; } const { issuesPath, projectPath } = el.dataset; @@ -19,21 +18,19 @@ export function mountJiraIssuesListApp() { const isJiraConfigured = parseBoolean(el.dataset.isJiraConfigured); if (!isJiraConfigured || !canEdit) { - return false; + return null; } Vue.use(VueApollo); - const defaultClient = createDefaultClient(); - const apolloProvider = new VueApollo({ - defaultClient, - }); return new Vue({ el, name: 'JiraIssuesImportStatusRoot', - apolloProvider, + apolloProvider: new VueApollo({ + defaultClient: gqlClient, + }), render(createComponent) { - return createComponent(JiraIssuesImportStatusRoot, { + return createComponent(JiraIssuesImportStatusApp, { props: { canEdit, isJiraConfigured, @@ -46,10 +43,10 @@ export function mountJiraIssuesListApp() { } export function mountIssuesListApp() { - const el = document.querySelector('.js-issues-list'); + const el = document.querySelector('.js-issues-list-root'); if (!el) { - return false; + return null; } Vue.use(VueApollo); @@ -77,6 +74,7 @@ export function mountIssuesListApp() { hasIssueWeightsFeature, hasIterationsFeature, hasScopedLabelsFeature, + hasOkrsFeature, importCsvIssuesPath, initialEmail, initialSort, @@ -127,6 +125,7 @@ export function mountIssuesListApp() { hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), hasIterationsFeature: parseBoolean(hasIterationsFeature), hasScopedLabelsFeature: parseBoolean(hasScopedLabelsFeature), + hasOkrsFeature: parseBoolean(hasOkrsFeature), initialSort, isAnonymousSearchDisabled: parseBoolean(isAnonymousSearchDisabled), isIssueRepositioningDisabled: parseBoolean(isIssueRepositioningDisabled), diff --git a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql index b447289b425..ee97fb6edca 100644 --- a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql +++ b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql @@ -10,6 +10,7 @@ query getIssues( $search: String $sort: IssueSort $state: IssuableState + $in: [IssuableSearchableField!] $assigneeId: String $assigneeUsernames: [String!] $authorUsername: String @@ -38,6 +39,7 @@ query getIssues( search: $search sort: $sort state: $state + in: $in assigneeId: $assigneeId assigneeUsernames: $assigneeUsernames authorUsername: $authorUsername @@ -72,6 +74,7 @@ query getIssues( search: $search sort: $sort state: $state + in: $in assigneeId: $assigneeId assigneeUsernames: $assigneeUsernames authorUsername: $authorUsername diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js index 2f9ab9d62ee..b566e08731c 100644 --- a/app/assets/javascripts/issues/list/utils.js +++ b/app/assets/javascripts/issues/list/utils.js @@ -4,9 +4,10 @@ import { getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import { FILTERED_SEARCH_TERM, - OPERATOR_IS_NOT, + OPERATOR_NOT, OPERATOR_OR, TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, TOKEN_TYPE_CONFIDENTIAL, TOKEN_TYPE_ITERATION, TOKEN_TYPE_MILESTONE, @@ -14,14 +15,19 @@ import { TOKEN_TYPE_TYPE, } from '~/vue_shared/components/filtered_search_bar/constants'; import { + ALTERNATIVE_FILTER, API_PARAM, BLOCKING_ISSUES_ASC, BLOCKING_ISSUES_DESC, + CLOSED_AT_ASC, + CLOSED_AT_DESC, CREATED_ASC, CREATED_DESC, DUE_DATE_ASC, DUE_DATE_DESC, filters, + HEALTH_STATUS_ASC, + HEALTH_STATUS_DESC, LABEL_PRIORITY_ASC, LABEL_PRIORITY_DESC, MILESTONE_DUE_ASC, @@ -44,8 +50,6 @@ import { urlSortParams, WEIGHT_ASC, WEIGHT_DESC, - CLOSED_ASC, - CLOSED_DESC, } from './constants'; export const getInitialPageParams = ( @@ -66,7 +70,11 @@ export const getSortKey = (sort) => export const isSortKey = (sort) => Object.keys(urlSortParams).includes(sort); -export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) => { +export const getSortOptions = ({ + hasBlockedIssuesFeature, + hasIssuableHealthStatusFeature, + hasIssueWeightsFeature, +}) => { const sortOptions = [ { id: 1, @@ -96,8 +104,8 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) id: 4, title: __('Closed date'), sortDirection: { - ascending: CLOSED_ASC, - descending: CLOSED_DESC, + ascending: CLOSED_AT_ASC, + descending: CLOSED_AT_DESC, }, }, { @@ -150,6 +158,17 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) }, ]; + if (hasIssuableHealthStatusFeature) { + sortOptions.push({ + id: sortOptions.length + 1, + title: __('Health'), + sortDirection: { + ascending: HEALTH_STATUS_ASC, + descending: HEALTH_STATUS_DESC, + }, + }); + } + if (hasIssueWeightsFeature) { sortOptions.push({ id: sortOptions.length + 1, @@ -223,13 +242,24 @@ export const getFilterTokens = (locationSearch) => { return tokens.length ? tokens : [createTerm()]; }; -const getFilterType = (data, tokenType = '') => { +const isSpecialFilter = (type, data) => { const isAssigneeIdParam = - tokenType === TOKEN_TYPE_ASSIGNEE && + type === TOKEN_TYPE_ASSIGNEE && isPositiveInteger(data) && getParameterByName(PARAM_ASSIGNEE_ID) === data; + return specialFilterValues.includes(data) || isAssigneeIdParam; +}; + +const getFilterType = ({ type, value: { data, operator } }) => { + const isUnionedAuthor = type === TOKEN_TYPE_AUTHOR && operator === OPERATOR_OR; - return specialFilterValues.includes(data) || isAssigneeIdParam ? SPECIAL_FILTER : NORMAL_FILTER; + if (isUnionedAuthor) { + return ALTERNATIVE_FILTER; + } + if (isSpecialFilter(type, data)) { + return SPECIAL_FILTER; + } + return NORMAL_FILTER; }; const wildcardTokens = [TOKEN_TYPE_ITERATION, TOKEN_TYPE_MILESTONE, TOKEN_TYPE_RELEASE]; @@ -258,10 +288,10 @@ export const convertToApiParams = (filterTokens) => { filterTokens .filter((token) => token.type !== FILTERED_SEARCH_TERM) .forEach((token) => { - const filterType = getFilterType(token.value.data, token.type); - const field = filters[token.type][API_PARAM][filterType]; + const filterType = getFilterType(token); + const apiField = filters[token.type][API_PARAM][filterType]; let obj; - if (token.value.operator === OPERATOR_IS_NOT) { + if (token.value.operator === OPERATOR_NOT) { obj = not; } else if (token.value.operator === OPERATOR_OR) { obj = or; @@ -270,7 +300,7 @@ export const convertToApiParams = (filterTokens) => { } const data = formatData(token); Object.assign(obj, { - [field]: obj[field] ? [obj[field], data].flat() : data, + [apiField]: obj[apiField] ? [obj[apiField], data].flat() : data, }); }); @@ -289,10 +319,10 @@ export const convertToUrlParams = (filterTokens) => filterTokens .filter((token) => token.type !== FILTERED_SEARCH_TERM) .reduce((acc, token) => { - const filterType = getFilterType(token.value.data, token.type); - const param = filters[token.type][URL_PARAM][token.value.operator]?.[filterType]; + const filterType = getFilterType(token); + const urlParam = filters[token.type][URL_PARAM][token.value.operator]?.[filterType]; return Object.assign(acc, { - [param]: acc[param] ? [acc[param], token.value.data].flat() : token.value.data, + [urlParam]: acc[urlParam] ? [acc[urlParam], token.value.data].flat() : token.value.data, }); }, {}); @@ -300,4 +330,4 @@ export const convertToSearchQuery = (filterTokens) => filterTokens .filter((token) => token.type === FILTERED_SEARCH_TERM && token.value.data) .map((token) => token.value.data) - .join(' '); + .join(' ') || undefined; |