diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-06-02 18:08:24 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-06-02 18:08:24 +0300 |
commit | eea1fbf9f980fed108601412b63e627d3eebd46d (patch) | |
tree | 11df1e2bab7dcf8c0127890a5b5f6767c5c7b786 | |
parent | 810bd2a662abaa60663ec19bcb55f883d329eb07 (diff) |
Add latest changes from gitlab-org/gitlab@master
64 files changed, 2033 insertions, 133 deletions
diff --git a/app/assets/javascripts/jobs/components/stuck_block.vue b/app/assets/javascripts/jobs/components/stuck_block.vue index ac1e5b31802..da01269a50c 100644 --- a/app/assets/javascripts/jobs/components/stuck_block.vue +++ b/app/assets/javascripts/jobs/components/stuck_block.vue @@ -29,7 +29,7 @@ export default { <p v-if="tags.length" class="js-stuck-with-tags gl-mb-0"> {{ s__(`This job is stuck because you don't have - any active runners online with any of these tags assigned to them:`) + any active runners online or available with any of these tags assigned to them:`) }} <span v-for="(tag, index) in tags" :key="index" class="badge badge-primary append-right-4"> {{ tag }} @@ -48,9 +48,9 @@ export default { }} </p> - {{ __('Go to') }} + {{ __('Go to project') }} <gl-link v-if="runnersPath" :href="runnersPath" class="js-runners-path"> - {{ __('Runners page') }} + {{ __('CI settings') }} </gl-link> </div> </template> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 6bd72a93f1c..0ca7ec24a12 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -147,6 +147,7 @@ export default { return { selectedTimeRange: timeRangeFromUrl() || defaultTimeRange, isRearrangingPanels: false, + originalDocumentTitle: document.title, }; }, computed: { @@ -192,6 +193,9 @@ export default { }, deep: true, }, + selectedDashboard(dashboard) { + this.prependToDocumentTitle(dashboard?.display_name); + }, }, created() { window.addEventListener('keyup', this.onKeyup); @@ -258,6 +262,11 @@ export default { // Collapse group if no data is available return !this.getMetricStates(groupKey).includes(metricStates.OK); }, + prependToDocumentTitle(text) { + if (text) { + document.title = `${text} · ${this.originalDocumentTitle}`; + } + }, onTimeRangeZoom({ start, end }) { updateHistory({ url: mergeUrlParams({ start, end }, window.location.href), diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js new file mode 100644 index 00000000000..6665a5754b3 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js @@ -0,0 +1,8 @@ +export const ANY_AUTHOR = 'Any'; + +export const DEBOUNCE_DELAY = 200; + +export const SortDirection = { + descending: 'descending', + ascending: 'ascending', +}; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue new file mode 100644 index 00000000000..14252c50d03 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -0,0 +1,252 @@ +<script> +import { + GlFilteredSearch, + GlButtonGroup, + GlButton, + GlNewDropdown as GlDropdown, + GlNewDropdownItem as GlDropdownItem, + GlTooltipDirective, +} from '@gitlab/ui'; + +import { __ } from '~/locale'; +import createFlash from '~/flash'; + +import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store'; +import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; +import RecentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys'; + +import { SortDirection } from './constants'; + +export default { + components: { + GlFilteredSearch, + GlButtonGroup, + GlButton, + GlDropdown, + GlDropdownItem, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + namespace: { + type: String, + required: true, + }, + recentSearchesStorageKey: { + type: String, + required: false, + default: '', + }, + tokens: { + type: Array, + required: true, + }, + sortOptions: { + type: Array, + required: true, + }, + initialFilterValue: { + type: Array, + required: false, + default: () => [], + }, + initialSortBy: { + type: String, + required: false, + default: '', + validator: value => value === '' || /(_desc)|(_asc)/g.test(value), + }, + searchInputPlaceholder: { + type: String, + required: true, + }, + }, + data() { + let selectedSortOption = this.sortOptions[0].sortDirection.descending; + let selectedSortDirection = SortDirection.descending; + + // Extract correct sortBy value based on initialSortBy + if (this.initialSortBy) { + selectedSortOption = this.sortOptions + .filter( + sortBy => + sortBy.sortDirection.ascending === this.initialSortBy || + sortBy.sortDirection.descending === this.initialSortBy, + ) + .pop(); + selectedSortDirection = this.initialSortBy.endsWith('_desc') + ? SortDirection.descending + : SortDirection.ascending; + } + + return { + initialRender: true, + recentSearchesPromise: null, + filterValue: this.initialFilterValue, + selectedSortOption, + selectedSortDirection, + }; + }, + computed: { + tokenSymbols() { + return this.tokens.reduce( + (tokenSymbols, token) => ({ + ...tokenSymbols, + [token.type]: token.symbol, + }), + {}, + ); + }, + sortDirectionIcon() { + return this.selectedSortDirection === SortDirection.ascending + ? 'sort-lowest' + : 'sort-highest'; + }, + sortDirectionTooltip() { + return this.selectedSortDirection === SortDirection.ascending + ? __('Sort direction: Ascending') + : __('Sort direction: Descending'); + }, + }, + watch: { + /** + * GlFilteredSearch currently doesn't emit any event when + * search field is cleared, but we still want our parent + * component to know that filters were cleared and do + * necessary data refetch, so this watcher is basically + * a dirty hack/workaround to identify if filter input + * was cleared. :( + */ + filterValue(value) { + const [firstVal] = value; + if ( + !this.initialRender && + value.length === 1 && + firstVal.type === 'filtered-search-term' && + !firstVal.value.data + ) { + this.$emit('onFilter', []); + } + + // Set initial render flag to false + // as we don't want to emit event + // on initial load when value is empty already. + this.initialRender = false; + }, + }, + created() { + if (this.recentSearchesStorageKey) this.setupRecentSearch(); + }, + methods: { + /** + * Initialize service and store instances for + * getting Recent Search functional. + */ + setupRecentSearch() { + this.recentSearchesService = new RecentSearchesService( + `${this.namespace}-${RecentSearchesStorageKeys[this.recentSearchesStorageKey]}`, + ); + + this.recentSearchesStore = new RecentSearchesStore({ + isLocalStorageAvailable: RecentSearchesService.isAvailable(), + allowedKeys: this.tokens.map(token => token.type), + }); + + this.recentSearchesPromise = this.recentSearchesService + .fetch() + .catch(error => { + if (error.name === 'RecentSearchesServiceError') return undefined; + + createFlash(__('An error occurred while parsing recent searches')); + + // Gracefully fail to empty array + return []; + }) + .then(searches => { + if (!searches) return; + + // Put any searches that may have come in before + // we fetched the saved searches ahead of the already saved ones + const resultantSearches = this.recentSearchesStore.setRecentSearches( + this.recentSearchesStore.state.recentSearches.concat(searches), + ); + this.recentSearchesService.save(resultantSearches); + }); + }, + getRecentSearches() { + return this.recentSearchesStore?.state.recentSearches; + }, + handleSortOptionClick(sortBy) { + this.selectedSortOption = sortBy; + this.$emit('onSort', sortBy.sortDirection[this.selectedSortDirection]); + }, + handleSortDirectionClick() { + this.selectedSortDirection = + this.selectedSortDirection === SortDirection.ascending + ? SortDirection.descending + : SortDirection.ascending; + this.$emit('onSort', this.selectedSortOption.sortDirection[this.selectedSortDirection]); + }, + handleFilterSubmit(filters) { + if (this.recentSearchesStorageKey) { + this.recentSearchesPromise + .then(() => { + if (filters.length) { + const searchTokens = filters.map(filter => { + // check filter was plain text search + if (typeof filter === 'string') { + return filter; + } + // filter was a token. + return `${filter.type}:${filter.value.operator}${this.tokenSymbols[filter.type]}${ + filter.value.data + }`; + }); + + const resultantSearches = this.recentSearchesStore.addRecentSearch( + searchTokens.join(' '), + ); + this.recentSearchesService.save(resultantSearches); + } + }) + .catch(() => { + // https://gitlab.com/gitlab-org/gitlab-foss/issues/30821 + }); + } + this.$emit('onFilter', filters); + }, + }, +}; +</script> + +<template> + <div class="vue-filtered-search-bar-container d-flex"> + <gl-filtered-search + v-model="filterValue" + :placeholder="searchInputPlaceholder" + :available-tokens="tokens" + :history-items="getRecentSearches()" + class="flex-grow-1" + @submit="handleFilterSubmit" + /> + <gl-button-group class="ml-2"> + <gl-dropdown :text="selectedSortOption.title" :right="true"> + <gl-dropdown-item + v-for="sortBy in sortOptions" + :key="sortBy.id" + :is-check-item="true" + :is-checked="sortBy.id === selectedSortOption.id" + @click="handleSortOptionClick(sortBy)" + >{{ sortBy.title }}</gl-dropdown-item + > + </gl-dropdown> + <gl-button + v-gl-tooltip + :title="sortDirectionTooltip" + :icon="sortDirectionIcon" + @click="handleSortDirectionClick" + /> + </gl-button-group> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue new file mode 100644 index 00000000000..412bfa5aa7f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue @@ -0,0 +1,114 @@ +<script> +import { + GlFilteredSearchToken, + GlAvatar, + GlFilteredSearchSuggestion, + GlDropdownDivider, + GlLoadingIcon, +} from '@gitlab/ui'; +import { debounce } from 'lodash'; + +import createFlash from '~/flash'; +import { __ } from '~/locale'; + +import { ANY_AUTHOR, DEBOUNCE_DELAY } from '../constants'; + +export default { + anyAuthor: ANY_AUTHOR, + components: { + GlFilteredSearchToken, + GlAvatar, + GlFilteredSearchSuggestion, + GlDropdownDivider, + GlLoadingIcon, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + data() { + return { + authors: this.config.initialAuthors || [], + loading: true, + }; + }, + computed: { + currentValue() { + return this.value.data.toLowerCase(); + }, + activeAuthor() { + return this.authors.find(author => author.username.toLowerCase() === this.currentValue); + }, + }, + methods: { + fetchAuthorBySearchTerm(searchTerm) { + const fetchPromise = this.config.fetchPath + ? this.config.fetchAuthors(this.config.fetchPath, searchTerm) + : this.config.fetchAuthors(searchTerm); + + fetchPromise + .then(res => { + // We'd want to avoid doing this check but + // users.json and /groups/:id/members & /projects/:id/users + // return response differently. + this.authors = Array.isArray(res) ? res : res.data; + }) + .catch(() => createFlash(__('There was a problem fetching users.'))) + .finally(() => { + this.loading = false; + }); + }, + searchAuthors: debounce(function debouncedSearch({ data }) { + this.fetchAuthorBySearchTerm(data); + }, DEBOUNCE_DELAY), + }, +}; +</script> + +<template> + <gl-filtered-search-token + :config="config" + v-bind="{ ...$props, ...$attrs }" + v-on="$listeners" + @input="searchAuthors" + > + <template #view="{ inputValue }"> + <gl-avatar + v-if="activeAuthor" + :size="16" + :src="activeAuthor.avatar_url" + shape="circle" + class="gl-mr-2" + /> + <span>{{ activeAuthor ? activeAuthor.name : inputValue }}</span> + </template> + <template #suggestions> + <gl-filtered-search-suggestion :value="$options.anyAuthor">{{ + __('Any') + }}</gl-filtered-search-suggestion> + <gl-dropdown-divider /> + <gl-loading-icon v-if="loading" /> + <template v-else> + <gl-filtered-search-suggestion + v-for="author in authors" + :key="author.username" + :value="author.username" + > + <div class="d-flex"> + <gl-avatar :size="32" :src="author.avatar_url" /> + <div> + <div>{{ author.name }}</div> + <div>@{{ author.username }}</div> + </div> + </div> + </gl-filtered-search-suggestion> + </template> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/finders/issuable_finder/params.rb b/app/finders/issuable_finder/params.rb index adf9f1ca9d8..5b48d0817e3 100644 --- a/app/finders/issuable_finder/params.rb +++ b/app/finders/issuable_finder/params.rb @@ -105,7 +105,7 @@ class IssuableFinder end def project? - params[:project_id].present? + project_id.present? end def group @@ -132,15 +132,19 @@ class IssuableFinder def project strong_memoize(:project) do - next nil unless params[:project_id].present? + next nil unless project? - project = Project.find(params[:project_id]) + project = project_id.is_a?(Project) ? project_id : Project.find(project_id) project = nil unless Ability.allowed?(current_user, :"read_#{klass.to_ability_name}", project) project end end + def project_id + params[:project_id] + end + def projects strong_memoize(:projects) do next [project] if project? diff --git a/app/graphql/mutations/alert_management/base.rb b/app/graphql/mutations/alert_management/base.rb index ca2057d4845..a2942968b70 100644 --- a/app/graphql/mutations/alert_management/base.rb +++ b/app/graphql/mutations/alert_management/base.rb @@ -3,7 +3,7 @@ module Mutations module AlertManagement class Base < BaseMutation - include Mutations::ResolvesProject + include ResolvesProject argument :project_path, GraphQL::ID_TYPE, required: true, diff --git a/app/graphql/mutations/branches/create.rb b/app/graphql/mutations/branches/create.rb index 127d5447d0a..214fead2e80 100644 --- a/app/graphql/mutations/branches/create.rb +++ b/app/graphql/mutations/branches/create.rb @@ -3,7 +3,7 @@ module Mutations module Branches class Create < BaseMutation - include Mutations::ResolvesProject + include ResolvesProject graphql_name 'CreateBranch' diff --git a/app/graphql/mutations/commits/create.rb b/app/graphql/mutations/commits/create.rb index d91ea3a06e0..9ed1bb819c8 100644 --- a/app/graphql/mutations/commits/create.rb +++ b/app/graphql/mutations/commits/create.rb @@ -3,7 +3,7 @@ module Mutations module Commits class Create < BaseMutation - include Mutations::ResolvesProject + include ResolvesProject graphql_name 'CommitCreate' diff --git a/app/graphql/mutations/concerns/mutations/resolves_issuable.rb b/app/graphql/mutations/concerns/mutations/resolves_issuable.rb index f3ed3565b03..13a56f2e709 100644 --- a/app/graphql/mutations/concerns/mutations/resolves_issuable.rb +++ b/app/graphql/mutations/concerns/mutations/resolves_issuable.rb @@ -3,7 +3,10 @@ module Mutations module ResolvesIssuable extend ActiveSupport::Concern - include Mutations::ResolvesProject + + included do + include ResolvesProject + end def resolve_issuable(type:, parent_path:, iid:) parent = resolve_issuable_parent(type, parent_path) @@ -29,7 +32,7 @@ module Mutations def resolve_issuable_parent(type, parent_path) return unless type == :issue || type == :merge_request - resolve_project(full_path: parent_path) + resolve_project(full_path: parent_path) if parent_path.present? end end end diff --git a/app/graphql/mutations/concerns/mutations/resolves_project.rb b/app/graphql/mutations/concerns/mutations/resolves_project.rb deleted file mode 100644 index e223e3edd94..00000000000 --- a/app/graphql/mutations/concerns/mutations/resolves_project.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -module Mutations - module ResolvesProject - extend ActiveSupport::Concern - - def resolve_project(full_path:) - project_resolver.resolve(full_path: full_path) - end - - def project_resolver - Resolvers::ProjectResolver.new(object: nil, context: context, field: nil) - end - end -end diff --git a/app/graphql/mutations/jira_import/start.rb b/app/graphql/mutations/jira_import/start.rb index 6b80c9f8ca4..6c75fe42b0b 100644 --- a/app/graphql/mutations/jira_import/start.rb +++ b/app/graphql/mutations/jira_import/start.rb @@ -3,7 +3,7 @@ module Mutations module JiraImport class Start < BaseMutation - include Mutations::ResolvesProject + include ResolvesProject graphql_name 'JiraImportStart' diff --git a/app/graphql/mutations/merge_requests/create.rb b/app/graphql/mutations/merge_requests/create.rb index 95d6fb100e7..e210987f259 100644 --- a/app/graphql/mutations/merge_requests/create.rb +++ b/app/graphql/mutations/merge_requests/create.rb @@ -3,7 +3,7 @@ module Mutations module MergeRequests class Create < BaseMutation - include Mutations::ResolvesProject + include ResolvesProject graphql_name 'MergeRequestCreate' diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb index b5ee38857fb..e1022358c09 100644 --- a/app/graphql/mutations/snippets/create.rb +++ b/app/graphql/mutations/snippets/create.rb @@ -3,7 +3,7 @@ module Mutations module Snippets class Create < BaseMutation - include Mutations::ResolvesProject + include ResolvesProject graphql_name 'CreateSnippet' diff --git a/app/graphql/resolvers/assigned_merge_requests_resolver.rb b/app/graphql/resolvers/assigned_merge_requests_resolver.rb new file mode 100644 index 00000000000..fa08b142a7e --- /dev/null +++ b/app/graphql/resolvers/assigned_merge_requests_resolver.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Resolvers + class AssignedMergeRequestsResolver < UserMergeRequestsResolver + def user_role + :assignee + end + end +end diff --git a/app/graphql/resolvers/authored_merge_requests_resolver.rb b/app/graphql/resolvers/authored_merge_requests_resolver.rb new file mode 100644 index 00000000000..e19bc9e8715 --- /dev/null +++ b/app/graphql/resolvers/authored_merge_requests_resolver.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Resolvers + class AuthoredMergeRequestsResolver < UserMergeRequestsResolver + def user_role + :author + end + end +end diff --git a/app/graphql/resolvers/concerns/resolves_merge_requests.rb b/app/graphql/resolvers/concerns/resolves_merge_requests.rb index 779ff0b50d4..0b834b3d558 100644 --- a/app/graphql/resolvers/concerns/resolves_merge_requests.rb +++ b/app/graphql/resolvers/concerns/resolves_merge_requests.rb @@ -13,10 +13,10 @@ module ResolvesMergeRequests args[:iids] = Array.wrap(args[:iids]) if args[:iids] args.compact! - if args.keys == [:iids] + if project && args.keys == [:iids] batch_load_merge_requests(args[:iids]) else - args[:project_id] = project.id + args[:project_id] ||= project MergeRequestsFinder.new(current_user, args).execute end.then(&(single? ? :first : :itself)) diff --git a/app/graphql/resolvers/concerns/resolves_project.rb b/app/graphql/resolvers/concerns/resolves_project.rb new file mode 100644 index 00000000000..3c5ce3dab01 --- /dev/null +++ b/app/graphql/resolvers/concerns/resolves_project.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module ResolvesProject + def resolve_project(full_path: nil, project_id: nil) + unless full_path.present? ^ project_id.present? + raise ::Gitlab::Graphql::Errors::ArgumentError, 'Incompatible arguments: projectId, projectPath.' + end + + if full_path.present? + ::Gitlab::Graphql::Loaders::FullPathModelLoader.new(Project, full_path).find + else + ::GitlabSchema.object_from_id(project_id, expected_type: Project) + end + end +end diff --git a/app/graphql/resolvers/full_path_resolver.rb b/app/graphql/resolvers/full_path_resolver.rb index 46d3360baae..cbb0bf998a6 100644 --- a/app/graphql/resolvers/full_path_resolver.rb +++ b/app/graphql/resolvers/full_path_resolver.rb @@ -11,12 +11,7 @@ module Resolvers end def model_by_full_path(model, full_path) - BatchLoader::GraphQL.for(full_path).batch(key: model) do |full_paths, loader, args| - # `with_route` avoids an N+1 calculating full_path - args[:key].where_full_path_in(full_paths).with_route.each do |model_instance| - loader.call(model_instance.full_path, model_instance) - end - end + ::Gitlab::Graphql::Loaders::FullPathModelLoader.new(model, full_path).find end end end diff --git a/app/graphql/resolvers/merge_requests_resolver.rb b/app/graphql/resolvers/merge_requests_resolver.rb index 44fc4e17cd4..3aa52341eec 100644 --- a/app/graphql/resolvers/merge_requests_resolver.rb +++ b/app/graphql/resolvers/merge_requests_resolver.rb @@ -34,7 +34,11 @@ module Resolvers end def no_results_possible?(args) - project.nil? || args.values.any? { |v| v.is_a?(Array) && v.empty? } + project.nil? || some_argument_is_empty?(args) + end + + def some_argument_is_empty?(args) + args.values.any? { |v| v.is_a?(Array) && v.empty? } end end end diff --git a/app/graphql/resolvers/user_merge_requests_resolver.rb b/app/graphql/resolvers/user_merge_requests_resolver.rb new file mode 100644 index 00000000000..b0d6e159f73 --- /dev/null +++ b/app/graphql/resolvers/user_merge_requests_resolver.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Resolvers + class UserMergeRequestsResolver < MergeRequestsResolver + include ResolvesProject + + argument :project_path, GraphQL::STRING_TYPE, + required: false, + description: 'The full-path of the project the authored merge requests should be in. Incompatible with projectId.' + + argument :project_id, GraphQL::ID_TYPE, + required: false, + description: 'The global ID of the project the authored merge requests should be in. Incompatible with projectPath.' + + attr_reader :project + alias_method :user, :synchronized_object + + def ready?(project_id: nil, project_path: nil, **args) + return early_return unless can_read_profile? + + if project_id || project_path + load_project(project_path, project_id) + return early_return unless can_read_project? + elsif args[:iids].present? + raise ::Gitlab::Graphql::Errors::ArgumentError, + 'iids requires projectPath or projectId' + end + + super(**args) + end + + def resolve(**args) + prepare_args(args) + key = :"#{user_role}_id" + super(key => user.id, **args) + end + + def user_role + raise NotImplementedError + end + + private + + def can_read_profile? + Ability.allowed?(current_user, :read_user_profile, user) + end + + def can_read_project? + Ability.allowed?(current_user, :read_merge_request, project) + end + + def load_project(project_path, project_id) + @project = resolve_project(full_path: project_path, project_id: project_id) + @project = @project.sync if @project.respond_to?(:sync) + end + + def no_results_possible?(args) + some_argument_is_empty?(args) + end + + # These arguments are handled in load_project, and should not be passed to + # the finder directly. + def prepare_args(args) + args.delete(:project_id) + args.delete(:project_path) + end + end +end diff --git a/app/graphql/resolvers/user_resolver.rb b/app/graphql/resolvers/user_resolver.rb index 9696988f3b4..a34cecba491 100644 --- a/app/graphql/resolvers/user_resolver.rb +++ b/app/graphql/resolvers/user_resolver.rb @@ -4,6 +4,8 @@ module Resolvers class UserResolver < BaseResolver description 'Retrieve a single user' + type Types::UserType, null: true + argument :id, GraphQL::ID_TYPE, required: false, description: 'ID of the User' @@ -12,13 +14,6 @@ module Resolvers required: false, description: 'Username of the User' - def resolve(id: nil, username: nil) - id_or_username = GitlabSchema.parse_gid(id, expected_type: ::User).model_id if id - id_or_username ||= username - - ::UserFinder.new(id_or_username).find_by_id_or_username - end - def ready?(id: nil, username: nil) unless id.present? ^ username.present? raise Gitlab::Graphql::Errors::ArgumentError, 'Provide either a single username or id' @@ -26,5 +21,23 @@ module Resolvers super end + + def resolve(id: nil, username: nil) + if id + GitlabSchema.object_from_id(id, expected_type: User) + else + batch_load(username) + end + end + + private + + def batch_load(username) + BatchLoader::GraphQL.for(username).batch do |usernames, loader| + User.by_username(usernames).each do |user| + loader.call(user.username, user) + end + end + end end end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index bedb68d989b..5184d17e94e 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -56,6 +56,10 @@ module Types description: 'Text to echo back', resolver: Resolvers::EchoResolver + field :user, Types::UserType, null: true, + description: 'Find a user on this instance', + resolver: Resolvers::UserResolver + def design_management DesignManagementObject.new(nil) end diff --git a/app/graphql/types/user_state_enum.rb b/app/graphql/types/user_state_enum.rb new file mode 100644 index 00000000000..d34936b4c48 --- /dev/null +++ b/app/graphql/types/user_state_enum.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + class UserStateEnum < BaseEnum + graphql_name 'UserState' + description 'Possible states of a user' + + value 'active', 'The user is active and is able to use the system', value: 'active' + value 'blocked', 'The user has been blocked and is prevented from using the system', value: 'blocked' + value 'deactivated', 'The user is no longer active and is unable to use the system', value: 'deactivated' + end +end diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb index 29a3f5d452f..04c125592d8 100644 --- a/app/graphql/types/user_type.rb +++ b/app/graphql/types/user_type.rb @@ -12,12 +12,12 @@ module Types field :id, GraphQL::ID_TYPE, null: false, description: 'ID of the user' - field :name, GraphQL::STRING_TYPE, null: false, - description: 'Human-readable name of the user' - field :state, GraphQL::STRING_TYPE, null: false, - description: 'State of the issue' field :username, GraphQL::STRING_TYPE, null: false, description: 'Username of the user. Unique within this instance of GitLab' + field :name, GraphQL::STRING_TYPE, null: false, + description: 'Human-readable name of the user' + field :state, Types::UserStateEnum, null: false, + description: 'State of the user' field :avatar_url, GraphQL::STRING_TYPE, null: true, description: "URL of the user's avatar" field :web_url, GraphQL::STRING_TYPE, null: false, @@ -26,6 +26,14 @@ module Types resolver: Resolvers::TodoResolver, description: 'Todos of the user' + # Merge request field: MRs can be either authored or assigned: + field :authored_merge_requests, Types::MergeRequestType.connection_type, null: true, + resolver: Resolvers::AuthoredMergeRequestsResolver, + description: 'Merge Requests authored by the user' + field :assigned_merge_requests, Types::MergeRequestType.connection_type, null: true, + resolver: Resolvers::AssignedMergeRequestsResolver, + description: 'Merge Requests assigned to the user' + field :snippets, Types::SnippetType.connection_type, null: true, diff --git a/app/models/issue.rb b/app/models/issue.rb index f978a1e43c1..9acd51ac9f2 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -139,6 +139,10 @@ class Issue < ApplicationRecord issue.closed_at = nil issue.closed_by = nil end + + after_transition any => :closed do |issue| + issue.resolve_associated_alert_management_alert + end end # Alias to state machine .with_state_id method @@ -352,6 +356,18 @@ class Issue < ApplicationRecord @design_collection ||= ::DesignManagement::DesignCollection.new(self) end + def resolve_associated_alert_management_alert + return unless alert_management_alert + return if alert_management_alert.resolve + + Gitlab::AppLogger.warn( + message: 'Cannot resolve an associated Alert Management alert', + issue_id: id, + alert_id: alert_management_alert.id, + alert_errors: alert_management_alert.errors.messages + ) + end + private def ensure_metrics diff --git a/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb b/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb index d97668d1c7c..8599c23c206 100644 --- a/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb +++ b/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb @@ -6,7 +6,7 @@ module Metrics module Dashboard class SelfMonitoringDashboardService < ::Metrics::Dashboard::PredefinedDashboardService DASHBOARD_PATH = 'config/prometheus/self_monitoring_default.yml' - DASHBOARD_NAME = 'Default' + DASHBOARD_NAME = N_('Default dashboard') SEQUENCE = [ STAGES::CustomMetricsInserter, @@ -23,7 +23,7 @@ module Metrics def all_dashboard_paths(_project) [{ path: DASHBOARD_PATH, - display_name: DASHBOARD_NAME, + display_name: _(DASHBOARD_NAME), default: true, system_dashboard: false }] diff --git a/app/services/metrics/dashboard/system_dashboard_service.rb b/app/services/metrics/dashboard/system_dashboard_service.rb index ed4b78ba159..db5599b4def 100644 --- a/app/services/metrics/dashboard/system_dashboard_service.rb +++ b/app/services/metrics/dashboard/system_dashboard_service.rb @@ -6,7 +6,7 @@ module Metrics module Dashboard class SystemDashboardService < ::Metrics::Dashboard::PredefinedDashboardService DASHBOARD_PATH = 'config/prometheus/common_metrics.yml' - DASHBOARD_NAME = 'Default' + DASHBOARD_NAME = N_('Default dashboard') SEQUENCE = [ STAGES::CommonMetricsInserter, @@ -22,7 +22,7 @@ module Metrics def all_dashboard_paths(_project) [{ path: DASHBOARD_PATH, - display_name: DASHBOARD_NAME, + display_name: _(DASHBOARD_NAME), default: true, system_dashboard: true }] diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml index aab30af5ed4..cd7339edd1a 100644 --- a/app/views/projects/environments/metrics.html.haml +++ b/app/views/projects/environments/metrics.html.haml @@ -1,4 +1,4 @@ -- page_title _("Metrics for environment"), @environment.name +- page_title _("Metrics Dashboard"), @environment.name .prometheus-container #prometheus-graphs{ data: metrics_data(@project, @environment) } diff --git a/changelogs/unreleased/214281-modify-dashboard-title.yml b/changelogs/unreleased/214281-modify-dashboard-title.yml new file mode 100644 index 00000000000..19919dd664b --- /dev/null +++ b/changelogs/unreleased/214281-modify-dashboard-title.yml @@ -0,0 +1,5 @@ +--- +title: Add metrics dashboard name to document title +merge_request: 30392 +author: +type: added diff --git a/changelogs/unreleased/216142-resolve-alert-when-associated-issue-closes.yml b/changelogs/unreleased/216142-resolve-alert-when-associated-issue-closes.yml new file mode 100644 index 00000000000..834fef6f01b --- /dev/null +++ b/changelogs/unreleased/216142-resolve-alert-when-associated-issue-closes.yml @@ -0,0 +1,5 @@ +--- +title: Automatically resolve alert when associated issue closes +merge_request: 33278 +author: +type: added diff --git a/changelogs/unreleased/ajk-GQL-user-mrs.yml b/changelogs/unreleased/ajk-GQL-user-mrs.yml new file mode 100644 index 00000000000..103ae1d33dd --- /dev/null +++ b/changelogs/unreleased/ajk-GQL-user-mrs.yml @@ -0,0 +1,5 @@ +--- +title: Add GraphQL support for authored and assigned Merge Requests +merge_request: 31227 +author: +type: added diff --git a/changelogs/unreleased/calebw-update-stuck-runner-message.yml b/changelogs/unreleased/calebw-update-stuck-runner-message.yml new file mode 100644 index 00000000000..57c48377ae5 --- /dev/null +++ b/changelogs/unreleased/calebw-update-stuck-runner-message.yml @@ -0,0 +1,5 @@ +--- +title: Clarify verbiage for stuck job messages. +merge_request: 32250 +author: +type: other diff --git a/danger/commit_messages/Dangerfile b/danger/commit_messages/Dangerfile index 59d42082de9..174fc10eef3 100644 --- a/danger/commit_messages/Dangerfile +++ b/danger/commit_messages/Dangerfile @@ -6,6 +6,16 @@ COMMIT_MESSAGE_GUIDELINES = "https://docs.gitlab.com/ee/development/contributing MORE_INFO = "For more information, take a look at our [Commit message guidelines](#{COMMIT_MESSAGE_GUIDELINES})." THE_DANGER_JOB_TEXT = "the `danger-review` job" MAX_COMMITS_COUNT = 10 +MAX_COMMITS_COUNT_EXCEEDED_MESSAGE = <<~MSG +This merge request includes more than %<max_commits_count>d commits. Each commit should meet the following criteria: + +1. Have a well-written commit message. +1. Has all tests passing when used on its own (e.g. when using git checkout SHA). +1. Can be reverted on its own without also requiring the revert of commit that came before it. +1. Is small enough that it can be reviewed in isolation in under 30 minutes or so. + +If this merge request contains commits that do not meet this criteria and/or contains intermediate work, please rebase these commits into a smaller number of commits or split this merge request into multiple smaller merge requests. +MSG def gitlab_danger @gitlab_danger ||= GitlabDanger.new(helper.gitlab_helper) @@ -94,11 +104,7 @@ def lint_commits(commits) warn_or_fail_commits(failed_commit_linters, default_to_fail: !squash_mr?) if count_non_fixup_commits(commit_linters) > MAX_COMMITS_COUNT - level = squash_mr? ? :warn : :fail - self.__send__(level, # rubocop:disable GitlabSecurity/PublicSend - "This merge request includes more than #{MAX_COMMITS_COUNT} commits. " \ - 'Please rebase these commits into a smaller number of commits or split ' \ - 'this merge request into multiple smaller merge requests.') + self.warn(format(MAX_COMMITS_COUNT_EXCEEDED_MESSAGE, max_commits_count: MAX_COMMITS_COUNT)) end if squash_mr? diff --git a/doc/administration/reference_architectures/25k_users.md b/doc/administration/reference_architectures/25k_users.md index d0eb3fe8db1..c81445434be 100644 --- a/doc/administration/reference_architectures/25k_users.md +++ b/doc/administration/reference_architectures/25k_users.md @@ -23,7 +23,7 @@ For a full list of reference architectures, see | Object Storage ([4](#footnotes)) | - | - | - | - | - | | NFS Server ([5](#footnotes)) ([7](#footnotes)) | 1 | 4 vCPU, 3.6GB Memory | n1-highcpu-4 | c5.xlarge | F4s v2 | | Monitoring node | 1 | 4 vCPU, 3.6GB Memory | n1-highcpu-4 | c5.xlarge | F4s v2 | -| External load balancing node ([6](#footnotes)) | 1 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | c5.large | F2s v2 | +| External load balancing node ([6](#footnotes)) | 1 | 4 vCPU, 3.6GB Memory | n1-highcpu-4 | c5.xlarge | F4s v2 | | Internal load balancing node ([6](#footnotes)) | 1 | 4 vCPU, 3.6GB Memory | n1-highcpu-4 | c5.xlarge | F4s v2 | ## Footnotes diff --git a/doc/administration/reference_architectures/2k_users.md b/doc/administration/reference_architectures/2k_users.md index accde72931c..5cf217d5816 100644 --- a/doc/administration/reference_architectures/2k_users.md +++ b/doc/administration/reference_architectures/2k_users.md @@ -16,7 +16,7 @@ For a full list of reference architectures, see | PostgreSQL | 1 | 2 vCPU, 7.5GB Memory | n1-standard-2 | m5.large | D2s v3 | | Redis ([3](#footnotes)) | 1 | 1 vCPU, 3.75GB Memory | n1-standard-1 | m5.large | D2s v3 | | Gitaly ([5](#footnotes)) ([7](#footnotes)) | X ([2](#footnotes)) | 4 vCPU, 15GB Memory | n1-standard-4 | m5.xlarge | D4s v3 | -| GitLab Rails ([1](#footnotes)) | 2 | 8 vCPU, 7.2GB Memory | n1-highcpu-8 | c5.2xlarge | F8s v2 | +| GitLab Rails ([1](#footnotes)), Sidekiq | 2 | 8 vCPU, 7.2GB Memory | n1-highcpu-8 | c5.2xlarge | F8s v2 | | Monitoring node | 1 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | c5.large | F2s v2 | ## Setup instructions diff --git a/doc/administration/reference_architectures/50k_users.md b/doc/administration/reference_architectures/50k_users.md index 92153499109..47c5b3e2bce 100644 --- a/doc/administration/reference_architectures/50k_users.md +++ b/doc/administration/reference_architectures/50k_users.md @@ -23,7 +23,7 @@ For a full list of reference architectures, see | NFS Server ([5](#footnotes)) ([7](#footnotes)) | 1 | 4 vCPU, 3.6GB Memory | n1-highcpu-4 | c5.xlarge | F4s v2 | | Object Storage ([4](#footnotes)) | - | - | - | - | - | | Monitoring node | 1 | 4 vCPU, 3.6GB Memory | n1-highcpu-4 | c5.xlarge | F4s v2 | -| External load balancing node ([6](#footnotes)) | 1 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | c5.large | F2s v2 | +| External load balancing node ([6](#footnotes)) | 1 | 8 vCPU, 7.2GB Memory | n1-highcpu-8 | c5.2xlarge | F8s v2 | | Internal load balancing node ([6](#footnotes)) | 1 | 8 vCPU, 7.2GB Memory | n1-highcpu-8 | c5.2xlarge | F8s v2 | ## Footnotes diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index b847e927846..c3fd02d8c9c 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -9348,7 +9348,7 @@ type Query { ): SnippetConnection """ - Find a user + Find a user on this instance """ user( """ @@ -12054,6 +12054,126 @@ scalar Upload type User { """ + Merge Requests assigned to the user + """ + assignedMergeRequests( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Array of IIDs of merge requests, for example `[1, 2]` + """ + iids: [String!] + + """ + Array of label names. All resolved merge requests will have all of these labels. + """ + labels: [String!] + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + The global ID of the project the authored merge requests should be in. Incompatible with projectPath. + """ + projectId: ID + + """ + The full-path of the project the authored merge requests should be in. Incompatible with projectId. + """ + projectPath: String + + """ + Array of source branch names. All resolved merge requests will have one of these branches as their source. + """ + sourceBranches: [String!] + + """ + A merge request state. If provided, all resolved merge requests will have this state. + """ + state: MergeRequestState + + """ + Array of target branch names. All resolved merge requests will have one of these branches as their target. + """ + targetBranches: [String!] + ): MergeRequestConnection + + """ + Merge Requests authored by the user + """ + authoredMergeRequests( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Array of IIDs of merge requests, for example `[1, 2]` + """ + iids: [String!] + + """ + Array of label names. All resolved merge requests will have all of these labels. + """ + labels: [String!] + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + The global ID of the project the authored merge requests should be in. Incompatible with projectPath. + """ + projectId: ID + + """ + The full-path of the project the authored merge requests should be in. Incompatible with projectId. + """ + projectPath: String + + """ + Array of source branch names. All resolved merge requests will have one of these branches as their source. + """ + sourceBranches: [String!] + + """ + A merge request state. If provided, all resolved merge requests will have this state. + """ + state: MergeRequestState + + """ + Array of target branch names. All resolved merge requests will have one of these branches as their target. + """ + targetBranches: [String!] + ): MergeRequestConnection + + """ URL of the user's avatar """ avatarUrl: String @@ -12109,9 +12229,9 @@ type User { ): SnippetConnection """ - State of the issue + State of the user """ - state: String! + state: UserState! """ Todos of the user @@ -12226,6 +12346,26 @@ type UserPermissions { createSnippet: Boolean! } +""" +Possible states of a user +""" +enum UserState { + """ + The user is active and is able to use the system + """ + active + + """ + The user has been blocked and is prevented from using the system + """ + blocked + + """ + The user is no longer active and is unable to use the system + """ + deactivated +} + enum VisibilityLevelsEnum { internal private diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index b980075b560..2ac543cb14a 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -27422,7 +27422,7 @@ }, { "name": "user", - "description": "Find a user", + "description": "Find a user on this instance", "args": [ { "name": "id", @@ -35623,6 +35623,316 @@ "description": null, "fields": [ { + "name": "assignedMergeRequests", + "description": "Merge Requests assigned to the user", + "args": [ + { + "name": "iids", + "description": "Array of IIDs of merge requests, for example `[1, 2]`", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "sourceBranches", + "description": "Array of source branch names. All resolved merge requests will have one of these branches as their source.", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "targetBranches", + "description": "Array of target branch names. All resolved merge requests will have one of these branches as their target.", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "state", + "description": "A merge request state. If provided, all resolved merge requests will have this state.", + "type": { + "kind": "ENUM", + "name": "MergeRequestState", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "labels", + "description": "Array of label names. All resolved merge requests will have all of these labels.", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "projectPath", + "description": "The full-path of the project the authored merge requests should be in. Incompatible with projectId.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "projectId", + "description": "The global ID of the project the authored merge requests should be in. Incompatible with projectPath.", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "after", + "description": "Returns the elements in the list that come after the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "before", + "description": "Returns the elements in the list that come before the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "first", + "description": "Returns the first _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "last", + "description": "Returns the last _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "MergeRequestConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "authoredMergeRequests", + "description": "Merge Requests authored by the user", + "args": [ + { + "name": "iids", + "description": "Array of IIDs of merge requests, for example `[1, 2]`", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "sourceBranches", + "description": "Array of source branch names. All resolved merge requests will have one of these branches as their source.", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "targetBranches", + "description": "Array of target branch names. All resolved merge requests will have one of these branches as their target.", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "state", + "description": "A merge request state. If provided, all resolved merge requests will have this state.", + "type": { + "kind": "ENUM", + "name": "MergeRequestState", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "labels", + "description": "Array of label names. All resolved merge requests will have all of these labels.", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "projectPath", + "description": "The full-path of the project the authored merge requests should be in. Incompatible with projectId.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "projectId", + "description": "The global ID of the project the authored merge requests should be in. Incompatible with projectPath.", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "after", + "description": "Returns the elements in the list that come after the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "before", + "description": "Returns the elements in the list that come before the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "first", + "description": "Returns the first _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "last", + "description": "Returns the last _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "MergeRequestConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "avatarUrl", "description": "URL of the user's avatar", "args": [ @@ -35765,7 +36075,7 @@ }, { "name": "state", - "description": "State of the issue", + "description": "State of the user", "args": [ ], @@ -35773,8 +36083,8 @@ "kind": "NON_NULL", "name": null, "ofType": { - "kind": "SCALAR", - "name": "String", + "kind": "ENUM", + "name": "UserState", "ofType": null } }, @@ -36153,6 +36463,35 @@ }, { "kind": "ENUM", + "name": "UserState", + "description": "Possible states of a user", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "active", + "description": "The user is active and is able to use the system", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "blocked", + "description": "The user has been blocked and is prevented from using the system", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deactivated", + "description": "The user is no longer active and is unable to use the system", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", "name": "VisibilityLevelsEnum", "description": null, "fields": null, diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 2ed8dfd0dd3..452d0059cca 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -1819,7 +1819,7 @@ Autogenerated return type of UpdateSnippet | `avatarUrl` | String | URL of the user's avatar | | `id` | ID! | ID of the user | | `name` | String! | Human-readable name of the user | -| `state` | String! | State of the issue | +| `state` | UserState! | State of the user | | `userPermissions` | UserPermissions! | Permissions for the current user on the resource | | `username` | String! | Username of the user. Unique within this instance of GitLab | | `webUrl` | String! | Web URL of the user | diff --git a/doc/development/fe_guide/graphql.md b/doc/development/fe_guide/graphql.md index fa11da62363..44f8ebbdd00 100644 --- a/doc/development/fe_guide/graphql.md +++ b/doc/development/fe_guide/graphql.md @@ -15,11 +15,11 @@ - [🎬 GraphQL at GitLab: Deep Dive](../api_graphql_styleguide.md#deep-dive) (video) by Nick Thomas - An overview of the history of GraphQL at GitLab (not frontend-specific) - [🎬 GitLab Feature Walkthrough with GraphQL and Vue Apollo](https://www.youtube.com/watch?v=6yYp2zB7FrM) (video) by Natalia Tepluhina - - A real-life example of implmenting a frontend feature in GitLab using GraphQL + - A real-life example of implementing a frontend feature in GitLab using GraphQL - [🎬 History of client-side GraphQL at GitLab](https://www.youtube.com/watch?v=mCKRJxvMnf0) (video) Illya Klymov and Natalia Tepluhina - [🎬 From Vuex to Apollo](https://www.youtube.com/watch?v=9knwu87IfU8) (video) by Natalia Tepluhina - A useful overview of when Apollo might be a better choice than Vuex, and how one could go about the transition -- [🛠 Vuex-> Apollo Migration: a proof-of-concept project](https://gitlab.com/ntepluhina/vuex-to-apollo/blob/master/README.md) +- [🛠 Vuex -> Apollo Migration: a proof-of-concept project](https://gitlab.com/ntepluhina/vuex-to-apollo/blob/master/README.md) - A collection of examples that show the possible approaches for state management with Vue+GraphQL+(Vuex or Apollo) apps ### Libraries @@ -30,7 +30,7 @@ when using GraphQL for frontend development. If you are using GraphQL within a Vue application, the [Usage in Vue](#usage-in-vue) section can help you learn how to integrate Vue Apollo. -For other usecases, check out the [Usage outside of Vue](#usage-outside-of-vue) section. +For other use cases, check out the [Usage outside of Vue](#usage-outside-of-vue) section. ### Tooling @@ -650,7 +650,7 @@ When [using Vuex](#Using-with-Vuex), disable the cache when: - The data is being cached elsewhere - The use case does not need caching -if the data is being cached elsewhere, or if there is simply no need for it for the given usecase. +if the data is being cached elsewhere, or if there is simply no need for it for the given use case. ```javascript import createDefaultClient from '~/lib/graphql'; diff --git a/doc/development/i18n/proofreader.md b/doc/development/i18n/proofreader.md index 52be7451b1e..bbce5e38fee 100644 --- a/doc/development/i18n/proofreader.md +++ b/doc/development/i18n/proofreader.md @@ -33,7 +33,7 @@ are very appreciative of the work done by translators and proofreaders! - Dutch - Emily Hendle - [GitLab](https://gitlab.com/pundachan), [CrowdIn](https://crowdin.com/profile/pandachan) - Esperanto -- Lyubomir Vasilev - [CrowdIn](https://crowdin.com/profile/lyubomirv) + - Lyubomir Vasilev - [CrowdIn](https://crowdin.com/profile/lyubomirv) - Estonian - Proofreaders needed. - Filipino diff --git a/doc/user/admin_area/license.md b/doc/user/admin_area/license.md index 59b71b05b16..a9c670247a0 100644 --- a/doc/user/admin_area/license.md +++ b/doc/user/admin_area/license.md @@ -64,7 +64,7 @@ export GITLAB_LICENSE_FILE="/path/to/license/file" Omnibus installations should add this entry to `gitlab.rb`: ```ruby -gitlab_rails['license_file'] = "/path/to/license/file" +gitlab_rails['initial_license_file'] = "/path/to/license/file" ``` CAUTION: **Caution:** diff --git a/doc/user/project/operations/alert_management.md b/doc/user/project/operations/alert_management.md index fae834e589b..7aaebd0da08 100644 --- a/doc/user/project/operations/alert_management.md +++ b/doc/user/project/operations/alert_management.md @@ -89,3 +89,6 @@ The Alert Management detail view enables you to create an issue with a description automatically populated from an alert. To create the issue, click the **Create Issue** button. You can then view the issue from the alert by clicking the **View Issue** button. + +Closing a GitLab issue associated with an alert changes the alert's status to Resolved. +See [Alert Management statuses](#alert-management-statuses) for more details about statuses. diff --git a/lib/gitlab/graphql/loaders/full_path_model_loader.rb b/lib/gitlab/graphql/loaders/full_path_model_loader.rb new file mode 100644 index 00000000000..0aa237c78de --- /dev/null +++ b/lib/gitlab/graphql/loaders/full_path_model_loader.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Gitlab + module Graphql + module Loaders + # Suitable for use to find resources that expose `where_full_path_in`, + # such as Project, Group, Namespace + class FullPathModelLoader + attr_reader :model_class, :full_path + + def initialize(model_class, full_path) + @model_class, @full_path = model_class, full_path + end + + def find + BatchLoader::GraphQL.for(full_path).batch(key: model_class) do |full_paths, loader, args| + # `with_route` avoids an N+1 calculating full_path + args[:key].where_full_path_in(full_paths).with_route.each do |model_instance| + loader.call(model_instance.full_path, model_instance) + end + end + end + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 6f55945829e..30928816cc1 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -3642,6 +3642,9 @@ msgstr "" msgid "CI Lint" msgstr "" +msgid "CI settings" +msgstr "" + msgid "CI variables" msgstr "" @@ -6988,6 +6991,9 @@ msgstr "" msgid "Default classification label" msgstr "" +msgid "Default dashboard" +msgstr "" + msgid "Default deletion adjourned period" msgstr "" @@ -10606,9 +10612,6 @@ msgstr "" msgid "Go full screen" msgstr "" -msgid "Go to" -msgstr "" - msgid "Go to %{link_to_google_takeout}." msgstr "" @@ -13756,9 +13759,6 @@ msgstr "" msgid "Metrics and profiling" msgstr "" -msgid "Metrics for environment" -msgstr "" - msgid "Metrics::Dashboard::Annotation|Annotation can't belong to both a cluster and an environment at the same time" msgstr "" @@ -18807,9 +18807,6 @@ msgstr "" msgid "Runners currently online: %{active_runners_count}" msgstr "" -msgid "Runners page" -msgstr "" - msgid "Runners page." msgstr "" @@ -20470,6 +20467,12 @@ msgstr "" msgid "Sort direction" msgstr "" +msgid "Sort direction: Ascending" +msgstr "" + +msgid "Sort direction: Descending" +msgstr "" + msgid "SortOptions|Access level, ascending" msgstr "" @@ -22057,6 +22060,9 @@ msgstr "" msgid "There was a problem fetching project users." msgstr "" +msgid "There was a problem fetching users." +msgstr "" + msgid "There was a problem refreshing the data, please try again" msgstr "" @@ -22483,7 +22489,7 @@ msgstr "" msgid "This job is preparing to start" msgstr "" -msgid "This job is stuck because you don't have any active runners online with any of these tags assigned to them:" +msgid "This job is stuck because you don't have any active runners online or available with any of these tags assigned to them:" msgstr "" msgid "This job is stuck because you don't have any active runners that can run this job." diff --git a/spec/controllers/concerns/metrics_dashboard_spec.rb b/spec/controllers/concerns/metrics_dashboard_spec.rb index e2fa03670d9..fb6b0f656b7 100644 --- a/spec/controllers/concerns/metrics_dashboard_spec.rb +++ b/spec/controllers/concerns/metrics_dashboard_spec.rb @@ -134,7 +134,7 @@ describe MetricsDashboard do it 'adds starred dashboard information and sorts the list' do all_dashboards = json_response['all_dashboards'].map { |dashboard| dashboard.slice('display_name', 'starred', 'user_starred_path') } expected_response = [ - { "display_name" => "Default", "starred" => false, 'user_starred_path' => api_v4_projects_metrics_user_starred_dashboards_path(id: project.id, params: { dashboard_path: 'config/prometheus/common_metrics.yml' }) }, + { "display_name" => "Default dashboard", "starred" => false, 'user_starred_path' => api_v4_projects_metrics_user_starred_dashboards_path(id: project.id, params: { dashboard_path: 'config/prometheus/common_metrics.yml' }) }, { "display_name" => "anomaly.yml", "starred" => false, 'user_starred_path' => api_v4_projects_metrics_user_starred_dashboards_path(id: project.id, params: { dashboard_path: '.gitlab/dashboards/anomaly.yml' }) }, { "display_name" => "errors.yml", "starred" => true, 'user_starred_path' => api_v4_projects_metrics_user_starred_dashboards_path(id: project.id, params: { dashboard_path: '.gitlab/dashboards/errors.yml' }) }, { "display_name" => "test.yml", "starred" => true, 'user_starred_path' => api_v4_projects_metrics_user_starred_dashboards_path(id: project.id, params: { dashboard_path: '.gitlab/dashboards/test.yml' }) } diff --git a/spec/fast_spec_helper.rb b/spec/fast_spec_helper.rb index 8ab2c7ffd64..cbab809cddb 100644 --- a/spec/fast_spec_helper.rb +++ b/spec/fast_spec_helper.rb @@ -13,6 +13,5 @@ require 'active_support/all' ActiveSupport::Dependencies.autoload_paths << 'lib' ActiveSupport::Dependencies.autoload_paths << 'ee/lib' -ActiveSupport::Dependencies.autoload_paths << 'tooling/lib' ActiveSupport::XmlMini.backend = 'Nokogiri' diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb index a17793bc6d6..6b7ea9215b0 100644 --- a/spec/features/projects/jobs_spec.rb +++ b/spec/features/projects/jobs_spec.rb @@ -940,7 +940,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do it 'renders message about job being stuck because of no runners with the specified tags' do expect(page).to have_css('.js-stuck-with-tags') - expect(page).to have_content("This job is stuck because you don't have any active runners online with any of these tags assigned to them:") + expect(page).to have_content("This job is stuck because you don't have any active runners online or available with any of these tags assigned to them:") end end @@ -950,7 +950,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do it 'renders message about job being stuck because of no runners with the specified tags' do expect(page).to have_css('.js-stuck-with-tags') - expect(page).to have_content("This job is stuck because you don't have any active runners online with any of these tags assigned to them:") + expect(page).to have_content("This job is stuck because you don't have any active runners online or available with any of these tags assigned to them:") end end diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js index 21f02efc640..c4084ab6ca1 100644 --- a/spec/frontend/monitoring/components/dashboard_spec.js +++ b/spec/frontend/monitoring/components/dashboard_spec.js @@ -851,6 +851,62 @@ describe('Dashboard', () => { }); }); + describe('document title', () => { + const originalTitle = 'Original Title'; + const defaultDashboardName = dashboardGitResponse[0].display_name; + + beforeEach(() => { + document.title = originalTitle; + createShallowWrapper({ hasMetrics: true }); + }); + + afterAll(() => { + document.title = ''; + }); + + it('is prepended with default dashboard name by default', () => { + setupAllDashboards(store); + + return wrapper.vm.$nextTick().then(() => { + expect(document.title.startsWith(`${defaultDashboardName} · `)).toBe(true); + }); + }); + + it('is prepended with dashboard name if path is known', () => { + const dashboard = dashboardGitResponse[1]; + const currentDashboard = dashboard.path; + + setupAllDashboards(store, currentDashboard); + + return wrapper.vm.$nextTick().then(() => { + expect(document.title.startsWith(`${dashboard.display_name} · `)).toBe(true); + }); + }); + + it('is prepended with default dashboard name is path is not known', () => { + setupAllDashboards(store, 'unknown/path'); + + return wrapper.vm.$nextTick().then(() => { + expect(document.title.startsWith(`${defaultDashboardName} · `)).toBe(true); + }); + }); + + it('is not modified when dashboard name is not provided', () => { + const dashboard = { ...dashboardGitResponse[1], display_name: null }; + const currentDashboard = dashboard.path; + + store.commit(`monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, [dashboard]); + + store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { + currentDashboard, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(document.title).toBe(originalTitle); + }); + }); + }); + describe('Dashboard dropdown', () => { beforeEach(() => { createMountedWrapper({ hasMetrics: true }); diff --git a/spec/frontend/monitoring/store_utils.js b/spec/frontend/monitoring/store_utils.js index 338af79dbbe..8ac8b4367c6 100644 --- a/spec/frontend/monitoring/store_utils.js +++ b/spec/frontend/monitoring/store_utils.js @@ -16,8 +16,13 @@ const setEnvironmentData = store => { store.commit(`monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, environmentData); }; -export const setupAllDashboards = store => { +export const setupAllDashboards = (store, path) => { store.commit(`monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, dashboardGitResponse); + if (path) { + store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { + currentDashboard: path, + }); + } }; export const setupStoreWithDashboard = store => { @@ -25,10 +30,6 @@ export const setupStoreWithDashboard = store => { `monitoringDashboard/${types.RECEIVE_METRICS_DASHBOARD_SUCCESS}`, metricsDashboardPayload, ); - store.commit( - `monitoringDashboard/${types.RECEIVE_METRICS_DASHBOARD_SUCCESS}`, - metricsDashboardPayload, - ); }; export const setupStoreWithVariable = store => { diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js new file mode 100644 index 00000000000..eded5b87abc --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js @@ -0,0 +1,259 @@ +import { shallowMount } from '@vue/test-utils'; +import { + GlFilteredSearch, + GlButtonGroup, + GlButton, + GlNewDropdown as GlDropdown, + GlNewDropdownItem as GlDropdownItem, +} from '@gitlab/ui'; + +import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import { SortDirection } from '~/vue_shared/components/filtered_search_bar/constants'; + +import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store'; +import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; + +import { mockAvailableTokens, mockSortOptions } from './mock_data'; + +const createComponent = ({ + namespace = 'gitlab-org/gitlab-test', + recentSearchesStorageKey = 'requirements', + tokens = mockAvailableTokens, + sortOptions = mockSortOptions, + searchInputPlaceholder = 'Filter requirements', +} = {}) => + shallowMount(FilteredSearchBarRoot, { + propsData: { + namespace, + recentSearchesStorageKey, + tokens, + sortOptions, + searchInputPlaceholder, + }, + }); + +describe('FilteredSearchBarRoot', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('data', () => { + it('initializes `filterValue`, `selectedSortOption` and `selectedSortDirection` data props', () => { + expect(wrapper.vm.filterValue).toEqual([]); + expect(wrapper.vm.selectedSortOption).toBe(mockSortOptions[0].sortDirection.descending); + expect(wrapper.vm.selectedSortDirection).toBe(SortDirection.descending); + }); + }); + + describe('computed', () => { + describe('tokenSymbols', () => { + it('returns array of map containing type and symbols from `tokens` prop', () => { + expect(wrapper.vm.tokenSymbols).toEqual({ author_username: '@' }); + }); + }); + + describe('sortDirectionIcon', () => { + it('returns string "sort-lowest" when `selectedSortDirection` is "ascending"', () => { + wrapper.setData({ + selectedSortDirection: SortDirection.ascending, + }); + + expect(wrapper.vm.sortDirectionIcon).toBe('sort-lowest'); + }); + + it('returns string "sort-highest" when `selectedSortDirection` is "descending"', () => { + wrapper.setData({ + selectedSortDirection: SortDirection.descending, + }); + + expect(wrapper.vm.sortDirectionIcon).toBe('sort-highest'); + }); + }); + + describe('sortDirectionTooltip', () => { + it('returns string "Sort direction: Ascending" when `selectedSortDirection` is "ascending"', () => { + wrapper.setData({ + selectedSortDirection: SortDirection.ascending, + }); + + expect(wrapper.vm.sortDirectionTooltip).toBe('Sort direction: Ascending'); + }); + + it('returns string "Sort direction: Descending" when `selectedSortDirection` is "descending"', () => { + wrapper.setData({ + selectedSortDirection: SortDirection.descending, + }); + + expect(wrapper.vm.sortDirectionTooltip).toBe('Sort direction: Descending'); + }); + }); + }); + + describe('watchers', () => { + describe('filterValue', () => { + it('emits component event `onFilter` with empty array when `filterValue` is cleared by GlFilteredSearch', () => { + wrapper.setData({ + initialRender: false, + filterValue: [ + { + type: 'filtered-search-term', + value: { data: '' }, + }, + ], + }); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.emitted('onFilter')[0]).toEqual([[]]); + }); + }); + }); + }); + + describe('methods', () => { + describe('setupRecentSearch', () => { + it('initializes `recentSearchesService` and `recentSearchesStore` props when `recentSearchesStorageKey` is available', () => { + expect(wrapper.vm.recentSearchesService instanceof RecentSearchesService).toBe(true); + expect(wrapper.vm.recentSearchesStore instanceof RecentSearchesStore).toBe(true); + }); + + it('initializes `recentSearchesPromise` prop with a promise by using `recentSearchesService.fetch()`', () => { + jest + .spyOn(wrapper.vm.recentSearchesService, 'fetch') + .mockReturnValue(new Promise(() => [])); + + wrapper.vm.setupRecentSearch(); + + expect(wrapper.vm.recentSearchesPromise instanceof Promise).toBe(true); + }); + }); + + describe('getRecentSearches', () => { + it('returns array of strings representing recent searches', () => { + wrapper.vm.recentSearchesStore.setRecentSearches(['foo']); + + expect(wrapper.vm.getRecentSearches()).toEqual(['foo']); + }); + }); + + describe('handleSortOptionClick', () => { + it('emits component event `onSort` with selected sort by value', () => { + wrapper.vm.handleSortOptionClick(mockSortOptions[1]); + + expect(wrapper.vm.selectedSortOption).toBe(mockSortOptions[1]); + expect(wrapper.emitted('onSort')[0]).toEqual([mockSortOptions[1].sortDirection.descending]); + }); + }); + + describe('handleSortDirectionClick', () => { + beforeEach(() => { + wrapper.setData({ + selectedSortOption: mockSortOptions[0], + }); + }); + + it('sets `selectedSortDirection` to be opposite of its current value', () => { + expect(wrapper.vm.selectedSortDirection).toBe(SortDirection.descending); + + wrapper.vm.handleSortDirectionClick(); + + expect(wrapper.vm.selectedSortDirection).toBe(SortDirection.ascending); + }); + + it('emits component event `onSort` with opposite of currently selected sort by value', () => { + wrapper.vm.handleSortDirectionClick(); + + expect(wrapper.emitted('onSort')[0]).toEqual([mockSortOptions[0].sortDirection.ascending]); + }); + }); + + describe('handleFilterSubmit', () => { + const mockFilters = [ + { + type: 'author_username', + value: { + data: 'root', + operator: '=', + }, + }, + 'foo', + ]; + + it('calls `recentSearchesStore.addRecentSearch` with serialized value of provided `filters` param', () => { + jest.spyOn(wrapper.vm.recentSearchesStore, 'addRecentSearch'); + // jest.spyOn(wrapper.vm.recentSearchesService, 'save'); + + wrapper.vm.handleFilterSubmit(mockFilters); + + return wrapper.vm.recentSearchesPromise.then(() => { + expect(wrapper.vm.recentSearchesStore.addRecentSearch).toHaveBeenCalledWith( + 'author_username:=@root foo', + ); + }); + }); + + it('calls `recentSearchesService.save` with array of searches', () => { + jest.spyOn(wrapper.vm.recentSearchesService, 'save'); + + wrapper.vm.handleFilterSubmit(mockFilters); + + return wrapper.vm.recentSearchesPromise.then(() => { + expect(wrapper.vm.recentSearchesService.save).toHaveBeenCalledWith([ + 'author_username:=@root foo', + ]); + }); + }); + + it('emits component event `onFilter` with provided filters param', () => { + wrapper.vm.handleFilterSubmit(mockFilters); + + expect(wrapper.emitted('onFilter')[0]).toEqual([mockFilters]); + }); + }); + }); + + describe('template', () => { + beforeEach(() => { + wrapper.setData({ + selectedSortOption: mockSortOptions[0], + selectedSortDirection: SortDirection.descending, + }); + + return wrapper.vm.$nextTick(); + }); + + it('renders gl-filtered-search component', () => { + const glFilteredSearchEl = wrapper.find(GlFilteredSearch); + + expect(glFilteredSearchEl.props('placeholder')).toBe('Filter requirements'); + expect(glFilteredSearchEl.props('availableTokens')).toEqual(mockAvailableTokens); + }); + + it('renders sort dropdown component', () => { + expect(wrapper.find(GlButtonGroup).exists()).toBe(true); + expect(wrapper.find(GlDropdown).exists()).toBe(true); + expect(wrapper.find(GlDropdown).props('text')).toBe(mockSortOptions[0].title); + }); + + it('renders dropdown items', () => { + const dropdownItemsEl = wrapper.findAll(GlDropdownItem); + + expect(dropdownItemsEl).toHaveLength(mockSortOptions.length); + expect(dropdownItemsEl.at(0).text()).toBe(mockSortOptions[0].title); + expect(dropdownItemsEl.at(0).props('isChecked')).toBe(true); + expect(dropdownItemsEl.at(1).text()).toBe(mockSortOptions[1].title); + }); + + it('renders sort direction button', () => { + const sortButtonEl = wrapper.find(GlButton); + + expect(sortButtonEl.attributes('title')).toBe('Sort direction: Descending'); + expect(sortButtonEl.props('icon')).toBe('sort-highest'); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js new file mode 100644 index 00000000000..edc0f119262 --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js @@ -0,0 +1,64 @@ +import Api from '~/api'; +import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; + +export const mockAuthor1 = { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + web_url: 'http://0.0.0.0:3000/root', +}; + +export const mockAuthor2 = { + id: 2, + name: 'Claudio Beer', + username: 'ericka_terry', + state: 'active', + avatar_url: 'https://www.gravatar.com/avatar/12a89d115b5a398d5082897ebbcba9c2?s=80&d=identicon', + web_url: 'http://0.0.0.0:3000/ericka_terry', +}; + +export const mockAuthor3 = { + id: 6, + name: 'Shizue Hartmann', + username: 'junita.weimann', + state: 'active', + avatar_url: 'https://www.gravatar.com/avatar/9da1abb41b1d4c9c9e81030b71ea61a0?s=80&d=identicon', + web_url: 'http://0.0.0.0:3000/junita.weimann', +}; + +export const mockAuthors = [mockAuthor1, mockAuthor2, mockAuthor3]; + +export const mockAuthorToken = { + type: 'author_username', + icon: 'user', + title: 'Author', + unique: false, + symbol: '@', + token: AuthorToken, + operators: [{ value: '=', description: 'is', default: 'true' }], + fetchPath: 'gitlab-org/gitlab-test', + fetchAuthors: Api.projectUsers.bind(Api), +}; + +export const mockAvailableTokens = [mockAuthorToken]; + +export const mockSortOptions = [ + { + id: 1, + title: 'Created date', + sortDirection: { + descending: 'created_desc', + ascending: 'created_asc', + }, + }, + { + id: 2, + title: 'Last updated', + sortDirection: { + descending: 'updated_desc', + ascending: 'updated_asc', + }, + }, +]; diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js new file mode 100644 index 00000000000..3650ef79136 --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js @@ -0,0 +1,150 @@ +import { mount } from '@vue/test-utils'; +import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; + +import createFlash from '~/flash'; +import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; + +import { mockAuthorToken, mockAuthors } from '../mock_data'; + +jest.mock('~/flash'); + +const createComponent = ({ config = mockAuthorToken, value = { data: '' } } = {}) => + mount(AuthorToken, { + propsData: { + config, + value, + }, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + }, + stubs: { + Portal: { + template: '<div><slot></slot></div>', + }, + GlFilteredSearchSuggestionList: { + template: '<div></div>', + methods: { + getValue: () => '=', + }, + }, + }, + }); + +describe('AuthorToken', () => { + let mock; + let wrapper; + + beforeEach(() => { + mock = new MockAdapter(axios); + wrapper = createComponent(); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + describe('computed', () => { + describe('currentValue', () => { + it('returns lowercase string for `value.data`', () => { + wrapper.setProps({ + value: { data: 'FOO' }, + }); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.vm.currentValue).toBe('foo'); + }); + }); + }); + + describe('activeAuthor', () => { + it('returns object for currently present `value.data`', () => { + wrapper.setData({ + authors: mockAuthors, + }); + + wrapper.setProps({ + value: { data: mockAuthors[0].username }, + }); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.vm.activeAuthor).toEqual(mockAuthors[0]); + }); + }); + }); + }); + + describe('fetchAuthorBySearchTerm', () => { + it('calls `config.fetchAuthors` with provided searchTerm param', () => { + jest.spyOn(wrapper.vm.config, 'fetchAuthors'); + + wrapper.vm.fetchAuthorBySearchTerm(mockAuthors[0].username); + + expect(wrapper.vm.config.fetchAuthors).toHaveBeenCalledWith( + mockAuthorToken.fetchPath, + mockAuthors[0].username, + ); + }); + + it('sets response to `authors` when request is succesful', () => { + jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockResolvedValue(mockAuthors); + + wrapper.vm.fetchAuthorBySearchTerm('root'); + + return waitForPromises().then(() => { + expect(wrapper.vm.authors).toEqual(mockAuthors); + }); + }); + + it('calls `createFlash` with flash error message when request fails', () => { + jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({}); + + wrapper.vm.fetchAuthorBySearchTerm('root'); + + return waitForPromises().then(() => { + expect(createFlash).toHaveBeenCalledWith('There was a problem fetching users.'); + }); + }); + + it('sets `loading` to false when request completes', () => { + jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({}); + + wrapper.vm.fetchAuthorBySearchTerm('root'); + + return waitForPromises().then(() => { + expect(wrapper.vm.loading).toBe(false); + }); + }); + }); + + describe('template', () => { + beforeEach(() => { + wrapper.setData({ + authors: mockAuthors, + }); + + return wrapper.vm.$nextTick(); + }); + + it('renders gl-filtered-search-token component', () => { + expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true); + }); + + it('renders token item when value is selected', () => { + wrapper.setProps({ + value: { data: mockAuthors[0].username }, + }); + + return wrapper.vm.$nextTick(() => { + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + + expect(tokenSegments).toHaveLength(3); // Author, =, "Administrator" + expect(tokenSegments.at(2).text()).toBe(mockAuthors[0].name); // "Administrator" + }); + }); + }); +}); diff --git a/spec/graphql/mutations/concerns/mutations/resolves_project_spec.rb b/spec/graphql/mutations/concerns/mutations/resolves_project_spec.rb deleted file mode 100644 index b5c349f6284..00000000000 --- a/spec/graphql/mutations/concerns/mutations/resolves_project_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe Mutations::ResolvesProject do - let(:mutation_class) do - Class.new(Mutations::BaseMutation) do - include Mutations::ResolvesProject - end - end - - let(:context) { double } - - subject(:mutation) { mutation_class.new(object: nil, context: context, field: nil) } - - it 'uses the ProjectsResolver to resolve projects by path' do - project = create(:project) - - expect(Resolvers::ProjectResolver).to receive(:new).with(object: nil, context: context, field: nil).and_call_original - expect(mutation.resolve_project(full_path: project.full_path).sync).to eq(project) - end -end diff --git a/spec/graphql/resolvers/concerns/resolves_project_spec.rb b/spec/graphql/resolvers/concerns/resolves_project_spec.rb new file mode 100644 index 00000000000..f29f54483d6 --- /dev/null +++ b/spec/graphql/resolvers/concerns/resolves_project_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ResolvesProject do + include GraphqlHelpers + + let(:implementing_class) do + Class.new do + include ResolvesProject + end + end + + subject(:instance) { implementing_class.new } + + let_it_be(:project) { create(:project) } + + it 'can resolve projects by path' do + expect(sync(instance.resolve_project(full_path: project.full_path))).to eq(project) + end + + it 'can resolve projects by id' do + expect(sync(instance.resolve_project(project_id: global_id_of(project)))).to eq(project) + end + + it 'complains when both are present' do + expect do + instance.resolve_project(full_path: project.full_path, project_id: global_id_of(project)) + end.to raise_error(::Gitlab::Graphql::Errors::ArgumentError) + end + + it 'complains when neither is present' do + expect do + instance.resolve_project(full_path: nil, project_id: nil) + end.to raise_error(::Gitlab::Graphql::Errors::ArgumentError) + end +end diff --git a/spec/graphql/resolvers/user_resolver_spec.rb b/spec/graphql/resolvers/user_resolver_spec.rb index 1bfede41567..45a8816bf26 100644 --- a/spec/graphql/resolvers/user_resolver_spec.rb +++ b/spec/graphql/resolvers/user_resolver_spec.rb @@ -40,6 +40,6 @@ describe Resolvers::UserResolver do private def resolve_user(args = {}) - resolve(described_class, args: args) + sync(resolve(described_class, args: args)) end end diff --git a/spec/graphql/types/user_type_spec.rb b/spec/graphql/types/user_type_spec.rb index cf1e91afb80..a49fb0ef627 100644 --- a/spec/graphql/types/user_type_spec.rb +++ b/spec/graphql/types/user_type_spec.rb @@ -10,6 +10,7 @@ describe GitlabSchema.types['User'] do it 'has the expected fields' do expected_fields = %w[ id user_permissions snippets name username avatarUrl webUrl todos state + authoredMergeRequests assignedMergeRequests ] expect(described_class).to have_graphql_fields(*expected_fields) diff --git a/spec/lib/gitlab/metrics/dashboard/finder_spec.rb b/spec/lib/gitlab/metrics/dashboard/finder_spec.rb index d772b0c7a5f..2703339d89c 100644 --- a/spec/lib/gitlab/metrics/dashboard/finder_spec.rb +++ b/spec/lib/gitlab/metrics/dashboard/finder_spec.rb @@ -142,7 +142,7 @@ describe Gitlab::Metrics::Dashboard::Finder, :use_clean_rails_memory_store_cachi describe '.find_all_paths' do let(:all_dashboard_paths) { described_class.find_all_paths(project) } - let(:system_dashboard) { { path: system_dashboard_path, display_name: 'Default', default: true, system_dashboard: true } } + let(:system_dashboard) { { path: system_dashboard_path, display_name: 'Default dashboard', default: true, system_dashboard: true } } it 'includes only the system dashboard by default' do expect(all_dashboard_paths).to eq([system_dashboard]) @@ -163,7 +163,7 @@ describe Gitlab::Metrics::Dashboard::Finder, :use_clean_rails_memory_store_cachi let(:self_monitoring_dashboard) do { path: self_monitoring_dashboard_path, - display_name: 'Default', + display_name: 'Default dashboard', default: true, system_dashboard: false } diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index 811266021fe..81a3d1ebc49 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -186,6 +186,35 @@ describe Issue do expect { issue.close }.to change { issue.state_id }.from(open_state).to(closed_state) end + + context 'when there is an associated Alert Management Alert' do + context 'when alert can be resolved' do + let!(:alert) { create(:alert_management_alert, project: issue.project, issue: issue) } + + it 'resolves an alert' do + expect { issue.close }.to change { alert.reload.resolved? }.to(true) + end + end + + context 'when alert cannot be resolved' do + let!(:alert) { create(:alert_management_alert, :with_validation_errors, project: issue.project, issue: issue) } + + before do + allow(Gitlab::AppLogger).to receive(:warn).and_call_original + end + + it 'writes a warning into the log' do + issue.close + + expect(Gitlab::AppLogger).to have_received(:warn).with( + message: 'Cannot resolve an associated Alert Management alert', + issue_id: issue.id, + alert_id: alert.id, + alert_errors: { hosts: ['hosts array is over 255 chars'] } + ) + end + end + end end describe '#reopen' do diff --git a/spec/requests/api/graphql/user_query_spec.rb b/spec/requests/api/graphql/user_query_spec.rb new file mode 100644 index 00000000000..5ac94bc7323 --- /dev/null +++ b/spec/requests/api/graphql/user_query_spec.rb @@ -0,0 +1,260 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'getting user information' do + include GraphqlHelpers + + let(:query) do + graphql_query_for(:user, user_params, user_fields) + end + + let(:user_fields) { all_graphql_fields_for('User', max_depth: 2) } + + context 'no parameters are provided' do + let(:user_params) { nil } + + it 'mentions the missing required parameters' do + post_graphql(query) + + expect_graphql_errors_to_include(/username/) + end + end + + context 'looking up a user by username' do + let_it_be(:project_a) { create(:project, :repository) } + let_it_be(:project_b) { create(:project, :repository) } + let_it_be(:user, reload: true) { create(:user, developer_projects: [project_a, project_b]) } + let_it_be(:authorised_user) { create(:user, developer_projects: [project_a, project_b]) } + let_it_be(:unauthorized_user) { create(:user) } + + let_it_be(:assigned_mr) do + create(:merge_request, :unique_branches, + source_project: project_a, assignees: [user]) + end + let_it_be(:assigned_mr_b) do + create(:merge_request, :unique_branches, + source_project: project_b, assignees: [user]) + end + let_it_be(:assigned_mr_c) do + create(:merge_request, :unique_branches, + source_project: project_b, assignees: [user]) + end + let_it_be(:authored_mr) do + create(:merge_request, :unique_branches, + source_project: project_a, author: user) + end + let_it_be(:authored_mr_b) do + create(:merge_request, :unique_branches, + source_project: project_b, author: user) + end + let_it_be(:authored_mr_c) do + create(:merge_request, :unique_branches, + source_project: project_b, author: user) + end + + let(:current_user) { authorised_user } + let(:authored_mrs) { graphql_data_at(:user, :authored_merge_requests, :nodes) } + let(:assigned_mrs) { graphql_data_at(:user, :assigned_merge_requests, :nodes) } + let(:user_params) { { username: user.username } } + + before do + post_graphql(query, current_user: current_user) + end + + context 'the user is an active user' do + it_behaves_like 'a working graphql query' + + it 'can access user profile fields' do + presenter = UserPresenter.new(user) + + expect(graphql_data['user']).to match( + a_hash_including( + 'id' => global_id_of(user), + 'state' => presenter.state, + 'name' => presenter.name, + 'username' => presenter.username, + 'webUrl' => presenter.web_url, + 'avatarUrl' => presenter.avatar_url + )) + end + + describe 'assignedMergeRequests' do + let(:user_fields) do + query_graphql_field(:assigned_merge_requests, mr_args, 'nodes { id }') + end + let(:mr_args) { nil } + + it_behaves_like 'a working graphql query' + + it 'can be found' do + expect(assigned_mrs).to contain_exactly( + a_hash_including('id' => global_id_of(assigned_mr)), + a_hash_including('id' => global_id_of(assigned_mr_b)), + a_hash_including('id' => global_id_of(assigned_mr_c)) + ) + end + + context 'applying filters' do + context 'filtering by IID without specifying a project' do + let(:mr_args) do + { iids: [assigned_mr_b.iid.to_s] } + end + + it 'return an argument error that mentions the missing fields' do + expect_graphql_errors_to_include(/projectPath/) + end + end + + context 'filtering by project path and IID' do + let(:mr_args) do + { project_path: project_b.full_path, iids: [assigned_mr_b.iid.to_s] } + end + + it 'selects the correct MRs' do + expect(assigned_mrs).to contain_exactly( + a_hash_including('id' => global_id_of(assigned_mr_b)) + ) + end + end + + context 'filtering by project path' do + let(:mr_args) do + { project_path: project_b.full_path } + end + + it 'selects the correct MRs' do + expect(assigned_mrs).to contain_exactly( + a_hash_including('id' => global_id_of(assigned_mr_b)), + a_hash_including('id' => global_id_of(assigned_mr_c)) + ) + end + end + end + + context 'the current user does not have access' do + let(:current_user) { unauthorized_user } + + it 'cannot be found' do + expect(assigned_mrs).to be_empty + end + end + end + + describe 'authoredMergeRequests' do + let(:user_fields) do + query_graphql_field(:authored_merge_requests, mr_args, 'nodes { id }') + end + let(:mr_args) { nil } + + it_behaves_like 'a working graphql query' + + it 'can be found' do + expect(authored_mrs).to contain_exactly( + a_hash_including('id' => global_id_of(authored_mr)), + a_hash_including('id' => global_id_of(authored_mr_b)), + a_hash_including('id' => global_id_of(authored_mr_c)) + ) + end + + context 'applying filters' do + context 'filtering by IID without specifying a project' do + let(:mr_args) do + { iids: [authored_mr_b.iid.to_s] } + end + + it 'return an argument error that mentions the missing fields' do + expect_graphql_errors_to_include(/projectPath/) + end + end + + context 'filtering by project path and IID' do + let(:mr_args) do + { project_path: project_b.full_path, iids: [authored_mr_b.iid.to_s] } + end + + it 'selects the correct MRs' do + expect(authored_mrs).to contain_exactly( + a_hash_including('id' => global_id_of(authored_mr_b)) + ) + end + end + + context 'filtering by project path' do + let(:mr_args) do + { project_path: project_b.full_path } + end + + it 'selects the correct MRs' do + expect(authored_mrs).to contain_exactly( + a_hash_including('id' => global_id_of(authored_mr_b)), + a_hash_including('id' => global_id_of(authored_mr_c)) + ) + end + end + end + + context 'the current user does not have access' do + let(:current_user) { unauthorized_user } + + it 'cannot be found' do + expect(authored_mrs).to be_empty + end + end + end + end + + context 'the user is private' do + before do + user.update(private_profile: true) + post_graphql(query, current_user: current_user) + end + + context 'we only request basic fields' do + let(:user_fields) { %i[id name username state web_url avatar_url] } + + it_behaves_like 'a working graphql query' + end + + context 'we request the authoredMergeRequests' do + let(:user_fields) { 'authoredMergeRequests { nodes { id } }' } + + it_behaves_like 'a working graphql query' + + it 'cannot be found' do + expect(authored_mrs).to be_empty + end + + context 'the current user is the user' do + let(:current_user) { user } + + it 'can be found' do + expect(authored_mrs).to include( + a_hash_including('id' => global_id_of(authored_mr)) + ) + end + end + end + + context 'we request the assignedMergeRequests' do + let(:user_fields) { 'assignedMergeRequests { nodes { id } }' } + + it_behaves_like 'a working graphql query' + + it 'cannot be found' do + expect(assigned_mrs).to be_empty + end + + context 'the current user is the user' do + let(:current_user) { user } + + it 'can be found' do + expect(assigned_mrs).to include( + a_hash_including('id' => global_id_of(assigned_mr)) + ) + end + end + end + end + end +end diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb index 51d34e1a02a..bd5e151ba03 100644 --- a/spec/support/helpers/graphql_helpers.rb +++ b/spec/support/helpers/graphql_helpers.rb @@ -153,7 +153,15 @@ module GraphqlHelpers end def wrap_fields(fields) - fields = Array.wrap(fields).join("\n") + fields = Array.wrap(fields).map do |field| + case field + when Symbol + GraphqlHelpers.fieldnamerize(field) + else + field + end + end.join("\n") + return unless fields.present? <<~FIELDS diff --git a/spec/support/shared_examples/graphql/resolves_issuable_shared_examples.rb b/spec/support/shared_examples/graphql/resolves_issuable_shared_examples.rb index 1befc459023..58cd3d21f66 100644 --- a/spec/support/shared_examples/graphql/resolves_issuable_shared_examples.rb +++ b/spec/support/shared_examples/graphql/resolves_issuable_shared_examples.rb @@ -29,16 +29,6 @@ RSpec.shared_examples 'resolving an issuable in GraphQL' do |type| subject end - it 'uses correct Resolver to resolve issuable parent' do - resolver_class = type == :epic ? 'Resolvers::GroupResolver' : 'Resolvers::ProjectResolver' - - expect(resolver_class.constantize).to receive(:new) - .with(object: nil, context: context, field: nil) - .and_call_original - - subject - end - it 'returns nil if issuable is not found' do result = mutation.resolve_issuable(type: type, parent_path: parent.full_path, iid: "100") result = result.respond_to?(:sync) ? result.sync : result diff --git a/spec/tooling/lib/tooling/test_file_finder_spec.rb b/spec/tooling/lib/tooling/test_file_finder_spec.rb index 7a5d39a014a..a56600709c6 100644 --- a/spec/tooling/lib/tooling/test_file_finder_spec.rb +++ b/spec/tooling/lib/tooling/test_file_finder_spec.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -require 'fast_spec_helper' +require_relative '../../../../tooling/lib/tooling/test_file_finder' -describe Tooling::TestFileFinder do +RSpec.describe Tooling::TestFileFinder do subject { Tooling::TestFileFinder.new(file) } describe '#test_files' do |