diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-11-17 14:33:21 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-11-17 14:33:21 +0300 |
commit | 7021455bd1ed7b125c55eb1b33c5a01f2bc55ee0 (patch) | |
tree | 5bdc2229f5198d516781f8d24eace62fc7e589e9 /app/assets/javascripts/issues | |
parent | 185b095e93520f96e9cfc31d9c3e69b498cdab7c (diff) |
Add latest changes from gitlab-org/gitlab@15-6-stable-eev15.6.0-rc42
Diffstat (limited to 'app/assets/javascripts/issues')
15 files changed, 228 insertions, 89 deletions
diff --git a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue new file mode 100644 index 00000000000..29f6aecca03 --- /dev/null +++ b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue @@ -0,0 +1,56 @@ +<script> +import { GlButton, GlEmptyState } from '@gitlab/ui'; +import { __ } from '~/locale'; +import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; +import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants'; + +export default { + i18n: { + calendarButtonText: __('Subscribe to calendar'), + emptyStateTitle: __('Please select at least one filter to see results'), + rssButtonText: __('Subscribe to RSS feed'), + searchInputPlaceholder: __('Search or filter results...'), + }, + IssuableListTabs, + components: { + GlButton, + GlEmptyState, + IssuableList, + }, + inject: ['calendarPath', 'emptyStateSvgPath', 'isSignedIn', 'rssPath'], + data() { + return { + issues: [], + searchTokens: [], + sortOptions: [], + state: IssuableStates.Opened, + }; + }, +}; +</script> + +<template> + <issuable-list + namespace="dashboard" + recent-searches-storage-key="issues" + :search-input-placeholder="$options.i18n.searchInputPlaceholder" + :search-tokens="searchTokens" + :sort-options="sortOptions" + :issuables="issues" + :tabs="$options.IssuableListTabs" + :current-tab="state" + > + <template #nav-actions> + <gl-button :href="rssPath" icon="rss"> + {{ $options.i18n.rssButtonText }} + </gl-button> + <gl-button :href="calendarPath" icon="calendar"> + {{ $options.i18n.calendarButtonText }} + </gl-button> + </template> + + <template #empty-state> + <gl-empty-state :svg-path="emptyStateSvgPath" :title="$options.i18n.emptyStateTitle" /> + </template> + </issuable-list> +</template> diff --git a/app/assets/javascripts/issues/dashboard/index.js b/app/assets/javascripts/issues/dashboard/index.js new file mode 100644 index 00000000000..a1ae3b93f7d --- /dev/null +++ b/app/assets/javascripts/issues/dashboard/index.js @@ -0,0 +1,25 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import IssuesDashboardApp from './components/issues_dashboard_app.vue'; + +export function mountIssuesDashboardApp() { + const el = document.querySelector('.js-issues-dashboard'); + + if (!el) { + return null; + } + + const { calendarPath, emptyStateSvgPath, isSignedIn, rssPath } = el.dataset; + + return new Vue({ + el, + name: 'IssuesDashboardRoot', + provide: { + calendarPath, + emptyStateSvgPath, + isSignedIn: parseBoolean(isSignedIn), + rssPath, + }, + render: (createComponent) => createComponent(IssuesDashboardApp), + }); +} diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js index 22ac37656ea..a785790169d 100644 --- a/app/assets/javascripts/issues/index.js +++ b/app/assets/javascripts/issues/index.js @@ -18,9 +18,9 @@ import { } from '~/issues/show'; import { parseIssuableData } from '~/issues/show/utils/parse_data'; import LabelsSelect from '~/labels/labels_select'; -import MilestoneSelect from '~/milestones/milestone_select'; import initNotesApp from '~/notes'; import { store } from '~/notes/stores'; +import { mountMilestoneDropdown } from '~/sidebar/mount_sidebar'; import ZenMode from '~/zen_mode'; import initAwardsApp from '~/emoji/awards_app'; import initLinkedResources from '~/linked_resources'; @@ -41,11 +41,11 @@ export function initForm() { new IssuableForm($('.issue-form')); // eslint-disable-line no-new new IssuableTemplateSelectors({ warnTemplateOverride: true }); // eslint-disable-line no-new new LabelsSelect(); // eslint-disable-line no-new - new MilestoneSelect(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new initTitleSuggestions(); initTypePopover(); + mountMilestoneDropdown(); } export function initShow() { 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 acb6aa93f0f..64de4b1947b 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -27,6 +27,7 @@ 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, TOKEN_TITLE_ASSIGNEE, TOKEN_TITLE_AUTHOR, @@ -38,12 +39,22 @@ import { TOKEN_TITLE_ORGANIZATION, TOKEN_TITLE_RELEASE, TOKEN_TITLE_TYPE, - FILTERED_SEARCH_TERM, + OPERATOR_IS_NOT_OR, + OPERATOR_IS_AND_IS_NOT, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_CONFIDENTIAL, + TOKEN_TYPE_CONTACT, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, + TOKEN_TYPE_MY_REACTION, + TOKEN_TYPE_ORGANIZATION, + TOKEN_TYPE_RELEASE, + TOKEN_TYPE_TYPE, } 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'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { WORK_ITEM_TYPE_ENUM_TASK } from '~/work_items/constants'; import { CREATED_DESC, defaultTypeTokenOptions, @@ -59,16 +70,6 @@ import { PARAM_SORT, PARAM_STATE, RELATIVE_POSITION_ASC, - TOKEN_TYPE_ASSIGNEE, - TOKEN_TYPE_AUTHOR, - TOKEN_TYPE_CONFIDENTIAL, - TOKEN_TYPE_CONTACT, - TOKEN_TYPE_LABEL, - TOKEN_TYPE_MILESTONE, - TOKEN_TYPE_MY_REACTION, - TOKEN_TYPE_ORGANIZATION, - TOKEN_TYPE_RELEASE, - TOKEN_TYPE_TYPE, TYPE_TOKEN_TASK_OPTION, UPDATED_DESC, urlSortParams, @@ -140,7 +141,6 @@ export default { 'hasAnyProjects', 'hasBlockedIssuesFeature', 'hasIssueWeightsFeature', - 'hasMultipleIssueAssigneesFeature', 'hasScopedLabelsFeature', 'initialEmail', 'initialSort', @@ -239,21 +239,17 @@ export default { state: this.state, ...this.pageParams, ...this.apiFilterParams, - types: this.apiFilterParams.types || this.defaultWorkItemTypes, + types: this.apiFilterParams.types || defaultWorkItemTypes, }; }, namespace() { return this.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP; }, - defaultWorkItemTypes() { - return this.isWorkItemsEnabled - ? defaultWorkItemTypes - : defaultWorkItemTypes.filter((type) => type !== WORK_ITEM_TYPE_ENUM_TASK); - }, typeTokenOptions() { - return this.isWorkItemsEnabled - ? defaultTypeTokenOptions.concat(TYPE_TOKEN_TASK_OPTION) - : defaultTypeTokenOptions; + return defaultTypeTokenOptions.concat(TYPE_TOKEN_TASK_OPTION); + }, + hasOrFeature() { + return this.glFeatures.orIssuableQueries; }, hasSearch() { return ( @@ -272,9 +268,6 @@ export default { isOpenTab() { return this.state === IssuableStates.Opened; }, - isWorkItemsEnabled() { - return this.glFeatures.workItems; - }, showCsvButtons() { return this.isProject && this.isSignedIn; }, @@ -324,8 +317,8 @@ export default { icon: 'user', token: AuthorToken, dataType: 'user', - unique: !this.hasMultipleIssueAssigneesFeature, defaultAuthors: DEFAULT_NONE_ANY, + operators: this.hasOrFeature ? OPERATOR_IS_NOT_OR : OPERATOR_IS_AND_IS_NOT, fetchAuthors: this.fetchUsers, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-assignee`, preloadedAuthors, @@ -565,6 +558,7 @@ export default { bulkUpdateSidebar.initBulkUpdateSidebar('issuable_'); bulkUpdateSidebar.initStatusDropdown(); bulkUpdateSidebar.initSubscriptionsDropdown(); + bulkUpdateSidebar.initMoveIssuesButton(); const usersSelect = await import('~/users_select'); const UsersSelect = usersSelect.default; @@ -793,6 +787,7 @@ export default { :show-page-size-change-controls="showPageSizeControls" :has-next-page="pageInfo.hasNextPage" :has-previous-page="pageInfo.hasPreviousPage" + :show-filtered-search-friendly-text="hasOrFeature" show-work-item-type-icon @click-tab="handleClickTab" @dismiss-alert="handleDismissAlert" @@ -959,12 +954,17 @@ export default { <gl-empty-state v-else - :description="$options.i18n.noIssuesSignedOutDescription" :title="$options.i18n.noIssuesSignedOutTitle" :svg-path="emptyStateSvgPath" :primary-button-text="$options.i18n.noIssuesSignedOutButtonText" :primary-button-link="signInPath" - /> + > + <template #description> + <gl-link :href="issuesHelpPagePath" target="_blank">{{ + $options.i18n.noIssuesSignedOutDescription + }}</gl-link> + </template> + </gl-empty-state> <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/constants.js b/app/assets/javascripts/issues/list/constants.js index 9fe8899ab39..5ed9ceea856 100644 --- a/app/assets/javascripts/issues/list/constants.js +++ b/app/assets/javascripts/issues/list/constants.js @@ -7,7 +7,21 @@ import { FILTER_UPCOMING, OPERATOR_IS, OPERATOR_IS_NOT, + OPERATOR_OR, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_CONFIDENTIAL, + TOKEN_TYPE_CONTACT, + TOKEN_TYPE_EPIC, TOKEN_TYPE_HEALTH, + TOKEN_TYPE_ITERATION, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, + TOKEN_TYPE_MY_REACTION, + TOKEN_TYPE_ORGANIZATION, + TOKEN_TYPE_RELEASE, + TOKEN_TYPE_TYPE, + TOKEN_TYPE_WEIGHT, } from '~/vue_shared/components/filtered_search_bar/constants'; import { WORK_ITEM_TYPE_ENUM_INCIDENT, @@ -46,10 +60,8 @@ export const i18n = { noIssuesSignedInDescription: __('Learn more about issues.'), noIssuesSignedInTitle: __('Use issues to collaborate on ideas, solve problems, and plan work'), noIssuesSignedOutButtonText: __('Register / Sign In'), - noIssuesSignedOutDescription: __( - 'The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.', - ), - noIssuesSignedOutTitle: __('There are no issues to show'), + 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'), @@ -136,20 +148,6 @@ export const specialFilterValues = [ FILTER_STARTED, ]; -export const TOKEN_TYPE_AUTHOR = 'author_username'; -export const TOKEN_TYPE_ASSIGNEE = 'assignee_username'; -export const TOKEN_TYPE_MILESTONE = 'milestone'; -export const TOKEN_TYPE_LABEL = 'labels'; -export const TOKEN_TYPE_TYPE = 'type'; -export const TOKEN_TYPE_RELEASE = 'release'; -export const TOKEN_TYPE_MY_REACTION = 'my_reaction_emoji'; -export const TOKEN_TYPE_CONFIDENTIAL = 'confidential'; -export const TOKEN_TYPE_ITERATION = 'iteration'; -export const TOKEN_TYPE_EPIC = 'epic_id'; -export const TOKEN_TYPE_WEIGHT = 'weight'; -export const TOKEN_TYPE_CONTACT = 'crm_contact'; -export const TOKEN_TYPE_ORGANIZATION = 'crm_organization'; - export const TYPE_TOKEN_TASK_OPTION = { icon: 'issue-type-task', title: 'task', value: 'task' }; // This should be consistent with Issue::TYPES_FOR_LIST in the backend @@ -195,6 +193,9 @@ export const filters = { [OPERATOR_IS_NOT]: { [NORMAL_FILTER]: 'not[assignee_username][]', }, + [OPERATOR_OR]: { + [NORMAL_FILTER]: 'or[assignee_username][]', + }, }, }, [TOKEN_TYPE_MILESTONE]: { diff --git a/app/assets/javascripts/issues/list/graphql.js b/app/assets/javascripts/issues/list/graphql.js new file mode 100644 index 00000000000..5ef61727a3d --- /dev/null +++ b/app/assets/javascripts/issues/list/graphql.js @@ -0,0 +1,25 @@ +import produce from 'immer'; +import createDefaultClient from '~/lib/graphql'; +import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; + +const resolvers = { + Mutation: { + reorderIssues: (_, { oldIndex, newIndex, namespace, serializedVariables }, { cache }) => { + const variables = JSON.parse(serializedVariables); + const sourceData = cache.readQuery({ query: getIssuesQuery, variables }); + + const data = produce(sourceData, (draftData) => { + const issues = draftData[namespace].issues.nodes.slice(); + const issueToMove = issues[oldIndex]; + issues.splice(oldIndex, 1); + issues.splice(newIndex, 0, issueToMove); + + draftData[namespace].issues.nodes = issues; + }); + + cache.writeQuery({ query: getIssuesQuery, variables, data }); + }, + }, +}; + +export const gqlClient = createDefaultClient(resolvers); diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js index 93333c31b34..5e04dd1971c 100644 --- a/app/assets/javascripts/issues/list/index.js +++ b/app/assets/javascripts/issues/list/index.js @@ -1,12 +1,11 @@ -import produce from 'immer'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import VueRouter from 'vue-router'; -import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; 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 { gqlClient } from './graphql'; export function mountJiraIssuesListApp() { const el = document.querySelector('.js-jira-issues-import-status'); @@ -56,26 +55,6 @@ export function mountIssuesListApp() { Vue.use(VueApollo); Vue.use(VueRouter); - const resolvers = { - Mutation: { - reorderIssues: (_, { oldIndex, newIndex, namespace, serializedVariables }, { cache }) => { - const variables = JSON.parse(serializedVariables); - const sourceData = cache.readQuery({ query: getIssuesQuery, variables }); - - const data = produce(sourceData, (draftData) => { - const issues = draftData[namespace].issues.nodes.slice(); - const issueToMove = issues[oldIndex]; - issues.splice(oldIndex, 1); - issues.splice(newIndex, 0, issueToMove); - - draftData[namespace].issues.nodes = issues; - }); - - cache.writeQuery({ query: getIssuesQuery, variables, data }); - }, - }, - }; - const { autocompleteAwardEmojisPath, calendarPath, @@ -97,7 +76,6 @@ export function mountIssuesListApp() { hasIssuableHealthStatusFeature, hasIssueWeightsFeature, hasIterationsFeature, - hasMultipleIssueAssigneesFeature, hasScopedLabelsFeature, importCsvIssuesPath, initialEmail, @@ -125,7 +103,7 @@ export function mountIssuesListApp() { el, name: 'IssuesListRoot', apolloProvider: new VueApollo({ - defaultClient: createDefaultClient(resolvers), + defaultClient: gqlClient, }), router: new VueRouter({ base: window.location.pathname, @@ -148,7 +126,6 @@ export function mountIssuesListApp() { hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), hasIterationsFeature: parseBoolean(hasIterationsFeature), - hasMultipleIssueAssigneesFeature: parseBoolean(hasMultipleIssueAssigneesFeature), hasScopedLabelsFeature: parseBoolean(hasScopedLabelsFeature), initialSort, isAnonymousSearchDisabled: parseBoolean(isAnonymousSearchDisabled), 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 df7016aeb74..b447289b425 100644 --- a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql +++ b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql @@ -24,6 +24,7 @@ query getIssues( $crmContactId: String $crmOrganizationId: String $not: NegatedIssueFilterInput + $or: UnionedIssueFilterInput $beforeCursor: String $afterCursor: String $firstPageSize: Int @@ -49,6 +50,7 @@ query getIssues( crmContactId: $crmContactId crmOrganizationId: $crmOrganizationId not: $not + or: $or before: $beforeCursor after: $afterCursor first: $firstPageSize @@ -84,6 +86,7 @@ query getIssues( crmContactId: $crmContactId crmOrganizationId: $crmOrganizationId not: $not + or: $or before: $beforeCursor after: $afterCursor first: $firstPageSize diff --git a/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql index c1aee772167..fdb0eeb5970 100644 --- a/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql +++ b/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql @@ -17,6 +17,7 @@ query getIssuesCount( $crmContactId: String $crmOrganizationId: String $not: NegatedIssueFilterInput + $or: UnionedIssueFilterInput ) { group(fullPath: $fullPath) @skip(if: $isProject) { id @@ -37,6 +38,7 @@ query getIssuesCount( crmContactId: $crmContactId crmOrganizationId: $crmOrganizationId not: $not + or: $or ) { count } @@ -57,6 +59,7 @@ query getIssuesCount( crmContactId: $crmContactId crmOrganizationId: $crmOrganizationId not: $not + or: $or ) { count } @@ -77,6 +80,7 @@ query getIssuesCount( crmContactId: $crmContactId crmOrganizationId: $crmOrganizationId not: $not + or: $or ) { count } @@ -101,6 +105,7 @@ query getIssuesCount( crmContactId: $crmContactId crmOrganizationId: $crmOrganizationId not: $not + or: $or ) { count } @@ -122,6 +127,7 @@ query getIssuesCount( crmContactId: $crmContactId crmOrganizationId: $crmOrganizationId not: $not + or: $or ) { count } @@ -143,6 +149,7 @@ query getIssuesCount( crmContactId: $crmContactId crmOrganizationId: $crmOrganizationId not: $not + or: $or ) { count } diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js index f02c7a23f51..2f9ab9d62ee 100644 --- a/app/assets/javascripts/issues/list/utils.js +++ b/app/assets/javascripts/issues/list/utils.js @@ -5,6 +5,13 @@ import { __ } from '~/locale'; import { FILTERED_SEARCH_TERM, OPERATOR_IS_NOT, + OPERATOR_OR, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_CONFIDENTIAL, + TOKEN_TYPE_ITERATION, + TOKEN_TYPE_MILESTONE, + TOKEN_TYPE_RELEASE, + TOKEN_TYPE_TYPE, } from '~/vue_shared/components/filtered_search_bar/constants'; import { API_PARAM, @@ -31,12 +38,6 @@ import { specialFilterValues, TITLE_ASC, TITLE_DESC, - TOKEN_TYPE_ASSIGNEE, - TOKEN_TYPE_CONFIDENTIAL, - TOKEN_TYPE_ITERATION, - TOKEN_TYPE_MILESTONE, - TOKEN_TYPE_RELEASE, - TOKEN_TYPE_TYPE, UPDATED_ASC, UPDATED_DESC, URL_PARAM, @@ -252,20 +253,36 @@ const formatData = (token) => { export const convertToApiParams = (filterTokens) => { const params = {}; const not = {}; + const or = {}; 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 obj = token.value.operator === OPERATOR_IS_NOT ? not : params; + let obj; + if (token.value.operator === OPERATOR_IS_NOT) { + obj = not; + } else if (token.value.operator === OPERATOR_OR) { + obj = or; + } else { + obj = params; + } const data = formatData(token); Object.assign(obj, { [field]: obj[field] ? [obj[field], data].flat() : data, }); }); - return Object.keys(not).length ? Object.assign(params, { not }) : params; + if (Object.keys(not).length) { + Object.assign(params, { not }); + } + + if (Object.keys(or).length) { + Object.assign(params, { or }); + } + + return params; }; export const convertToUrlParams = (filterTokens) => diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue index dbe634e7295..180dea77003 100644 --- a/app/assets/javascripts/issues/show/components/fields/description.vue +++ b/app/assets/javascripts/issues/show/components/fields/description.vue @@ -67,7 +67,7 @@ export default { :quick-actions-docs-path="quickActionsDocsPath" :enable-autocomplete="enableAutocomplete" supports-quick-actions - init-on-autofocus + autofocus @input="$emit('input', $event)" @keydown.meta.enter="updateIssuable" @keydown.ctrl.enter="updateIssuable" diff --git a/app/assets/javascripts/issues/show/components/fields/type.vue b/app/assets/javascripts/issues/show/components/fields/type.vue index 75d0b9e5e76..5695efd7114 100644 --- a/app/assets/javascripts/issues/show/components/fields/type.vue +++ b/app/assets/javascripts/issues/show/components/fields/type.vue @@ -42,7 +42,7 @@ export default { const { issueState: { issueType }, } = this; - return capitalize(issueType); + return issuableTypes.find((type) => type.value === issueType)?.text || capitalize(issueType); }, shouldShowIncident() { return this.issueType === INCIDENT_TYPE || this.canCreateIncident; diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue index 74d166f82bb..c01de63ced9 100644 --- a/app/assets/javascripts/issues/show/components/header_actions.vue +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -7,6 +7,7 @@ import { GlLink, GlModal, GlModalDirective, + GlTooltipDirective, } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import createFlash, { FLASH_TYPES } from '~/flash'; @@ -51,6 +52,7 @@ export default { }, directives: { GlModal: GlModalDirective, + GlTooltip: GlTooltipDirective, }, mixins: [trackingMixin], inject: { @@ -287,12 +289,15 @@ export default { <gl-dropdown v-if="hasDesktopDropdown" + v-gl-tooltip.hover class="gl-display-none gl-sm-display-inline-flex! gl-ml-3" icon="ellipsis_v" category="tertiary" data-qa-selector="issue_actions_ellipsis_dropdown" :text="dropdownText" :text-sr-only="true" + :title="dropdownText" + :aria-label="dropdownText" data-testid="desktop-dropdown" no-caret right diff --git a/app/assets/javascripts/issues/show/components/incidents/constants.js b/app/assets/javascripts/issues/show/components/incidents/constants.js index aa7b9805b5f..db846009409 100644 --- a/app/assets/javascripts/issues/show/components/incidents/constants.js +++ b/app/assets/javascripts/issues/show/components/incidents/constants.js @@ -1,4 +1,4 @@ -import { __, s__ } from '~/locale'; +import { __, n__, s__ } from '~/locale'; export const timelineTabI18n = Object.freeze({ title: s__('Incident|Timeline'), @@ -15,6 +15,8 @@ export const timelineFormI18n = Object.freeze({ save: __('Save'), cancel: __('Cancel'), description: __('Description'), + hint: __('You can enter up to 280 characters'), + textRemaining: (count) => n__('%d character remaining', '%d characters remaining', count), saveAndAdd: s__('Incident|Save and add another event'), areaLabel: s__('Incident|Timeline text'), }); @@ -38,3 +40,5 @@ export const timelineItemI18n = Object.freeze({ moreActions: __('More actions'), timeUTC: __('%{time} UTC'), }); + +export const MAX_TEXT_LENGTH = 280; 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 55cd8b5f606..72dfccca467 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 @@ -2,7 +2,7 @@ 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 { timelineFormI18n } from './constants'; +import { MAX_TEXT_LENGTH, timelineFormI18n } from './constants'; import { getUtcShiftedDate } from './utils'; export default { @@ -26,6 +26,7 @@ export default { GlButton, }, i18n: timelineFormI18n, + MAX_TEXT_LENGTH, directives: { autofocusonshow, }, @@ -63,6 +64,9 @@ export default { }; }, computed: { + isTimelineTextValid() { + return this.timelineTextCount > 0 && this.timelineTextRemainingCount >= 0; + }, occurredAtString() { const year = this.datePickerInput.getFullYear(); const month = this.datePickerInput.getMonth(); @@ -74,8 +78,11 @@ export default { return utcDate.toISOString(); }, - hasTimelineText() { - return this.timelineText.length > 0; + timelineTextRemainingCount() { + return MAX_TEXT_LENGTH - this.timelineTextCount; + }, + timelineTextCount() { + return this.timelineText.length; }, }, mounted() { @@ -158,9 +165,21 @@ export default { dir="auto" data-supports-quick-actions="false" :aria-label="$options.i18n.description" + aria-describedby="timeline-form-hint" :placeholder="$options.i18n.areaPlaceholder" + :maxlength="$options.MAX_TEXT_LENGTH" > </textarea> + <div id="timeline-form-hint" class="gl-sr-only">{{ $options.i18n.hint }}</div> + <div + aria-hidden="true" + class="gl-absolute gl-text-gray-500 gl-font-sm gl-line-height-14 gl-right-4 gl-bottom-3" + > + {{ timelineTextRemainingCount }} + </div> + <div role="status" class="gl-sr-only"> + {{ $options.i18n.textRemaining(timelineTextRemainingCount) }} + </div> </template> </markdown-field> </gl-form-group> @@ -171,7 +190,7 @@ export default { category="primary" class="gl-mr-3" data-testid="save-button" - :disabled="!hasTimelineText" + :disabled="!isTimelineTextValid" :loading="isEventProcessed" @click="handleSave(false)" > @@ -183,7 +202,7 @@ export default { category="secondary" class="gl-mr-3 gl-ml-n2" data-testid="save-and-add-button" - :disabled="!hasTimelineText" + :disabled="!isTimelineTextValid" :loading="isEventProcessed" @click="handleSave(true)" > |