diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-01-12 06:08:58 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-01-12 06:08:58 +0300 |
commit | 15a3bba5243f072fa4fea79de92211672a45bb64 (patch) | |
tree | fe0c66406fd623f2a7802729c0769dbf7941e65d | |
parent | b53b2fbb6b393f28211fa2c2c5bdb519b6e7bc08 (diff) |
Add latest changes from gitlab-org/gitlab@master
21 files changed, 275 insertions, 137 deletions
diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/index.js b/app/assets/javascripts/ci_settings_pipeline_triggers/index.js index f2972133aad..3ea8e0df022 100644 --- a/app/assets/javascripts/ci_settings_pipeline_triggers/index.js +++ b/app/assets/javascripts/ci_settings_pipeline_triggers/index.js @@ -13,11 +13,6 @@ const parseJsonArray = (triggers) => { export default (containerId = 'js-ci-pipeline-triggers-list') => { const containerEl = document.getElementById(containerId); - // Note: Remove this check when FF `ci_pipeline_triggers_settings_vue_ui` is removed. - if (!containerEl) { - return null; - } - const triggers = parseJsonArray(containerEl.dataset.triggers); return new Vue({ diff --git a/app/assets/javascripts/issues/show/components/incidents/constants.js b/app/assets/javascripts/issues/show/components/incidents/constants.js index 22db19610c1..2fdae538902 100644 --- a/app/assets/javascripts/issues/show/components/incidents/constants.js +++ b/app/assets/javascripts/issues/show/components/incidents/constants.js @@ -12,6 +12,9 @@ export const timelineFormI18n = Object.freeze({ 'Incident|Something went wrong while creating the incident timeline event.', ), areaPlaceholder: s__('Incident|Timeline text...'), + areaDefaultMessage: s__('Incident|Incident'), + selectTags: __('Select tags'), + tagsLabel: __('Event tag (optional)'), save: __('Save'), cancel: __('Cancel'), delete: __('Delete'), @@ -42,4 +45,14 @@ export const timelineItemI18n = Object.freeze({ timeUTC: __('%{time} UTC'), }); +export const timelineEventTagsI18n = Object.freeze({ + startTime: __('Start time'), + endTime: __('End time'), +}); + export const MAX_TEXT_LENGTH = 280; + +export const TIMELINE_EVENT_TAGS = Object.values(timelineEventTagsI18n).map((item) => ({ + text: item, + value: item, +})); diff --git a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue index 6bb72e82778..81111d42b39 100644 --- a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue +++ b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue @@ -74,6 +74,7 @@ export default { incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId), note: eventDetails.note, occurredAt: eventDetails.occurredAt, + timelineEventTagNames: eventDetails.timelineEventTags, }, }, update: this.updateCache, 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 8cdd62ca9ef..4ef9b9c5a99 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,7 +40,7 @@ export default { :is-event-processed="editTimelineEventActive" :previous-occurred-at="event.occurredAt" :previous-note="event.note" - show-delete + is-editing @save-event="saveEvent" @cancel="$emit('hide-edit')" @delete="$emit('delete')" 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 f1a3aebc990..6648e20865d 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,9 @@ <script> -import { GlDatepicker, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui'; +import { GlDatepicker, GlFormInput, GlFormGroup, GlButton, GlListbox } from '@gitlab/ui'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; -import { MAX_TEXT_LENGTH, timelineFormI18n } from './constants'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { __, sprintf } from '~/locale'; +import { MAX_TEXT_LENGTH, TIMELINE_EVENT_TAGS, timelineFormI18n } from './constants'; import { getUtcShiftedDate } from './utils'; export default { @@ -23,7 +25,9 @@ export default { GlFormInput, GlFormGroup, GlButton, + GlListbox, }, + mixins: [glFeatureFlagsMixin()], i18n: timelineFormI18n, MAX_TEXT_LENGTH, props: { @@ -32,7 +36,7 @@ export default { required: false, default: false, }, - showDelete: { + isEditing: { type: Boolean, required: false, default: false, @@ -51,6 +55,16 @@ export default { required: false, default: '', }, + previousTags: { + type: Array, + required: false, + default: () => [], + }, + tags: { + type: Array, + required: false, + default: () => TIMELINE_EVENT_TAGS, + }, }, data() { // if occurredAt is null, returns "now" in UTC @@ -58,10 +72,12 @@ export default { return { timelineText: this.previousNote, + timelineTextIsDirty: this.isEditing, placeholderDate, hourPickerInput: placeholderDate.getHours(), minutePickerInput: placeholderDate.getMinutes(), datePickerInput: placeholderDate, + selectedTags: [...this.previousTags], }; }, computed: { @@ -85,6 +101,20 @@ export default { timelineTextCount() { return this.timelineText.length; }, + dropdownText() { + if (!this.selectedTags.length) { + return timelineFormI18n.selectTags; + } + + const dropdownText = + this.selectedTags.length === 1 + ? this.selectedTags[0] + : sprintf(__('%{numberOfSelectedTags} tags'), { + numberOfSelectedTags: this.selectedTags.length, + }); + + return dropdownText; + }, }, mounted() { this.focusDate(); @@ -96,14 +126,35 @@ export default { this.hourPickerInput = newPlaceholderDate.getHours(); this.minutePickerInput = newPlaceholderDate.getMinutes(); this.timelineText = ''; + this.selectedTags = []; }, focusDate() { this.$refs.datepicker.$el.querySelector('input')?.focus(); }, + setTimelineTextDirty() { + this.timelineTextIsDirty = true; + }, + onTagsChange(tagValue) { + this.selectedTags = [...tagValue]; + + if (!this.timelineTextIsDirty) { + this.timelineText = this.generateTimelineTextFromTags(this.selectedTags); + } + }, + generateTimelineTextFromTags(tags) { + if (!tags.length) { + return ''; + } + + const tagsMessage = tags.map((tag) => tag.toLocaleLowerCase()).join(', '); + + return `${timelineFormI18n.areaDefaultMessage} ${tagsMessage}`; + }, handleSave(addAnotherEvent) { const event = { note: this.timelineText, occurredAt: this.occurredAtString, + timelineEventTags: this.selectedTags, }; this.$emit('save-event', event, addAnotherEvent); }, @@ -146,6 +197,16 @@ export default { <p class="gl-ml-3 gl-align-self-end gl-line-height-32">{{ __('UTC') }}</p> </div> </div> + <gl-form-group v-if="glFeatures.incidentEventTags" :label="$options.i18n.tagsLabel"> + <gl-listbox + :selected="selectedTags" + :toggle-text="dropdownText" + :items="tags" + :is-check-centered="true" + :multiple="true" + @select="onTagsChange" + /> + </gl-form-group> <div class="common-note-form"> <gl-form-group class="gl-mb-3" :label="$options.i18n.areaLabel"> <markdown-field @@ -169,6 +230,7 @@ export default { aria-describedby="timeline-form-hint" :placeholder="$options.i18n.areaPlaceholder" :maxlength="$options.MAX_TEXT_LENGTH" + @input="setTimelineTextDirty" > </textarea> <div id="timeline-form-hint" class="gl-sr-only">{{ $options.i18n.hint }}</div> @@ -214,7 +276,7 @@ export default { {{ $options.i18n.cancel }} </gl-button> <gl-button - v-if="showDelete" + v-if="isEditing" class="gl-ml-auto btn-danger" :disabled="isEventProcessed" @click="$emit('delete')" diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb index 3842a88d15b..8e4fbf24ca2 100644 --- a/app/controllers/projects/incidents_controller.rb +++ b/app/controllers/projects/incidents_controller.rb @@ -10,6 +10,7 @@ class Projects::IncidentsController < Projects::ApplicationController push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?) push_force_frontend_feature_flag(:work_items_mvc, @project&.work_items_mvc_feature_flag_enabled?) push_force_frontend_feature_flag(:work_items_mvc_2, @project&.work_items_mvc_2_feature_flag_enabled?) + push_frontend_feature_flag(:incident_event_tags, project) end feature_category :incident_management diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index 3365f36c874..f8133c5836d 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -22,13 +22,11 @@ module Projects @entity = :project @variable_limit = ::Plan.default.actual_limits.project_ci_variables - if Feature.enabled?(:ci_pipeline_triggers_settings_vue_ui, @project) - triggers = ::Ci::TriggerSerializer.new.represent( - @project.triggers, current_user: current_user, project: @project - ) + triggers = ::Ci::TriggerSerializer.new.represent( + @project.triggers, current_user: current_user, project: @project + ) - @triggers_json = Gitlab::Json.dump(triggers) - end + @triggers_json = Gitlab::Json.dump(triggers) render end diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml index a7f29b5cbf9..de127d15351 100644 --- a/app/views/projects/triggers/_index.html.haml +++ b/app/views/projects/triggers/_index.html.haml @@ -6,32 +6,7 @@ - c.body do = render 'projects/triggers/form', btn_text: _('Add trigger') .gl-mb-5 - - if Feature.enabled?(:ci_pipeline_triggers_settings_vue_ui, @project) - #js-ci-pipeline-triggers-list.triggers-list{ data: { triggers: @triggers_json } } - - else - - if @triggers.any? - .table-responsive.triggers-list - %table.table - %thead - %th - %strong - = _('Token') - %th - %strong - = _('Description') - %th - %strong - = _('Owner') - %th - %strong - = _('Last used') - %th - = render partial: 'projects/triggers/trigger', collection: @triggers, as: :trigger - - else - = render Pajamas::AlertComponent.new(variant: :warning, show_icon: false, dismissible: false, - alert_options: { data: { testid: 'no_triggers_content' }}) do |c| - = c.body do - = _('No triggers exist yet. Use the form above to create one.') + #js-ci-pipeline-triggers-list.triggers-list{ data: { triggers: @triggers_json } } - c.footer do %p = _("These examples show how to trigger this project's pipeline for a branch or tag.") diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml deleted file mode 100644 index bce7dc8a94b..00000000000 --- a/app/views/projects/triggers/_trigger.html.haml +++ /dev/null @@ -1,37 +0,0 @@ -%tr - %td - - if trigger.has_token_exposed? - %span= trigger.token - = clipboard_button(text: trigger.token, title: _("Copy trigger token"), testid: 'clipboard-btn') - - else - %span= trigger.short_token - - .gl-display-inline-block.gl-ml-3 - - unless trigger.can_access_project? - = gl_badge_tag s_('Trigger|invalid'), { variant: :danger }, { title: s_('Trigger|Trigger user has insufficient permissions to project'), data: { toggle: 'tooltip', container: 'body' } } - - %td - - if trigger.description? && trigger.description.length > 15 - %span.has-tooltip{ title: trigger.description }= truncate(trigger.description, length: 15) - - else - = trigger.description - - %td - - if trigger.owner - .trigger-owner.sr-only= trigger.owner.name - = user_avatar(user: trigger.owner, size: 20) - - %td - - if trigger.last_used - = time_ago_with_tooltip trigger.last_used - - else - Never - - %td.text-right.gl-white-space-nowrap - - revoke_trigger_confirmation = "By revoking a trigger you will break any processes making use of it. Are you sure?" - - if can?(current_user, :admin_trigger, trigger) - = link_to edit_project_trigger_path(@project, trigger), method: :get, title: "Edit", class: "gl-button btn btn-default btn-icon" do - = sprite_icon('pencil') - - if can?(current_user, :manage_trigger, trigger) - = link_to project_trigger_path(@project, trigger), aria: { label: _('Revoke') }, data: { confirm: revoke_trigger_confirmation, testid: 'trigger_revoke_button', confirm_btn_variant: "danger" }, method: :delete, title: "Revoke", class: "gl-button btn btn-default btn-icon btn-trigger-revoke gl-ml-3" do - = sprite_icon('remove') diff --git a/config/feature_flags/development/ci_pipeline_triggers_settings_vue_ui.yml b/config/feature_flags/development/incident_event_tags.yml index 8b880772d78..68101b21569 100644 --- a/config/feature_flags/development/ci_pipeline_triggers_settings_vue_ui.yml +++ b/config/feature_flags/development/incident_event_tags.yml @@ -1,8 +1,8 @@ --- -name: ci_pipeline_triggers_settings_vue_ui -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/41864 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/247486 -milestone: '13.5' +name: incident_event_tags +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/107194 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/387647 +milestone: '15.8' type: development -group: group::pipeline execution +group: group::respond default_enabled: false diff --git a/doc/administration/operations/moving_repositories.md b/doc/administration/operations/moving_repositories.md index 96c1fcc422d..64118082467 100644 --- a/doc/administration/operations/moving_repositories.md +++ b/doc/administration/operations/moving_repositories.md @@ -59,9 +59,12 @@ To move repositories: so that the new storages receives all new projects. This stops new projects from being created on existing storages while the migration is in progress. 1. Schedule repository moves for: - - [Projects](#bulk-schedule-project-moves). - - [Snippets](#bulk-schedule-snippet-moves). - - [Groups](#bulk-schedule-group-moves). **(PREMIUM SELF)** + - [All projects](#bulk-schedule-project-moves) or + [individual projects](../../api/project_repository_storage_moves.md#schedule-a-repository-storage-move-for-a-project). + - [All snippets](#bulk-schedule-snippet-moves) or + [individual snippets](../../api/snippet_repository_storage_moves.md#schedule-a-repository-storage-move-for-a-snippet). + - [All groups](#bulk-schedule-group-moves) or + [individual groups](../../api/group_repository_storage_moves.md#schedule-a-repository-storage-move-for-a-group). **(PREMIUM SELF)** ### Bulk schedule project moves diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index f8dfc8176ef..3edf01cf216 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -17100,6 +17100,7 @@ Represents vulnerability finding of a security report on the pipeline. | <a id="pipelinesecurityreportfindingissuelinks"></a>`issueLinks` | [`VulnerabilityIssueLinkConnection`](#vulnerabilityissuelinkconnection) | List of issue links related to the vulnerability. (see [Connections](#connections)) | | <a id="pipelinesecurityreportfindinglinks"></a>`links` | [`[VulnerabilityLink!]`](#vulnerabilitylink) | List of links associated with the vulnerability. | | <a id="pipelinesecurityreportfindinglocation"></a>`location` | [`VulnerabilityLocation`](#vulnerabilitylocation) | Location metadata for the vulnerability. Its fields depend on the type of security scan that found the vulnerability. | +| <a id="pipelinesecurityreportfindingmergerequest"></a>`mergeRequest` | [`MergeRequest`](#mergerequest) | Merge request that fixes the vulnerability. | | <a id="pipelinesecurityreportfindingname"></a>`name` **{warning-solid}** | [`String`](#string) | **Deprecated** in 15.1. Use `title`. | | <a id="pipelinesecurityreportfindingproject"></a>`project` | [`Project`](#project) | Project on which the vulnerability finding was found. | | <a id="pipelinesecurityreportfindingprojectfingerprint"></a>`projectFingerprint` **{warning-solid}** | [`String`](#string) | **Deprecated** in 15.1. The `project_fingerprint` attribute is being deprecated. Use `uuid` to identify findings. | diff --git a/doc/api/group_repository_storage_moves.md b/doc/api/group_repository_storage_moves.md index a4baf7936dd..ea7764c3f39 100644 --- a/doc/api/group_repository_storage_moves.md +++ b/doc/api/group_repository_storage_moves.md @@ -239,6 +239,8 @@ Example response: ## Schedule repository storage moves for all groups on a storage shard Schedules repository storage moves for each group repository stored on the source storage shard. +This endpoint migrates all groups at once. For more information, see +[Bulk schedule group moves](../administration/operations/moving_repositories.md#bulk-schedule-group-moves). ```plaintext POST /group_repository_storage_moves diff --git a/doc/api/project_repository_storage_moves.md b/doc/api/project_repository_storage_moves.md index 429cb97c404..0f094f62af2 100644 --- a/doc/api/project_repository_storage_moves.md +++ b/doc/api/project_repository_storage_moves.md @@ -250,6 +250,8 @@ Example response: > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/47142) in GitLab 13.7. Schedules repository storage moves for each project repository stored on the source storage shard. +This endpoint migrates all projects at once. For more information, see +[Bulk schedule project moves](../administration/operations/moving_repositories.md#bulk-schedule-project-moves). ```plaintext POST /project_repository_storage_moves diff --git a/doc/api/snippet_repository_storage_moves.md b/doc/api/snippet_repository_storage_moves.md index acf9b50704d..29061bcbfb9 100644 --- a/doc/api/snippet_repository_storage_moves.md +++ b/doc/api/snippet_repository_storage_moves.md @@ -266,6 +266,8 @@ Example response: > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/49228) in GitLab 13.8. Schedules repository storage moves for each snippet repository stored on the source storage shard. +This endpoint migrates all snippets at once. For more information, see +[Bulk schedule snippet moves](../administration/operations/moving_repositories.md#bulk-schedule-snippet-moves). ```plaintext POST /snippet_repository_storage_moves diff --git a/doc/user/project/repository/branches/index.md b/doc/user/project/repository/branches/index.md index a86e32b4721..c905c3e771c 100644 --- a/doc/user/project/repository/branches/index.md +++ b/doc/user/project/repository/branches/index.md @@ -119,6 +119,30 @@ The Swap revisions feature allows you to swap the Source and Target revisions. W ![After swap revisions](img/swap_revisions_after_v13_12.png) +## View branches with configured rules **(FREE SELF)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/88279) in GitLab 15.1 with a flag named `branch_rules`. Disabled by default. + +FLAG: +On self-managed GitLab, by default this feature is not available. To make it available, ask an administrator to [enable the feature flag](../../../feature_flags.md) named `branch_rules`. +On GitLab.com, this feature is not available. +This feature is not ready for production use. + +Branches in your repository can be [protected](../../protected_branches.md) by limiting +who can push to a branch, require approval for those pushed changes, or merge those changes. +To help you track the protections for all branches, the **Branch rules overview** +page shows your branches with their configured rules. + +To view the **Branch rules overview** list: + +1. On the top bar, select **Main menu > Projects** and find your project. +1. On the left sidebar, select **Settings > Repository**. +1. Expand **Branch Rules** to view all branches with protections. +1. Select **Details** next to your desired branch to show information about its: + - [Branch protections](../../protected_branches.md). + - [Approval rules](../../merge_requests/approvals/rules.md). + - [Status checks](../../merge_requests/status_checks.md). + ## Troubleshooting ### Error: ambiguous `HEAD` branch exists diff --git a/locale/gitlab.pot b/locale/gitlab.pot index f35fb3e14d6..71fbff57787 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -900,6 +900,9 @@ msgid_plural "%{no_of_days} days" msgstr[0] "" msgstr[1] "" +msgid "%{numberOfSelectedTags} tags" +msgstr "" + msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead" msgstr "" @@ -11212,9 +11215,6 @@ msgstr "" msgid "Copy token" msgstr "" -msgid "Copy trigger token" -msgstr "" - msgid "Copy value" msgstr "" @@ -15523,6 +15523,9 @@ msgstr "" msgid "End Time" msgstr "" +msgid "End time" +msgstr "" + msgid "Ends" msgstr "" @@ -16363,6 +16366,9 @@ msgstr "" msgid "Even if you reach the number of seats in your subscription, you can continue to add users, and GitLab will bill you for the overage." msgstr "" +msgid "Event tag (optional)" +msgstr "" + msgid "EventFilterBy|Filter by all" msgstr "" @@ -22105,6 +22111,9 @@ msgstr "" msgid "Incident|Error updating incident timeline event: %{error}" msgstr "" +msgid "Incident|Incident" +msgstr "" + msgid "Incident|Metrics" msgstr "" @@ -28053,9 +28062,6 @@ msgstr "" msgid "No test coverage" msgstr "" -msgid "No triggers exist yet. Use the form above to create one." -msgstr "" - msgid "No user provided" msgstr "" @@ -38500,6 +38506,9 @@ msgstr "" msgid "Select subscription" msgstr "" +msgid "Select tags" +msgstr "" + msgid "Select target branch" msgstr "" @@ -40256,6 +40265,9 @@ msgstr "" msgid "Start thread" msgstr "" +msgid "Start time" +msgstr "" + msgid "Start your Free Ultimate Trial" msgstr "" @@ -44435,12 +44447,6 @@ msgstr "" msgid "Trigger|Trigger description" msgstr "" -msgid "Trigger|Trigger user has insufficient permissions to project" -msgstr "" - -msgid "Trigger|invalid" -msgstr "" - msgid "Trusted" msgstr "" diff --git a/spec/features/triggers_spec.rb b/spec/features/triggers_spec.rb index 3616fdb2e8e..23a13994fa4 100644 --- a/spec/features/triggers_spec.rb +++ b/spec/features/triggers_spec.rb @@ -23,7 +23,7 @@ RSpec.describe 'Triggers', :js, feature_category: :continuous_integration do wait_for_requests end - shared_examples 'triggers page' do + describe 'triggers page' do describe 'create trigger workflow' do it 'prevents adding new trigger with no description' do fill_in 'trigger_description', with: '' @@ -139,16 +139,4 @@ RSpec.describe 'Triggers', :js, feature_category: :continuous_integration do end end end - - context 'when ci_pipeline_triggers_settings_vue_ui is enabled' do - it_behaves_like 'triggers page' - end - - context 'when ci_pipeline_triggers_settings_vue_ui is disabled' do - before do - stub_feature_flags(ci_pipeline_triggers_settings_vue_ui: false) - end - - it_behaves_like 'triggers page' - end end diff --git a/spec/frontend/issues/show/components/incidents/create_timeline_events_form_spec.js b/spec/frontend/issues/show/components/incidents/create_timeline_events_form_spec.js index 1286617d64a..6c923cae0cc 100644 --- a/spec/frontend/issues/show/components/incidents/create_timeline_events_form_spec.js +++ b/spec/frontend/issues/show/components/incidents/create_timeline_events_form_spec.js @@ -1,6 +1,6 @@ import VueApollo from 'vue-apollo'; import Vue from 'vue'; -import { GlDatepicker } from '@gitlab/ui'; +import { GlDatepicker, GlListboxItem } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import CreateTimelineEvent from '~/issues/show/components/incidents/create_timeline_event.vue'; @@ -27,6 +27,7 @@ const mockInputData = { incidentId: 'gid://gitlab/Issue/1', note: 'test', occurredAt: '2020-07-08T00:00:00.000Z', + timelineEventTagNames: ['Start time'], }; describe('Create Timeline events', () => { @@ -51,9 +52,14 @@ describe('Create Timeline events', () => { findHourInput().setValue(inputDate.getHours()); findMinuteInput().setValue(inputDate.getMinutes()); }; + const findListboxItems = () => wrapper.findAllComponents(GlListboxItem); + const setEventTags = () => { + findListboxItems().at(0).vm.$emit('select', true); + }; const fillForm = () => { setDatetime(); setNoteInput(); + setEventTags(); }; function createMockApolloProvider() { @@ -80,6 +86,7 @@ describe('Create Timeline events', () => { provide: { fullPath: 'group/project', issuableId: '1', + glFeatures: { incidentEventTags: true }, }, apolloProvider, }); diff --git a/spec/frontend/issues/show/components/incidents/mock_data.js b/spec/frontend/issues/show/components/incidents/mock_data.js index 9accfcea791..6606bed1567 100644 --- a/spec/frontend/issues/show/components/incidents/mock_data.js +++ b/spec/frontend/issues/show/components/incidents/mock_data.js @@ -74,6 +74,7 @@ const mockUpdatedEvent = { action: 'comment', occurredAt: '2022-07-01T12:47:00Z', createdAt: '2022-07-20T12:47:40Z', + timelineEventTags: [], }; export const timelineEventsQueryListResponse = { diff --git a/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js b/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js index d5b199cc790..f06d968a4c5 100644 --- a/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js +++ b/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js @@ -1,11 +1,15 @@ import VueApollo from 'vue-apollo'; import Vue, { nextTick } from 'vue'; -import { GlDatepicker } from '@gitlab/ui'; -import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; +import { GlDatepicker, GlListbox } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import TimelineEventsForm from '~/issues/show/components/incidents/timeline_events_form.vue'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; -import { timelineFormI18n } from '~/issues/show/components/incidents/constants'; +import { + timelineFormI18n, + TIMELINE_EVENT_TAGS, + timelineEventTagsI18n, +} from '~/issues/show/components/incidents/constants'; import { createAlert } from '~/flash'; import { useFakeDate } from 'helpers/fake_date'; @@ -17,17 +21,23 @@ const fakeDate = '2020-07-08T00:00:00.000Z'; const mockInputDate = new Date('2021-08-12'); +const mockTags = TIMELINE_EVENT_TAGS; + describe('Timeline events form', () => { // July 8 2020 useFakeDate(fakeDate); let wrapper; - const mountComponent = ({ mountMethod = shallowMountExtended } = {}, props = {}) => { + const mountComponent = ({ mountMethod = mountExtended } = {}, props = {}, glFeatures = {}) => { wrapper = mountMethod(TimelineEventsForm, { + provide: { + glFeatures, + }, propsData: { showSaveAndAdd: true, isEventProcessed: false, ...props, + tags: mockTags, }, stubs: { GlButton: true, @@ -35,6 +45,10 @@ describe('Timeline events form', () => { }); }; + beforeEach(() => { + mountComponent(); + }); + afterEach(() => { createAlert.mockReset(); wrapper.destroy(); @@ -48,16 +62,26 @@ describe('Timeline events form', () => { const findDatePicker = () => wrapper.findComponent(GlDatepicker); const findHourInput = () => wrapper.findByTestId('input-hours'); const findMinuteInput = () => wrapper.findByTestId('input-minutes'); - const setDatetime = () => { - findDatePicker().vm.$emit('input', mockInputDate); - findHourInput().setValue(5); - findMinuteInput().setValue(45); - }; + const findTagDropdown = () => wrapper.findComponent(GlListbox); const findTextarea = () => wrapper.findByTestId('input-note'); + const findTextareaValue = () => findTextarea().element.value; const findCountNumeric = (count) => wrapper.findByText(count); const findCountVerbose = (count) => wrapper.findByText(`${count} characters remaining`); const findCountHint = () => wrapper.findByText(timelineFormI18n.hint); + const setDatetime = () => { + findDatePicker().vm.$emit('input', mockInputDate); + findHourInput().setValue(5); + findMinuteInput().setValue(45); + }; + const selectTags = async (tags) => { + findTagDropdown().vm.$emit( + 'select', + tags.map((x) => x.value), + ); + await nextTick(); + }; + const selectOneTag = () => selectTags([mockTags[0]]); const submitForm = async () => { findSubmitButton().vm.$emit('click'); await waitForPromises(); @@ -90,23 +114,97 @@ describe('Timeline events form', () => { ]); }); - describe('form button behaviour', () => { + describe('with incident_event_tag feature flag enabled', () => { beforeEach(() => { - mountComponent({ mountMethod: mountExtended }); + mountComponent( + {}, + {}, + { + incidentEventTags: true, + }, + ); + }); + + describe('event tag dropdown', () => { + it('should render option list from provided array', () => { + expect(findTagDropdown().props('items')).toEqual(mockTags); + }); + + it('should allow to choose multiple tags', async () => { + await selectTags(mockTags); + + expect(findTagDropdown().props('selected')).toEqual(mockTags.map((x) => x.value)); + }); + + it('should show default option, when none is chosen', () => { + expect(findTagDropdown().props('toggleText')).toBe(timelineFormI18n.selectTags); + }); + + it('should show the tag, when one is selected', async () => { + await selectOneTag(); + + expect(findTagDropdown().props('toggleText')).toBe(timelineEventTagsI18n.startTime); + }); + + it('should show the number of selected tags, when more than one is selected', async () => { + await selectTags(mockTags); + + expect(findTagDropdown().props('toggleText')).toBe('2 tags'); + }); + + it('should be cleared when clear is triggered', async () => { + await selectTags(mockTags); + + // This component expects the parent to call `clear`, so this is the only way to trigger this + wrapper.vm.clear(); + await nextTick(); + + expect(findTagDropdown().props('toggleText')).toBe(timelineFormI18n.selectTags); + expect(findTagDropdown().props('selected')).toEqual([]); + }); + + it('should populate incident note with tags if a note was empty', async () => { + await selectTags(mockTags); + + expect(findTextareaValue()).toBe( + `${timelineFormI18n.areaDefaultMessage} ${mockTags + .map((x) => x.value.toLowerCase()) + .join(', ')}`, + ); + }); + + it('should populate incident note with tag but allow to customise it', async () => { + await selectOneTag(); + + await findTextarea().setValue('my customised event note'); + + await nextTick(); + + expect(findTextareaValue()).toBe('my customised event note'); + }); + + it('should not populate incident note with tag if it had a note', async () => { + await findTextarea().setValue('hello'); + await selectOneTag(); + + expect(findTextareaValue()).toBe('hello'); + }); }); + }); + describe('form button behaviour', () => { it('should save event on submit', async () => { await submitForm(); expect(wrapper.emitted()).toEqual({ - 'save-event': [[{ note: '', occurredAt: fakeDate }, false]], + 'save-event': [[{ note: '', occurredAt: fakeDate, timelineEventTags: [] }, false]], }); }); it('should save event on "submit and add another"', async () => { await submitFormAndAddAnother(); expect(wrapper.emitted()).toEqual({ - 'save-event': [[{ note: '', occurredAt: fakeDate }, true]], + 'save-event': [[{ note: '', occurredAt: fakeDate, timelineEventTags: [] }, true]], }); }); @@ -145,10 +243,6 @@ describe('Timeline events form', () => { }); describe('form character limit', () => { - beforeEach(() => { - mountComponent({ mountMethod: mountExtended }); - }); - it('sets a character limit hint', () => { expect(findCountHint().exists()).toBe(true); }); @@ -172,32 +266,32 @@ describe('Timeline events form', () => { }); describe('Delete button', () => { - it('does not show the delete button if showDelete prop is false', () => { - mountComponent({ mountMethod: mountExtended }, { showDelete: false }); + it('does not show the delete button if isEditing prop is false', () => { + mountComponent({ mountMethod: mountExtended }, { isEditing: false }); expect(findDeleteButton().exists()).toBe(false); }); - it('shows the delete button if showDelete prop is true', () => { - mountComponent({ mountMethod: mountExtended }, { showDelete: true }); + it('shows the delete button if isEditing prop is true', () => { + mountComponent({ mountMethod: mountExtended }, { isEditing: true }); expect(findDeleteButton().exists()).toBe(true); }); it('disables the delete button if isEventProcessed prop is true', () => { - mountComponent({ mountMethod: mountExtended }, { showDelete: true, isEventProcessed: true }); + mountComponent({ mountMethod: mountExtended }, { isEditing: true, isEventProcessed: true }); expect(findDeleteButton().props('disabled')).toBe(true); }); it('does not disable the delete button if isEventProcessed prop is false', () => { - mountComponent({ mountMethod: mountExtended }, { showDelete: true, isEventProcessed: false }); + mountComponent({ mountMethod: mountExtended }, { isEditing: true, isEventProcessed: false }); expect(findDeleteButton().props('disabled')).toBe(false); }); it('emits delete event on click', () => { - mountComponent({ mountMethod: mountExtended }, { showDelete: true, isEventProcessed: true }); + mountComponent({ mountMethod: mountExtended }, { isEditing: true, isEventProcessed: true }); deleteForm(); |