提交 eea1fbf9 编写于 作者: G GitLab Bot

Add latest changes from gitlab-org/gitlab@master

上级 810bd2a6
...@@ -29,7 +29,7 @@ export default { ...@@ -29,7 +29,7 @@ export default {
<p v-if="tags.length" class="js-stuck-with-tags gl-mb-0"> <p v-if="tags.length" class="js-stuck-with-tags gl-mb-0">
{{ {{
s__(`This job is stuck because you don't have 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"> <span v-for="(tag, index) in tags" :key="index" class="badge badge-primary append-right-4">
{{ tag }} {{ tag }}
...@@ -48,9 +48,9 @@ export default { ...@@ -48,9 +48,9 @@ export default {
}} }}
</p> </p>
{{ __('Go to') }} {{ __('Go to project') }}
<gl-link v-if="runnersPath" :href="runnersPath" class="js-runners-path"> <gl-link v-if="runnersPath" :href="runnersPath" class="js-runners-path">
{{ __('Runners page') }} {{ __('CI settings') }}
</gl-link> </gl-link>
</div> </div>
</template> </template>
...@@ -147,6 +147,7 @@ export default { ...@@ -147,6 +147,7 @@ export default {
return { return {
selectedTimeRange: timeRangeFromUrl() || defaultTimeRange, selectedTimeRange: timeRangeFromUrl() || defaultTimeRange,
isRearrangingPanels: false, isRearrangingPanels: false,
originalDocumentTitle: document.title,
}; };
}, },
computed: { computed: {
...@@ -192,6 +193,9 @@ export default { ...@@ -192,6 +193,9 @@ export default {
}, },
deep: true, deep: true,
}, },
selectedDashboard(dashboard) {
this.prependToDocumentTitle(dashboard?.display_name);
},
}, },
created() { created() {
window.addEventListener('keyup', this.onKeyup); window.addEventListener('keyup', this.onKeyup);
...@@ -258,6 +262,11 @@ export default { ...@@ -258,6 +262,11 @@ export default {
// Collapse group if no data is available // Collapse group if no data is available
return !this.getMetricStates(groupKey).includes(metricStates.OK); return !this.getMetricStates(groupKey).includes(metricStates.OK);
}, },
prependToDocumentTitle(text) {
if (text) {
document.title = `${text} · ${this.originalDocumentTitle}`;
}
},
onTimeRangeZoom({ start, end }) { onTimeRangeZoom({ start, end }) {
updateHistory({ updateHistory({
url: mergeUrlParams({ start, end }, window.location.href), url: mergeUrlParams({ start, end }, window.location.href),
......
export const ANY_AUTHOR = 'Any';
export const DEBOUNCE_DELAY = 200;
export const SortDirection = {
descending: 'descending',
ascending: 'ascending',
};
<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>
<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>
...@@ -105,7 +105,7 @@ class IssuableFinder ...@@ -105,7 +105,7 @@ class IssuableFinder
end end
def project? def project?
params[:project_id].present? project_id.present?
end end
def group def group
...@@ -132,15 +132,19 @@ class IssuableFinder ...@@ -132,15 +132,19 @@ class IssuableFinder
def project def project
strong_memoize(:project) do 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 = nil unless Ability.allowed?(current_user, :"read_#{klass.to_ability_name}", project)
project project
end end
end end
def project_id
params[:project_id]
end
def projects def projects
strong_memoize(:projects) do strong_memoize(:projects) do
next [project] if project? next [project] if project?
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
module Mutations module Mutations
module AlertManagement module AlertManagement
class Base < BaseMutation class Base < BaseMutation
include Mutations::ResolvesProject include ResolvesProject
argument :project_path, GraphQL::ID_TYPE, argument :project_path, GraphQL::ID_TYPE,
required: true, required: true,
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
module Mutations module Mutations
module Branches module Branches
class Create < BaseMutation class Create < BaseMutation
include Mutations::ResolvesProject include ResolvesProject
graphql_name 'CreateBranch' graphql_name 'CreateBranch'
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
module Mutations module Mutations
module Commits module Commits
class Create < BaseMutation class Create < BaseMutation
include Mutations::ResolvesProject include ResolvesProject
graphql_name 'CommitCreate' graphql_name 'CommitCreate'
......
...@@ -3,7 +3,10 @@ ...@@ -3,7 +3,10 @@
module Mutations module Mutations
module ResolvesIssuable module ResolvesIssuable
extend ActiveSupport::Concern extend ActiveSupport::Concern
include Mutations::ResolvesProject
included do
include ResolvesProject
end
def resolve_issuable(type:, parent_path:, iid:) def resolve_issuable(type:, parent_path:, iid:)
parent = resolve_issuable_parent(type, parent_path) parent = resolve_issuable_parent(type, parent_path)
...@@ -29,7 +32,7 @@ module Mutations ...@@ -29,7 +32,7 @@ module Mutations
def resolve_issuable_parent(type, parent_path) def resolve_issuable_parent(type, parent_path)
return unless type == :issue || type == :merge_request 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 end
end end
......
# 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
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
module Mutations module Mutations
module JiraImport module JiraImport
class Start < BaseMutation class Start < BaseMutation
include Mutations::ResolvesProject include ResolvesProject
graphql_name 'JiraImportStart' graphql_name 'JiraImportStart'
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
module Mutations module Mutations
module MergeRequests module MergeRequests
class Create < BaseMutation class Create < BaseMutation
include Mutations::ResolvesProject include ResolvesProject
graphql_name 'MergeRequestCreate' graphql_name 'MergeRequestCreate'
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
module Mutations module Mutations
module Snippets module Snippets
class Create < BaseMutation class Create < BaseMutation
include Mutations::ResolvesProject include ResolvesProject
graphql_name 'CreateSnippet' graphql_name 'CreateSnippet'
......
# frozen_string_literal: true
module Resolvers
class AssignedMergeRequestsResolver < UserMergeRequestsResolver
def user_role
:assignee
end
end
end
# frozen_string_literal: true
module Resolvers
class AuthoredMergeRequestsResolver < UserMergeRequestsResolver
def user_role
:author
end
end
end
...@@ -13,10 +13,10 @@ module ResolvesMergeRequests ...@@ -13,10 +13,10 @@ module ResolvesMergeRequests
args[:iids] = Array.wrap(args[:iids]) if args[:iids] args[:iids] = Array.wrap(args[:iids]) if args[:iids]
args.compact! args.compact!
if args.keys == [:iids] if project && args.keys == [:iids]
batch_load_merge_requests(args[:iids]) batch_load_merge_requests(args[:iids])
else else
args[:project_id] = project.id args[:project_id] ||= project
MergeRequestsFinder.new(current_user, args).execute MergeRequestsFinder.new(current_user, args).execute
end.then(&(single? ? :first : :itself)) end.then(&(single? ? :first : :itself))
......
# 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
...@@ -11,12 +11,7 @@ module Resolvers ...@@ -11,12 +11,7 @@ module Resolvers
end end
def model_by_full_path(model, full_path) def model_by_full_path(model, full_path)
BatchLoader::GraphQL.for(full_path).batch(key: model) do |full_paths, loader, args| ::Gitlab::Graphql::Loaders::FullPathModelLoader.new(model, full_path).find
# `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 end
...@@ -34,7 +34,11 @@ module Resolvers ...@@ -34,7 +34,11 @@ module Resolvers
end end
def no_results_possible?(args) 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 end
end end
# 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
...@@ -4,6 +4,8 @@ module Resolvers ...@@ -4,6 +4,8 @@ module Resolvers
class UserResolver < BaseResolver class UserResolver < BaseResolver
description 'Retrieve a single user' description 'Retrieve a single user'
type Types::UserType, null: true
argument :id, GraphQL::ID_TYPE, argument :id, GraphQL::ID_TYPE,
required: false, required: false,
description: 'ID of the User' description: 'ID of the User'
...@@ -12,13 +14,6 @@ module Resolvers ...@@ -12,13 +14,6 @@ module Resolvers
required: false, required: false,
description: 'Username of the User' 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) def ready?(id: nil, username: nil)
unless id.present? ^ username.present? unless id.present? ^ username.present?
raise Gitlab::Graphql::Errors::ArgumentError, 'Provide either a single username or id' raise Gitlab::Graphql::Errors::ArgumentError, 'Provide either a single username or id'
...@@ -26,5 +21,23 @@ module Resolvers ...@@ -26,5 +21,23 @@ module Resolvers
super super
end 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
end end
...@@ -56,6 +56,10 @@ module Types ...@@ -56,6 +56,10 @@ module Types
description: 'Text to echo back', description: 'Text to echo back',
resolver: Resolvers::EchoResolver resolver: Resolvers::EchoResolver
field :user, Types::UserType, null: true,
description: 'Find a user on this instance',
resolver: Resolvers::UserResolver
def design_management def design_management
DesignManagementObject.new(nil) DesignManagementObject.new(nil)
end end
......
# 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
...@@ -12,12 +12,12 @@ module Types ...@@ -12,12 +12,12 @@ module Types
field :id, GraphQL::ID_TYPE, null: false, field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the user' 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, field :username, GraphQL::STRING_TYPE, null: false,
description: 'Username of the user. Unique within this instance of GitLab' 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, field :avatar_url, GraphQL::STRING_TYPE, null: true,
description: "URL of the user's avatar" description: "URL of the user's avatar"
field :web_url, GraphQL::STRING_TYPE, null: false, field :web_url, GraphQL::STRING_TYPE, null: false,
...@@ -26,6 +26,14 @@ module Types ...@@ -26,6 +26,14 @@ module Types
resolver: Resolvers::TodoResolver, resolver: Resolvers::TodoResolver,
description: 'Todos of the user' 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, field :snippets,
Types::SnippetType.connection_type, Types::SnippetType.connection_type,
null: true, null: true,
......
...@@ -139,6 +139,10 @@ class Issue < ApplicationRecord ...@@ -139,6 +139,10 @@ class Issue < ApplicationRecord
issue.closed_at = nil issue.closed_at = nil
issue.closed_by = nil issue.closed_by = nil
end end
after_transition any => :closed do |issue|
issue.resolve_associated_alert_management_alert
end
end end
# Alias to state machine .with_state_id method # Alias to state machine .with_state_id method
...@@ -352,6 +356,18 @@ class Issue < ApplicationRecord ...@@ -352,6 +356,18 @@ class Issue < ApplicationRecord
@design_collection ||= ::DesignManagement::DesignCollection.new(self) @design_collection ||= ::DesignManagement::DesignCollection.new(self)
end 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 private
def ensure_metrics def ensure_metrics
......
...@@ -6,7 +6,7 @@ module Metrics ...@@ -6,7 +6,7 @@ module Metrics
module Dashboard module Dashboard
class SelfMonitoringDashboardService < ::Metrics::Dashboard::PredefinedDashboardService class SelfMonitoringDashboardService < ::Metrics::Dashboard::PredefinedDashboardService
DASHBOARD_PATH = 'config/prometheus/self_monitoring_default.yml' DASHBOARD_PATH = 'config/prometheus/self_monitoring_default.yml'
DASHBOARD_NAME = 'Default' DASHBOARD_NAME = N_('Default dashboard')
SEQUENCE = [ SEQUENCE = [
STAGES::CustomMetricsInserter, STAGES::CustomMetricsInserter,
...@@ -23,7 +23,7 @@ module Metrics ...@@ -23,7 +23,7 @@ module Metrics
def all_dashboard_paths(_project) def all_dashboard_paths(_project)
[{ [{
path: DASHBOARD_PATH, path: DASHBOARD_PATH,
display_name: DASHBOARD_NAME, display_name: _(DASHBOARD_NAME),
default: true, default: true,
system_dashboard: false system_dashboard: false
}] }]
......
...@@ -6,7 +6,7 @@ module Metrics ...@@ -6,7 +6,7 @@ module Metrics
module Dashboard module Dashboard
class SystemDashboardService < ::Metrics::Dashboard::PredefinedDashboardService class SystemDashboardService < ::Metrics::Dashboard::PredefinedDashboardService
DASHBOARD_PATH = 'config/prometheus/common_metrics.yml' DASHBOARD_PATH = 'config/prometheus/common_metrics.yml'
DASHBOARD_NAME = 'Default' DASHBOARD_NAME = N_('Default dashboard')
SEQUENCE = [ SEQUENCE = [
STAGES::CommonMetricsInserter, STAGES::CommonMetricsInserter,
...@@ -22,7 +22,7 @@ module Metrics ...@@ -22,7 +22,7 @@ module Metrics
def all_dashboard_paths(_project) def all_dashboard_paths(_project)
[{ [{
path: DASHBOARD_PATH, path: DASHBOARD_PATH,
display_name: DASHBOARD_NAME, display_name: _(DASHBOARD_NAME),
default: true, default: true,
system_dashboard: true system_dashboard: true
}] }]
......
- page_title _("Metrics for environment"), @environment.name - page_title _("Metrics Dashboard"), @environment.name
.prometheus-container .prometheus-container
#prometheus-graphs{ data: metrics_data(@project, @environment) } #prometheus-graphs{ data: metrics_data(@project, @environment) }
---
title: Add metrics dashboard name to document title
merge_request: 30392
author:
type: added
---
title: Automatically resolve alert when associated issue closes
merge_request: 33278
author:
type: added
---
title: Add GraphQL support for authored and assigned Merge Requests
merge_request: 31227
author:
type: added
---
title: Clarify verbiage for stuck job messages.
merge_request: 32250
author:
type: other
...@@ -6,6 +6,16 @@ COMMIT_MESSAGE_GUIDELINES = "https://docs.gitlab.com/ee/development/contributing ...@@ -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})." MORE_INFO = "For more information, take a look at our [Commit message guidelines](#{COMMIT_MESSAGE_GUIDELINES})."
THE_DANGER_JOB_TEXT = "the `danger-review` job" THE_DANGER_JOB_TEXT = "the `danger-review` job"
MAX_COMMITS_COUNT = 10 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 def gitlab_danger
@gitlab_danger ||= GitlabDanger.new(helper.gitlab_helper) @gitlab_danger ||= GitlabDanger.new(helper.gitlab_helper)
...@@ -94,11 +104,7 @@ def lint_commits(commits) ...@@ -94,11 +104,7 @@ def lint_commits(commits)
warn_or_fail_commits(failed_commit_linters, default_to_fail: !squash_mr?) warn_or_fail_commits(failed_commit_linters, default_to_fail: !squash_mr?)
if count_non_fixup_commits(commit_linters) > MAX_COMMITS_COUNT if count_non_fixup_commits(commit_linters) > MAX_COMMITS_COUNT
level = squash_mr? ? :warn : :fail self.warn(format(MAX_COMMITS_COUNT_EXCEEDED_MESSAGE, max_commits_count: MAX_COMMITS_COUNT))
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.')
end end
if squash_mr? if squash_mr?
......
...@@ -23,7 +23,7 @@ For a full list of reference architectures, see ...@@ -23,7 +23,7 @@ For a full list of reference architectures, see
| Object Storage ([4](#footnotes)) | - | - | - | - | - | | Object Storage ([4](#footnotes)) | - | - | - | - | - |
| NFS Server ([5](#footnotes)) ([7](#footnotes)) | 1 | 4 vCPU, 3.6GB Memory | n1-highcpu-4 | c5.xlarge | F4s v2 | | 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 | | 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 | | Internal load balancing node ([6](#footnotes)) | 1 | 4 vCPU, 3.6GB Memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
## Footnotes ## Footnotes
......
...@@ -16,7 +16,7 @@ For a full list of reference architectures, see ...@@ -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 | | 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 | | 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 | | 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 | | Monitoring node | 1 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | c5.large | F2s v2 |
## Setup instructions ## Setup instructions
......
...@@ -23,7 +23,7 @@ For a full list of reference architectures, see ...@@ -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 | | NFS Server ([5](#footnotes)) ([7](#footnotes)) | 1 | 4 vCPU, 3.6GB Memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
| Object Storage ([4](#footnotes)) | - | - | - | - | - | | Object Storage ([4](#footnotes)) | - | - | - | - | - |
| Monitoring node | 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 | 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 | | Internal load balancing node ([6](#footnotes)) | 1 | 8 vCPU, 7.2GB Memory | n1-highcpu-8 | c5.2xlarge | F8s v2 |
## Footnotes ## Footnotes
......
...@@ -9348,7 +9348,7 @@ type Query { ...@@ -9348,7 +9348,7 @@ type Query {
): SnippetConnection ): SnippetConnection
""" """
Find a user Find a user on this instance
""" """
user( user(
""" """
...@@ -12053,6 +12053,126 @@ type UpdateSnippetPayload { ...@@ -12053,6 +12053,126 @@ type UpdateSnippetPayload {
scalar Upload scalar Upload
type User { 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 URL of the user's avatar
""" """
...@@ -12109,9 +12229,9 @@ type User { ...@@ -12109,9 +12229,9 @@ type User {
): SnippetConnection ): SnippetConnection
""" """
State of the issue State of the user
""" """
state: String! state: UserState!
""" """
Todos of the user Todos of the user
...@@ -12226,6 +12346,26 @@ type UserPermissions { ...@@ -12226,6 +12346,26 @@ type UserPermissions {
createSnippet: Boolean! 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 { enum VisibilityLevelsEnum {
internal internal
private private
......
...@@ -27422,7 +27422,7 @@ ...@@ -27422,7 +27422,7 @@
}, },
{ {
"name": "user", "name": "user",
"description": "Find a user", "description": "Find a user on this instance",
"args": [ "args": [
{ {
"name": "id", "name": "id",
...@@ -35622,6 +35622,316 @@ ...@@ -35622,6 +35622,316 @@
"name": "User", "name": "User",
"description": null, "description": null,
"fields": [ "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", "name": "avatarUrl",
"description": "URL of the user's avatar", "description": "URL of the user's avatar",
...@@ -35765,7 +36075,7 @@ ...@@ -35765,7 +36075,7 @@
}, },
{ {
"name": "state", "name": "state",
"description": "State of the issue", "description": "State of the user",
"args": [ "args": [
], ],
...@@ -35773,8 +36083,8 @@ ...@@ -35773,8 +36083,8 @@
"kind": "NON_NULL", "kind": "NON_NULL",
"name": null, "name": null,
"ofType": { "ofType": {
"kind": "SCALAR", "kind": "ENUM",
"name": "String", "name": "UserState",
"ofType": null "ofType": null
} }
}, },
...@@ -36151,6 +36461,35 @@ ...@@ -36151,6 +36461,35 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"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", "kind": "ENUM",
"name": "VisibilityLevelsEnum", "name": "VisibilityLevelsEnum",
...@@ -1819,7 +1819,7 @@ Autogenerated return type of UpdateSnippet ...@@ -1819,7 +1819,7 @@ Autogenerated return type of UpdateSnippet
| `avatarUrl` | String | URL of the user's avatar | | `avatarUrl` | String | URL of the user's avatar |
| `id` | ID! | ID of the user | | `id` | ID! | ID of the user |
| `name` | String! | Human-readable name 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 | | `userPermissions` | UserPermissions! | Permissions for the current user on the resource |
| `username` | String! | Username of the user. Unique within this instance of GitLab | | `username` | String! | Username of the user. Unique within this instance of GitLab |
| `webUrl` | String! | Web URL of the user | | `webUrl` | String! | Web URL of the user |
......
...@@ -15,11 +15,11 @@ ...@@ -15,11 +15,11 @@
- [🎬 GraphQL at GitLab: Deep Dive](../api_graphql_styleguide.md#deep-dive) (video) by Nick Thomas - [🎬 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) - 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 - [🎬 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 - [🎬 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 - [🎬 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 - 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 - A collection of examples that show the possible approaches for state management with Vue+GraphQL+(Vuex or Apollo) apps
### Libraries ### Libraries
...@@ -30,7 +30,7 @@ when using GraphQL for frontend development. ...@@ -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 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. 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 ### Tooling
...@@ -650,7 +650,7 @@ When [using Vuex](#Using-with-Vuex), disable the cache when: ...@@ -650,7 +650,7 @@ When [using Vuex](#Using-with-Vuex), disable the cache when:
- The data is being cached elsewhere - The data is being cached elsewhere
- The use case does not need caching - 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 ```javascript
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
......
...@@ -33,7 +33,7 @@ are very appreciative of the work done by translators and proofreaders! ...@@ -33,7 +33,7 @@ are very appreciative of the work done by translators and proofreaders!
- Dutch - Dutch
- Emily Hendle - [GitLab](https://gitlab.com/pundachan), [CrowdIn](https://crowdin.com/profile/pandachan) - Emily Hendle - [GitLab](https://gitlab.com/pundachan), [CrowdIn](https://crowdin.com/profile/pandachan)
- Esperanto - Esperanto
- Lyubomir Vasilev - [CrowdIn](https://crowdin.com/profile/lyubomirv) - Lyubomir Vasilev - [CrowdIn](https://crowdin.com/profile/lyubomirv)
- Estonian - Estonian
- Proofreaders needed. - Proofreaders needed.
- Filipino - Filipino
......
...@@ -64,7 +64,7 @@ export GITLAB_LICENSE_FILE="/path/to/license/file" ...@@ -64,7 +64,7 @@ export GITLAB_LICENSE_FILE="/path/to/license/file"
Omnibus installations should add this entry to `gitlab.rb`: Omnibus installations should add this entry to `gitlab.rb`:
```ruby ```ruby
gitlab_rails['license_file'] = "/path/to/license/file" gitlab_rails['initial_license_file'] = "/path/to/license/file"
``` ```
CAUTION: **Caution:** CAUTION: **Caution:**
......
...@@ -89,3 +89,6 @@ The Alert Management detail view enables you to create an issue with a ...@@ -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, description automatically populated from an alert. To create the issue,
click the **Create Issue** button. You can then view the issue from the click the **Create Issue** button. You can then view the issue from the
alert by clicking the **View Issue** button. 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.
# 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
...@@ -3642,6 +3642,9 @@ msgstr "" ...@@ -3642,6 +3642,9 @@ msgstr ""
msgid "CI Lint" msgid "CI Lint"
msgstr "" msgstr ""
msgid "CI settings"
msgstr ""
msgid "CI variables" msgid "CI variables"
msgstr "" msgstr ""
...@@ -6988,6 +6991,9 @@ msgstr "" ...@@ -6988,6 +6991,9 @@ msgstr ""
msgid "Default classification label" msgid "Default classification label"
msgstr "" msgstr ""
msgid "Default dashboard"
msgstr ""
msgid "Default deletion adjourned period" msgid "Default deletion adjourned period"
msgstr "" msgstr ""
...@@ -10606,9 +10612,6 @@ msgstr "" ...@@ -10606,9 +10612,6 @@ msgstr ""
msgid "Go full screen" msgid "Go full screen"
msgstr "" msgstr ""
msgid "Go to"
msgstr ""
msgid "Go to %{link_to_google_takeout}." msgid "Go to %{link_to_google_takeout}."
msgstr "" msgstr ""
...@@ -13756,9 +13759,6 @@ msgstr "" ...@@ -13756,9 +13759,6 @@ msgstr ""
msgid "Metrics and profiling" msgid "Metrics and profiling"
msgstr "" 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" msgid "Metrics::Dashboard::Annotation|Annotation can't belong to both a cluster and an environment at the same time"
msgstr "" msgstr ""
...@@ -18807,9 +18807,6 @@ msgstr "" ...@@ -18807,9 +18807,6 @@ msgstr ""
msgid "Runners currently online: %{active_runners_count}" msgid "Runners currently online: %{active_runners_count}"
msgstr "" msgstr ""
msgid "Runners page"
msgstr ""
msgid "Runners page." msgid "Runners page."
msgstr "" msgstr ""
...@@ -20470,6 +20467,12 @@ msgstr "" ...@@ -20470,6 +20467,12 @@ msgstr ""
msgid "Sort direction" msgid "Sort direction"
msgstr "" msgstr ""
msgid "Sort direction: Ascending"
msgstr ""
msgid "Sort direction: Descending"
msgstr ""
msgid "SortOptions|Access level, ascending" msgid "SortOptions|Access level, ascending"
msgstr "" msgstr ""
...@@ -22057,6 +22060,9 @@ msgstr "" ...@@ -22057,6 +22060,9 @@ msgstr ""
msgid "There was a problem fetching project users." msgid "There was a problem fetching project users."
msgstr "" msgstr ""
msgid "There was a problem fetching users."
msgstr ""
msgid "There was a problem refreshing the data, please try again" msgid "There was a problem refreshing the data, please try again"
msgstr "" msgstr ""
...@@ -22483,7 +22489,7 @@ msgstr "" ...@@ -22483,7 +22489,7 @@ msgstr ""
msgid "This job is preparing to start" msgid "This job is preparing to start"
msgstr "" 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 "" msgstr ""
msgid "This job is stuck because you don't have any active runners that can run this job." msgid "This job is stuck because you don't have any active runners that can run this job."
......
...@@ -134,7 +134,7 @@ describe MetricsDashboard do ...@@ -134,7 +134,7 @@ describe MetricsDashboard do
it 'adds starred dashboard information and sorts the list' 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') } all_dashboards = json_response['all_dashboards'].map { |dashboard| dashboard.slice('display_name', 'starred', 'user_starred_path') }
expected_response = [ 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" => "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" => "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' }) } { "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' }) }
......
...@@ -13,6 +13,5 @@ require 'active_support/all' ...@@ -13,6 +13,5 @@ require 'active_support/all'
ActiveSupport::Dependencies.autoload_paths << 'lib' ActiveSupport::Dependencies.autoload_paths << 'lib'
ActiveSupport::Dependencies.autoload_paths << 'ee/lib' ActiveSupport::Dependencies.autoload_paths << 'ee/lib'
ActiveSupport::Dependencies.autoload_paths << 'tooling/lib'
ActiveSupport::XmlMini.backend = 'Nokogiri' ActiveSupport::XmlMini.backend = 'Nokogiri'
...@@ -940,7 +940,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do ...@@ -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 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_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
end end
...@@ -950,7 +950,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do ...@@ -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 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_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
end end
......
...@@ -851,6 +851,62 @@ describe('Dashboard', () => { ...@@ -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', () => { describe('Dashboard dropdown', () => {
beforeEach(() => { beforeEach(() => {
createMountedWrapper({ hasMetrics: true }); createMountedWrapper({ hasMetrics: true });
......
...@@ -16,8 +16,13 @@ const setEnvironmentData = store => { ...@@ -16,8 +16,13 @@ const setEnvironmentData = store => {
store.commit(`monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, environmentData); 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); store.commit(`monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, dashboardGitResponse);
if (path) {
store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, {
currentDashboard: path,
});
}
}; };
export const setupStoreWithDashboard = store => { export const setupStoreWithDashboard = store => {
...@@ -25,10 +30,6 @@ export const setupStoreWithDashboard = store => { ...@@ -25,10 +30,6 @@ export const setupStoreWithDashboard = store => {
`monitoringDashboard/${types.RECEIVE_METRICS_DASHBOARD_SUCCESS}`, `monitoringDashboard/${types.RECEIVE_METRICS_DASHBOARD_SUCCESS}`,
metricsDashboardPayload, metricsDashboardPayload,
); );
store.commit(
`monitoringDashboard/${types.RECEIVE_METRICS_DASHBOARD_SUCCESS}`,
metricsDashboardPayload,
);
}; };
export const setupStoreWithVariable = store => { export const setupStoreWithVariable = store => {
......
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');
});
});
});
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',
},
},
];
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"
});
});
});
});
# 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
# 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
...@@ -40,6 +40,6 @@ describe Resolvers::UserResolver do ...@@ -40,6 +40,6 @@ describe Resolvers::UserResolver do
private private
def resolve_user(args = {}) def resolve_user(args = {})
resolve(described_class, args: args) sync(resolve(described_class, args: args))
end end
end end
...@@ -10,6 +10,7 @@ describe GitlabSchema.types['User'] do ...@@ -10,6 +10,7 @@ describe GitlabSchema.types['User'] do
it 'has the expected fields' do it 'has the expected fields' do
expected_fields = %w[ expected_fields = %w[
id user_permissions snippets name username avatarUrl webUrl todos state id user_permissions snippets name username avatarUrl webUrl todos state
authoredMergeRequests assignedMergeRequests
] ]
expect(described_class).to have_graphql_fields(*expected_fields) expect(described_class).to have_graphql_fields(*expected_fields)
......
...@@ -142,7 +142,7 @@ describe Gitlab::Metrics::Dashboard::Finder, :use_clean_rails_memory_store_cachi ...@@ -142,7 +142,7 @@ describe Gitlab::Metrics::Dashboard::Finder, :use_clean_rails_memory_store_cachi
describe '.find_all_paths' do describe '.find_all_paths' do
let(:all_dashboard_paths) { described_class.find_all_paths(project) } 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 it 'includes only the system dashboard by default' do
expect(all_dashboard_paths).to eq([system_dashboard]) expect(all_dashboard_paths).to eq([system_dashboard])
...@@ -163,7 +163,7 @@ describe Gitlab::Metrics::Dashboard::Finder, :use_clean_rails_memory_store_cachi ...@@ -163,7 +163,7 @@ describe Gitlab::Metrics::Dashboard::Finder, :use_clean_rails_memory_store_cachi
let(:self_monitoring_dashboard) do let(:self_monitoring_dashboard) do
{ {
path: self_monitoring_dashboard_path, path: self_monitoring_dashboard_path,
display_name: 'Default', display_name: 'Default dashboard',
default: true, default: true,
system_dashboard: false system_dashboard: false
} }
......
...@@ -186,6 +186,35 @@ describe Issue do ...@@ -186,6 +186,35 @@ describe Issue do
expect { issue.close }.to change { issue.state_id }.from(open_state).to(closed_state) expect { issue.close }.to change { issue.state_id }.from(open_state).to(closed_state)
end 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 end
describe '#reopen' do describe '#reopen' do
......
# 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
...@@ -153,7 +153,15 @@ module GraphqlHelpers ...@@ -153,7 +153,15 @@ module GraphqlHelpers
end end
def wrap_fields(fields) 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? return unless fields.present?
<<~FIELDS <<~FIELDS
......
...@@ -29,16 +29,6 @@ RSpec.shared_examples 'resolving an issuable in GraphQL' do |type| ...@@ -29,16 +29,6 @@ RSpec.shared_examples 'resolving an issuable in GraphQL' do |type|
subject subject
end 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 it 'returns nil if issuable is not found' do
result = mutation.resolve_issuable(type: type, parent_path: parent.full_path, iid: "100") result = mutation.resolve_issuable(type: type, parent_path: parent.full_path, iid: "100")
result = result.respond_to?(:sync) ? result.sync : result result = result.respond_to?(:sync) ? result.sync : result
......
# frozen_string_literal: true # 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) } subject { Tooling::TestFileFinder.new(file) }
describe '#test_files' do describe '#test_files' do
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册