diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-06-28 18:08:20 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-06-28 18:08:20 +0000 |
commit | d12d801795043280c3d726fae0abfec63266d156 (patch) | |
tree | 629b2dcbe1b83c5c558ece6d18986cd9f3934dcc | |
parent | 5bb54b8711a6fd0993ab014f6749cbb74c7b071b (diff) | |
download | gitlab-ce-d12d801795043280c3d726fae0abfec63266d156.tar.gz |
Add latest changes from gitlab-org/gitlab@master
49 files changed, 763 insertions, 499 deletions
diff --git a/.rubocop_todo/layout/line_length.yml b/.rubocop_todo/layout/line_length.yml index 6aed65b586e..9d3f426a228 100644 --- a/.rubocop_todo/layout/line_length.yml +++ b/.rubocop_todo/layout/line_length.yml @@ -4583,7 +4583,7 @@ Layout/LineLength: - 'spec/graphql/resolvers/projects/jira_projects_resolver_spec.rb' - 'spec/graphql/resolvers/releases_resolver_spec.rb' - 'spec/graphql/resolvers/snippets_resolver_spec.rb' - - 'spec/graphql/resolvers/todo_resolver_spec.rb' + - 'spec/graphql/resolvers/todos_resolver_spec.rb' - 'spec/graphql/resolvers/user_discussions_count_resolver_spec.rb' - 'spec/graphql/resolvers/users/group_count_resolver_spec.rb' - 'spec/graphql/resolvers/users/groups_resolver_spec.rb' @@ -145,9 +145,9 @@ gem 'seed-fu', '~> 2.3.7' gem 'elasticsearch-model', '~> 7.2' gem 'elasticsearch-rails', '~> 7.2', require: 'elasticsearch/rails/instrumentation' gem 'elasticsearch-api', '7.13.3' -gem 'aws-sdk-core', '~> 3' +gem 'aws-sdk-core', '~> 3.131.0' gem 'aws-sdk-cloudformation', '~> 1' -gem 'aws-sdk-s3', '~> 1' +gem 'aws-sdk-s3', '~> 1.114.0' gem 'faraday_middleware-aws-sigv4', '~>0.3.0' gem 'typhoeus', '~> 1.4.0' # Used with Elasticsearch to support http keep-alive connections diff --git a/Gemfile.lock b/Gemfile.lock index c9a06738760..bd08985692f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -105,24 +105,24 @@ GEM execjs (> 0) awesome_print (1.9.2) awrence (1.1.1) - aws-eventstream (1.1.0) - aws-partitions (1.345.0) + aws-eventstream (1.2.0) + aws-partitions (1.600.0) aws-sdk-cloudformation (1.41.0) aws-sdk-core (~> 3, >= 3.99.0) aws-sigv4 (~> 1.1) - aws-sdk-core (3.104.3) + aws-sdk-core (3.131.1) aws-eventstream (~> 1, >= 1.0.2) - aws-partitions (~> 1, >= 1.239.0) + aws-partitions (~> 1, >= 1.525.0) aws-sigv4 (~> 1.1) - jmespath (~> 1.0) - aws-sdk-kms (1.36.0) - aws-sdk-core (~> 3, >= 3.99.0) + jmespath (~> 1, >= 1.6.1) + aws-sdk-kms (1.57.0) + aws-sdk-core (~> 3, >= 3.127.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.75.0) - aws-sdk-core (~> 3, >= 3.104.1) + aws-sdk-s3 (1.114.0) + aws-sdk-core (~> 3, >= 3.127.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.1) - aws-sigv4 (1.2.1) + aws-sigv4 (~> 1.4) + aws-sigv4 (1.5.0) aws-eventstream (~> 1, >= 1.0.2) azure-storage-blob (2.0.3) azure-storage-common (~> 2.0) @@ -679,7 +679,7 @@ GEM atlassian-jwt multipart-post oauth (~> 0.5, >= 0.5.0) - jmespath (1.4.0) + jmespath (1.6.1) js_regex (3.7.0) character_set (~> 1.4) regexp_parser (~> 2.1) @@ -1464,8 +1464,8 @@ DEPENDENCIES autoprefixer-rails (= 10.2.5.1) awesome_print aws-sdk-cloudformation (~> 1) - aws-sdk-core (~> 3) - aws-sdk-s3 (~> 1) + aws-sdk-core (~> 3.131.0) + aws-sdk-s3 (~> 1.114.0) babosa (~> 1.0.4) base32 (~> 0.3.0) batch-loader (~> 2.0.1) diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js index 2a00f9d42a9..cc2608b5c62 100644 --- a/app/assets/javascripts/issuable/issuable_form.js +++ b/app/assets/javascripts/issuable/issuable_form.js @@ -68,8 +68,7 @@ export default class IssuableForm { this.gfmAutoComplete = new GfmAutoComplete( gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources, ).setup(); - const autoAssignToMe = form.get(0).id === 'new_merge_request'; - this.usersSelect = new UsersSelect(undefined, undefined, { autoAssignToMe }); + this.usersSelect = new UsersSelect(); this.reviewersSelect = new UsersSelect(undefined, '.js-reviewer-search'); this.zenMode = new ZenMode(); diff --git a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue index 2f31d8ef3fb..b14e816a674 100644 --- a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue +++ b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue @@ -136,7 +136,9 @@ export default { <template> <section class="settings no-animate js-self-monitoring-settings"> <div class="settings-header"> - <h4 class="js-section-header"> + <h4 + class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only" + > {{ s__('SelfMonitoring|Self monitoring') }} </h4> <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button> diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index 0904aae0347..94b4ee77e7e 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -35,7 +35,7 @@ function UsersSelect(currentUser, els, options = {}) { } } - const { handleClick, autoAssignToMe } = options; + const { handleClick } = options; const userSelect = this; $els.each((i, dropdown) => { @@ -172,7 +172,10 @@ function UsersSelect(currentUser, els, options = {}) { }); }; - const onAssignToMeClick = () => { + $assignToMeLink.on('click', (e) => { + e.preventDefault(); + $(e.currentTarget).hide(); + if ($dropdown.data('multiSelect')) { assignYourself(); checkMaxSelect(); @@ -191,19 +194,8 @@ function UsersSelect(currentUser, els, options = {}) { .text(gon.current_user_fullname) .removeClass('is-default'); } - }; - - $assignToMeLink.on('click', (e) => { - e.preventDefault(); - $(e.currentTarget).hide(); - onAssignToMeClick(); }); - if (autoAssignToMe) { - $assignToMeLink.hide(); - onAssignToMeClick(); - } - $block.on('click', '.js-assign-yourself', (e) => { e.preventDefault(); return assignTo(userSelect.currentUser.id); diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue index 4d1c171772e..46920969415 100644 --- a/app/assets/javascripts/work_items/components/work_item_assignees.vue +++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue @@ -1,10 +1,23 @@ <script> -import { GlTokenSelector, GlIcon, GlAvatar, GlLink } from '@gitlab/ui'; +import { GlTokenSelector, GlIcon, GlAvatar, GlLink, GlSkeletonLoader } from '@gitlab/ui'; +import { debounce } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; +import { n__ } from '~/locale'; +import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql'; +import { i18n } from '../constants'; -function isClosingIcon(el) { - return el?.classList.contains('gl-token-close'); +function isTokenSelectorElement(el) { + return el?.classList.contains('gl-token-close') || el?.classList.contains('dropdown-item'); +} + +function addClass(el) { + return { + ...el, + class: 'gl-bg-transparent', + }; } export default { @@ -13,7 +26,10 @@ export default { GlIcon, GlAvatar, GlLink, + GlSkeletonLoader, + SidebarParticipant, }, + inject: ['fullPath'], props: { workItemId: { type: String, @@ -27,45 +43,95 @@ export default { data() { return { isEditing: false, - localAssignees: this.assignees.map((assignee) => ({ - ...assignee, - class: 'gl-bg-transparent!', - })), + searchStarted: false, + localAssignees: this.assignees.map(addClass), + searchKey: '', + searchUsers: [], }; }, - computed: { - assigneeIds() { - return this.localAssignees.map((assignee) => assignee.id); + apollo: { + searchUsers: { + query() { + return userSearchQuery; + }, + variables() { + return { + fullPath: this.fullPath, + search: this.searchKey, + }; + }, + skip() { + return !this.searchStarted; + }, + update(data) { + return data.workspace?.users?.nodes.map((node) => addClass({ ...node, ...node.user })); + }, + error() { + this.$emit('error', i18n.fetchError); + }, }, + }, + computed: { assigneeListEmpty() { return this.assignees.length === 0; }, containerClass() { return !this.isEditing ? 'gl-shadow-none! gl-bg-transparent!' : ''; }, + isLoading() { + return this.$apollo.queries.searchUsers.loading; + }, + assigneeText() { + return n__('WorkItem|Assignee', 'WorkItem|Assignees', this.localAssignees.length); + }, + }, + watch: { + assignees(newVal) { + if (!this.isEditing) { + this.localAssignees = newVal.map(addClass); + } + }, + }, + created() { + this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); }, methods: { getUserId(id) { return getIdFromGraphQLId(id); }, setAssignees(e) { - if (isClosingIcon(e.relatedTarget) || !this.isEditing) return; + if (isTokenSelectorElement(e.relatedTarget) || !this.isEditing) return; this.isEditing = false; this.$apollo.mutate({ mutation: localUpdateWorkItemMutation, variables: { input: { id: this.workItemId, - assigneeIds: this.assigneeIds, + assignees: this.localAssignees, }, }, }); }, - async focusTokenSelector() { + handleFocus() { this.isEditing = true; + this.searchStarted = true; + }, + async focusTokenSelector() { + this.handleFocus(); await this.$nextTick(); this.$refs.tokenSelector.focusTextInput(); }, + handleMouseOver() { + this.timeout = setTimeout(() => { + this.searchStarted = true; + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + handleMouseOut() { + clearTimeout(this.timeout); + }, + setSearchKey(value) { + this.searchKey = value; + }, }, }; </script> @@ -73,17 +139,21 @@ export default { <template> <div class="gl-display-flex gl-mb-4 work-item-assignees gl-relative"> <span class="gl-font-weight-bold gl-w-15 gl-pt-2" data-testid="assignees-title">{{ - __('Assignee(s)') + assigneeText }}</span> <gl-token-selector ref="tokenSelector" v-model="localAssignees" - hide-dropdown-with-no-items :container-class="containerClass" + :dropdown-items="searchUsers" + :loading="isLoading" class="gl-w-full gl-border gl-border-white gl-hover-border-gray-200 gl-rounded-base" - @token-remove="focusTokenSelector" - @focus="isEditing = true" + @input="focusTokenSelector" + @text-input="debouncedSearchKeyUpdate" + @focus="handleFocus" @blur="setAssignees" + @mouseover.native="handleMouseOver" + @mouseout.native="handleMouseOut" > <template #empty-placeholder> <div @@ -106,6 +176,17 @@ export default { <span class="gl-pl-2">{{ token.name }}</span> </gl-link> </template> + <template #dropdown-item-content="{ dropdownItem }"> + <sidebar-participant :user="dropdownItem" /> + </template> + <template #loading-content> + <gl-skeleton-loader :height="170"> + <rect width="380" height="20" x="10" y="15" rx="4" /> + <rect width="280" height="20" x="10" y="50" rx="4" /> + <rect width="380" height="20" x="10" y="95" rx="4" /> + <rect width="280" height="20" x="10" y="130" rx="4" /> + </gl-skeleton-loader> + </template> </gl-token-selector> </div> </template> diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js index 09d929faae2..9266b4cdccb 100644 --- a/app/assets/javascripts/work_items/graphql/provider.js +++ b/app/assets/javascripts/work_items/graphql/provider.js @@ -70,9 +70,7 @@ export const resolvers = { const assigneesWidget = draftData.workItem.mockWidgets.find( (widget) => widget.type === WIDGET_TYPE_ASSIGNEE, ); - assigneesWidget.nodes = assigneesWidget.nodes.filter((assignee) => - input.assigneeIds.includes(assignee.id), - ); + assigneesWidget.nodes = [...input.assignees]; }); cache.writeQuery({ diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql index bfe2f0fe0ce..de4bdad5659 100644 --- a/app/assets/javascripts/work_items/graphql/typedefs.graphql +++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql @@ -23,7 +23,7 @@ extend type WorkItem { type LocalWorkItemAssigneesInput { id: WorkItemID! - assigneeIds: [ID!] + assignees: [UserCore!] } type LocalWorkItemPayload { diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss index be72ec33465..cf4a415446e 100644 --- a/app/assets/stylesheets/_page_specific_files.scss +++ b/app/assets/stylesheets/_page_specific_files.scss @@ -32,3 +32,4 @@ @import './pages/storage_quota'; @import './pages/tree'; @import './pages/users'; +@import './pages/work_items'; diff --git a/app/assets/stylesheets/pages/work_items.scss b/app/assets/stylesheets/pages/work_items.scss new file mode 100644 index 00000000000..b98f55df1ed --- /dev/null +++ b/app/assets/stylesheets/pages/work_items.scss @@ -0,0 +1,4 @@ +.gl-token-selector-token-container { + display: flex; + align-items: center; +} diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 169d38ab5aa..d7a5e21e303 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -253,11 +253,6 @@ $gl-line-height-42: px-to-rem(42px); max-width: 50%; } -// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2894 -.gl-form-lg { - max-width: 320px; -} - /** Note: ::-webkit-scrollbar is a non-standard rule only supported by webkit browsers. diff --git a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb index 6a91a097a17..7af55d42f2a 100644 --- a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb +++ b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb @@ -15,6 +15,9 @@ module Mutations argument :title, GraphQL::Types::String, required: false, description: copy_field_description(Types::WorkItemType, :title) + argument :description_widget, ::Types::WorkItems::Widgets::DescriptionInputType, + required: false, + description: 'Input for description widget.' end end end diff --git a/app/graphql/mutations/work_items/update.rb b/app/graphql/mutations/work_items/update.rb index c495da00f41..ff4aba4830f 100644 --- a/app/graphql/mutations/work_items/update.rb +++ b/app/graphql/mutations/work_items/update.rb @@ -24,11 +24,13 @@ module Mutations end spam_params = ::Spam::SpamParams.new_from_request(request: context[:request]) + widget_params = extract_widget_params(work_item, attributes) ::WorkItems::UpdateService.new( project: work_item.project, current_user: current_user, params: attributes, + widget_params: widget_params, spam_params: spam_params ).execute(work_item) @@ -45,6 +47,16 @@ module Mutations def find_object(id:) GitlabSchema.find_by_gid(id) end + + def extract_widget_params(work_item, attributes) + # Get the list of widgets for the work item's type to extract only the supported attributes + widget_keys = work_item.work_item_type.widgets.map(&:api_symbol) + widget_params = attributes.extract!(*widget_keys) + + # Cannot use prepare to use `.to_h` on each input due to + # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87472#note_945199865 + widget_params.transform_values { |values| values.to_h } + end end end end diff --git a/app/graphql/mutations/work_items/update_widgets.rb b/app/graphql/mutations/work_items/update_widgets.rb index d19da0abaac..7037b7e5a2a 100644 --- a/app/graphql/mutations/work_items/update_widgets.rb +++ b/app/graphql/mutations/work_items/update_widgets.rb @@ -2,6 +2,7 @@ module Mutations module WorkItems + # TODO: Deprecate in favor of using WorkItemUpdate. See https://gitlab.com/gitlab-org/gitlab/-/issues/366300 class UpdateWidgets < BaseMutation graphql_name 'WorkItemUpdateWidgets' description "Updates the attributes of a work item's widgets by global ID." \ diff --git a/app/graphql/resolvers/todo_resolver.rb b/app/graphql/resolvers/todo_resolver.rb index f0be1b6e9a5..0653cd27b4d 100644 --- a/app/graphql/resolvers/todo_resolver.rb +++ b/app/graphql/resolvers/todo_resolver.rb @@ -2,68 +2,16 @@ module Resolvers class TodoResolver < BaseResolver - type Types::TodoType.connection_type, null: true + description 'Retrieve a single to-do item' - alias_method :target, :object + type Types::TodoType, null: true - argument :action, [Types::TodoActionEnum], - required: false, - description: 'Action to be filtered.' + argument :id, Types::GlobalIDType[Todo], + required: true, + description: 'ID of the to-do item.' - argument :author_id, [GraphQL::Types::ID], - required: false, - description: 'ID of an author.' - - argument :project_id, [GraphQL::Types::ID], - required: false, - description: 'ID of a project.' - - argument :group_id, [GraphQL::Types::ID], - required: false, - description: 'ID of a group.' - - argument :state, [Types::TodoStateEnum], - required: false, - description: 'State of the todo.' - - argument :type, [Types::TodoTargetEnum], - required: false, - description: 'Type of the todo.' - - before_connection_authorization do |nodes, current_user| - Preloaders::UserMaxAccessLevelInProjectsPreloader.new( - nodes.map(&:project).compact, - current_user - ).execute - end - - def resolve(**args) - return Todo.none unless current_user.present? && target.present? - return Todo.none if target.is_a?(User) && target != current_user - - TodosFinder.new(current_user, todo_finder_params(args)).execute.with_entity_associations - end - - private - - def todo_finder_params(args) - { - state: args[:state], - type: args[:type], - group_id: args[:group_id], - author_id: args[:author_id], - action_id: args[:action], - project_id: args[:project_id] - }.merge(target_params) - end - - def target_params - return {} unless TodosFinder::TODO_TYPES.include?(target.class.name) - - { - type: target.class.name, - target_id: target.id - } + def resolve(id:) + GitlabSchema.find_by_gid(id) end end end diff --git a/app/graphql/resolvers/todos_resolver.rb b/app/graphql/resolvers/todos_resolver.rb new file mode 100644 index 00000000000..3e8dddb4859 --- /dev/null +++ b/app/graphql/resolvers/todos_resolver.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Resolvers + class TodosResolver < BaseResolver + type Types::TodoType.connection_type, null: true + + alias_method :target, :object + + argument :action, [Types::TodoActionEnum], + required: false, + description: 'Action to be filtered.' + + argument :author_id, [GraphQL::Types::ID], + required: false, + description: 'ID of an author.' + + argument :project_id, [GraphQL::Types::ID], + required: false, + description: 'ID of a project.' + + argument :group_id, [GraphQL::Types::ID], + required: false, + description: 'ID of a group.' + + argument :state, [Types::TodoStateEnum], + required: false, + description: 'State of the todo.' + + argument :type, [Types::TodoTargetEnum], + required: false, + description: 'Type of the todo.' + + before_connection_authorization do |nodes, current_user| + Preloaders::UserMaxAccessLevelInProjectsPreloader.new( + nodes.map(&:project).compact, + current_user + ).execute + end + + def resolve(**args) + return Todo.none unless current_user.present? && target.present? + return Todo.none if target.is_a?(User) && target != current_user + + TodosFinder.new(current_user, todo_finder_params(args)).execute.with_entity_associations + end + + private + + def todo_finder_params(args) + { + state: args[:state], + type: args[:type], + group_id: args[:group_id], + author_id: args[:author_id], + action_id: args[:action], + project_id: args[:project_id] + }.merge(target_params) + end + + def target_params + return {} unless TodosFinder::TODO_TYPES.include?(target.class.name) + + { + type: target.class.name, + target_id: target.id + } + end + end +end diff --git a/app/graphql/types/alert_management/alert_type.rb b/app/graphql/types/alert_management/alert_type.rb index 43b7bbb419f..a0d19229d3d 100644 --- a/app/graphql/types/alert_management/alert_type.rb +++ b/app/graphql/types/alert_management/alert_type.rb @@ -116,7 +116,7 @@ module Types null: true, description: 'Runbook for the alert as defined in alert details.' - field :todos, description: 'To-do items of the current user for the alert.', resolver: Resolvers::TodoResolver + field :todos, description: 'To-do items of the current user for the alert.', resolver: Resolvers::TodosResolver field :details_url, GraphQL::Types::String, diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 46d121f6552..c54dab618d2 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -136,6 +136,10 @@ module Types null: true, resolver: Resolvers::BoardListResolver + field :todo, + null: true, + resolver: Resolvers::TodoResolver + field :topics, Types::Projects::TopicType.connection_type, null: true, resolver: Resolvers::TopicsResolver, diff --git a/app/graphql/types/user_interface.rb b/app/graphql/types/user_interface.rb index 1c8a1352c72..edbc8aee9c5 100644 --- a/app/graphql/types/user_interface.rb +++ b/app/graphql/types/user_interface.rb @@ -88,7 +88,7 @@ module Types null: true, description: 'Personal namespace of the user.' - field :todos, resolver: Resolvers::TodoResolver, description: 'To-do items of the user.' + field :todos, resolver: Resolvers::TodosResolver, description: 'To-do items of the user.' # Merge request field: MRs can be authored, assigned, or assigned-for-review: field :authored_merge_requests, diff --git a/app/models/work_items/widgets/description.rb b/app/models/work_items/widgets/description.rb index 35b6d295321..1e84d172bef 100644 --- a/app/models/work_items/widgets/description.rb +++ b/app/models/work_items/widgets/description.rb @@ -4,10 +4,6 @@ module WorkItems module Widgets class Description < Base delegate :description, to: :work_item - - def update(params:) - work_item.description = params[:description] if params&.key?(:description) - end end end end diff --git a/app/services/work_items/update_service.rb b/app/services/work_items/update_service.rb index 0b420881b4b..7b50040a716 100644 --- a/app/services/work_items/update_service.rb +++ b/app/services/work_items/update_service.rb @@ -6,6 +6,7 @@ module WorkItems super(project: project, current_user: current_user, params: params, spam_params: nil) @widget_params = widget_params + @widget_services = {} end private @@ -24,8 +25,20 @@ module WorkItems def execute_widgets(work_item:, callback:) work_item.widgets.each do |widget| - widget.try(callback, params: @widget_params[widget.class.api_symbol]) + widget_service(widget).try(callback, params: @widget_params[widget.class.api_symbol]) end end + + def widget_service(widget) + service_class = begin + "WorkItems::Widgets::#{widget.type.capitalize}Service::UpdateService".constantize + rescue NameError + nil + end + + return unless service_class + + @widget_services[widget] ||= service_class.new(widget: widget, current_user: current_user) + end end end diff --git a/app/services/work_items/widgets/base_service.rb b/app/services/work_items/widgets/base_service.rb new file mode 100644 index 00000000000..72debc272bd --- /dev/null +++ b/app/services/work_items/widgets/base_service.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + class BaseService < ::BaseService + attr_reader :widget, :current_user + + def initialize(widget:, current_user:) + @widget = widget + @current_user = current_user + end + end + end +end diff --git a/app/services/work_items/widgets/description_service/update_service.rb b/app/services/work_items/widgets/description_service/update_service.rb new file mode 100644 index 00000000000..e63b6b2ee6c --- /dev/null +++ b/app/services/work_items/widgets/description_service/update_service.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + module DescriptionService + class UpdateService < WorkItems::Widgets::BaseService + def update(params: {}) + return unless params.present? && params[:description] + + widget.work_item.description = params[:description] + end + end + end + end +end diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml index 96dcd7e1111..3d4c5561dba 100644 --- a/app/views/admin/application_settings/_visibility_and_access.html.haml +++ b/app/views/admin/application_settings/_visibility_and_access.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-visibility-settings'), html: { class: 'fieldset-form', id: 'visibility-settings' } do |f| - = form_errors(@application_setting) + = form_errors(@application_setting, pajamas_alert: true) %fieldset = render 'shared/project_creation_levels', f: f, method: :default_project_creation, legend: s_('ProjectCreationLevel|Default project creation protection') diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml index 7cc0ff2c28e..d4476bf838a 100644 --- a/app/views/admin/application_settings/metrics_and_profiling.html.haml +++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml @@ -6,7 +6,7 @@ %section.settings.as-prometheus.no-animate#js-prometheus-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Metrics - Prometheus') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -17,7 +17,7 @@ %section.settings.as-grafana.no-animate#js-grafana-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Metrics - Grafana') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -30,7 +30,7 @@ %section.settings.as-performance-bar.no-animate#js-performance-bar-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'performance_bar_settings_content' } } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Profiling - Performance bar') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -44,7 +44,7 @@ %section.settings.as-usage.no-animate#js-usage-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'usage_statistics_settings_content' } } .settings-header#usage-statistics - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Usage statistics') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -56,7 +56,7 @@ - if Feature.enabled?(:configure_sentry_in_application_settings) %section.settings.as-sentry.no-animate#js-sentry-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'sentry_settings_content' } } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Sentry') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') diff --git a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml index 13603c8f946..a3addfb9b1a 100644 --- a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml +++ b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml @@ -8,7 +8,7 @@ .form-group.row = f.label :name, s_('ProtectedBranch|Branch:'), class: 'col-sm-12' .col-sm-12 - = render partial: "projects/protected_branches/shared/dropdown", locals: { f: f, toggle_classes: 'gl-w-full! gl-form-lg' } + = render partial: "projects/protected_branches/shared/dropdown", locals: { f: f, toggle_classes: 'gl-w-full! gl-form-input-lg' } .form-text.text-muted - wildcards_url = help_page_url('user/project/protected_branches', anchor: 'configure-multiple-protected-branches-by-using-a-wildcard') - wildcards_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wildcards_url } diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index af91c842838..5ded21eeb60 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -431,6 +431,18 @@ four standard [pagination arguments](#connection-pagination-arguments): | <a id="querytimelogsstarttime"></a>`startTime` | [`Time`](#time) | List timelogs within a time range where the logged time is equal to or after startTime. | | <a id="querytimelogsusername"></a>`username` | [`String`](#string) | List timelogs for a user. | +### `Query.todo` + +Retrieve a single to-do item. + +Returns [`Todo`](#todo). + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="querytodoid"></a>`id` | [`TodoID!`](#todoid) | ID of the to-do item. | + ### `Query.topics` Find project topics. @@ -5608,6 +5620,7 @@ Input type: `WorkItemUpdateInput` | Name | Type | Description | | ---- | ---- | ----------- | | <a id="mutationworkitemupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationworkitemupdatedescriptionwidget"></a>`descriptionWidget` | [`WorkItemWidgetDescriptionInput`](#workitemwidgetdescriptioninput) | Input for description widget. | | <a id="mutationworkitemupdateid"></a>`id` | [`WorkItemID!`](#workitemid) | Global ID of the work item. | | <a id="mutationworkitemupdatestateevent"></a>`stateEvent` | [`WorkItemStateEvent`](#workitemstateevent) | Close or reopen a work item. | | <a id="mutationworkitemupdatetitle"></a>`title` | [`String`](#string) | Title of the work item. | @@ -11751,6 +11764,7 @@ four standard [pagination arguments](#connection-pagination-arguments): | <a id="groupdescriptionhtml"></a>`descriptionHtml` | [`String`](#string) | The GitLab Flavored Markdown rendering of `description`. | | <a id="groupdora"></a>`dora` | [`Dora`](#dora) | Group's DORA metrics. | | <a id="groupemailsdisabled"></a>`emailsDisabled` | [`Boolean`](#boolean) | Indicates if a group has email notifications disabled. | +| <a id="groupenforcefreeusercap"></a>`enforceFreeUserCap` | [`Boolean`](#boolean) | Indicates whether the group has limited users for a free plan. | | <a id="groupepicboards"></a>`epicBoards` | [`EpicBoardConnection`](#epicboardconnection) | Find epic boards. (see [Connections](#connections)) | | <a id="groupepicsenabled"></a>`epicsEnabled` | [`Boolean`](#boolean) | Indicates if Epics are enabled for namespace. | | <a id="groupexternalauditeventdestinations"></a>`externalAuditEventDestinations` | [`ExternalAuditEventDestinationConnection`](#externalauditeventdestinationconnection) | External locations that receive audit events belonging to the group. (see [Connections](#connections)) | @@ -21925,6 +21939,7 @@ A time-frame defined as a closed inclusive range of two dates. | Name | Type | Description | | ---- | ---- | ----------- | +| <a id="workitemupdatedtaskinputdescriptionwidget"></a>`descriptionWidget` | [`WorkItemWidgetDescriptionInput`](#workitemwidgetdescriptioninput) | Input for description widget. | | <a id="workitemupdatedtaskinputid"></a>`id` | [`WorkItemID!`](#workitemid) | Global ID of the work item. | | <a id="workitemupdatedtaskinputstateevent"></a>`stateEvent` | [`WorkItemStateEvent`](#workitemstateevent) | Close or reopen a work item. | | <a id="workitemupdatedtaskinputtitle"></a>`title` | [`String`](#string) | Title of the work item. | diff --git a/doc/user/application_security/dast/dast_troubleshooting.md b/doc/user/application_security/dast/dast_troubleshooting.md index 50570b89920..0c7a9806c72 100644 --- a/doc/user/application_security/dast/dast_troubleshooting.md +++ b/doc/user/application_security/dast/dast_troubleshooting.md @@ -102,3 +102,8 @@ To avoid this error, make sure you are using the latest stable version of Docker ## Lack of IPv6 support Due to the underlying [ZAProxy engine not supporting IPv6](https://github.com/zaproxy/zaproxy/issues/3705), DAST is unable to scan or crawl IPv6-based applications. + +## Additional insight into DAST scan activity + +For additional insight into what a DAST scan is doing at a given time, you may find it helpful to review +the web server access logs for a DAST target endpoint during or following a scan. diff --git a/locale/gitlab.pot b/locale/gitlab.pot index b97c4e6ec28..ecea30cb802 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -43507,6 +43507,11 @@ msgstr "" msgid "WorkItem|Are you sure you want to delete the work item? This action cannot be reversed." msgstr "" +msgid "WorkItem|Assignee" +msgid_plural "WorkItem|Assignees" +msgstr[0] "" +msgstr[1] "" + msgid "WorkItem|Cancel" msgstr "" diff --git a/qa/qa/specs/features/browser_ui/3_create/merge_request/create_merge_request_spec.rb b/qa/qa/specs/features/browser_ui/3_create/merge_request/create_merge_request_spec.rb index f4ed9f28dac..25dec82b74c 100644 --- a/qa/qa/specs/features/browser_ui/3_create/merge_request/create_merge_request_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/merge_request/create_merge_request_spec.rb @@ -24,6 +24,7 @@ module QA Resource::MergeRequest.fabricate_via_browser_ui! do |merge_request| merge_request.project = project merge_request.title = merge_request_title + merge_request.assignee = 'me' merge_request.description = merge_request_description end @@ -53,6 +54,7 @@ module QA merge_request.description = merge_request_description merge_request.project = project merge_request.milestone = milestone + merge_request.assignee = 'me' merge_request.labels.push(label) end diff --git a/spec/features/issues/user_creates_branch_and_merge_request_spec.rb b/spec/features/issues/user_creates_branch_and_merge_request_spec.rb index 1c707466b51..ae1bce7ea4c 100644 --- a/spec/features/issues/user_creates_branch_and_merge_request_spec.rb +++ b/spec/features/issues/user_creates_branch_and_merge_request_spec.rb @@ -25,20 +25,6 @@ RSpec.describe 'User creates branch and merge request on issue page', :js do sign_in(user) end - context 'when ’Create merge request’ button is clicked' do - before do - visit project_issue_path(project, issue) - - wait_for_requests - - click_button('Create merge request') - - wait_for_requests - end - - it_behaves_like 'merge request author auto assign' - end - context 'when interacting with the dropdown' do before do visit project_issue_path(project, issue) diff --git a/spec/features/merge_request/user_creates_merge_request_spec.rb b/spec/features/merge_request/user_creates_merge_request_spec.rb index 2bf8e9ba6a4..c8b22bb3125 100644 --- a/spec/features/merge_request/user_creates_merge_request_spec.rb +++ b/spec/features/merge_request/user_creates_merge_request_spec.rb @@ -15,39 +15,27 @@ RSpec.describe "User creates a merge request", :js do sign_in(user) end - context 'when completed the compare branches form' do - before do - visit(project_new_merge_request_path(project)) + it "creates a merge request" do + visit(project_new_merge_request_path(project)) - find(".js-source-branch").click - click_link("fix") + find(".js-source-branch").click + click_link("fix") - find(".js-target-branch").click - click_link("feature") + find(".js-target-branch").click + click_link("feature") - click_button("Compare branches") - end + click_button("Compare branches") - it "shows merge request form" do - page.within('.merge-request-form') do - expect(page.find('#merge_request_description')['placeholder']).to eq 'Describe the goal of the changes and what reviewers should be aware of.' - end + page.within('.merge-request-form') do + expect(page.find('#merge_request_description')['placeholder']).to eq 'Describe the goal of the changes and what reviewers should be aware of.' end - context "when completed the merge request form" do - before do - fill_in("Title", with: title) - click_button("Create merge request") - end + fill_in("Title", with: title) + click_button("Create merge request") - it "creates a merge request" do - page.within(".merge-request") do - expect(page).to have_content(title) - end - end + page.within(".merge-request") do + expect(page).to have_content(title) end - - it_behaves_like 'merge request author auto assign' end context "XSS branch name exists" do diff --git a/spec/frontend/fixtures/jobs.rb b/spec/frontend/fixtures/jobs.rb index 3cc87432655..c76b06bd39e 100644 --- a/spec/frontend/fixtures/jobs.rb +++ b/spec/frontend/fixtures/jobs.rb @@ -2,40 +2,68 @@ require 'spec_helper' -RSpec.describe Projects::JobsController, '(JavaScript fixtures)', type: :controller do +RSpec.describe 'Jobs (JavaScript fixtures)' do + include ApiHelpers include JavaScriptFixturesHelpers + include GraphqlHelpers let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project, :repository, namespace: namespace, path: 'builds-project') } let(:user) { project.first_owner } let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.id) } - let!(:build_with_artifacts) { create(:ci_build, :success, :artifacts, :trace_artifact, pipeline: pipeline, stage: 'test', artifacts_expire_at: Time.now + 18.months) } - let!(:failed_build) { create(:ci_build, :failed, pipeline: pipeline, stage: 'build') } - let!(:pending_build) { create(:ci_build, :pending, pipeline: pipeline, stage: 'deploy') } - let!(:delayed_job) do - create(:ci_build, :scheduled, - pipeline: pipeline, - name: 'delayed job', - stage: 'test') + + after do + remove_repository(project) end - render_views + describe Projects::JobsController, type: :controller do + let!(:delayed) { create(:ci_build, :scheduled, pipeline: pipeline, name: 'delayed job') } - before do - sign_in(user) - end + before do + sign_in(user) + end - after do - remove_repository(project) + it 'jobs/delayed.json' do + get :show, params: { + namespace_id: project.namespace.to_param, + project_id: project, + id: delayed.to_param + }, format: :json + + expect(response).to be_successful + end end - it 'jobs/delayed.json' do - get :show, params: { - namespace_id: project.namespace.to_param, - project_id: project, - id: delayed_job.to_param - }, format: :json + describe GraphQL::Query, type: :request do + let!(:build) { create(:ci_build, :success, name: 'build', pipeline: pipeline) } + let!(:created_by_tag) { create(:ci_build, :success, name: 'created_by_tag', tag: true, pipeline: pipeline) } + let!(:with_coverage) { create(:ci_build, :success, name: 'with_coverage', coverage: 40.0, pipeline: pipeline) } + let!(:stuck) { create(:ci_build, :pending, name: 'stuck', pipeline: pipeline) } + + fixtures_path = 'graphql/jobs/' + get_jobs_query = 'get_jobs.query.graphql' + + let_it_be(:query) do + get_graphql_query_as_string("jobs/components/table/graphql/queries/#{get_jobs_query}") + end + + it "#{fixtures_path}#{get_jobs_query}.json" do + post_graphql(query, current_user: user, variables: { + fullPath: 'frontend-fixtures/builds-project' + }) + + expect_graphql_errors_to_be_empty + end + + it "#{fixtures_path}#{get_jobs_query}.as_guest.json" do + guest = create(:user) + project.add_guest(guest) + + post_graphql(query, current_user: guest, variables: { + fullPath: 'frontend-fixtures/builds-project' + }) - expect(response).to be_successful + expect_graphql_errors_to_be_empty + end end end diff --git a/spec/frontend/jobs/components/table/cells/job_cell_spec.js b/spec/frontend/jobs/components/table/cells/job_cell_spec.js index fc4e5586349..e3bef17b6fa 100644 --- a/spec/frontend/jobs/components/table/cells/job_cell_spec.js +++ b/spec/frontend/jobs/components/table/cells/job_cell_spec.js @@ -2,12 +2,9 @@ import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import JobCell from '~/jobs/components/table/cells/job_cell.vue'; -import { mockJobsInTable } from '../../../mock_data'; +import { mockJobsInTable, mockJobsAsGuestInTable } from '../../../mock_data'; -const mockJob = mockJobsInTable[0]; -const mockJobCreatedByTag = mockJobsInTable[1]; -const mockJobLimitedAccess = mockJobsInTable[2]; -const mockStuckJob = mockJobsInTable[3]; +const getMockJob = (name) => mockJobsInTable.find((job) => job.name === name); describe('Job Cell', () => { let wrapper; @@ -23,6 +20,8 @@ describe('Job Cell', () => { const findBadgeById = (id) => wrapper.findByTestId(id); + const mockJob = getMockJob('build'); + const createComponent = (jobData = mockJob) => { wrapper = extendedWrapper( shallowMount(JobCell, { @@ -49,9 +48,11 @@ describe('Job Cell', () => { }); it('display the job id with no link', () => { - createComponent(mockJobLimitedAccess); + const mockJobAsGuest = mockJobsAsGuestInTable[0]; + + createComponent(mockJobAsGuest); - const expectedJobId = `#${getIdFromGraphQLId(mockJobLimitedAccess.id)}`; + const expectedJobId = `#${getIdFromGraphQLId(mockJobAsGuest.id)}`; expect(findJobIdNoLink().text()).toBe(expectedJobId); expect(findJobIdNoLink().exists()).toBe(true); @@ -75,7 +76,7 @@ describe('Job Cell', () => { }); it('displays label icon when job is created by a tag', () => { - createComponent(mockJobCreatedByTag); + createComponent(getMockJob('created_by_tag')); expect(findLabelIcon().exists()).toBe(true); expect(findForkIcon().exists()).toBe(false); @@ -131,7 +132,7 @@ describe('Job Cell', () => { }); it('stuck icon is shown if job is stuck', () => { - createComponent(mockStuckJob); + createComponent(getMockJob('stuck')); expect(findStuckIcon().exists()).toBe(true); expect(findStuckIcon().attributes('name')).toBe('warning'); diff --git a/spec/frontend/jobs/mock_data.js b/spec/frontend/jobs/mock_data.js index 4676635cce0..57ec1c7ef3f 100644 --- a/spec/frontend/jobs/mock_data.js +++ b/spec/frontend/jobs/mock_data.js @@ -1,8 +1,14 @@ +import mockJobs from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.json'; +import mockJobsAsGuest from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.as_guest.json'; import { TEST_HOST } from 'spec/test_constants'; const threeWeeksAgo = new Date(); threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21); +// Fixtures generated at spec/frontend/fixtures/jobs.rb +export const mockJobsInTable = mockJobs.data.project.jobs.nodes; +export const mockJobsAsGuestInTable = mockJobsAsGuest.data.project.jobs.nodes; + export const stages = [ { name: 'build', @@ -1283,199 +1289,6 @@ export const mockPipelineDetached = { }, }; -export const mockJobsInTable = [ - { - detailedStatus: { - icon: 'status_manual', - label: 'manual play action', - text: 'manual', - tooltip: 'manual action', - action: { - buttonTitle: 'Trigger this manual action', - icon: 'play', - method: 'post', - path: '/root/ci-project/-/jobs/2004/play', - title: 'Play', - __typename: 'StatusAction', - }, - detailsPath: '/root/ci-project/-/jobs/2004', - __typename: 'DetailedStatus', - }, - id: 'gid://gitlab/Ci::Build/2004', - refName: 'main', - refPath: '/root/ci-project/-/commits/main', - tags: [], - shortSha: '2d5d8323', - commitPath: '/root/ci-project/-/commit/2d5d83230bdea0e003d83ef4c16d2bf9a8808ebe', - pipeline: { - id: 'gid://gitlab/Ci::Pipeline/423', - path: '/root/ci-project/-/pipelines/423', - user: { - webPath: '/root', - avatarUrl: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - __typename: 'User', - }, - __typename: 'Pipeline', - }, - stage: { name: 'test', __typename: 'CiStage' }, - name: 'test_manual_job', - duration: null, - finishedAt: null, - coverage: null, - createdByTag: false, - retryable: false, - playable: true, - cancelable: false, - active: false, - stuck: false, - userPermissions: { readBuild: true, __typename: 'JobPermissions' }, - __typename: 'CiJob', - }, - { - detailedStatus: { - icon: 'status_skipped', - label: 'skipped', - text: 'skipped', - tooltip: 'skipped', - action: null, - __typename: 'DetailedStatus', - }, - id: 'gid://gitlab/Ci::Build/2021', - refName: 'main', - refPath: '/root/ci-project/-/commits/main', - tags: [], - shortSha: '2d5d8323', - commitPath: '/root/ci-project/-/commit/2d5d83230bdea0e003d83ef4c16d2bf9a8808ebe', - pipeline: { - id: 'gid://gitlab/Ci::Pipeline/425', - path: '/root/ci-project/-/pipelines/425', - user: { - webPath: '/root', - avatarUrl: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - __typename: 'User', - }, - __typename: 'Pipeline', - }, - stage: { name: 'test', __typename: 'CiStage' }, - name: 'coverage_job', - duration: null, - finishedAt: null, - coverage: null, - createdByTag: true, - retryable: false, - playable: false, - cancelable: false, - active: false, - stuck: false, - userPermissions: { readBuild: true, __typename: 'JobPermissions' }, - __typename: 'CiJob', - }, - { - detailedStatus: { - icon: 'status_success', - label: 'passed', - text: 'passed', - tooltip: 'passed', - action: { - buttonTitle: 'Retry this job', - icon: 'retry', - method: 'post', - path: '/root/ci-project/-/jobs/2015/retry', - title: 'Retry', - __typename: 'StatusAction', - }, - __typename: 'DetailedStatus', - }, - id: 'gid://gitlab/Ci::Build/2015', - refName: 'main', - refPath: '/root/ci-project/-/commits/main', - tags: [], - shortSha: '2d5d8323', - commitPath: '/root/ci-project/-/commit/2d5d83230bdea0e003d83ef4c16d2bf9a8808ebe', - pipeline: { - id: 'gid://gitlab/Ci::Pipeline/424', - path: '/root/ci-project/-/pipelines/424', - user: { - webPath: '/root', - avatarUrl: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - __typename: 'User', - }, - __typename: 'Pipeline', - }, - stage: { name: 'deploy', __typename: 'CiStage' }, - name: 'artifact_job', - duration: 2, - finishedAt: '2021-04-01T17:36:18Z', - coverage: 82.71, - createdByTag: false, - retryable: true, - playable: false, - cancelable: false, - active: false, - stuck: false, - userPermissions: { readBuild: false, __typename: 'JobPermissions' }, - __typename: 'CiJob', - }, - { - artifacts: { nodes: [], __typename: 'CiJobArtifactConnection' }, - allowFailure: false, - status: 'PENDING', - scheduledAt: null, - manualJob: false, - triggered: null, - createdByTag: false, - detailedStatus: { - detailsPath: '/root/ci-project/-/jobs/2391', - group: 'pending', - icon: 'status_pending', - label: 'pending', - text: 'pending', - tooltip: 'pending', - action: { - buttonTitle: 'Cancel this job', - icon: 'cancel', - method: 'post', - path: '/root/ci-project/-/jobs/2391/cancel', - title: 'Cancel', - __typename: 'StatusAction', - }, - __typename: 'DetailedStatus', - }, - id: 'gid://gitlab/Ci::Build/2391', - refName: 'master', - refPath: '/root/ci-project/-/commits/master', - tags: [], - shortSha: '916330b4', - commitPath: '/root/ci-project/-/commit/916330b4fda5dae226524ceb51c756c0ed26679d', - pipeline: { - id: 'gid://gitlab/Ci::Pipeline/482', - path: '/root/ci-project/-/pipelines/482', - user: { - webPath: '/root', - avatarUrl: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - __typename: 'UserCore', - }, - __typename: 'Pipeline', - }, - stage: { name: 'build', __typename: 'CiStage' }, - name: 'build_job', - duration: null, - finishedAt: null, - coverage: null, - retryable: false, - playable: false, - cancelable: true, - active: true, - stuck: true, - userPermissions: { readBuild: true, __typename: 'JobPermissions' }, - __typename: 'CiJob', - }, -]; - export const mockJobsQueryResponse = { data: { project: { diff --git a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap index 62a9ff98243..11841106ed0 100644 --- a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap +++ b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap @@ -8,7 +8,7 @@ exports[`self monitor component When the self monitor project has not been creat class="settings-header" > <h4 - class="js-section-header" + class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only" > Self monitoring diff --git a/spec/frontend/work_items/components/work_item_assignees_spec.js b/spec/frontend/work_items/components/work_item_assignees_spec.js index 0552fe5050e..b2678293c05 100644 --- a/spec/frontend/work_items/components/work_item_assignees_spec.js +++ b/spec/frontend/work_items/components/work_item_assignees_spec.js @@ -1,52 +1,59 @@ -import { GlLink, GlTokenSelector } from '@gitlab/ui'; -import { nextTick } from 'vue'; +import { GlLink, GlTokenSelector, GlSkeletonLoader } from '@gitlab/ui'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; +import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue'; -import localUpdateWorkItemMutation from '~/work_items/graphql/local_update_work_item.mutation.graphql'; - -const mockAssignees = [ - { - __typename: 'UserCore', - id: 'gid://gitlab/User/1', - avatarUrl: '', - webUrl: '', - name: 'John Doe', - username: 'doe_I', - }, - { - __typename: 'UserCore', - id: 'gid://gitlab/User/2', - avatarUrl: '', - webUrl: '', - name: 'Marcus Rutherford', - username: 'ruthfull', - }, -]; +import { i18n } from '~/work_items/constants'; +import { temporaryConfig, resolvers } from '~/work_items/graphql/provider'; +import { projectMembersResponse, mockAssignees, workItemQueryResponse } from '../mock_data'; -const workItemId = 'gid://gitlab/WorkItem/1'; +Vue.use(VueApollo); -const mutate = jest.fn(); +const workItemId = 'gid://gitlab/WorkItem/1'; describe('WorkItemAssignees component', () => { let wrapper; const findAssigneeLinks = () => wrapper.findAllComponents(GlLink); const findTokenSelector = () => wrapper.findComponent(GlTokenSelector); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); const findEmptyState = () => wrapper.findByTestId('empty-state'); - const createComponent = ({ assignees = mockAssignees } = {}) => { + const successSearchQueryHandler = jest.fn().mockResolvedValue(projectMembersResponse); + const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); + + const createComponent = ({ + assignees = mockAssignees, + searchQueryHandler = successSearchQueryHandler, + } = {}) => { + const apolloProvider = createMockApollo([[userSearchQuery, searchQueryHandler]], resolvers, { + typePolicies: temporaryConfig.cacheConfig.typePolicies, + }); + + apolloProvider.clients.defaultClient.writeQuery({ + query: workItemQuery, + variables: { + id: workItemId, + }, + data: workItemQueryResponse.data, + }); + wrapper = mountExtended(WorkItemAssignees, { + provide: { + fullPath: 'test-project-path', + }, propsData: { assignees, workItemId, }, - mocks: { - $apollo: { - mutate, - }, - }, attachTo: document.body, + apolloProvider, }); }; @@ -54,40 +61,114 @@ describe('WorkItemAssignees component', () => { wrapper.destroy(); }); - it('should pass the correct data-user-id attribute', () => { + it('passes the correct data-user-id attribute', () => { createComponent(); expect(findAssigneeLinks().at(0).attributes('data-user-id')).toBe('1'); }); - describe('when there are assignees', () => { - beforeEach(() => { - createComponent(); - }); + it('focuses token selector on token selector input event', async () => { + createComponent(); + findTokenSelector().vm.$emit('input', [mockAssignees[0]]); + await nextTick(); - it('should focus token selector on token removal', async () => { - findTokenSelector().vm.$emit('token-remove', mockAssignees[0].id); - await nextTick(); + expect(findEmptyState().exists()).toBe(false); + expect(findTokenSelector().element.contains(document.activeElement)).toBe(true); + }); - expect(findEmptyState().exists()).toBe(false); - expect(findTokenSelector().element.contains(document.activeElement)).toBe(true); - }); + it('calls a mutation on clicking outside the token selector', async () => { + createComponent(); + findTokenSelector().vm.$emit('input', [mockAssignees[0]]); + findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null })); + await waitForPromises(); - it('should call a mutation on clicking outside the token selector', async () => { - findTokenSelector().vm.$emit('input', [mockAssignees[0]]); - findTokenSelector().vm.$emit('token-remove'); - await nextTick(); - expect(mutate).not.toHaveBeenCalled(); - - findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null })); - await nextTick(); - - expect(mutate).toHaveBeenCalledWith({ - mutation: localUpdateWorkItemMutation, - variables: { - input: { id: workItemId, assigneeIds: [mockAssignees[0].id] }, - }, - }); - }); + expect(findTokenSelector().props('selectedTokens')).toEqual([mockAssignees[0]]); + }); + + it('does not start user search by default', () => { + createComponent(); + + expect(findTokenSelector().props('loading')).toBe(false); + expect(findTokenSelector().props('dropdownItems')).toEqual([]); + }); + + it('starts user search on hovering for more than 250ms', async () => { + createComponent(); + findTokenSelector().trigger('mouseover'); + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + await nextTick(); + + expect(findTokenSelector().props('loading')).toBe(true); + }); + + it('starts user search on focusing token selector', async () => { + createComponent(); + findTokenSelector().vm.$emit('focus'); + await nextTick(); + + expect(findTokenSelector().props('loading')).toBe(true); + }); + + it('does not start searching if token-selector was hovered for less than 250ms', async () => { + createComponent(); + findTokenSelector().trigger('mouseover'); + jest.advanceTimersByTime(100); + await nextTick(); + + expect(findTokenSelector().props('loading')).toBe(false); + }); + + it('does not start searching if cursor was moved out from token selector before 250ms passed', async () => { + createComponent(); + findTokenSelector().trigger('mouseover'); + jest.advanceTimersByTime(100); + + findTokenSelector().trigger('mouseout'); + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + await nextTick(); + + expect(findTokenSelector().props('loading')).toBe(false); + }); + + it('shows skeleton loader on dropdown when loading users', async () => { + createComponent(); + findTokenSelector().vm.$emit('focus'); + await nextTick(); + + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('shows correct user list in dropdown when loaded', async () => { + createComponent(); + findTokenSelector().vm.$emit('focus'); + await nextTick(); + + expect(findSkeletonLoader().exists()).toBe(true); + + await waitForPromises(); + + expect(findSkeletonLoader().exists()).toBe(false); + expect(findTokenSelector().props('dropdownItems')).toHaveLength(2); + }); + + it('emits error event if search users query fails', async () => { + createComponent({ searchQueryHandler: errorHandler }); + findTokenSelector().vm.$emit('focus'); + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[i18n.fetchError]]); + }); + + it('should search for users with correct key after text input', async () => { + const searchKey = 'Hello'; + + createComponent(); + findTokenSelector().vm.$emit('focus'); + findTokenSelector().vm.$emit('text-input', searchKey); + await waitForPromises(); + + expect(successSearchQueryHandler).toHaveBeenCalledWith( + expect.objectContaining({ search: searchKey }), + ); }); }); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 91dfc61198c..116bf48901d 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -300,3 +300,60 @@ export const availableWorkItemsResponse = { }, }, }; + +export const projectMembersResponse = { + data: { + workspace: { + id: '1', + __typename: 'Project', + users: { + nodes: [ + { + id: 'user-1', + user: { + __typename: 'UserCore', + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + name: 'Administrator', + username: 'root', + webUrl: '/root', + status: null, + }, + }, + { + id: 'user-2', + user: { + __typename: 'UserCore', + id: 'gid://gitlab/User/5', + avatarUrl: '/avatar2', + name: 'rookie', + username: 'rookie', + webUrl: 'rookie', + status: null, + }, + }, + ], + }, + }, + }, +}; + +export const mockAssignees = [ + { + __typename: 'UserCore', + id: 'gid://gitlab/User/1', + avatarUrl: '', + webUrl: '', + name: 'John Doe', + username: 'doe_I', + }, + { + __typename: 'UserCore', + id: 'gid://gitlab/User/2', + avatarUrl: '', + webUrl: '', + name: 'Marcus Rutherford', + username: 'ruthfull', + }, +]; diff --git a/spec/graphql/resolvers/todo_resolver_spec.rb b/spec/graphql/resolvers/todos_resolver_spec.rb index 0760935a2fe..40ca2de0385 100644 --- a/spec/graphql/resolvers/todo_resolver_spec.rb +++ b/spec/graphql/resolvers/todos_resolver_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Resolvers::TodoResolver do +RSpec.describe Resolvers::TodosResolver do include GraphqlHelpers include DesignManagementTestHelpers diff --git a/spec/graphql/types/work_items/widgets/description_input_type_spec.rb b/spec/graphql/types/work_items/widgets/description_input_type_spec.rb new file mode 100644 index 00000000000..81c64bc38ab --- /dev/null +++ b/spec/graphql/types/work_items/widgets/description_input_type_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Types::WorkItems::Widgets::DescriptionInputType do + it { expect(described_class.graphql_name).to eq('WorkItemWidgetDescriptionInput') } + + it { expect(described_class.arguments.keys).to match_array(%w[description]) } +end diff --git a/spec/requests/api/graphql/mutations/work_items/update_spec.rb b/spec/requests/api/graphql/mutations/work_items/update_spec.rb index 71b03103115..7a160819a41 100644 --- a/spec/requests/api/graphql/mutations/work_items/update_spec.rb +++ b/spec/requests/api/graphql/mutations/work_items/update_spec.rb @@ -11,8 +11,17 @@ RSpec.describe 'Update a work item' do let(:work_item_event) { 'CLOSE' } let(:input) { { 'stateEvent' => work_item_event, 'title' => 'updated title' } } + let(:fields) do + <<~FIELDS + workItem { + state + title + } + errors + FIELDS + end - let(:mutation) { graphql_mutation(:workItemUpdate, input.merge('id' => work_item.to_global_id.to_s)) } + let(:mutation) { graphql_mutation(:workItemUpdate, input.merge('id' => work_item.to_global_id.to_s), fields) } let(:mutation_response) { graphql_mutation_response(:work_item_update) } @@ -80,5 +89,29 @@ RSpec.describe 'Update a work item' do expect(mutation_response['errors']).to contain_exactly('`work_items` feature flag disabled for this project') end end + + context 'with description widget input' do + let(:fields) do + <<~FIELDS + workItem { + description + widgets { + type + ... on WorkItemWidgetDescription { + description + } + } + } + errors + FIELDS + end + + it_behaves_like 'update work item description widget' do + let(:new_description) { 'updated description' } + let(:input) do + { 'descriptionWidget' => { 'description' => new_description } } + end + end + end end end diff --git a/spec/requests/api/graphql/mutations/work_items/update_widgets_spec.rb b/spec/requests/api/graphql/mutations/work_items/update_widgets_spec.rb index 595d8fe97ed..2a5cb937a2f 100644 --- a/spec/requests/api/graphql/mutations/work_items/update_widgets_spec.rb +++ b/spec/requests/api/graphql/mutations/work_items/update_widgets_spec.rb @@ -9,16 +9,23 @@ RSpec.describe 'Update work item widgets' do let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } } let_it_be(:work_item, refind: true) { create(:work_item, project: project) } - let(:input) do - { - 'descriptionWidget' => { 'description' => 'updated description' } + let(:input) { { 'descriptionWidget' => { 'description' => 'updated description' } } } + let(:mutation_response) { graphql_mutation_response(:work_item_update_widgets) } + let(:mutation) do + graphql_mutation(:workItemUpdateWidgets, input.merge('id' => work_item.to_global_id.to_s), <<~FIELDS) + errors + workItem { + description + widgets { + type + ... on WorkItemWidgetDescription { + description + } + } } + FIELDS end - let(:mutation) { graphql_mutation(:workItemUpdateWidgets, input.merge('id' => work_item.to_global_id.to_s)) } - - let(:mutation_response) { graphql_mutation_response(:work_item_update_widgets) } - context 'the user is not allowed to update a work item' do let(:current_user) { create(:user) } @@ -28,32 +35,8 @@ RSpec.describe 'Update work item widgets' do context 'when user has permissions to update a work item', :aggregate_failures do let(:current_user) { developer } - context 'when the updated work item is not valid' do - it 'returns validation errors without the work item' do - errors = ActiveModel::Errors.new(work_item).tap { |e| e.add(:description, 'error message') } - - allow_next_found_instance_of(::WorkItem) do |instance| - allow(instance).to receive(:valid?).and_return(false) - allow(instance).to receive(:errors).and_return(errors) - end - - post_graphql_mutation(mutation, current_user: current_user) - - expect(mutation_response['workItem']).to be_nil - expect(mutation_response['errors']).to match_array(['Description error message']) - end - end - - it 'updates the work item widgets' do - expect do - post_graphql_mutation(mutation, current_user: current_user) - work_item.reload - end.to change(work_item, :description).from(nil).to('updated description') - - expect(response).to have_gitlab_http_status(:success) - expect(mutation_response['workItem']).to include( - 'title' => work_item.title - ) + it_behaves_like 'update work item description widget' do + let(:new_description) { 'updated description' } end it_behaves_like 'has spam protection' do @@ -69,7 +52,7 @@ RSpec.describe 'Update work item widgets' do expect do post_graphql_mutation(mutation, current_user: current_user) work_item.reload - end.to not_change(work_item, :title) + end.to not_change(work_item, :description) expect(mutation_response['errors']).to contain_exactly('`work_items` feature flag disabled for this project') end diff --git a/spec/requests/api/graphql/todo_query_spec.rb b/spec/requests/api/graphql/todo_query_spec.rb new file mode 100644 index 00000000000..3f743f4402a --- /dev/null +++ b/spec/requests/api/graphql/todo_query_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Todo Query' do + include GraphqlHelpers + + let_it_be(:current_user) { nil } + let_it_be(:project) { create(:project) } + let_it_be(:issue) { create(:issue, project: project) } + + let_it_be(:todo_owner) { create(:user) } + + let_it_be(:todo) { create(:todo, user: todo_owner, target: project) } + + before do + project.add_developer(todo_owner) + end + + let(:fields) do + <<~GRAPHQL + id + GRAPHQL + end + + let(:query) do + graphql_query_for(:todo, { id: todo.to_global_id.to_s }, fields) + end + + subject do + result = GitlabSchema.execute(query, context: { current_user: current_user }).to_h + graphql_dig_at(result, :data, :todo) + end + + context 'when requesting user is todo owner' do + let(:current_user) { todo_owner } + + it { is_expected.to include('id' => todo.to_global_id.to_s) } + end + + context 'when requesting user is not todo owner' do + let(:current_user) { create(:user) } + + it { is_expected.to be_nil } + end + + context 'when unauthenticated' do + it { is_expected.to be_nil } + end +end diff --git a/spec/services/work_items/widgets/description_service/update_service_spec.rb b/spec/services/work_items/widgets/description_service/update_service_spec.rb new file mode 100644 index 00000000000..a2eceb97f09 --- /dev/null +++ b/spec/services/work_items/widgets/description_service/update_service_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WorkItems::Widgets::DescriptionService::UpdateService do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be_with_reload(:work_item) { create(:work_item, project: project, description: 'old description') } + + let(:widget) { work_item.widgets.find {|widget| widget.is_a?(WorkItems::Widgets::Description) } } + + describe '#update' do + subject { described_class.new(widget: widget, current_user: user).update(params: params) } # rubocop:disable Rails/SaveBang + + context 'when description param is present' do + let(:params) { { description: 'updated description' } } + + it 'correctly sets work item description value' do + subject + + expect(work_item.description).to eq('updated description') + end + end + + context 'when description param is not present' do + let(:params) { {} } + + it 'does not change work item description value' do + subject + + expect(work_item.description).to eq('old description') + end + end + end +end diff --git a/spec/support/shared_examples/features/multiple_assignees_mr_shared_examples.rb b/spec/support/shared_examples/features/multiple_assignees_mr_shared_examples.rb index 4565108b5e4..9d023d9514a 100644 --- a/spec/support/shared_examples/features/multiple_assignees_mr_shared_examples.rb +++ b/spec/support/shared_examples/features/multiple_assignees_mr_shared_examples.rb @@ -4,7 +4,7 @@ RSpec.shared_examples 'multiple assignees merge request' do |action, save_button it "#{action} a MR with multiple assignees", :js do find('.js-assignee-search').click page.within '.dropdown-menu-user' do - click_link user.name unless action == 'creates' + click_link user.name click_link user2.name end diff --git a/spec/support/shared_examples/features/multiple_assignees_widget_mr_shared_examples.rb b/spec/support/shared_examples/features/multiple_assignees_widget_mr_shared_examples.rb index a44a699c878..bbde448a1a1 100644 --- a/spec/support/shared_examples/features/multiple_assignees_widget_mr_shared_examples.rb +++ b/spec/support/shared_examples/features/multiple_assignees_widget_mr_shared_examples.rb @@ -4,7 +4,7 @@ RSpec.shared_examples 'multiple assignees widget merge request' do |action, save it "#{action} a MR with multiple assignees", :js do find('.js-assignee-search').click page.within '.dropdown-menu-user' do - click_link user.name unless action == 'creates' + click_link user.name click_link user2.name end diff --git a/spec/support/shared_examples/graphql/mutations/work_items/update_description_widget_shared_examples.rb b/spec/support/shared_examples/graphql/mutations/work_items/update_description_widget_shared_examples.rb new file mode 100644 index 00000000000..56c2ca22e15 --- /dev/null +++ b/spec/support/shared_examples/graphql/mutations/work_items/update_description_widget_shared_examples.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'update work item description widget' do + it 'updates the description widget' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.to change(work_item, :description).from(nil).to(new_description) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include( + { + 'description' => new_description, + 'type' => 'DESCRIPTION' + } + ) + end + + context 'when the updated work item is not valid' do + it 'returns validation errors without the work item' do + errors = ActiveModel::Errors.new(work_item).tap { |e| e.add(:description, 'error message') } + + allow_next_found_instance_of(::WorkItem) do |instance| + allow(instance).to receive(:valid?).and_return(false) + allow(instance).to receive(:errors).and_return(errors) + end + + post_graphql_mutation(mutation, current_user: current_user) + + expect(mutation_response['workItem']).to be_nil + expect(mutation_response['errors']).to match_array(['Description error message']) + end + end +end diff --git a/spec/support/shared_examples/merge_request_author_auto_assign_shared_examples.rb b/spec/support/shared_examples/merge_request_author_auto_assign_shared_examples.rb deleted file mode 100644 index d4986975f03..00000000000 --- a/spec/support/shared_examples/merge_request_author_auto_assign_shared_examples.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -RSpec.shared_examples 'merge request author auto assign' do - it 'populates merge request author as assignee' do - expect(find('.js-assignee-search')).to have_content(user.name) - expect(page).not_to have_content 'Assign yourself' - end -end |