diff options
Diffstat (limited to 'app/assets/javascripts/issues')
34 files changed, 1048 insertions, 442 deletions
diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js index 92ff7f21eff..977a505437d 100644 --- a/app/assets/javascripts/issues/create_merge_request_dropdown.js +++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js @@ -7,7 +7,7 @@ import { import confidentialMergeRequestState from '~/confidential_merge_request/state'; import DropLab from '~/filtered_search/droplab/drop_lab_deprecated'; import ISetter from '~/filtered_search/droplab/plugins/input_setter'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __, sprintf } from '~/locale'; import { mergeUrlParams } from '~/lib/utils/url_utility'; @@ -141,7 +141,7 @@ export default class CreateMergeRequestDropdown { .catch(() => { this.unavailable(); this.disable(); - createFlash({ + createAlert({ message: __('Failed to check related branches.'), }); }); @@ -162,7 +162,7 @@ export default class CreateMergeRequestDropdown { } }) .catch(() => - createFlash({ + createAlert({ message: __('Failed to create a branch for this issue. Please try again.'), }), ); @@ -293,7 +293,7 @@ export default class CreateMergeRequestDropdown { } this.unavailable(); this.disable(); - createFlash({ + createAlert({ message: __('Failed to get ref.'), }); diff --git a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue index 29f6aecca03..b9d876ef72f 100644 --- a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue +++ b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue @@ -1,13 +1,50 @@ <script> -import { GlButton, GlEmptyState } from '@gitlab/ui'; +import { GlButton, GlEmptyState, GlTooltipDirective } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import getIssuesQuery from 'ee_else_ce/issues/dashboard/queries/get_issues.query.graphql'; +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 { IssuableStatus } from '~/issues/constants'; +import { + CREATED_DESC, + PAGE_SIZE, + PARAM_STATE, + UPDATED_DESC, + urlSortParams, +} from '~/issues/list/constants'; +import setSortPreferenceMutation from '~/issues/list/queries/set_sort_preference.mutation.graphql'; +import { + convertToApiParams, + convertToSearchQuery, + convertToUrlParams, + getFilterTokens, + getInitialPageParams, + getSortKey, + getSortOptions, + isSortKey, +} from '~/issues/list/utils'; +import axios from '~/lib/utils/axios_utils'; +import { scrollUp } from '~/lib/utils/scroll_utils'; +import { getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; +import { + TOKEN_TITLE_ASSIGNEE, + TOKEN_TITLE_AUTHOR, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, +} from '~/vue_shared/components/filtered_search_bar/constants'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants'; +const UserToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'); + export default { i18n: { calendarButtonText: __('Subscribe to calendar'), + closed: __('CLOSED'), + closedMoved: __('CLOSED (MOVED)'), emptyStateTitle: __('Please select at least one filter to see results'), + errorFetchingIssues: __('An error occurred while loading issues'), rssButtonText: __('Subscribe to RSS feed'), searchInputPlaceholder: __('Search or filter results...'), }, @@ -16,29 +53,237 @@ export default { GlButton, GlEmptyState, IssuableList, + IssueCardStatistics, + IssueCardTimeInfo, + }, + directives: { + GlTooltip: GlTooltipDirective, }, - inject: ['calendarPath', 'emptyStateSvgPath', 'isSignedIn', 'rssPath'], + inject: [ + 'calendarPath', + 'emptyStateSvgPath', + 'hasBlockedIssuesFeature', + 'hasIssuableHealthStatusFeature', + 'hasIssueWeightsFeature', + 'hasScopedLabelsFeature', + 'initialSort', + 'isPublicVisibilityRestricted', + 'isSignedIn', + 'rssPath', + ], data() { + const state = getParameterByName(PARAM_STATE); + + const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC; + const dashboardSortKey = getSortKey(this.initialSort); + const graphQLSortKey = + isSortKey(this.initialSort?.toUpperCase()) && this.initialSort.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. + const sortKey = dashboardSortKey || graphQLSortKey || defaultSortKey; + return { + filterTokens: getFilterTokens(window.location.search), issues: [], - searchTokens: [], - sortOptions: [], - state: IssuableStates.Opened, + issuesError: null, + pageInfo: {}, + pageParams: getInitialPageParams(), + sortKey, + state: state || IssuableStates.Opened, }; }, + apollo: { + issues: { + query: getIssuesQuery, + variables() { + return { + hideUsers: this.isPublicVisibilityRestricted && !this.isSignedIn, + isSignedIn: this.isSignedIn, + search: this.searchQuery, + sort: this.sortKey, + state: this.state, + ...this.pageParams, + ...this.apiFilterParams, + }; + }, + update(data) { + return data.issues.nodes ?? []; + }, + result({ data }) { + this.pageInfo = data?.issues.pageInfo ?? {}; + }, + error(error) { + this.issuesError = this.$options.i18n.errorFetchingIssues; + Sentry.captureException(error); + }, + debounce: 200, + }, + }, + computed: { + apiFilterParams() { + return convertToApiParams(this.filterTokens); + }, + searchQuery() { + return convertToSearchQuery(this.filterTokens); + }, + searchTokens() { + const preloadedUsers = []; + + if (gon.current_user_id) { + preloadedUsers.push({ + id: gon.current_user_id, + name: gon.current_user_fullname, + username: gon.current_username, + avatar_url: gon.current_user_avatar_url, + }); + } + + const tokens = [ + { + type: TOKEN_TYPE_ASSIGNEE, + title: TOKEN_TITLE_ASSIGNEE, + icon: 'user', + token: UserToken, + fetchUsers: this.fetchUsers, + preloadedUsers, + recentSuggestionsStorageKey: 'dashboard-issues-recent-tokens-assignee', + }, + { + type: TOKEN_TYPE_AUTHOR, + title: TOKEN_TITLE_AUTHOR, + icon: 'pencil', + token: UserToken, + fetchUsers: this.fetchUsers, + defaultUsers: [], + preloadedUsers, + recentSuggestionsStorageKey: 'dashboard-issues-recent-tokens-author', + }, + ]; + + return tokens; + }, + showPaginationControls() { + return this.issues.length > 0 && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage); + }, + sortOptions() { + return getSortOptions({ + hasBlockedIssuesFeature: this.hasBlockedIssuesFeature, + hasIssuableHealthStatusFeature: this.hasIssuableHealthStatusFeature, + hasIssueWeightsFeature: this.hasIssueWeightsFeature, + }); + }, + urlFilterParams() { + return convertToUrlParams(this.filterTokens); + }, + urlParams() { + return { + search: this.searchQuery, + sort: urlSortParams[this.sortKey], + state: this.state, + ...this.urlFilterParams, + }; + }, + }, + methods: { + fetchUsers(search) { + return axios.get('/-/autocomplete/users.json', { params: { active: true, search } }); + }, + getStatus(issue) { + if (issue.state === IssuableStatus.Closed && issue.moved) { + return this.$options.i18n.closedMoved; + } + if (issue.state === IssuableStatus.Closed) { + return this.$options.i18n.closed; + } + return undefined; + }, + handleClickTab(state) { + if (this.state === state) { + return; + } + this.state = state; + this.pageParams = getInitialPageParams(); + }, + handleDismissAlert() { + this.issuesError = null; + }, + handleFilter(tokens) { + this.filterTokens = tokens; + this.pageParams = getInitialPageParams(); + }, + handleNextPage() { + this.pageParams = { + afterCursor: this.pageInfo.endCursor, + firstPageSize: PAGE_SIZE, + }; + scrollUp(); + }, + handlePreviousPage() { + this.pageParams = { + beforeCursor: this.pageInfo.startCursor, + lastPageSize: PAGE_SIZE, + }; + scrollUp(); + }, + handleSort(sortKey) { + if (this.sortKey === sortKey) { + return; + } + + this.sortKey = sortKey; + this.pageParams = getInitialPageParams(); + + if (this.isSignedIn) { + this.saveSortPreference(sortKey); + } + }, + saveSortPreference(sortKey) { + this.$apollo + .mutate({ + mutation: setSortPreferenceMutation, + variables: { input: { issuesSort: sortKey } }, + }) + .then(({ data }) => { + if (data.userPreferencesUpdate.errors.length) { + throw new Error(data.userPreferencesUpdate.errors); + } + }) + .catch((error) => { + Sentry.captureException(error); + }); + }, + }, }; </script> <template> <issuable-list + :current-tab="state" + :error="issuesError" + :has-next-page="pageInfo.hasNextPage" + :has-previous-page="pageInfo.hasPreviousPage" + :has-scoped-labels-feature="hasScopedLabelsFeature" + :initial-filter-value="filterTokens" + :initial-sort-by="sortKey" + :issuables="issues" + :issuables-loading="$apollo.queries.issues.loading" namespace="dashboard" recent-searches-storage-key="issues" :search-input-placeholder="$options.i18n.searchInputPlaceholder" :search-tokens="searchTokens" + :show-pagination-controls="showPaginationControls" + show-work-item-type-icon :sort-options="sortOptions" - :issuables="issues" :tabs="$options.IssuableListTabs" - :current-tab="state" + :url-params="urlParams" + use-keyset-pagination + @click-tab="handleClickTab" + @dismiss-alert="handleDismissAlert" + @filter="handleFilter" + @next-page="handleNextPage" + @previous-page="handlePreviousPage" + @sort="handleSort" > <template #nav-actions> <gl-button :href="rssPath" icon="rss"> @@ -49,6 +294,18 @@ export default { </gl-button> </template> + <template #timeframe="{ issuable = {} }"> + <issue-card-time-info :issue="issuable" /> + </template> + + <template #status="{ issuable = {} }"> + {{ getStatus(issuable) }} + </template> + + <template #statistics="{ issuable = {} }"> + <issue-card-statistics :issue="issuable" /> + </template> + <template #empty-state> <gl-empty-state :svg-path="emptyStateSvgPath" :title="$options.i18n.emptyStateTitle" /> </template> diff --git a/app/assets/javascripts/issues/dashboard/index.js b/app/assets/javascripts/issues/dashboard/index.js index a1ae3b93f7d..e3e5cc614cb 100644 --- a/app/assets/javascripts/issues/dashboard/index.js +++ b/app/assets/javascripts/issues/dashboard/index.js @@ -1,4 +1,6 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; import IssuesDashboardApp from './components/issues_dashboard_app.vue'; @@ -9,14 +11,36 @@ export function mountIssuesDashboardApp() { return null; } - const { calendarPath, emptyStateSvgPath, isSignedIn, rssPath } = el.dataset; + Vue.use(VueApollo); + + const { + calendarPath, + emptyStateSvgPath, + hasBlockedIssuesFeature, + hasIssuableHealthStatusFeature, + hasIssueWeightsFeature, + hasScopedLabelsFeature, + initialSort, + isPublicVisibilityRestricted, + isSignedIn, + rssPath, + } = el.dataset; return new Vue({ el, name: 'IssuesDashboardRoot', + apolloProvider: new VueApollo({ + defaultClient: createDefaultClient(), + }), provide: { calendarPath, emptyStateSvgPath, + hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature), + hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), + hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), + hasScopedLabelsFeature: parseBoolean(hasScopedLabelsFeature), + initialSort, + isPublicVisibilityRestricted: parseBoolean(isPublicVisibilityRestricted), isSignedIn: parseBoolean(isSignedIn), rssPath, }, diff --git a/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql b/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql new file mode 100644 index 00000000000..8ffcb456755 --- /dev/null +++ b/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql @@ -0,0 +1,36 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" +#import "~/issues/list/queries/issue.fragment.graphql" + +query getDashboardIssues( + $hideUsers: Boolean = false + $isSignedIn: Boolean = false + $search: String + $sort: IssueSort + $state: IssuableState + $assigneeUsernames: [String!] + $authorUsername: String + $afterCursor: String + $beforeCursor: String + $firstPageSize: Int + $lastPageSize: Int +) { + issues( + search: $search + sort: $sort + state: $state + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + after: $afterCursor + before: $beforeCursor + first: $firstPageSize + last: $lastPageSize + ) { + nodes { + ...IssueFragment + reference(full: true) + } + pageInfo { + ...PageInfo + } + } +} diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js index a785790169d..e3716d0e111 100644 --- a/app/assets/javascripts/issues/index.js +++ b/app/assets/javascripts/issues/index.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import IssuableForm from 'ee_else_ce/issuable/issuable_form'; +import IssuableLabelSelector from '~/issuable/issuable_label_selector'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import GLForm from '~/gl_form'; @@ -39,6 +40,7 @@ export function initFilteredSearchServiceDesk() { export function initForm() { new GLForm($('.issue-form')); // eslint-disable-line no-new new IssuableForm($('.issue-form')); // eslint-disable-line no-new + IssuableLabelSelector(); new IssuableTemplateSelectors({ warnTemplateOverride: true }); // eslint-disable-line no-new new LabelsSelect(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/issues/issue.js b/app/assets/javascripts/issues/issue.js index a9321cf200d..de1c689e590 100644 --- a/app/assets/javascripts/issues/issue.js +++ b/app/assets/javascripts/issues/issue.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import { joinPaths } from '~/lib/utils/url_utility'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import axios from '~/lib/utils/axios_utils'; import { addDelimiter } from '~/lib/utils/text_utility'; @@ -68,7 +68,7 @@ export default class Issue { this.createMergeRequestDropdown.checkAbilityToCreateBranch(); } } else { - createFlash({ + createAlert({ message: issueFailMessage, }); } @@ -105,7 +105,7 @@ export default class Issue { } }) .catch(() => - createFlash({ + createAlert({ message: __('Failed to load related branches'), }), ); 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; diff --git a/app/assets/javascripts/issues/manual_ordering.js b/app/assets/javascripts/issues/manual_ordering.js index bc1cffef943..1bb53dfd50d 100644 --- a/app/assets/javascripts/issues/manual_ordering.js +++ b/app/assets/javascripts/issues/manual_ordering.js @@ -1,5 +1,5 @@ import Sortable from 'sortablejs'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { s__ } from '~/locale'; import { getSortableDefaultOptions, sortableStart } from '~/sortable/utils'; @@ -11,7 +11,7 @@ const updateIssue = (url, { move_before_id, move_after_id }) => move_after_id, }) .catch(() => { - createFlash({ + createAlert({ message: s__("ManualOrdering|Couldn't save the order of the issues"), }); }); diff --git a/app/assets/javascripts/issues/related_merge_requests/store/actions.js b/app/assets/javascripts/issues/related_merge_requests/store/actions.js index 94abb50de89..4c81f1d9bc1 100644 --- a/app/assets/javascripts/issues/related_merge_requests/store/actions.js +++ b/app/assets/javascripts/issues/related_merge_requests/store/actions.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { normalizeHeaders } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; @@ -29,7 +29,7 @@ export const fetchMergeRequests = ({ state, dispatch }) => { }) .catch(() => { dispatch('receiveDataError'); - createFlash({ + createAlert({ message: __('Something went wrong while fetching related merge requests.'), }); }); diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index 0daf77e03dc..e5428f87095 100644 --- a/app/assets/javascripts/issues/show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlBadge, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui'; import Visibility from 'visibilityjs'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { IssuableStatus, IssuableStatusText, @@ -327,7 +327,7 @@ export default { this.store.updateState(data); }) .catch(() => { - createFlash({ + createAlert({ message: this.defaultErrorMessage, }); }); @@ -362,7 +362,7 @@ export default { this.updateAndShowForm(res.data); }) .catch(() => { - createFlash({ + createAlert({ message: this.defaultErrorMessage, }); this.updateAndShowForm(); @@ -429,7 +429,7 @@ export default { errMsg += `. ${message}`; } - this.flashContainer = createFlash({ + this.flashContainer = createAlert({ message: errMsg, }); }) diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index 5c2a154362f..78e729b97da 100644 --- a/app/assets/javascripts/issues/show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -1,11 +1,12 @@ <script> -import { GlSafeHtmlDirective as SafeHtml, GlToast, GlTooltip, GlModalDirective } from '@gitlab/ui'; +import { GlToast, GlTooltip, GlModalDirective } from '@gitlab/ui'; import $ from 'jquery'; import Sortable from 'sortablejs'; import Vue from 'vue'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { IssuableType } from '~/issues/constants'; import { isMetaKey } from '~/lib/utils/common_utils'; import { isPositiveInteger } from '~/lib/utils/number_utils'; @@ -27,6 +28,7 @@ import { TASK_TYPE_NAME, WIDGET_TYPE_DESCRIPTION, } from '~/work_items/constants'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import animateMixin from '../mixins/animate'; import { convertDescriptionWithNewSort } from '../utils'; @@ -165,7 +167,7 @@ export default { this.renderGFM(); this.updateTaskStatusText(); - if (this.workItemId) { + if (this.workItemId && this.workItemsEnabled) { const taskLink = this.$el.querySelector( `.gfm-issue[data-issue="${getIdFromGraphQLId(this.workItemId)}"]`, ); @@ -177,7 +179,7 @@ export default { }, methods: { renderGFM() { - $(this.$refs['gfm-content']).renderGFM(); + renderGFM(this.$refs['gfm-content']); if (this.canUpdate) { // eslint-disable-next-line no-new @@ -283,7 +285,7 @@ export default { }, taskListUpdateError() { - createFlash({ + createAlert({ message: sprintf( __( 'Someone edited this %{issueType} at the same time you did. The description has been updated and you will need to make your changes again.', @@ -467,7 +469,7 @@ export default { this.workItemId = newWorkItem.id; this.openWorkItemDetailModal(el); } catch (error) { - createFlash({ + createAlert({ message: sprintfWorkItem(I18N_WORK_ITEM_ERROR_CREATING, workItemTypes.TASK), error, captureError: true, diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue index 180dea77003..04c5007dbec 100644 --- a/app/assets/javascripts/issues/show/components/fields/description.vue +++ b/app/assets/javascripts/issues/show/components/fields/description.vue @@ -67,6 +67,7 @@ export default { :quick-actions-docs-path="quickActionsDocsPath" :enable-autocomplete="enableAutocomplete" supports-quick-actions + use-bottom-toolbar autofocus @input="$emit('input', $event)" @keydown.meta.enter="updateIssuable" diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue index 0c6b61fb893..b56c91d7983 100644 --- a/app/assets/javascripts/issues/show/components/form.vue +++ b/app/assets/javascripts/issues/show/components/form.vue @@ -164,7 +164,7 @@ export default { <template> <form data-testid="issuable-form"> - <locked-warning v-if="showLockedWarning" /> + <locked-warning v-if="showLockedWarning" :issuable-type="issuableType" /> <gl-alert v-if="showOutdatedDescriptionWarning" class="gl-mb-5" diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue index c01de63ced9..983e2e6530e 100644 --- a/app/assets/javascripts/issues/show/components/header_actions.vue +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -10,7 +10,7 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; -import createFlash, { FLASH_TYPES } from '~/flash'; +import { createAlert, VARIANT_SUCCESS } from '~/flash'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import { IssuableStatus, IssueType } from '~/issues/constants'; import { ISSUE_STATE_EVENT_CLOSE, ISSUE_STATE_EVENT_REOPEN } from '~/issues/show/constants'; @@ -40,6 +40,7 @@ export default { promoteSuccessMessage: __( 'The issue was successfully promoted to an epic. Redirecting to epic...', ), + reportAbuse: __('Report abuse to administrator'), }, components: { DeleteIssueModal, @@ -191,7 +192,7 @@ export default { // Dispatch event which updates open/close state, shared among the issue show page document.dispatchEvent(new CustomEvent(EVENT_ISSUABLE_VUE_APP_CHANGE, payload)); }) - .catch(() => createFlash({ message: __('Error occurred while updating the issue status') })) + .catch(() => createAlert({ message: __('Error occurred while updating the issue status') })) .finally(() => { this.toggleStateButtonLoading(false); }); @@ -214,14 +215,14 @@ export default { throw new Error(); } - createFlash({ + createAlert({ message: this.$options.i18n.promoteSuccessMessage, - type: FLASH_TYPES.SUCCESS, + variant: VARIANT_SUCCESS, }); visitUrl(data.promoteToEpic.epic.webPath); }) - .catch(() => createFlash({ message: this.$options.i18n.promoteErrorMessage })) + .catch(() => createAlert({ message: this.$options.i18n.promoteErrorMessage })) .finally(() => { this.toggleStateButtonLoading(false); }); @@ -255,7 +256,7 @@ export default { {{ __('Promote to epic') }} </gl-dropdown-item> <gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath"> - {{ __('Report abuse') }} + {{ $options.i18n.reportAbuse }} </gl-dropdown-item> <gl-dropdown-item v-if="canReportSpam" @@ -314,7 +315,7 @@ export default { {{ __('Promote to epic') }} </gl-dropdown-item> <gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath"> - {{ __('Report abuse') }} + {{ $options.i18n.reportAbuse }} </gl-dropdown-item> <gl-dropdown-item v-if="canReportSpam" diff --git a/app/assets/javascripts/issues/show/components/incidents/constants.js b/app/assets/javascripts/issues/show/components/incidents/constants.js index db846009409..22db19610c1 100644 --- a/app/assets/javascripts/issues/show/components/incidents/constants.js +++ b/app/assets/javascripts/issues/show/components/incidents/constants.js @@ -14,6 +14,7 @@ export const timelineFormI18n = Object.freeze({ areaPlaceholder: s__('Incident|Timeline text...'), save: __('Save'), cancel: __('Cancel'), + delete: __('Delete'), description: __('Description'), hint: __('You can enter up to 280 characters'), textRemaining: (count) => n__('%d character remaining', '%d characters remaining', count), diff --git a/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue index 60fa8cb949b..8cdd62ca9ef 100644 --- a/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue +++ b/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue @@ -40,8 +40,10 @@ export default { :is-event-processed="editTimelineEventActive" :previous-occurred-at="event.occurredAt" :previous-note="event.note" + show-delete @save-event="saveEvent" @cancel="$emit('hide-edit')" + @delete="$emit('delete')" /> </div> </template> diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql index f1fc27dcb2a..4a8786b04b1 100644 --- a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql +++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql @@ -7,6 +7,12 @@ mutation CreateTimelineEvent($input: TimelineEventCreateInput!) { action occurredAt createdAt + timelineEventTags { + nodes { + id + name + } + } } errors } diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_alert.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_alert.graphql index d88633f2ae9..e057267b006 100644 --- a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_alert.graphql +++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_alert.graphql @@ -4,6 +4,7 @@ query getAlert($iid: String!, $fullPath: ID!) { issue(iid: $iid) { id alertManagementAlert { + id iid title detailsUrl diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql index bc4e8414bfc..baeb81745ab 100644 --- a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql +++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql @@ -9,6 +9,12 @@ query GetTimelineEvents($fullPath: ID!, $incidentId: IssueID!) { action occurredAt createdAt + timelineEventTags { + nodes { + id + name + } + } } } } diff --git a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue index 5725d0f8d6a..53956fcb4b2 100644 --- a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue +++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue @@ -1,16 +1,29 @@ <script> import { GlTab, GlTabs } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { trackIncidentDetailsViewsOptions } from '~/incidents/constants'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import DescriptionComponent from '../description.vue'; import getAlert from './graphql/queries/get_alert.graphql'; import HighlightBar from './highlight_bar.vue'; import TimelineTab from './timeline_events_tab.vue'; +export const incidentTabsI18n = Object.freeze({ + summaryTitle: s__('Incident|Summary'), + metricsTitle: s__('Incident|Metrics'), + alertsTitle: s__('Incident|Alert details'), + timelineTitle: s__('Incident|Timeline'), +}); + +export const TAB_NAMES = Object.freeze({ + SUMMARY: '', + ALERTS: 'alerts', + METRICS: 'metrics', + TIMELINE: 'timeline', +}); + export default { components: { AlertDetailsTable, @@ -22,8 +35,8 @@ export default { IncidentMetricTab: () => import('ee_component/issues/show/components/incidents/incident_metric_tab.vue'), }, - mixins: [glFeatureFlagsMixin()], - inject: ['fullPath', 'iid'], + inject: ['fullPath', 'iid', 'uploadMetricsFeatureAvailable'], + i18n: incidentTabsI18n, apollo: { alert: { query: getAlert, @@ -37,7 +50,7 @@ export default { return data?.project?.issue?.alertManagementAlert; }, error() { - createFlash({ + createAlert({ message: s__('Incident|There was an issue loading alert data. Please try again.'), }); }, @@ -46,12 +59,44 @@ export default { data() { return { alert: null, + activeTabIndex: 0, }; }, computed: { loading() { return this.$apollo.queries.alert.loading; }, + tabMapping() { + const availableTabs = [TAB_NAMES.SUMMARY]; + + if (this.uploadMetricsFeatureAvailable) { + availableTabs.push(TAB_NAMES.METRICS); + } + if (this.alert) { + availableTabs.push(TAB_NAMES.ALERTS); + } + + availableTabs.push(TAB_NAMES.TIMELINE); + + const tabNamesToIndex = {}; + const tabIndexToName = {}; + + availableTabs.forEach((item, index) => { + tabNamesToIndex[item] = index; + tabIndexToName[index] = item; + }); + + return { tabNamesToIndex, tabIndexToName }; + }, + currentTabIndex: { + get() { + return this.activeTabIndex; + }, + set(index) { + this.handleTabChange(index); + this.activeTabIndex = index; + }, + }, }, mounted() { this.trackPageViews(); @@ -91,25 +136,33 @@ export default { <template> <div> <gl-tabs + v-model="currentTabIndex" content-class="gl-reset-line-height" class="gl-mt-n3" data-testid="incident-tabs" - @input="handleTabChange" > - <gl-tab :title="s__('Incident|Summary')"> + <gl-tab :title="$options.i18n.summaryTitle" data-testid="summary-tab"> <highlight-bar :alert="alert" /> <description-component v-bind="$attrs" v-on="$listeners" /> </gl-tab> - <incident-metric-tab /> + <gl-tab + v-if="uploadMetricsFeatureAvailable" + :title="$options.i18n.metricsTitle" + data-testid="metrics-tab" + > + <incident-metric-tab /> + </gl-tab> <gl-tab v-if="alert" class="alert-management-details" - :title="s__('Incident|Alert details')" + :title="$options.i18n.alertsTitle" data-testid="alert-details-tab" > <alert-details-table :alert="alert" :loading="loading" /> </gl-tab> - <timeline-tab /> + <gl-tab :title="$options.i18n.timelineTitle" data-testid="timeline-tab"> + <timeline-tab /> + </gl-tab> </gl-tabs> </div> </template> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue index 72dfccca467..f1a3aebc990 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue @@ -1,7 +1,6 @@ <script> import { GlDatepicker, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; -import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; import { MAX_TEXT_LENGTH, timelineFormI18n } from './constants'; import { getUtcShiftedDate } from './utils'; @@ -27,15 +26,17 @@ export default { }, i18n: timelineFormI18n, MAX_TEXT_LENGTH, - directives: { - autofocusonshow, - }, props: { showSaveAndAdd: { type: Boolean, required: false, default: false, }, + showDelete: { + type: Boolean, + required: false, + default: false, + }, isEventProcessed: { type: Boolean, required: true, @@ -97,7 +98,7 @@ export default { this.timelineText = ''; }, focusDate() { - this.$refs.datepicker.$el.querySelector('input').focus(); + this.$refs.datepicker.$el.querySelector('input')?.focus(); }, handleSave(addAnotherEvent) { const event = { @@ -185,32 +186,42 @@ export default { </gl-form-group> </div> <gl-form-group class="gl-mb-0"> - <gl-button - variant="confirm" - category="primary" - class="gl-mr-3" - data-testid="save-button" - :disabled="!isTimelineTextValid" - :loading="isEventProcessed" - @click="handleSave(false)" - > - {{ $options.i18n.save }} - </gl-button> - <gl-button - v-if="showSaveAndAdd" - variant="confirm" - category="secondary" - class="gl-mr-3 gl-ml-n2" - data-testid="save-and-add-button" - :disabled="!isTimelineTextValid" - :loading="isEventProcessed" - @click="handleSave(true)" - > - {{ $options.i18n.saveAndAdd }} - </gl-button> - <gl-button class="gl-ml-n2" :disabled="isEventProcessed" @click="$emit('cancel')"> - {{ $options.i18n.cancel }} - </gl-button> + <div class="gl-display-flex"> + <gl-button + variant="confirm" + category="primary" + class="gl-mr-3" + data-testid="save-button" + :disabled="!isTimelineTextValid" + :loading="isEventProcessed" + @click="handleSave(false)" + > + {{ $options.i18n.save }} + </gl-button> + <gl-button + v-if="showSaveAndAdd" + variant="confirm" + category="secondary" + class="gl-mr-3 gl-ml-n2" + data-testid="save-and-add-button" + :disabled="!isTimelineTextValid" + :loading="isEventProcessed" + @click="handleSave(true)" + > + {{ $options.i18n.saveAndAdd }} + </gl-button> + <gl-button class="gl-ml-n2" :disabled="isEventProcessed" @click="$emit('cancel')"> + {{ $options.i18n.cancel }} + </gl-button> + <gl-button + v-if="showDelete" + class="gl-ml-auto btn-danger" + :disabled="isEventProcessed" + @click="$emit('delete')" + > + {{ $options.i18n.delete }} + </gl-button> + </div> <div class="timeline-event-bottom-border"></div> </gl-form-group> </form> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue index cbf3c387fa3..90ee4351e39 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue @@ -1,5 +1,6 @@ <script> -import { GlDropdown, GlDropdownItem, GlIcon, GlSafeHtmlDirective, GlSprintf } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlIcon, GlSprintf, GlBadge } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { formatDate } from '~/lib/utils/datetime_utility'; import { timelineItemI18n } from './constants'; import { getEventIcon } from './utils'; @@ -12,9 +13,10 @@ export default { GlDropdownItem, GlIcon, GlSprintf, + GlBadge, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, inject: ['canUpdateTimelineEvent'], props: { @@ -30,6 +32,11 @@ export default { type: String, required: true, }, + eventTag: { + type: String, + required: false, + default: null, + }, }, computed: { time() { @@ -42,41 +49,41 @@ export default { }; </script> <template> - <div class="gl-display-flex gl-align-items-start"> + <div class="timeline-event gl-display-grid"> <div class="gl-display-flex gl-align-items-center gl-justify-content-center gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-p-3 gl-z-index-1" > <gl-icon :name="getEventIcon(action)" class="note-icon" /> </div> - <div - class="timeline-event-note timeline-event-border gl-w-full gl-display-flex gl-flex-direction-row" - data-testid="event-text-container" - > - <div> + <div class="timeline-event-note timeline-event-border" data-testid="event-text-container"> + <div class="gl-display-flex gl-align-items-center gl-mb-3"> <strong class="gl-font-lg" data-testid="event-time"> <gl-sprintf :message="$options.i18n.timeUTC"> <template #time>{{ time }}</template> </gl-sprintf> </strong> - <div v-safe-html="noteHtml"></div> + <gl-badge v-if="eventTag" variant="muted" icon="tag" class="gl-ml-3"> + {{ eventTag }} + </gl-badge> </div> - <gl-dropdown - v-if="canUpdateTimelineEvent" - right - class="event-note-actions gl-ml-auto gl-align-self-start" - icon="ellipsis_v" - text-sr-only - :text="$options.i18n.moreActions" - category="tertiary" - no-caret - > - <gl-dropdown-item @click="$emit('edit')"> - {{ $options.i18n.edit }} - </gl-dropdown-item> - <gl-dropdown-item @click="$emit('delete')"> - {{ $options.i18n.delete }} - </gl-dropdown-item> - </gl-dropdown> + <div v-safe-html="noteHtml" class="md"></div> </div> + <gl-dropdown + v-if="canUpdateTimelineEvent" + right + class="event-note-actions gl-ml-auto gl-align-self-start" + icon="ellipsis_v" + text-sr-only + :text="$options.i18n.moreActions" + category="tertiary" + no-caret + > + <gl-dropdown-item @click="$emit('edit')"> + {{ $options.i18n.edit }} + </gl-dropdown-item> + <gl-dropdown-item @click="$emit('delete')"> + {{ $options.i18n.delete }} + </gl-dropdown-item> + </gl-dropdown> </div> </template> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue index 321b7ccc14a..c6b93201c97 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue @@ -50,6 +50,9 @@ export default { }, }, methods: { + getFirstTag(eventTag) { + return eventTag.nodes?.[0]?.name; + }, handleEditSelection(event) { this.eventToEdit = event.id; this.$emit('hide-new-incident-timeline-event-form'); @@ -153,6 +156,7 @@ export default { :edit-timeline-event-active="editTimelineEventActive" @handle-save-edit="handleSaveEdit" @hide-edit="hideEdit()" + @delete="handleDelete(event)" /> <incident-timeline-event-item v-else @@ -160,6 +164,7 @@ export default { :action="event.action" :occurred-at="event.occurredAt" :note-html="event.noteHtml" + :event-tag="getFirstTag(event.timelineEventTags)" @delete="handleDelete(event)" @edit="handleEditSelection(event)" /> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue index 5f70d9acac9..c8237766505 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlEmptyState, GlLoadingIcon, GlTab } from '@gitlab/ui'; +import { GlButton, GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPE_ISSUE } from '~/graphql_shared/constants'; import { fetchPolicies } from '~/lib/graphql'; @@ -15,7 +15,6 @@ export default { GlButton, GlEmptyState, GlLoadingIcon, - GlTab, CreateTimelineEvent, IncidentTimelineEventsList, }, @@ -77,7 +76,7 @@ export default { </script> <template> - <gl-tab :title="$options.i18n.title"> + <div> <gl-loading-icon v-if="timelineEventLoading" size="lg" color="dark" class="gl-mt-5" /> <gl-empty-state v-else-if="showEmptyState" @@ -106,5 +105,5 @@ export default { > {{ $options.i18n.addEventButton }} </gl-button> - </gl-tab> + </div> </template> diff --git a/app/assets/javascripts/issues/show/components/locked_warning.vue b/app/assets/javascripts/issues/show/components/locked_warning.vue index 12feacb027b..4414e693ed0 100644 --- a/app/assets/javascripts/issues/show/components/locked_warning.vue +++ b/app/assets/javascripts/issues/show/components/locked_warning.vue @@ -1,29 +1,44 @@ <script> import { GlSprintf, GlLink, GlAlert } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; +import { IssuableType } from '~/issues/constants'; -const alertMessage = __( - 'Someone edited the issue at the same time you did. Please check out %{linkStart}the issue%{linkEnd} and make sure your changes will not unintentionally remove theirs.', -); +export const i18n = Object.freeze({ + alertMessage: __( + "Someone edited the %{issuableType} at the same time you did. Review %{linkStart}the %{issuableType}%{linkEnd} and make sure you don't unintentionally overwrite their changes.", + ), +}); export default { - alertMessage, components: { GlSprintf, GlLink, GlAlert, }, + props: { + issuableType: { + type: String, + required: true, + validator(value) { + return Object.values(IssuableType).includes(value); + }, + }, + }, computed: { currentPath() { return window.location.pathname; }, + alertMessage() { + return sprintf(this.$options.i18n.alertMessage, { issuableType: this.issuableType }); + }, }, + i18n, }; </script> <template> <gl-alert variant="danger" class="gl-mb-5" :dismissible="false"> - <gl-sprintf :message="$options.alertMessage"> + <gl-sprintf :message="alertMessage"> <template #link="{ content }"> <gl-link :href="currentPath" target="_blank" rel="nofollow"> {{ content }} diff --git a/app/assets/javascripts/issues/show/components/title.vue b/app/assets/javascripts/issues/show/components/title.vue index 307d9f9f69a..6978f730e1d 100644 --- a/app/assets/javascripts/issues/show/components/title.vue +++ b/app/assets/javascripts/issues/show/components/title.vue @@ -1,5 +1,6 @@ <script> -import { GlButton, GlTooltipDirective, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { __ } from '~/locale'; import eventHub from '../event_hub'; import animateMixin from '../mixins/animate'; |