Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-04-22 12:09:49 +00:00
parent b81fd57f3d
commit 4b074c5f63
104 changed files with 13742 additions and 7184 deletions

View File

@ -1,7 +1,7 @@
import {
MarkdownSerializer as ProseMirrorMarkdownSerializer,
defaultMarkdownSerializer,
} from 'prosemirror-markdown';
} from 'prosemirror-markdown/src/to_markdown';
import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
const wrapHtmlPayload = (payload) => `<div>${payload}</div>`;

View File

@ -1,8 +1,9 @@
<script>
import { GlTable, GlBadge } from '@gitlab/ui';
import { GlTable, GlBadge, GlPagination } from '@gitlab/ui';
import { mapState } from 'vuex';
import MembersTableCell from 'ee_else_ce/members/components/table/members_table_cell.vue';
import { canOverride, canRemove, canResend, canUpdate } from 'ee_else_ce/members/utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import initUserPopovers from '~/user_popovers';
import { FIELDS } from '../../constants';
import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue';
@ -19,6 +20,7 @@ export default {
components: {
GlTable,
GlBadge,
GlPagination,
MemberAvatar,
CreatedAt,
ExpiresAt,
@ -43,6 +45,9 @@ export default {
tableAttrs(state) {
return state[this.namespace].tableAttrs;
},
pagination(state) {
return state[this.namespace].pagination;
},
}),
filteredFields() {
return FIELDS.filter(
@ -59,6 +64,11 @@ export default {
userIsLoggedIn() {
return this.currentUserId !== null;
},
showPagination() {
const { paramName, currentPage, perPage, totalItems } = this.pagination;
return paramName && currentPage && perPage && totalItems;
},
},
mounted() {
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
@ -99,6 +109,11 @@ export default {
...(member?.id && { 'data-testid': `members-table-row-${member.id}` }),
};
},
paginationLinkGenerator(page) {
const { params = {}, paramName } = this.pagination;
return mergeUrlParams({ ...params, [paramName]: page }, window.location.href);
},
},
};
</script>
@ -179,6 +194,18 @@ export default {
<span data-testid="col-actions" class="gl-sr-only">{{ label }}</span>
</template>
</gl-table>
<gl-pagination
v-if="showPagination"
:value="pagination.currentPage"
:per-page="pagination.perPage"
:total-items="pagination.totalItems"
:link-gen="paginationLinkGenerator"
:prev-text="__('Prev')"
:next-text="__('Next')"
:label-next-page="__('Go to next page')"
:label-prev-page="__('Go to previous page')"
align="center"
/>
<remove-group-link-modal />
<ldap-override-confirmation-modal />
</div>

View File

@ -1,5 +1,6 @@
export default ({
members,
pagination,
tableFields,
tableAttrs,
tableSortableFields,
@ -8,6 +9,7 @@ export default ({
filteredSearchBar,
}) => ({
members,
pagination,
tableFields,
tableAttrs,
tableSortableFields,

View File

@ -105,10 +105,14 @@ export const buildSortHref = ({
export const canOverride = () => false;
export const parseDataAttributes = (el) => {
const { members, sourceId, memberPath, canManageMembers } = el.dataset;
const { members, pagination, sourceId, memberPath, canManageMembers } = el.dataset;
return {
members: convertObjectPropsToCamelCase(JSON.parse(members), { deep: true }),
pagination: convertObjectPropsToCamelCase(JSON.parse(pagination || '{}'), {
deep: true,
ignoreKeyNames: ['params'],
}),
sourceId: parseInt(sourceId, 10),
memberPath,
canManageMembers: parseBoolean(canManageMembers),

View File

@ -247,7 +247,7 @@ export default class ActivityCalendar {
renderKey() {
const keyValues = [
__('no contributions'),
__('No contributions'),
__('1-9 contributions'),
__('10-19 contributions'),
__('20-29 contributions'),

View File

@ -10,12 +10,15 @@ class Admin::UsersController < Admin::ApplicationController
feature_category :users
PAGINATION_WITH_COUNT_LIMIT = 1000
def index
@users = User.filter_items(params[:filter]).order_name_asc
@users = @users.search_with_secondary_emails(params[:search_query]) if params[:search_query].present?
@users = users_with_included_associations(@users)
@users = @users.sort_by_attribute(@sort = params[:sort])
@users = @users.page(params[:page])
@users = @users.without_count if paginate_without_count?
@cohorts = load_cohorts
@ -228,6 +231,12 @@ class Admin::UsersController < Admin::ApplicationController
protected
def paginate_without_count?
counts = Gitlab::Database::Count.approximate_counts([User])
counts[User] > PAGINATION_WITH_COUNT_LIMIT
end
def users_with_included_associations(users)
users.includes(:authorized_projects) # rubocop: disable CodeReuse/ActiveRecord
end

View File

@ -119,12 +119,13 @@ module Projects
.assignable_for(project)
.ordered
.page(params[:specific_page]).per(NUMBER_OF_RUNNERS_PER_PAGE)
.with_tags
@shared_runners = ::Ci::Runner.instance_type.active
@shared_runners = ::Ci::Runner.instance_type.active.with_tags
@shared_runners_count = @shared_runners.count(:all)
@group_runners = ::Ci::Runner.belonging_to_parent_group_of_project(@project.id)
@group_runners = ::Ci::Runner.belonging_to_parent_group_of_project(@project.id).with_tags
end
def define_ci_variables

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
module ProjectCommitCount
include Gitlab::Git::WrapsGitalyErrors
def commit_count_for(project, default_count: 0, max_count: nil, **exception_details)
raw_repo = project.repository&.raw_repository
root_ref = raw_repo&.root_ref
return default_count unless root_ref
Gitlab::GitalyClient::CommitService.new(raw_repo).commit_count(root_ref, {
all: true, # include all branches
max_count: max_count # limit as an optimization
})
rescue StandardError => e
Gitlab::ErrorTracking.track_exception(e, exception_details)
default_count
end
end

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
class EmptyRepoUploadExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
include ProjectCommitCount
TRACKING_START_DATE = DateTime.parse('2021/4/20')
INITIAL_COMMIT_COUNT = 1
def track_initial_write
return unless should_track? # early return if we don't need to ask for commit counts
return unless context.project.created_at > TRACKING_START_DATE # early return for older projects
return unless commit_count == INITIAL_COMMIT_COUNT
track(:initial_write, project: context.project)
end
private
def commit_count
commit_count_for(context.project, max_count: INITIAL_COMMIT_COUNT, experiment: name)
end
end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
class NewProjectReadmeExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
include Gitlab::Git::WrapsGitalyErrors
include ProjectCommitCount
INITIAL_WRITE_LIMIT = 3
EXPERIMENT_START_DATE = DateTime.parse('2021/1/20')
@ -21,25 +21,18 @@ class NewProjectReadmeExperiment < ApplicationExperiment # rubocop:disable Gitla
def track_initial_writes(project)
return unless should_track? # early return if we don't need to ask for commit counts
return unless project.created_at > EXPERIMENT_START_DATE # early return for older projects
return unless (commit_count = commit_count_for(project)) < INITIAL_WRITE_LIMIT
return unless (count = commit_count(project)) < INITIAL_WRITE_LIMIT
track(:write, property: project.created_at.to_s, value: commit_count)
track(:write, property: project.created_at.to_s, value: count)
end
private
def commit_count_for(project)
raw_repo = project.repository&.raw_repository
return INITIAL_WRITE_LIMIT unless raw_repo&.root_ref
begin
Gitlab::GitalyClient::CommitService.new(raw_repo).commit_count(raw_repo.root_ref, {
all: true, # include all branches
max_count: INITIAL_WRITE_LIMIT # limit as an optimization
})
rescue StandardError => e
Gitlab::ErrorTracking.track_exception(e, experiment: name)
INITIAL_WRITE_LIMIT
end
def commit_count(project)
commit_count_for(project,
default_count: INITIAL_WRITE_LIMIT,
max_count: INITIAL_WRITE_LIMIT,
experiment: name
)
end
end

View File

@ -23,7 +23,11 @@ module Mutations
argument :color, GraphQL::STRING_TYPE,
required: false,
default_value: Label::DEFAULT_COLOR,
description: "The color of the label given in 6-digit hex notation with leading '#' sign (e.g. #FFAABB) or one of the CSS color names in https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#Color_keywords."
description: <<~DESC
The color of the label given in 6-digit hex notation with leading '#' sign
(for example, `#FFAABB`) or one of the CSS color names
<https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#Color_keywords>.
DESC
authorize :admin_label

View File

@ -7,57 +7,57 @@ module IssueResolverArguments
include LooksAhead
argument :iid, GraphQL::STRING_TYPE,
required: false,
description: 'IID of the issue. For example, "1".'
required: false,
description: 'IID of the issue. For example, "1".'
argument :iids, [GraphQL::STRING_TYPE],
required: false,
description: 'List of IIDs of issues. For example, [1, 2].'
required: false,
description: 'List of IIDs of issues. For example, ["1", "2"].'
argument :label_name, [GraphQL::STRING_TYPE, null: true],
required: false,
description: 'Labels applied to this issue.'
required: false,
description: 'Labels applied to this issue.'
argument :milestone_title, [GraphQL::STRING_TYPE, null: true],
required: false,
description: 'Milestone applied to this issue.'
required: false,
description: 'Milestone applied to this issue.'
argument :author_username, GraphQL::STRING_TYPE,
required: false,
description: 'Username of the author of the issue.'
required: false,
description: 'Username of the author of the issue.'
argument :assignee_username, GraphQL::STRING_TYPE,
required: false,
description: 'Username of a user assigned to the issue.',
deprecated: { reason: 'Use `assigneeUsernames`', milestone: '13.11' }
required: false,
description: 'Username of a user assigned to the issue.',
deprecated: { reason: 'Use `assigneeUsernames`', milestone: '13.11' }
argument :assignee_usernames, [GraphQL::STRING_TYPE],
required: false,
description: 'Usernames of users assigned to the issue.'
required: false,
description: 'Usernames of users assigned to the issue.'
argument :assignee_id, GraphQL::STRING_TYPE,
required: false,
description: 'ID of a user assigned to the issues, "none" and "any" values are supported.'
required: false,
description: 'ID of a user assigned to the issues, "none" and "any" values are supported.'
argument :created_before, Types::TimeType,
required: false,
description: 'Issues created before this date.'
required: false,
description: 'Issues created before this date.'
argument :created_after, Types::TimeType,
required: false,
description: 'Issues created after this date.'
required: false,
description: 'Issues created after this date.'
argument :updated_before, Types::TimeType,
required: false,
description: 'Issues updated before this date.'
required: false,
description: 'Issues updated before this date.'
argument :updated_after, Types::TimeType,
required: false,
description: 'Issues updated after this date.'
required: false,
description: 'Issues updated after this date.'
argument :closed_before, Types::TimeType,
required: false,
description: 'Issues closed before this date.'
required: false,
description: 'Issues closed before this date.'
argument :closed_after, Types::TimeType,
required: false,
description: 'Issues closed after this date.'
required: false,
description: 'Issues closed after this date.'
argument :search, GraphQL::STRING_TYPE,
required: false,
description: 'Search query for issue title or description.'
required: false,
description: 'Search query for issue title or description.'
argument :types, [Types::IssueTypeEnum],
as: :issue_types,
description: 'Filter issues by the given issue types.',
required: false
as: :issue_types,
description: 'Filter issues by the given issue types.',
required: false
argument :not, Types::Issues::NegatedIssueFilterInputType,
description: 'List of negated params.',
description: 'Negated arguments.',
prepare: ->(negated_args, ctx) { negated_args.to_h },
required: false
end

View File

@ -166,6 +166,16 @@ module GitlabRoutingHelper
resend_invite_group_group_member_path(group_member.source, group_member)
end
# Members
def source_members_url(member)
case member.source_type
when 'Namespace'
group_group_members_url(member.source)
when 'Project'
project_project_members_url(member.source)
end
end
# Artifacts
# Rails path generators are slow because they need to do large regex comparisons

View File

@ -22,9 +22,10 @@ module Groups::GroupMembersHelper
end
# Overridden in `ee/app/helpers/ee/groups/group_members_helper.rb`
def group_members_list_data_attributes(group, members)
def group_members_list_data_attributes(group, members, pagination = {})
{
members: members_data_json(group, members),
pagination: members_pagination_data_json(members, pagination),
member_path: group_group_member_path(group, ':id'),
source_id: group.id,
can_manage_members: can?(current_user, :admin_group_member, group).to_s
@ -32,8 +33,11 @@ module Groups::GroupMembersHelper
end
def group_group_links_list_data_attributes(group)
group_links = group.shared_with_group_links
{
members: group_group_links_data_json(group.shared_with_group_links),
members: group_group_links_data_json(group_links),
pagination: members_pagination_data_json(group_links),
member_path: group_group_link_path(group, ':id'),
source_id: group.id
}

View File

@ -65,4 +65,14 @@ module MembersHelper
'group and any subresources'
end
def members_pagination_data_json(members, pagination = {})
{
current_page: members.respond_to?(:current_page) ? members.current_page : nil,
per_page: members.respond_to?(:limit_value) ? members.limit_value : nil,
total_items: members.respond_to?(:total_count) ? members.total_count : members.count,
param_name: pagination[:param_name] || nil,
params: pagination[:params] || {}
}.to_json
end
end

View File

@ -35,9 +35,10 @@ module Projects::ProjectMembersHelper
MemberSerializer.new.represent(members, { current_user: current_user, group: project.group, source: project }).to_json
end
def project_members_list_data_attributes(project, members)
def project_members_list_data_attributes(project, members, pagination = {})
{
members: project_members_data_json(project, members),
pagination: members_pagination_data_json(members, pagination),
member_path: project_project_member_path(project, ':id'),
source_id: project.id,
can_manage_members: can_manage_project_members?(project).to_s
@ -47,6 +48,7 @@ module Projects::ProjectMembersHelper
def project_group_links_list_data_attributes(project, group_links)
{
members: project_group_links_data_json(group_links),
pagination: members_pagination_data_json(group_links),
member_path: project_group_link_path(project, ':id'),
source_id: project.id,
can_manage_members: can_manage_project_members?(project).to_s

View File

@ -55,6 +55,7 @@ module CascadingNamespaceSettingAttribute
# public methods
define_attr_reader(attribute)
define_attr_writer(attribute)
define_lock_attr_writer(attribute)
define_lock_methods(attribute)
alias_boolean(attribute)
@ -97,7 +98,17 @@ module CascadingNamespaceSettingAttribute
def define_attr_writer(attribute)
define_method("#{attribute}=") do |value|
return value if value == cascaded_ancestor_value(attribute)
clear_memoization(attribute)
super(value)
end
end
def define_lock_attr_writer(attribute)
define_method("lock_#{attribute}=") do |value|
attr_value = public_send(attribute) # rubocop:disable GitlabSecurity/PublicSend
write_attribute(attribute, attr_value) if self[attribute].nil?
super(value)
end

View File

@ -8,7 +8,11 @@ class ProjectMemberPolicy < BasePolicy
condition(:project_bot) { @subject.user&.project_bot? }
rule { anonymous }.prevent_all
rule { target_is_owner }.prevent_all
rule { target_is_owner }.policy do
prevent :update_project_member
prevent :destroy_project_member
end
rule { ~project_bot & can?(:admin_project_member) }.policy do
enable :update_project_member

View File

@ -83,6 +83,6 @@
= render partial: 'admin/users/user', collection: @users
= paginate @users, theme: "gitlab"
= paginate_collection @users
= render partial: 'admin/users/modals'

View File

@ -62,10 +62,9 @@
%span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @requesters.count
.tab-content
#tab-members.tab-pane{ class: ('active' unless invited_active) }
.js-group-members-list{ data: group_members_list_data_attributes(@group, @members) }
.js-group-members-list{ data: group_members_list_data_attributes(@group, @members, { param_name: :page, params: { invited_members_page: nil, search_invited: nil } }) }
.loading
.spinner.spinner-md
= paginate @members, theme: 'gitlab', params: { invited_members_page: nil, search_invited: nil }
- if @group.shared_with_group_links.any?
#tab-groups.tab-pane
.js-group-group-links-list{ data: group_group_links_list_data_attributes(@group) }
@ -73,10 +72,9 @@
.spinner.spinner-md
- if show_invited_members
#tab-invited-members.tab-pane{ class: ('active' if invited_active) }
.js-group-invited-members-list{ data: group_members_list_data_attributes(@group, @invited_members) }
.js-group-invited-members-list{ data: group_members_list_data_attributes(@group, @invited_members, { param_name: :invited_members_page, params: { page: nil } }) }
.loading
.spinner.spinner-md
= paginate @invited_members, param_name: 'invited_members_page', theme: 'gitlab', params: { page: nil }
- if show_access_requests
#tab-access-requests.tab-pane
.js-group-access-requests-list{ data: group_members_list_data_attributes(@group, @requesters) }

View File

@ -1,8 +1,6 @@
- license = viewer.license
= sprite_icon('scale')
This project is licensed under the
= succeed '.' do
%strong= license.name
= _("This project is licensed under the %{strong_start}%{license_name}%{strong_end}.").html_safe % { license_name: license.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
= link_to 'Learn more', license.url, target: '_blank', rel: 'noopener noreferrer'
= link_to _('Learn more'), license.url, target: '_blank', rel: 'noopener noreferrer'

View File

@ -75,10 +75,9 @@
%span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @requesters.count
.tab-content
#tab-members.tab-pane{ class: ('active' unless groups_tab_active?) }
.js-project-members-list{ data: project_members_list_data_attributes(@project, @project_members) }
.js-project-members-list{ data: project_members_list_data_attributes(@project, @project_members, { param_name: :page, params: { search_groups: nil } }) }
.loading
.spinner.spinner-md
= paginate @project_members, theme: "gitlab", params: { search_groups: nil }
- if show_groups?(@group_links)
#tab-groups.tab-pane{ class: ('active' if groups_tab_active?) }
.js-project-group-links-list{ data: project_group_links_list_data_attributes(@project, @group_links) }

View File

@ -123,7 +123,7 @@ class PostReceive # rubocop:disable Scalability/IdempotentWorker
def after_project_changes_hooks(project, user, refs, changes)
experiment(:new_project_readme, actor: user).track_initial_writes(project)
experiment(:empty_repo_upload, project: project).track(:initial_write) if project.empty_repo?
experiment(:empty_repo_upload, project: project).track_initial_write
repository_update_hook_data = Gitlab::DataBuilder::Repository.update(project, user, changes, refs)
SystemHooksService.new.execute_hooks(repository_update_hook_data, :repository_update_hooks)
Gitlab::UsageDataCounters::SourceCodeCounter.count(:pushes)

View File

@ -0,0 +1,5 @@
---
title: Optimize CI Settings page to reduce N+1 queries
merge_request: 59625
author:
type: performance

View File

@ -0,0 +1,5 @@
---
title: Fix restrictive permissions for ProjectMembers who are owners
merge_request: 59844
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Improve pagination of users in the admin panel
merge_request: 59884
author:
type: performance

View File

@ -0,0 +1,5 @@
---
title: Externalize strings in viewers/_license.html.haml
merge_request: 58452
author: nuwe1
type: other

View File

@ -0,0 +1,5 @@
---
title: Add missing parts of GraphQL schema to GraphQL documentation
merge_request: 55944
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Fix cascading settings attr writer behavior
merge_request: 59910
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Rename 'no contributions' to 'No contributions' in profile activity graph helper
merge_request: 59076
author: Yogi (@yo)
type: changed

View File

@ -1,10 +1,10 @@
---
key_path: counts.grafana_integrated_projects
description:
description: Total Grafana integrations attached to projects
product_section: ops
product_stage:
product_group: group::monitor
product_category:
product_category: metrics
value_type: number
status: data_available
time_frame: all
@ -13,4 +13,3 @@ distribution:
- ce
tier:
- free
skip_validation: true

View File

@ -8,9 +8,8 @@ product_category: collection
value_type: boolean
status: data_available
time_frame: none
data_source:
data_source: database
distribution:
- ce
tier:
- free
skip_validation: true

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,7 @@ WARNING:
This API is in the process of being deprecated and considered unstable.
The response payload may be subject to change or breakage
across GitLab releases. Please use the
[GraphQL API](graphql/reference/index.md#vulnerabilities)
[GraphQL API](graphql/reference/index.md#queryvulnerabilities)
instead.
Every API call to vulnerabilities must be [authenticated](README.md#authentication).

View File

@ -143,11 +143,17 @@ state synchronization mechanisms and hooking into existing ones.
## Iterations
1. [Build comprehensive Grafana dashboard for GraphQL](https://gitlab.com/groups/gitlab-com/-/epics/1343)
1. [Improve logging of GraphQL requests in Elastic](https://gitlab.com/groups/gitlab-org/-/epics/4646)
### In the scope of the blueprint
1. [GraphQL API architecture](https://gitlab.com/groups/gitlab-org/-/epics/5842)
1. [Build comprehensive Grafana dashboard for GraphQL](https://gitlab.com/groups/gitlab-org/-/epics/5841)
1. [Improve logging of GraphQL requests in Elastic](https://gitlab.com/groups/gitlab-org/-/epics/4646)
1. [Build GraphQL query correlation mechanisms](https://gitlab.com/groups/gitlab-org/-/epics/5320)
1. [Design a better data-informed deprecation policy](https://gitlab.com/groups/gitlab-org/-/epics/5321)
### Future iterations
1. [Build a scalable state synchronization for GraphQL](https://gitlab.com/groups/gitlab-org/-/epics/5319)
1. [Build GraphQL feature-to-query correlation mechanisms](https://gitlab.com/groups/gitlab-org/-/epics/5320)
1. [Design a better data-informed deprecation policy](https://gitlab.com/groups/gitlab-org/-/epics/5321)
1. [Add support for direct uploads for GraphQL](https://gitlab.com/gitlab-org/gitlab/-/issues/280819)
1. [Review GraphQL design choices related to security](https://gitlab.com/gitlab-org/security/gitlab/-/issues/339)
@ -179,6 +185,11 @@ DRIs:
| Leadership | Darva Satcher |
| Product | Patrick Deuley |
| Engineering | Paul Slaughter |
Domain Experts:
| Area | Who
|------------------------------|------------------------|
| Domain Expert / GraphQL | Charlie Ablett |
| Domain Expert / GraphQL | Alex Kalderimis |
| Domain Expert / GraphQL | Natalia Tepluhina |

View File

@ -1042,7 +1042,7 @@ Tiers: `premium`, `ultimate`
### `counts.grafana_integrated_projects`
Missing description
Total Grafana integrations attached to projects
[YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/config/metrics/counts_all/20210216180927_grafana_integrated_projects.yml)

View File

@ -101,7 +101,7 @@ module.exports = (path, options = {}) => {
'^.+\\.(md|zip|png)$': 'jest-raw-loader',
},
transformIgnorePatterns: [
'node_modules/(?!(@gitlab/ui|@gitlab/favicon-overlay|bootstrap-vue|three|monaco-editor|monaco-yaml|fast-mersenne-twister)/)',
'node_modules/(?!(@gitlab/ui|@gitlab/favicon-overlay|bootstrap-vue|three|monaco-editor|monaco-yaml|fast-mersenne-twister|prosemirror-markdown)/)',
],
timers: 'fake',
testEnvironment: '<rootDir>/spec/frontend/environment.js',

View File

@ -16,22 +16,9 @@ module Banzai
REFERENCE_PLACEHOLDER = "_reference_#{SecureRandom.hex(16)}_"
REFERENCE_PLACEHOLDER_PATTERN = %r{#{REFERENCE_PLACEHOLDER}(\d+)}.freeze
def self.object_class
# Implement in child class
# Example: MergeRequest
end
def self.object_name
@object_name ||= object_class.name.underscore
end
def self.object_sym
@object_sym ||= object_name.to_sym
end
# Public: Find references in text (like `!123` for merge requests)
#
# AnyReferenceFilter.references_in(text) do |match, id, project_ref, matches|
# references_in(text) do |match, id, project_ref, matches|
# object = find_object(project_ref, id)
# "<a href=...>#{object.to_reference}</a>"
# end
@ -42,7 +29,7 @@ module Banzai
# of the external project reference, and all of the matchdata.
#
# Returns a String replaced with the return of the block.
def self.references_in(text, pattern = object_class.reference_pattern)
def references_in(text, pattern = object_class.reference_pattern)
text.gsub(pattern) do |match|
if ident = identifier($~)
yield match, ident, $~[:project], $~[:namespace], $~
@ -52,17 +39,13 @@ module Banzai
end
end
def self.identifier(match_data)
def identifier(match_data)
symbol = symbol_from_match(match_data)
parse_symbol(symbol, match_data) if object_class.reference_valid?(symbol)
end
def identifier(match_data)
self.class.identifier(match_data)
end
def self.symbol_from_match(match)
def symbol_from_match(match)
key = object_sym
match[key] if match.names.include?(key.to_s)
end
@ -72,7 +55,7 @@ module Banzai
#
# This method has the contract that if a string `ref` refers to a
# record `record`, then `parse_symbol(ref) == record_identifier(record)`.
def self.parse_symbol(symbol, match_data)
def parse_symbol(symbol, match_data)
symbol.to_i
end
@ -84,21 +67,10 @@ module Banzai
record.id
end
def object_class
self.class.object_class
end
def object_sym
self.class.object_sym
end
def references_in(*args, &block)
self.class.references_in(*args, &block)
end
# Implement in child class
# Example: project.merge_requests.find
def find_object(parent_object, id)
raise NotImplementedError, "#{self.class} must implement method: #{__callee__}"
end
# Override if the link reference pattern produces a different ID (global
@ -110,6 +82,7 @@ module Banzai
# Implement in child class
# Example: project_merge_request_url
def url_for_object(object, parent_object)
raise NotImplementedError, "#{self.class} must implement method: #{__callee__}"
end
def find_object_cached(parent_object, id)
@ -139,7 +112,7 @@ module Banzai
def call
return doc unless project || group || user
ref_pattern = object_class.reference_pattern
ref_pattern = object_reference_pattern
link_pattern = object_class.link_reference_pattern
# Compile often used regexps only once outside of the loop
@ -425,14 +398,6 @@ module Banzai
group_ref
end
def unescape_html_entities(text)
CGI.unescapeHTML(text.to_s)
end
def escape_html_entities(text)
CGI.escapeHTML(text.to_s)
end
def escape_with_placeholders(text, placeholder_data)
escaped = escape_html_entities(text)

View File

@ -5,12 +5,9 @@ module Banzai
module References
class AlertReferenceFilter < IssuableReferenceFilter
self.reference_type = :alert
self.object_class = AlertManagement::Alert
def self.object_class
AlertManagement::Alert
end
def self.object_sym
def object_sym
:alert
end

View File

@ -8,12 +8,9 @@ module Banzai
# This filter supports cross-project references.
class CommitRangeReferenceFilter < AbstractReferenceFilter
self.reference_type = :commit_range
self.object_class = CommitRange
def self.object_class
CommitRange
end
def self.references_in(text, pattern = CommitRange.reference_pattern)
def references_in(text, pattern = object_reference_pattern)
text.gsub(pattern) do |match|
yield match, $~[:commit_range], $~[:project], $~[:namespace], $~
end

View File

@ -8,12 +8,9 @@ module Banzai
# This filter supports cross-project references.
class CommitReferenceFilter < AbstractReferenceFilter
self.reference_type = :commit
self.object_class = Commit
def self.object_class
Commit
end
def self.references_in(text, pattern = Commit.reference_pattern)
def references_in(text, pattern = object_reference_pattern)
text.gsub(pattern) do |match|
yield match, $~[:commit], $~[:project], $~[:namespace], $~
end
@ -39,7 +36,7 @@ module Banzai
end
# The default behaviour is `#to_i` - we just pass the hash through.
def self.parse_symbol(sha_hash, _match)
def parse_symbol(sha_hash, _match)
sha_hash
end

View File

@ -33,6 +33,7 @@ module Banzai
end
self.reference_type = :design
self.object_class = ::DesignManagement::Design
def find_object(project, identifier)
records_per_parent[project][identifier]
@ -76,15 +77,11 @@ module Banzai
super.merge(issue: design.issue_id)
end
def self.object_class
::DesignManagement::Design
end
def self.object_sym
def object_sym
:design
end
def self.parse_symbol(raw, match_data)
def parse_symbol(raw, match_data)
filename = match_data[:url_filename]
iid = match_data[:issue].to_i
Identifier.new(filename: CGI.unescape(filename), issue_iid: iid)

View File

@ -10,10 +10,11 @@ module Banzai
# This filter does not support cross-project references.
class ExternalIssueReferenceFilter < ReferenceFilter
self.reference_type = :external_issue
self.object_class = ExternalIssue
# Public: Find `JIRA-123` issue references in text
#
# ExternalIssueReferenceFilter.references_in(text, pattern) do |match, issue|
# references_in(text, pattern) do |match, issue|
# "<a href=...>##{issue}</a>"
# end
#
@ -22,7 +23,7 @@ module Banzai
# Yields the String match and the String issue reference.
#
# Returns a String replaced with the return of the block.
def self.references_in(text, pattern)
def references_in(text, pattern = object_reference_pattern)
text.gsub(pattern) do |match|
yield match, $~[:issue]
end
@ -32,27 +33,7 @@ module Banzai
# Early return if the project isn't using an external tracker
return doc if project.nil? || default_issues_tracker?
ref_pattern = issue_reference_pattern
ref_start_pattern = /\A#{ref_pattern}\z/
nodes.each_with_index do |node, index|
if text_node?(node)
replace_text_when_pattern_matches(node, index, ref_pattern) do |content|
issue_link_filter(content)
end
elsif element_node?(node)
yield_valid_link(node) do |link, inner_html|
if link =~ ref_start_pattern
replace_link_node_with_href(node, index, link) do
issue_link_filter(link, link_content: inner_html)
end
end
end
end
end
doc
super
end
private
@ -65,8 +46,8 @@ module Banzai
#
# Returns a String with `JIRA-123` references replaced with links. All
# links have `gfm` and `gfm-issue` class names attached for styling.
def issue_link_filter(text, link_content: nil)
self.class.references_in(text, issue_reference_pattern) do |match, id|
def object_link_filter(text, pattern, link_content: nil, link_reference: false)
references_in(text) do |match, id|
url = url_for_issue(id)
klass = reference_class(:issue)
data = data_attribute(project: project.id, external_issue: id)
@ -97,14 +78,10 @@ module Banzai
external_issues_cached(:default_issues_tracker?)
end
def issue_reference_pattern
def object_reference_pattern
external_issues_cached(:external_issue_reference_pattern)
end
def project
context[:project]
end
def issue_title
"Issue in #{project.external_issue_tracker.title}"
end

View File

@ -5,12 +5,9 @@ module Banzai
module References
class FeatureFlagReferenceFilter < IssuableReferenceFilter
self.reference_type = :feature_flag
self.object_class = Operations::FeatureFlag
def self.object_class
Operations::FeatureFlag
end
def self.object_sym
def object_sym
:feature_flag
end

View File

@ -13,10 +13,7 @@ module Banzai
# to reference issues from other GitLab projects.
class IssueReferenceFilter < IssuableReferenceFilter
self.reference_type = :issue
def self.object_class
Issue
end
self.object_class = Issue
def url_for_object(issue, project)
return issue_path(issue, project) if only_path?

View File

@ -6,10 +6,7 @@ module Banzai
# The actual filter is implemented in the EE mixin
class IterationReferenceFilter < AbstractReferenceFilter
self.reference_type = :iteration
def self.object_class
Iteration
end
self.object_class = Iteration
end
end
end

View File

@ -6,10 +6,7 @@ module Banzai
# HTML filter that replaces label references with links.
class LabelReferenceFilter < AbstractReferenceFilter
self.reference_type = :label
def self.object_class
Label
end
self.object_class = Label
def find_object(parent_object, id)
find_labels(parent_object).find(id)

View File

@ -9,10 +9,7 @@ module Banzai
# This filter supports cross-project references.
class MergeRequestReferenceFilter < IssuableReferenceFilter
self.reference_type = :merge_request
def self.object_class
MergeRequest
end
self.object_class = MergeRequest
def url_for_object(mr, project)
h = Gitlab::Routing.url_helpers

View File

@ -8,10 +8,7 @@ module Banzai
include Gitlab::Utils::StrongMemoize
self.reference_type = :milestone
def self.object_class
Milestone
end
self.object_class = Milestone
# Links to project milestones contain the IID, but when we're handling
# 'regular' references, we need to use the global ID to disambiguate

View File

@ -6,10 +6,11 @@ module Banzai
# HTML filter that replaces project references with links.
class ProjectReferenceFilter < ReferenceFilter
self.reference_type = :project
self.object_class = Project
# Public: Find `namespace/project>` project references in text
#
# ProjectReferenceFilter.references_in(text) do |match, project|
# references_in(text) do |match, project|
# "<a href=...>#{project}></a>"
# end
#
@ -18,33 +19,16 @@ module Banzai
# Yields the String match, and the String project name.
#
# Returns a String replaced with the return of the block.
def self.references_in(text)
text.gsub(Project.markdown_reference_pattern) do |match|
def references_in(text, pattern = object_reference_pattern)
text.gsub(pattern) do |match|
yield match, "#{$~[:namespace]}/#{$~[:project]}"
end
end
def call
ref_pattern = Project.markdown_reference_pattern
ref_pattern_start = /\A#{ref_pattern}\z/
private
nodes.each_with_index do |node, index|
if text_node?(node)
replace_text_when_pattern_matches(node, index, ref_pattern) do |content|
project_link_filter(content)
end
elsif element_node?(node)
yield_valid_link(node) do |link, inner_html|
if link =~ ref_pattern_start
replace_link_node_with_href(node, index, link) do
project_link_filter(link, link_content: inner_html)
end
end
end
end
end
doc
def object_reference_pattern
@object_reference_pattern ||= Project.markdown_reference_pattern
end
# Replace `namespace/project>` project references in text with links to the referenced
@ -55,8 +39,8 @@ module Banzai
#
# Returns a String with `namespace/project>` references replaced with links. All links
# have `gfm` and `gfm-project` class names attached for styling.
def project_link_filter(text, link_content: nil)
self.class.references_in(text) do |match, project_path|
def object_link_filter(text, pattern, link_content: nil, link_reference: false)
references_in(text) do |match, project_path|
cached_call(:banzai_url_for_object, match, path: [Project, project_path.downcase]) do
if project = projects_hash[project_path.downcase]
link_to_project(project, link_content: link_content) || match
@ -92,8 +76,6 @@ module Banzai
refs.to_a
end
private
def urls
Gitlab::Routing.url_helpers
end

View File

@ -16,8 +16,14 @@ module Banzai
include OutputSafety
class << self
# Implement in child class
# Example: self.reference_type = :merge_request
attr_accessor :reference_type
# Implement in child class
# Example: self.object_class = MergeRequest
attr_accessor :object_class
def call(doc, context = nil, result = nil)
new(doc, context, result).call_and_update_nodes
end
@ -34,6 +40,65 @@ module Banzai
with_update_nodes { call }
end
def call
ref_pattern_start = /\A#{object_reference_pattern}\z/
nodes.each_with_index do |node, index|
if text_node?(node)
replace_text_when_pattern_matches(node, index, object_reference_pattern) do |content|
object_link_filter(content, object_reference_pattern)
end
elsif element_node?(node)
yield_valid_link(node) do |link, inner_html|
if link =~ ref_pattern_start
replace_link_node_with_href(node, index, link) do
object_link_filter(link, object_reference_pattern, link_content: inner_html)
end
end
end
end
end
doc
end
# Public: Find references in text (like `!123` for merge requests)
#
# references_in(text) do |match, id, project_ref, matches|
# object = find_object(project_ref, id)
# "<a href=...>#{object.to_reference}</a>"
# end
#
# text - String text to search.
#
# Yields the String match, the Integer referenced object ID, an optional String
# of the external project reference, and all of the matchdata.
#
# Returns a String replaced with the return of the block.
def references_in(text, pattern = object_reference_pattern)
raise NotImplementedError, "#{self.class} must implement method: #{__callee__}"
end
# Iterates over all <a> and text() nodes in a document.
#
# Nodes are skipped whenever their ancestor is one of the nodes returned
# by `ignore_ancestor_query`. Link tags are not processed if they have a
# "gfm" class or the "href" attribute is empty.
def each_node
return to_enum(__method__) unless block_given?
doc.xpath(query).each do |node|
yield node
end
end
# Returns an Array containing all HTML nodes.
def nodes
@nodes ||= each_node.to_a
end
private
# Returns a data attribute String to attach to a reference link
#
# attributes - Hash, where the key becomes the data attribute name and the
@ -69,6 +134,13 @@ module Banzai
end
end
# Ensure that a :project key exists in context
#
# Note that while the key might exist, its value could be nil!
def validate
needs :project unless skip_project_check?
end
def project
context[:project]
end
@ -93,31 +165,6 @@ module Banzai
"#{gfm_klass} has-tooltip"
end
# Ensure that a :project key exists in context
#
# Note that while the key might exist, its value could be nil!
def validate
needs :project unless skip_project_check?
end
# Iterates over all <a> and text() nodes in a document.
#
# Nodes are skipped whenever their ancestor is one of the nodes returned
# by `ignore_ancestor_query`. Link tags are not processed if they have a
# "gfm" class or the "href" attribute is empty.
def each_node
return to_enum(__method__) unless block_given?
doc.xpath(query).each do |node|
yield node
end
end
# Returns an Array containing all HTML nodes.
def nodes
@nodes ||= each_node.to_a
end
# Yields the link's URL and inner HTML whenever the node is a valid <a> tag.
def yield_valid_link(node)
link = unescape_link(node.attr('href').to_s)
@ -132,6 +179,14 @@ module Banzai
CGI.unescape(href)
end
def unescape_html_entities(text)
CGI.unescapeHTML(text.to_s)
end
def escape_html_entities(text)
CGI.escapeHTML(text.to_s)
end
def replace_text_when_pattern_matches(node, index, pattern)
return unless node.text =~ pattern
@ -161,7 +216,25 @@ module Banzai
node.is_a?(Nokogiri::XML::Element)
end
private
def object_class
self.class.object_class
end
def object_reference_pattern
@object_reference_pattern ||= object_class.reference_pattern
end
def object_name
@object_name ||= object_class.name.underscore
end
def object_sym
@object_sym ||= object_name.to_sym
end
def object_link_filter(text, pattern, link_content: nil, link_reference: false)
raise NotImplementedError, "#{self.class} must implement method: #{__callee__}"
end
def query
@query ||= %Q{descendant-or-self::text()[not(#{ignore_ancestor_query})]

View File

@ -9,10 +9,7 @@ module Banzai
# This filter supports cross-project references.
class SnippetReferenceFilter < AbstractReferenceFilter
self.reference_type = :snippet
def self.object_class
Snippet
end
self.object_class = Snippet
def find_object(project, id)
return unless project.is_a?(Project)

View File

@ -8,10 +8,11 @@ module Banzai
# A special `@all` reference is also supported.
class UserReferenceFilter < ReferenceFilter
self.reference_type = :user
self.object_class = User
# Public: Find `@user` user references in text
#
# UserReferenceFilter.references_in(text) do |match, username|
# references_in(text) do |match, username|
# "<a href=...>@#{user}</a>"
# end
#
@ -20,8 +21,8 @@ module Banzai
# Yields the String match, and the String user name.
#
# Returns a String replaced with the return of the block.
def self.references_in(text)
text.gsub(User.reference_pattern) do |match|
def references_in(text, pattern = object_reference_pattern)
text.gsub(pattern) do |match|
yield match, $~[:user]
end
end
@ -29,28 +30,11 @@ module Banzai
def call
return doc if project.nil? && group.nil? && !skip_project_check?
ref_pattern = User.reference_pattern
ref_pattern_start = /\A#{ref_pattern}\z/
nodes.each_with_index do |node, index|
if text_node?(node)
replace_text_when_pattern_matches(node, index, ref_pattern) do |content|
user_link_filter(content)
end
elsif element_node?(node)
yield_valid_link(node) do |link, inner_html|
if link =~ ref_pattern_start
replace_link_node_with_href(node, index, link) do
user_link_filter(link, link_content: inner_html)
end
end
end
end
end
doc
super
end
private
# Replace `@user` user references in text with links to the referenced
# user's profile page.
#
@ -59,8 +43,8 @@ module Banzai
#
# Returns a String with `@user` references replaced with links. All links
# have `gfm` and `gfm-project_member` class names attached for styling.
def user_link_filter(text, link_content: nil)
self.class.references_in(text) do |match, username|
def object_link_filter(text, pattern, link_content: nil, link_reference: false)
references_in(text, pattern) do |match, username|
if username == 'all' && !skip_project_check?
link_to_all(link_content: link_content)
else
@ -100,8 +84,6 @@ module Banzai
refs.to_a
end
private
def urls
Gitlab::Routing.url_helpers
end

View File

@ -6,16 +6,7 @@ module Banzai
# The actual filter is implemented in the EE mixin
class VulnerabilityReferenceFilter < IssuableReferenceFilter
self.reference_type = :vulnerability
def self.object_class
Vulnerability
end
private
def project
context[:project]
end
self.object_class = Vulnerability
end
end
end

View File

@ -41,7 +41,7 @@ module Gitlab
parts = [
"#{deprecated_in(format: :markdown)}.",
reason_text,
replacement.then { |r| "Use: `#{r}`." if r }
replacement.then { |r| "Use: [`#{r}`](##{r.downcase.tr('.', '')})." if r }
].compact
case context

View File

@ -5,11 +5,52 @@ return if Rails.env.production?
module Gitlab
module Graphql
module Docs
# We assume a few things about the schema. We use the graphql-ruby gem, which enforces:
# - All mutations have a single input field named 'input'
# - All mutations have a payload type, named after themselves
# - All mutations have an input type, named after themselves
# If these things change, then some of this code will break. Such places
# are guarded with an assertion that our assumptions are not violated.
ViolatedAssumption = Class.new(StandardError)
SUGGESTED_ACTION = <<~MSG
We expect it to be impossible to violate our assumptions about
how mutation arguments work.
If that is not the case, then something has probably changed in the
way we generate our schema, perhaps in the library we use: graphql-ruby
Please ask for help in the #f_graphql or #backend channels.
MSG
CONNECTION_ARGS = %w[after before first last].to_set
FIELD_HEADER = <<~MD
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
MD
ARG_HEADER = <<~MD
# Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
MD
CONNECTION_NOTE = <<~MD
This field returns a [connection](#connections). It accepts the
four standard [pagination arguments](#connection-pagination-arguments):
`before: String`, `after: String`, `first: Int`, `last: Int`.
MD
# Helper with functions to be used by HAML templates
# This includes graphql-docs gem helpers class.
# You can check the included module on: https://github.com/gjtorikian/graphql-docs/blob/v1.6.0/lib/graphql-docs/helpers.rb
module Helper
include GraphQLDocs::Helpers
include Gitlab::Utils::StrongMemoize
def auto_generated_comment
<<-MD.strip_heredoc
@ -30,44 +71,52 @@ module Gitlab
# Template methods:
# Methods that return chunks of Markdown for insertion into the document
def render_full_field(field, heading_level: 3, owner: nil)
conn = connection?(field)
args = field[:arguments].reject { |arg| conn && CONNECTION_ARGS.include?(arg[:name]) }
arg_owner = [owner, field[:name]]
chunks = [
render_name_and_description(field, level: heading_level, owner: owner),
render_return_type(field),
render_input_type(field),
render_connection_note(field),
render_argument_table(heading_level, args, arg_owner),
render_return_fields(field, owner: owner)
]
join(:block, chunks)
end
def render_argument_table(level, args, owner)
arg_header = ('#' * level) + ARG_HEADER
render_field_table(arg_header, args, owner)
end
def render_name_and_description(object, owner: nil, level: 3)
content = []
content << "#{'#' * level} `#{object[:name]}`"
heading = '#' * level
name = [owner, object[:name]].compact.join('.')
if object[:description].present?
desc = object[:description].strip
desc += '.' unless desc.ends_with?('.')
end
content << "#{heading} `#{name}`"
content << render_description(object, owner, :block)
if object[:is_deprecated]
owner = Array.wrap(owner)
deprecation = schema_deprecation(owner, object[:name])
content << (deprecation&.original_description || desc)
content << render_deprecation(object, owner, :block)
else
content << desc
end
content.compact.join("\n\n")
join(:block, content)
end
def render_return_type(query)
"Returns #{render_field_type(query[:type])}.\n"
end
def render_object_fields(fields, owner:, level_bump: 0)
return if fields.blank?
def sorted_by_name(objects)
return [] unless objects.present?
(with_args, no_args) = fields.partition { |f| args?(f) }
type_name = owner[:name] if owner
header_prefix = '#' * level_bump
sections = [
render_simple_fields(no_args, type_name, header_prefix),
render_fields_with_arguments(with_args, type_name, header_prefix)
]
objects.sort_by { |o| o[:name] }
end
def render_field(field, owner)
render_row(
render_name(field, owner),
render_field_type(field[:type]),
render_description(field, owner, :inline)
)
join(:block, sections)
end
def render_enum_value(enum, value)
@ -82,104 +131,278 @@ module Gitlab
# Methods that return parts of the schema, or related information:
# We are ignoring connections and built in types for now,
# they should be added when queries are generated.
def objects
object_types = graphql_object_types.select do |object_type|
!object_type[:name]["__"]
end
def connection_object_types
objects.select { |t| t[:is_edge] || t[:is_connection] }
end
object_types.each do |type|
type[:fields] += type[:connections]
def object_types
objects.reject { |t| t[:is_edge] || t[:is_connection] || t[:is_payload] }
end
def interfaces
graphql_interface_types.map { |t| t.merge(fields: t[:fields] + t[:connections]) }
end
def fields_of(type_name)
graphql_operation_types
.find { |type| type[:name] == type_name }
.values_at(:fields, :connections)
.flatten
.then { |fields| sorted_by_name(fields) }
end
# Place the arguments of the input types on the mutation itself.
# see: `#input_types` - this method must not call `#input_types` to avoid mutual recursion
def mutations
@mutations ||= sorted_by_name(graphql_mutation_types).map do |t|
inputs = t[:input_fields]
input = inputs.first
name = t[:name]
assert!(inputs.one?, "Expected exactly 1 input field named #{name}. Found #{inputs.count} instead.")
assert!(input[:name] == 'input', "Expected the input of #{name} to be named 'input'")
input_type_name = input[:type][:name]
input_type = graphql_input_object_types.find { |t| t[:name] == input_type_name }
assert!(input_type.present?, "Cannot find #{input_type_name} for #{name}.input")
arguments = input_type[:input_fields]
seen_type!(input_type_name)
t.merge(arguments: arguments)
end
end
def queries
graphql_operation_types.find { |type| type[:name] == 'Query' }.to_h.values_at(:fields, :connections).flatten
# We assume that the mutations have been processed first, marking their
# inputs as `seen_type?`
def input_types
mutations # ensure that mutations have seen their inputs first
graphql_input_object_types.reject { |t| seen_type?(t[:name]) }
end
# We ignore the built-in enum types.
# We ignore the built-in enum types, and sort values by name
def enums
graphql_enum_types.select do |enum_type|
!enum_type[:name].in?(%w[__DirectiveLocation __TypeKind])
end
graphql_enum_types
.reject { |type| type[:values].empty? }
.reject { |enum_type| enum_type[:name].start_with?('__') }
.map { |type| type.merge(values: sorted_by_name(type[:values])) }
end
private # DO NOT CALL THESE METHODS IN TEMPLATES
# Template methods
def render_return_type(query)
return unless query[:type] # for example, mutations
"Returns #{render_field_type(query[:type])}."
end
def render_simple_fields(fields, type_name, header_prefix)
render_field_table(header_prefix + FIELD_HEADER, fields, type_name)
end
def render_fields_with_arguments(fields, type_name, header_prefix)
return if fields.empty?
level = 5 + header_prefix.length
sections = sorted_by_name(fields).map do |f|
render_full_field(f, heading_level: level, owner: type_name)
end
<<~MD.chomp
#{header_prefix}#### Fields with arguments
#{join(:block, sections)}
MD
end
def render_field_table(header, fields, owner)
return if fields.empty?
fields = sorted_by_name(fields)
header + join(:table, fields.map { |f| render_field(f, owner) })
end
def render_field(field, owner)
render_row(
render_name(field, owner),
render_field_type(field[:type]),
render_description(field, owner, :inline)
)
end
def render_return_fields(mutation, owner:)
fields = mutation[:return_fields]
return if fields.blank?
name = owner.to_s + mutation[:name]
render_object_fields(fields, owner: { name: name })
end
def render_connection_note(field)
return unless connection?(field)
CONNECTION_NOTE.chomp
end
def render_row(*values)
"| #{values.map { |val| val.to_s.squish }.join(' | ')} |"
end
def render_name(object, owner = nil)
rendered_name = "`#{object[:name]}`"
rendered_name += ' **{warning-solid}**' if object[:is_deprecated]
rendered_name
rendered_name += ' **{warning-solid}**' if deprecated?(object, owner)
return rendered_name unless owner
owner = Array.wrap(owner).join('')
id = (owner + object[:name]).downcase
%(<a id="#{id}"></a>) + rendered_name
end
# Returns the object description. If the object has been deprecated,
# the deprecation reason will be returned in place of the description.
def render_description(object, owner = nil, context = :block)
owner = Array.wrap(owner)
return render_deprecation(object, owner, context) if object[:is_deprecated]
return if object[:description].blank?
if deprecated?(object, owner)
render_deprecation(object, owner, context)
else
render_description_of(object)
end
end
def deprecated?(object, owner)
return true if object[:is_deprecated] # only populated for fields, not arguments!
key = [*Array.wrap(owner), object[:name]].join('.')
deprecations.key?(key)
end
def render_description_of(object)
desc = if object[:is_edge]
base = object[:name].chomp('Edge')
"The edge type for [`#{base}`](##{base.downcase})."
elsif object[:is_connection]
base = object[:name].chomp('Connection')
"The connection type for [`#{base}`](##{base.downcase})."
else
object[:description]&.strip
end
return if desc.blank?
desc = object[:description].strip
desc += '.' unless desc.ends_with?('.')
desc
end
def render_deprecation(object, owner, context)
buff = []
deprecation = schema_deprecation(owner, object[:name])
return deprecation.markdown(context: context) if deprecation
reason = object[:deprecation_reason] || 'Use of this is deprecated.'
"**Deprecated:** #{reason}"
buff << (deprecation&.original_description || render_description_of(object)) if context == :block
buff << if deprecation
deprecation.markdown(context: context)
else
"**Deprecated:** #{object[:deprecation_reason]}"
end
join(context, buff)
end
def render_field_type(type)
"[`#{type[:info]}`](##{type[:name].downcase})"
end
def join(context, chunks)
chunks.compact!
return if chunks.blank?
case context
when :block
chunks.join("\n\n")
when :inline
chunks.join(" ").squish.presence
when :table
chunks.join("\n")
end
end
# Queries
def sorted_by_name(objects)
return [] unless objects.present?
objects.sort_by { |o| o[:name] }
end
def connection?(field)
type_name = field.dig(:type, :name)
type_name.present? && type_name.ends_with?('Connection')
end
# We are ignoring connections and built in types for now,
# they should be added when queries are generated.
def objects
strong_memoize(:objects) do
mutations = schema.mutation&.fields&.keys&.to_set || []
graphql_object_types
.reject { |object_type| object_type[:name]["__"] } # We ignore introspection types.
.map do |type|
name = type[:name]
type.merge(
is_edge: name.ends_with?('Edge'),
is_connection: name.ends_with?('Connection'),
is_payload: name.ends_with?('Payload') && mutations.include?(name.chomp('Payload').camelcase(:lower)),
fields: type[:fields] + type[:connections]
)
end
end
end
def args?(field)
args = field[:arguments]
return false if args.blank?
return true unless connection?(field)
args.any? { |arg| CONNECTION_ARGS.exclude?(arg[:name]) }
end
# returns the deprecation information for a field or argument
# See: Gitlab::Graphql::Deprecation
def schema_deprecation(type_name, field_name)
schema_member(type_name, field_name)&.deprecation
key = [*Array.wrap(type_name), field_name].join('.')
deprecations[key]
end
# Return a part of the schema.
#
# This queries the Schema by owner and name to find:
#
# - fields (e.g. `schema_member('Query', 'currentUser')`)
# - arguments (e.g. `schema_member(['Query', 'project], 'fullPath')`)
def schema_member(type_name, field_name)
type_name = Array.wrap(type_name)
if type_name.size == 2
arg_name = field_name
type_name, field_name = type_name
else
type_name = type_name.first
arg_name = nil
def render_input_type(query)
input_field = query[:input_fields]&.first
return unless input_field
"Input type: `#{input_field[:type][:name]}`"
end
def deprecations
strong_memoize(:deprecations) do
mapping = {}
schema.types.each do |type_name, type|
next unless type.kind.fields?
type.fields.each do |field_name, field|
mapping["#{type_name}.#{field_name}"] = field.try(:deprecation)
field.arguments.each do |arg_name, arg|
mapping["#{type_name}.#{field_name}.#{arg_name}"] = arg.try(:deprecation)
end
end
end
mapping.compact
end
end
return if type_name.nil? || field_name.nil?
type = schema.types[type_name]
return unless type && type.kind.fields?
field = type.fields[field_name]
return field if arg_name.nil?
args = field.arguments
is_mutation = field.mutation && field.mutation <= ::Mutations::BaseMutation
args = args['input'].type.unwrap.arguments if is_mutation
args[arg_name]
def assert!(claim, message)
raise ViolatedAssumption, "#{message}\n#{SUGGESTED_ACTION}" unless claim
end
end
end

View File

@ -24,6 +24,7 @@ module Gitlab
@layout = Haml::Engine.new(File.read(template))
@parsed_schema = GraphQLDocs::Parser.new(schema.graphql_definition, {}).parse
@schema = schema
@seen = Set.new
end
def contents
@ -37,6 +38,16 @@ module Gitlab
FileUtils.mkdir_p(@output_dir)
File.write(filename, contents)
end
private
def seen_type?(name)
@seen.include?(name)
end
def seen_type!(name)
@seen << name
end
end
end
end

View File

@ -26,17 +26,81 @@
The `Query` type contains the API's top-level entry points for all executable queries.
\
- sorted_by_name(queries).each do |query|
= render_name_and_description(query, owner: 'Query')
- fields_of('Query').each do |field|
= render_full_field(field, heading_level: 3, owner: 'Query')
\
:plain
## `Mutation` type
The `Mutation` type contains all the mutations you can execute.
All mutations receive their arguments in a single input object named `input`, and all mutations
support at least a return field `errors` containing a list of error messages.
All input objects may have a `clientMutationId: String` field, identifying the mutation.
For example:
```graphql
mutation($id: NoteableID!, $body: String!) {
createNote(input: { noteableId: $id, body: $body }) {
errors
}
}
```
\
- mutations.each do |field|
= render_full_field(field, heading_level: 3, owner: 'Mutation')
\
:plain
## Connections
Some types in our schema are `Connection` types - they represent a paginated
collection of edges between two nodes in the graph. These follow the
[Relay cursor connections specification](https://relay.dev/graphql/connections.htm).
### Pagination arguments {#connection-pagination-arguments}
All connection fields support the following pagination arguments:
| Name | Type | Description |
|------|------|-------------|
| `after` | [`String`](#string) | Returns the elements in the list that come after the specified cursor. |
| `before` | [`String`](#string) | Returns the elements in the list that come before the specified cursor. |
| `first` | [`Int`](#int) | Returns the first _n_ elements from the list. |
| `last` | [`Int`](#int) | Returns the last _n_ elements from the list. |
Since these arguments are common to all connection fields, they are not repeated for each connection.
### Connection fields
All connections have at least the following fields:
| Name | Type | Description |
|------|------|-------------|
| `pageInfo` | [`PageInfo!`](#pageinfo) | Pagination information. |
| `edges` | `[edge!]` | The edges. |
| `nodes` | `[item!]` | The items in the current page. |
The precise type of `Edge` and `Item` depends on the kind of connection. A
[`UserConnection`](#userconnection) will have nodes that have the type
[`[User!]`](#user), and edges that have the type [`UserEdge`](#useredge).
### Connection types
Some of the types in the schema exist solely to model connections. Each connection
has a distinct, named type, with a distinct named edge type. These are listed separately
below.
\
- connection_object_types.each do |type|
= render_name_and_description(type, level: 4)
\
= render_object_fields(type[:fields], owner: type, level_bump: 1)
\
= render_return_type(query)
- unless query[:arguments].empty?
~ "#### Arguments\n"
~ "| Name | Type | Description |"
~ "| ---- | ---- | ----------- |"
- sorted_by_name(query[:arguments]).each do |argument|
= render_field(argument, query[:type][:name])
\
:plain
## Object types
@ -44,22 +108,20 @@
Object types represent the resources that the GitLab GraphQL API can return.
They contain _fields_. Each field has its own type, which will either be one of the
basic GraphQL [scalar types](https://graphql.org/learn/schema/#scalar-types)
(e.g.: `String` or `Boolean`) or other object types.
(e.g.: `String` or `Boolean`) or other object types. Fields may have arguments.
Fields with arguments are exactly like top-level queries, and are listed beneath
the table of fields for each object type.
For more information, see
[Object Types and Fields](https://graphql.org/learn/schema/#object-types-and-fields)
on `graphql.org`.
\
- objects.each do |type|
- unless type[:fields].empty?
= render_name_and_description(type)
\
~ "| Field | Type | Description |"
~ "| ----- | ---- | ----------- |"
- sorted_by_name(type[:fields]).each do |field|
= render_field(field, type[:name])
\
- object_types.each do |type|
= render_name_and_description(type)
\
= render_object_fields(type[:fields], owner: type)
\
:plain
## Enumeration types
@ -73,14 +135,13 @@
\
- enums.each do |enum|
- unless enum[:values].empty?
= render_name_and_description(enum)
\
~ "| Value | Description |"
~ "| ----- | ----------- |"
- sorted_by_name(enum[:values]).each do |value|
= render_enum_value(enum, value)
\
= render_name_and_description(enum)
\
~ "| Value | Description |"
~ "| ----- | ----------- |"
- enum[:values].each do |value|
= render_enum_value(enum, value)
\
:plain
## Scalar types
@ -133,7 +194,7 @@
### Interfaces
\
- graphql_interface_types.each do |type|
- interfaces.each do |type|
= render_name_and_description(type, level: 4)
\
Implementations:
@ -141,8 +202,21 @@
- type[:implemented_by].each do |type_name|
~ "- [`#{type_name}`](##{type_name.downcase})"
\
~ "| Field | Type | Description |"
~ "| ----- | ---- | ----------- |"
- sorted_by_name(type[:fields] + type[:connections]).each do |field|
= render_field(field, type[:name])
= render_object_fields(type[:fields], owner: type, level_bump: 1)
\
:plain
## Input types
Types that may be used as arguments (all scalar types may also
be used as arguments).
Only general use input types are listed here. For mutation input types,
see the associated mutation type above.
\
- input_types.each do |type|
= render_name_and_description(type)
\
= render_argument_table(3, type[:input_fields], type[:name])
\

View File

@ -15064,9 +15064,15 @@ msgstr ""
msgid "Go to metrics"
msgstr ""
msgid "Go to next page"
msgstr ""
msgid "Go to parent"
msgstr ""
msgid "Go to previous page"
msgstr ""
msgid "Go to project"
msgstr ""
@ -32460,6 +32466,9 @@ msgstr ""
msgid "This project is archived and cannot be commented on."
msgstr ""
msgid "This project is licensed under the %{strong_start}%{license_name}%{strong_end}."
msgstr ""
msgid "This project manages its dependencies using %{strong_start}%{manager_name}%{strong_end}"
msgstr ""
@ -37878,9 +37887,6 @@ msgstr ""
msgid "no approvers"
msgstr ""
msgid "no contributions"
msgstr ""
msgid "no expiration"
msgstr ""

View File

@ -34,6 +34,29 @@ RSpec.describe Admin::UsersController do
let(:target_id) { 'i_analytics_cohorts' }
let(:request_params) { { tab: 'cohorts' } }
end
context 'pagination' do
context 'when number of users is over the pagination limit' do
before do
stub_const('Admin::UsersController::PAGINATION_WITH_COUNT_LIMIT', 5)
allow(Gitlab::Database::Count).to receive(:approximate_counts).with([User]).and_return({ User => 6 })
end
it 'marks the relation for pagination without counts' do
get :index
expect(assigns(:users)).to be_a(Kaminari::PaginatableWithoutCount)
end
end
context 'when number of users is below the pagination limit' do
it 'marks the relation for pagination with counts' do
get :index
expect(assigns(:users)).not_to be_a(Kaminari::PaginatableWithoutCount)
end
end
end
end
describe 'GET :id' do

View File

@ -14,6 +14,10 @@ RSpec.describe Projects::Settings::CiCdController do
end
describe 'GET show' do
let_it_be(:parent_group) { create(:group) }
let_it_be(:group) { create(:group, parent: parent_group) }
let_it_be(:other_project) { create(:project, group: group) }
it 'renders show with 200 status code' do
get :show, params: { namespace_id: project.namespace, project_id: project }
@ -22,12 +26,9 @@ RSpec.describe Projects::Settings::CiCdController do
end
context 'with group runners' do
let(:parent_group) { create(:group) }
let(:group) { create(:group, parent: parent_group) }
let(:group_runner) { create(:ci_runner, :group, groups: [group]) }
let(:other_project) { create(:project, group: group) }
let!(:project_runner) { create(:ci_runner, :project, projects: [other_project]) }
let!(:shared_runner) { create(:ci_runner, :instance) }
let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group]) }
let_it_be(:project_runner) { create(:ci_runner, :project, projects: [other_project]) }
let_it_be(:shared_runner) { create(:ci_runner, :instance) }
it 'sets assignable project runners only' do
group.add_maintainer(user)
@ -37,6 +38,33 @@ RSpec.describe Projects::Settings::CiCdController do
expect(assigns(:assignable_runners)).to contain_exactly(project_runner)
end
end
context 'prevents N+1 queries for tags' do
render_views
def show
get :show, params: { namespace_id: project.namespace, project_id: project }
end
it 'has the same number of queries with one tag or with many tags', :request_store do
group.add_maintainer(user)
show # warmup
# with one tag
create(:ci_runner, :instance, tag_list: %w(shared_runner))
create(:ci_runner, :project, projects: [other_project], tag_list: %w(project_runner))
create(:ci_runner, :group, groups: [group], tag_list: %w(group_runner))
control = ActiveRecord::QueryRecorder.new { show }
# with several tags
create(:ci_runner, :instance, tag_list: %w(shared_runner tag2 tag3))
create(:ci_runner, :project, projects: [other_project], tag_list: %w(project_runner tag2 tag3))
create(:ci_runner, :group, groups: [group], tag_list: %w(group_runner tag2 tag3))
expect { show }.not_to exceed_query_limit(control)
end
end
end
describe '#reset_cache' do

View File

@ -0,0 +1,41 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ProjectCommitCount do
let(:klass) { Class.include(ProjectCommitCount) }
let(:instance) { klass.new }
describe '#commit_count_for' do
subject { instance.commit_count_for(project, default_count: 42, caller_info: :identifiable) }
let(:project) { create(:project, :repository) }
context 'when a root_ref exists' do
it 'returns commit count from GitlayClient' do
allow(Gitlab::GitalyClient).to receive(:call).and_call_original
allow(Gitlab::GitalyClient).to receive(:call).with(anything, :commit_service, :count_commits, anything, anything)
.and_return(double(count: 4))
expect(subject).to eq(4)
end
end
context 'when a root_ref does not exist' do
let(:project) { create(:project, :empty_repo) }
it 'returns the default_count' do
expect(subject).to eq(42)
end
end
it "handles exceptions by logging them with exception_details and returns the default_count" do
allow(Gitlab::GitalyClient).to receive(:call).and_call_original
allow(Gitlab::GitalyClient).to receive(:call).with(anything, :commit_service, :count_commits, anything, anything).and_raise(e = StandardError.new('_message_'))
expect(Gitlab::ErrorTracking).to receive(:track_exception).with(e, caller_info: :identifiable)
expect(subject).to eq(42)
end
end
end

View File

@ -0,0 +1,43 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe EmptyRepoUploadExperiment, :experiment do
subject { described_class.new(project: project) }
let(:project) { create(:project, :repository) }
describe '#track_initial_write' do
it "tracks an event for the first commit on a project" do
expect(subject).to receive(:commit_count_for).with(project, max_count: described_class::INITIAL_COMMIT_COUNT, experiment: 'empty_repo_upload').and_return(1)
expect(subject).to receive(:track).with(:initial_write, project: project).and_call_original
subject.track_initial_write
end
it "doesn't track an event for projects with a commit count more than 1" do
expect(subject).to receive(:commit_count_for).and_return(2)
expect(subject).not_to receive(:track)
subject.track_initial_write
end
it "doesn't track when we generally shouldn't" do
allow(subject).to receive(:should_track?).and_return(false)
expect(subject).not_to receive(:track)
subject.track_initial_write
end
it "doesn't track if the project is older" do
expect(project).to receive(:created_at).and_return(described_class::TRACKING_START_DATE - 1.minute)
expect(subject).not_to receive(:track)
subject.track_initial_write
end
end
end

View File

@ -29,24 +29,15 @@ RSpec.describe NewProjectReadmeExperiment, :experiment do
context "when tracking initial writes" do
let!(:project) { create(:project, :repository) }
def stub_gitaly_count(count = 1)
allow(Gitlab::GitalyClient).to receive(:call).and_call_original
allow(Gitlab::GitalyClient).to receive(:call).with(anything, :commit_service, :count_commits, anything, anything)
.and_return(double(count: count))
end
before do
stub_gitaly_count
end
it "tracks an event for the first commit on a project with a repository" do
expect(subject).to receive(:commit_count_for).with(project, default_count: described_class::INITIAL_WRITE_LIMIT, max_count: described_class::INITIAL_WRITE_LIMIT, experiment: 'new_project_readme').and_return(1)
expect(subject).to receive(:track).with(:write, property: project.created_at.to_s, value: 1).and_call_original
subject.track_initial_writes(project)
end
it "tracks an event for the second commit on a project with a repository" do
stub_gitaly_count(2)
allow(subject).to receive(:commit_count_for).and_return(2)
expect(subject).to receive(:track).with(:write, property: project.created_at.to_s, value: 2).and_call_original
@ -54,7 +45,7 @@ RSpec.describe NewProjectReadmeExperiment, :experiment do
end
it "doesn't track if the repository has more then 2 commits" do
stub_gitaly_count(3)
allow(subject).to receive(:commit_count_for).and_return(3)
expect(subject).not_to receive(:track)
@ -76,14 +67,5 @@ RSpec.describe NewProjectReadmeExperiment, :experiment do
subject.track_initial_writes(project)
end
it "handles exceptions by logging them" do
allow(Gitlab::GitalyClient).to receive(:call).with(anything, :commit_service, :count_commits, anything, anything)
.and_raise(e = StandardError.new('_message_'))
expect(Gitlab::ErrorTracking).to receive(:track_exception).with(e, experiment: 'new_project_readme')
subject.track_initial_writes(project)
end
end
end

View File

@ -359,7 +359,7 @@ describe('Environment table', () => {
},
},
{
name: 'review/master',
name: 'review/main',
last_deployment: {
created_at: '2019-02-17T16:26:15.125Z',
},
@ -374,7 +374,7 @@ describe('Environment table', () => {
},
];
const [production, review, staging] = mockItems;
const [addcibuildstatus, master] = mockItems[1].children;
const [addcibuildstatus, main] = mockItems[1].children;
factory({
propsData: {
@ -390,7 +390,7 @@ describe('Environment table', () => {
production.name,
]);
expect(wrapper.vm.sortedEnvironments[0].children).toEqual([master, addcibuildstatus]);
expect(wrapper.vm.sortedEnvironments[0].children).toEqual([main, addcibuildstatus]);
});
});
});

View File

@ -76,8 +76,8 @@ const environment = {
iid: 6,
sha: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
ref: {
name: 'master',
ref_url: 'root/ci-folders/tree/master',
name: 'main',
ref_url: 'root/ci-folders/tree/main',
},
tag: true,
'last?': true,
@ -130,8 +130,8 @@ const environment = {
iid: 27,
sha: '1132df044b73943943c949e7ac2c2f120a89bf59',
ref: {
name: 'master',
ref_path: '/root/environment-test/-/tree/master',
name: 'main',
ref_path: '/root/environment-test/-/tree/main',
},
status: 'running',
created_at: '2020-12-04T19:57:49.514Z',

View File

@ -1,4 +1,4 @@
import { GlBadge, GlTable } from '@gitlab/ui';
import { GlBadge, GlPagination, GlTable } from '@gitlab/ui';
import {
getByText as getByTextHelper,
getByTestId as getByTestIdHelper,
@ -6,6 +6,7 @@ import {
} from '@testing-library/dom';
import { mount, createLocalVue, createWrapper } from '@vue/test-utils';
import Vuex from 'vuex';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import CreatedAt from '~/members/components/table/created_at.vue';
import ExpirationDatepicker from '~/members/components/table/expiration_datepicker.vue';
import ExpiresAt from '~/members/components/table/expires_at.vue';
@ -16,7 +17,13 @@ import MembersTable from '~/members/components/table/members_table.vue';
import RoleDropdown from '~/members/components/table/role_dropdown.vue';
import { MEMBER_TYPES } from '~/members/constants';
import * as initUserPopovers from '~/user_popovers';
import { member as memberMock, directMember, invite, accessRequest } from '../../mock_data';
import {
member as memberMock,
directMember,
invite,
accessRequest,
pagination,
} from '../../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
@ -36,6 +43,7 @@ describe('MembersTable', () => {
table: { 'data-qa-selector': 'members_list' },
tr: { 'data-qa-selector': 'member_row' },
},
pagination,
...state,
},
},
@ -66,6 +74,8 @@ describe('MembersTable', () => {
});
};
const url = 'https://localhost/foo-bar/-/project_members';
const getByText = (text, options) =>
createWrapper(getByTextHelper(wrapper.element, text, options));
@ -78,6 +88,14 @@ describe('MembersTable', () => {
`[data-label="${tableCellLabel}"][role="cell"]`,
);
const findPagination = () => extendedWrapper(wrapper.find(GlPagination));
const expectCorrectLinkToPage2 = () => {
expect(findPagination().findByText('2', { selector: 'a' }).attributes('href')).toBe(
`${url}?page=2`,
);
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
@ -219,4 +237,80 @@ describe('MembersTable', () => {
expect(findTable().find('tbody tr').attributes('data-qa-selector')).toBe('member_row');
});
describe('when required pagination data is provided', () => {
beforeEach(() => {
delete window.location;
});
it('renders `gl-pagination` component with correct props', () => {
window.location = new URL(url);
createComponent();
const glPagination = findPagination();
expect(glPagination.exists()).toBe(true);
expect(glPagination.props()).toMatchObject({
value: pagination.currentPage,
perPage: pagination.perPage,
totalItems: pagination.totalItems,
prevText: 'Prev',
nextText: 'Next',
labelNextPage: 'Go to next page',
labelPrevPage: 'Go to previous page',
align: 'center',
});
});
it('uses `pagination.paramName` to generate the pagination links', () => {
window.location = new URL(url);
createComponent({
pagination: {
currentPage: 1,
perPage: 5,
totalItems: 10,
paramName: 'page',
},
});
expectCorrectLinkToPage2();
});
it('removes any url params defined as `null` in the `params` attribute', () => {
window.location = new URL(`${url}?search_groups=foo`);
createComponent({
pagination: {
currentPage: 1,
perPage: 5,
totalItems: 10,
paramName: 'page',
params: { search_groups: null },
},
});
expectCorrectLinkToPage2();
});
});
describe.each`
attribute | value
${'paramName'} | ${null}
${'currentPage'} | ${null}
${'perPage'} | ${null}
${'totalItems'} | ${0}
`('when pagination.$attribute is $value', ({ attribute, value }) => {
it('does not render `gl-pagination`', () => {
createComponent({
pagination: {
...pagination,
[attribute]: value,
},
});
expect(findPagination().exists()).toBe(false);
});
});
});

View File

@ -2,7 +2,7 @@ import { createWrapper } from '@vue/test-utils';
import MembersApp from '~/members/components/app.vue';
import { MEMBER_TYPES } from '~/members/constants';
import { initMembersApp } from '~/members/index';
import { membersJsonString, members } from './mock_data';
import { membersJsonString, members, paginationJsonString, pagination } from './mock_data';
describe('initMembersApp', () => {
let el;
@ -24,6 +24,7 @@ describe('initMembersApp', () => {
beforeEach(() => {
el = document.createElement('div');
el.setAttribute('data-members', membersJsonString);
el.setAttribute('data-pagination', paginationJsonString);
el.setAttribute('data-source-id', '234');
el.setAttribute('data-can-manage-members', 'true');
el.setAttribute('data-member-path', '/groups/foo-bar/-/group_members/:id');
@ -50,6 +51,12 @@ describe('initMembersApp', () => {
expect(vm.$store.state[MEMBER_TYPES.user].members).toEqual(members);
});
it('parses and sets `pagination` in Vuex store', () => {
setup();
expect(vm.$store.state[MEMBER_TYPES.user].pagination).toEqual(pagination);
});
it('sets `tableFields` in Vuex store', () => {
setup();

View File

@ -79,3 +79,19 @@ export const directMember = { ...member, isDirectMember: true };
export const inheritedMember = { ...member, isDirectMember: false };
export const member2faEnabled = { ...member, user: { ...member.user, twoFactorEnabled: true } };
export const paginationJsonString = JSON.stringify({
current_page: 1,
per_page: 5,
total_items: 10,
param_name: 'page',
params: { search_groups: null },
});
export const pagination = {
currentPage: 1,
perPage: 5,
totalItems: 10,
paramName: 'page',
params: { search_groups: null },
};

View File

@ -22,6 +22,8 @@ import {
invite,
membersJsonString,
members,
paginationJsonString,
pagination,
} from './mock_data';
const IS_CURRENT_USER_ID = 123;
@ -259,6 +261,7 @@ describe('Members Utils', () => {
beforeEach(() => {
el = document.createElement('div');
el.setAttribute('data-members', membersJsonString);
el.setAttribute('data-pagination', paginationJsonString);
el.setAttribute('data-source-id', '234');
el.setAttribute('data-can-manage-members', 'true');
});
@ -270,6 +273,7 @@ describe('Members Utils', () => {
it('correctly parses the data attributes', () => {
expect(parseDataAttributes(el)).toEqual({
members,
pagination,
sourceId: 234,
canManageMembers: true,
});

View File

@ -17,7 +17,7 @@ const projectRefsEndpoint = '/root/project/refs';
const pipelinesPath = '/root/project/-/pipelines';
const configVariablesPath = '/root/project/-/pipelines/config_variables';
const newPipelinePostResponse = { id: 1 };
const defaultBranch = 'master';
const defaultBranch = 'main';
describe('Pipeline New Form', () => {
let wrapper;
@ -187,13 +187,13 @@ describe('Pipeline New Form', () => {
await waitForPromises();
});
it('variables persist between ref changes', async () => {
selectBranch('master');
selectBranch('main');
await waitForPromises();
const masterInput = findKeyInputs().at(0);
masterInput.element.value = 'build_var';
masterInput.trigger('change');
const mainInput = findKeyInputs().at(0);
mainInput.element.value = 'build_var';
mainInput.trigger('change');
await wrapper.vm.$nextTick();
@ -207,7 +207,7 @@ describe('Pipeline New Form', () => {
await wrapper.vm.$nextTick();
selectBranch('master');
selectBranch('main');
await waitForPromises();

View File

@ -10,8 +10,8 @@ import RefsDropdown from '~/pipeline_new/components/refs_dropdown.vue';
import { mockRefs, mockFilteredRefs } from '../mock_data';
const projectRefsEndpoint = '/root/project/refs';
const refShortName = 'master';
const refFullName = 'refs/heads/master';
const refShortName = 'main';
const refFullName = 'refs/heads/main';
jest.mock('~/flash');

View File

@ -1,5 +1,5 @@
export const mockRefs = {
Branches: ['master', 'branch-1', 'branch-2'],
Branches: ['main', 'branch-1', 'branch-2'],
Tags: ['1.0.0', '1.1.0', '1.2.0'],
};
@ -40,6 +40,6 @@ export const mockError = {
total_warnings: 7,
};
export const mockBranchRefs = ['master', 'dev', 'release'];
export const mockBranchRefs = ['main', 'dev', 'release'];
export const mockTagRefs = ['1.0.0', '1.1.0', '1.2.0'];

View File

@ -5,7 +5,7 @@ import { mockBranchRefs, mockTagRefs } from '../mock_data';
describe('Format refs util', () => {
it('formats branch ref correctly', () => {
expect(formatRefs(mockBranchRefs, BRANCH_REF_TYPE)).toEqual([
{ fullName: 'refs/heads/master', shortName: 'master' },
{ fullName: 'refs/heads/main', shortName: 'main' },
{ fullName: 'refs/heads/dev', shortName: 'dev' },
{ fullName: 'refs/heads/release', shortName: 'release' },
]);

View File

@ -138,7 +138,7 @@ describe('Pipelines filtered search', () => {
describe('Url query params', () => {
const params = {
username: 'deja.green',
ref: 'master',
ref: 'main',
};
beforeEach(() => {

View File

@ -3727,8 +3727,8 @@ export default {
scheduled_actions: [],
},
ref: {
name: 'master',
path: '/h5bp/html5-boilerplate/commits/master',
name: 'main',
path: '/h5bp/html5-boilerplate/commits/main',
tag: false,
branch: true,
merge_request: false,

View File

@ -221,22 +221,22 @@ export default {
cancelable: false,
},
ref: {
name: 'master',
path: '/root/ci-mock/tree/master',
name: 'main',
path: '/root/ci-mock/tree/main',
tag: false,
branch: true,
},
commit: {
id: '798e5f902592192afaba73f4668ae30e56eae492',
short_id: '798e5f90',
title: "Merge branch 'new-branch' into 'master'\r",
title: "Merge branch 'new-branch' into 'main'\r",
created_at: '2017-04-13T10:25:17.000+01:00',
parent_ids: [
'54d483b1ed156fbbf618886ddf7ab023e24f8738',
'c8e2d38a6c538822e81c57022a6e3a0cfedebbcc',
],
message:
"Merge branch 'new-branch' into 'master'\r\n\r\nAdd new file\r\n\r\nSee merge request !1",
"Merge branch 'new-branch' into 'main'\r\n\r\nAdd new file\r\n\r\nSee merge request !1",
author_name: 'Root',
author_email: 'admin@example.com',
authored_date: '2017-04-13T10:25:17.000+01:00',

View File

@ -387,7 +387,7 @@ export const tags = [
protected: false,
},
{
name: 'master-tag',
name: 'main-tag',
message: '',
target: '66673b07efef254dab7d537f0433a40e61cf84fe',
commit: {
@ -413,10 +413,10 @@ export const tags = [
export const mockSearch = [
{ type: 'username', value: { data: 'root', operator: '=' } },
{ type: 'ref', value: { data: 'master', operator: '=' } },
{ type: 'ref', value: { data: 'main', operator: '=' } },
{ type: 'status', value: { data: 'pending', operator: '=' } },
];
export const mockBranchesAfterMap = ['branch-1', 'branch-10', 'branch-11'];
export const mockTagsAfterMap = ['tag-3', 'tag-2', 'tag-1', 'master-tag'];
export const mockTagsAfterMap = ['tag-3', 'tag-2', 'tag-1', 'main-tag'];

View File

@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import ExperimentTracking from '~/experimentation/experiment_tracking';
import PipelinesCiTemplate from '~/pipelines/components/pipelines_list/pipelines_ci_templates.vue';
const addCiYmlPath = "/-/new/master?commit_message='Add%20.gitlab-ci.yml'";
const addCiYmlPath = "/-/new/main?commit_message='Add%20.gitlab-ci.yml'";
const suggestedCiTemplates = [
{ name: 'Android', logo: '/assets/illustrations/logos/android.svg' },
{ name: 'Bash', logo: '/assets/illustrations/logos/bash.svg' },

View File

@ -289,7 +289,7 @@ describe('Pipelines', () => {
page: '1',
scope: 'all',
username: 'root',
ref: 'master',
ref: 'main',
status: 'pending',
};
@ -321,7 +321,7 @@ describe('Pipelines', () => {
expect(window.history.pushState).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
`${window.location.pathname}?page=1&scope=all&username=root&ref=master&status=pending`,
`${window.location.pathname}?page=1&scope=all&username=root&ref=main&status=pending`,
);
});
});

View File

@ -13,7 +13,7 @@ describe('Test case details', () => {
formattedTime: '10.04ms',
recent_failures: {
count: 2,
base_branch: 'master',
base_branch: 'main',
},
system_output: 'Line 42 is broken',
};

View File

@ -89,7 +89,7 @@ describe('Pipeline Branch Name Token', () => {
});
it('renders only the branch searched for', () => {
const mockBranches = ['master'];
const mockBranches = ['main'];
createComponent({ stubs }, { branches: mockBranches, loading: false });
expect(findAllFilteredSearchSuggestions()).toHaveLength(mockBranches.length);

View File

@ -89,7 +89,7 @@ describe('Pipeline Branch Name Token', () => {
});
it('renders only the tag searched for', () => {
const mockTags = ['master-tag'];
const mockTags = ['main-tag'];
createComponent({ stubs }, { tags: mockTags, loading: false });
expect(findAllFilteredSearchSuggestions()).toHaveLength(mockTags.length);

View File

@ -34,7 +34,7 @@ describe('MRWidgetHeader', () => {
divergedCommitsCount: 12,
sourceBranch: 'mr-widget-refactor',
sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>',
targetBranch: 'master',
targetBranch: 'main',
statusPath: 'abc',
},
});
@ -48,7 +48,7 @@ describe('MRWidgetHeader', () => {
divergedCommitsCount: 0,
sourceBranch: 'mr-widget-refactor',
sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>',
targetBranch: 'master',
targetBranch: 'main',
statusPath: 'abc',
},
});
@ -64,14 +64,14 @@ describe('MRWidgetHeader', () => {
divergedCommitsCount: 1,
sourceBranch: 'mr-widget-refactor',
sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>',
targetBranch: 'master',
targetBranchPath: '/foo/bar/master',
targetBranch: 'main',
targetBranchPath: '/foo/bar/main',
statusPath: 'abc',
},
});
expect(wrapper.vm.commitsBehindText).toBe(
'The source branch is <a href="/foo/bar/master">1 commit behind</a> the target branch',
'The source branch is <a href="/foo/bar/main">1 commit behind</a> the target branch',
);
});
@ -81,14 +81,14 @@ describe('MRWidgetHeader', () => {
divergedCommitsCount: 2,
sourceBranch: 'mr-widget-refactor',
sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>',
targetBranch: 'master',
targetBranchPath: '/foo/bar/master',
targetBranch: 'main',
targetBranchPath: '/foo/bar/main',
statusPath: 'abc',
},
});
expect(wrapper.vm.commitsBehindText).toBe(
'The source branch is <a href="/foo/bar/master">2 commits behind</a> the target branch',
'The source branch is <a href="/foo/bar/main">2 commits behind</a> the target branch',
);
});
});
@ -105,7 +105,7 @@ describe('MRWidgetHeader', () => {
sourceBranchRemoved: false,
targetBranchPath: 'foo/bar/commits-path',
targetBranchTreePath: 'foo/bar/tree/path',
targetBranch: 'master',
targetBranch: 'main',
isOpen: true,
emailPatchesPath: '/mr/email-patches',
plainDiffPath: '/mr/plainDiffPath',
@ -125,7 +125,7 @@ describe('MRWidgetHeader', () => {
});
it('renders target branch', () => {
expect(wrapper.find('.js-target-branch').text().trim()).toBe('master');
expect(wrapper.find('.js-target-branch').text().trim()).toBe('main');
});
});
@ -138,7 +138,7 @@ describe('MRWidgetHeader', () => {
sourceBranchRemoved: false,
targetBranchPath: 'foo/bar/commits-path',
targetBranchTreePath: 'foo/bar/tree/path',
targetBranch: 'master',
targetBranch: 'main',
isOpen: true,
canPushToSourceBranch: true,
emailPatchesPath: '/mr/email-patches',
@ -227,7 +227,7 @@ describe('MRWidgetHeader', () => {
sourceBranchRemoved: false,
targetBranchPath: 'foo/bar/commits-path',
targetBranchTreePath: 'foo/bar/tree/path',
targetBranch: 'master',
targetBranch: 'main',
isOpen: false,
emailPatchesPath: '/mr/email-patches',
plainDiffPath: '/mr/plainDiffPath',
@ -257,7 +257,7 @@ describe('MRWidgetHeader', () => {
sourceBranchRemoved: false,
targetBranchPath: 'foo/bar/commits-path',
targetBranchTreePath: 'foo/bar/tree/path',
targetBranch: 'master',
targetBranch: 'main',
isOpen: true,
emailPatchesPath: '/mr/email-patches',
plainDiffPath: '/mr/plainDiffPath',
@ -281,7 +281,7 @@ describe('MRWidgetHeader', () => {
sourceBranchRemoved: false,
targetBranchPath: 'foo/bar/commits-path',
targetBranchTreePath: 'foo/bar/tree/path',
targetBranch: 'master',
targetBranch: 'main',
isOpen: true,
emailPatchesPath: '/mr/email-patches',
plainDiffPath: '/mr/plainDiffPath',

View File

@ -8,7 +8,7 @@ describe('Commits header component', () => {
wrapper = shallowMount(CommitsHeader, {
propsData: {
isSquashEnabled: false,
targetBranch: 'master',
targetBranch: 'main',
commitsCount: 5,
isFastForwardEnabled: false,
...props,
@ -94,7 +94,7 @@ describe('Commits header component', () => {
it('has correct target branch displayed', () => {
createComponent();
expect(findTargetBranchMessage().text()).toBe('master');
expect(findTargetBranchMessage().text()).toBe('main');
});
it('does has merge commit part of the message', () => {

View File

@ -42,7 +42,7 @@ const createTestMr = (customConfig) => {
commitMessageWithDescription,
shouldRemoveSourceBranch: true,
canRemoveSourceBranch: false,
targetBranch: 'master',
targetBranch: 'main',
preferredAutoMergeStrategy: MWPS_MERGE_STRATEGY,
availableAutoMergeStrategies: [MWPS_MERGE_STRATEGY],
mergeImmediatelyDocsPath: 'path/to/merge/immediately/docs',

View File

@ -45,15 +45,15 @@ const deploymentMockData = {
changes: [
{
path: 'index.html',
external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/index.html',
external_url: 'http://root-main-patch-91341.volatile-watch.surge.sh/index.html',
},
{
path: 'imgs/gallery.html',
external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/imgs/gallery.html',
external_url: 'http://root-main-patch-91341.volatile-watch.surge.sh/imgs/gallery.html',
},
{
path: 'about/',
external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/about/',
external_url: 'http://root-main-patch-91341.volatile-watch.surge.sh/about/',
},
],
};

View File

@ -48,7 +48,7 @@ export default {
source_branch_link: 'daaaa',
source_project_id: 19,
source_project_full_path: '/group1/project1',
target_branch: 'master',
target_branch: 'main',
target_project_id: 19,
target_project_full_path: '/group2/project2',
merge_request_add_ci_config_path: '/group2/project2/new/pipeline',
@ -83,7 +83,7 @@ export default {
diff_head_sha: '104096c51715e12e7ae41f9333e9fa35b73f385d',
diff_head_commit_short_id: '104096c5',
default_merge_commit_message:
"Merge branch 'daaaa' into 'master'\n\nUpdate README.md\n\nSee merge request !22",
"Merge branch 'daaaa' into 'main'\n\nUpdate README.md\n\nSee merge request !22",
pipeline: {
id: 172,
user: {
@ -173,8 +173,8 @@ export default {
title: 'Update README.md',
source_branch: 'feature-1',
source_branch_path: '/root/detached-merge-request-pipelines/branches/feature-1',
target_branch: 'master',
target_branch_path: '/root/detached-merge-request-pipelines/branches/master',
target_branch: 'main',
target_branch_path: '/root/detached-merge-request-pipelines/branches/main',
},
commit: {
id: '104096c51715e12e7ae41f9333e9fa35b73f385d',
@ -243,7 +243,7 @@ export default {
head_path: 'blob_path',
},
codequality_help_path: 'code_quality.html',
target_branch_path: '/root/acets-app/branches/master',
target_branch_path: '/root/acets-app/branches/main',
source_branch_path: '/root/acets-app/branches/daaaa',
conflict_resolution_ui_path: '/root/acets-app/-/merge_requests/22/conflicts',
remove_wip_path: '/root/acets-app/-/merge_requests/22/remove_wip',
@ -264,7 +264,7 @@ export default {
ci_environments_status_url: '/root/acets-app/-/merge_requests/22/ci_environments_status',
project_archived: false,
default_merge_commit_message_with_description:
"Merge branch 'daaaa' into 'master'\n\nUpdate README.md\n\nSee merge request !22",
"Merge branch 'daaaa' into 'main'\n\nUpdate README.md\n\nSee merge request !22",
default_squash_commit_message: 'Test squash commit message',
diverged_commits_count: 0,
only_allow_merge_if_pipeline_succeeds: false,

View File

@ -559,15 +559,15 @@ describe('MrWidgetOptions', () => {
const changes = [
{
path: 'index.html',
external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/index.html',
external_url: 'http://root-main-patch-91341.volatile-watch.surge.sh/index.html',
},
{
path: 'imgs/gallery.html',
external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/imgs/gallery.html',
external_url: 'http://root-main-patch-91341.volatile-watch.surge.sh/imgs/gallery.html',
},
{
path: 'about/',
external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/about/',
external_url: 'http://root-main-patch-91341.volatile-watch.surge.sh/about/',
},
];
const deploymentMockData = {
@ -688,22 +688,22 @@ describe('MrWidgetOptions', () => {
scheduled_actions: [],
},
ref: {
name: 'master',
path: '/root/ci-web-terminal/commits/master',
name: 'main',
path: '/root/ci-web-terminal/commits/main',
tag: false,
branch: true,
},
commit: {
id: 'aa1939133d373c94879becb79d91828a892ee319',
short_id: 'aa193913',
title: "Merge branch 'master-test' into 'master'",
title: "Merge branch 'main-test' into 'main'",
created_at: '2018-10-22T11:41:33.000Z',
parent_ids: [
'4622f4dd792468993003caf2e3be978798cbe096',
'76598df914cdfe87132d0c3c40f80db9fa9396a4',
],
message:
"Merge branch 'master-test' into 'master'\n\nUpdate .gitlab-ci.yml\n\nSee merge request root/ci-web-terminal!1",
"Merge branch 'main-test' into 'main'\n\nUpdate .gitlab-ci.yml\n\nSee merge request root/ci-web-terminal!1",
author_name: 'Administrator',
author_email: 'admin@example.com',
authored_date: '2018-10-22T11:41:33.000Z',
@ -751,17 +751,16 @@ describe('MrWidgetOptions', () => {
changes: [
{
path: 'index.html',
external_url:
'http://root-master-patch-91341.volatile-watch.surge.sh/index.html',
external_url: 'http://root-main-patch-91341.volatile-watch.surge.sh/index.html',
},
{
path: 'imgs/gallery.html',
external_url:
'http://root-master-patch-91341.volatile-watch.surge.sh/imgs/gallery.html',
'http://root-main-patch-91341.volatile-watch.surge.sh/imgs/gallery.html',
},
{
path: 'about/',
external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/about/',
external_url: 'http://root-main-patch-91341.volatile-watch.surge.sh/about/',
},
],
status: 'success',

View File

@ -113,6 +113,24 @@ RSpec.describe GitlabRoutingHelper do
end
end
describe 'members helpers' do
describe '#source_members_url' do
it 'returns a url to the memberships page for a group membership' do
membership = build_stubbed(:group_member)
group_members_url = "http://test.host/groups/#{membership.source.full_path}/-/group_members"
expect(source_members_url(membership)).to eq(group_members_url)
end
it 'returns a url to the memberships page for a project membership' do
membership = build_stubbed(:project_member)
project_members_url = "http://test.host/#{membership.source.full_path}/-/project_members"
expect(source_members_url(membership)).to eq(project_members_url)
end
end
end
context 'artifacts' do
let_it_be(:project) { create(:project) }
let_it_be(:job) { create(:ci_build, project: project, name: 'test:job', artifacts_expire_at: 1.hour.from_now) }

View File

@ -70,7 +70,7 @@ RSpec.describe Groups::GroupMembersHelper do
end
describe '#group_members_list_data_attributes' do
let(:group_member) { create(:group_member, group: group, created_by: current_user) }
let_it_be(:group_members) { create_list(:group_member, 2, group: group, created_by: current_user) }
before do
allow(helper).to receive(:group_group_member_path).with(group, ':id').and_return('/groups/foo-bar/-/group_members/:id')
@ -78,13 +78,45 @@ RSpec.describe Groups::GroupMembersHelper do
end
it 'returns expected hash' do
expect(helper.group_members_list_data_attributes(group, present_members([group_member]))).to include({
members: helper.members_data_json(group, present_members([group_member])),
expect(helper.group_members_list_data_attributes(group, present_members(group_members))).to include({
members: helper.members_data_json(group, present_members(group_members)),
member_path: '/groups/foo-bar/-/group_members/:id',
source_id: group.id,
can_manage_members: 'true'
})
end
context 'when pagination is not available' do
it 'sets `pagination` attribute to expected json' do
expect(helper.group_members_list_data_attributes(group, present_members(group_members))[:pagination]).to match({
current_page: nil,
per_page: nil,
total_items: 2,
param_name: nil,
params: {}
}.to_json)
end
end
context 'when pagination is available' do
let(:collection) { Kaminari.paginate_array(group_members).page(1).per(1) }
it 'sets `pagination` attribute to expected json' do
expect(
helper.group_members_list_data_attributes(
group,
present_members(collection),
{ param_name: :page, params: { search_groups: nil } }
)[:pagination]
).to match({
current_page: 1,
per_page: 1,
total_items: 2,
param_name: :page,
params: { search_groups: nil }
}.to_json)
end
end
end
describe '#group_group_links_list_data_attributes' do
@ -96,6 +128,13 @@ RSpec.describe Groups::GroupMembersHelper do
it 'returns expected hash' do
expect(helper.group_group_links_list_data_attributes(shared_group)).to include({
pagination: {
current_page: nil,
per_page: nil,
total_items: 1,
param_name: nil,
params: {}
}.to_json,
members: helper.group_group_links_data_json(shared_group.shared_with_group_links),
member_path: '/groups/foo-bar/-/group_links/:id',
source_id: shared_group.id

View File

@ -147,7 +147,7 @@ RSpec.describe Projects::ProjectMembersHelper do
end
describe 'project members' do
let_it_be(:project_members) { create_list(:project_member, 1, project: project) }
let_it_be(:project_members) { create_list(:project_member, 2, project: project) }
describe '#project_members_data_json' do
it 'matches json schema' do
@ -170,6 +170,38 @@ RSpec.describe Projects::ProjectMembersHelper do
can_manage_members: 'true'
})
end
context 'when pagination is not available' do
it 'sets `pagination` attribute to expected json' do
expect(helper.project_members_list_data_attributes(project, present_members(project_members))[:pagination]).to match({
current_page: nil,
per_page: nil,
total_items: 2,
param_name: nil,
params: {}
}.to_json)
end
end
context 'when pagination is available' do
let(:collection) { Kaminari.paginate_array(project_members).page(1).per(1) }
it 'sets `pagination` attribute to expected json' do
expect(
helper.project_members_list_data_attributes(
project,
present_members(collection),
{ param_name: :page, params: { search_groups: nil } }
)[:pagination]
).to match({
current_page: 1,
per_page: 1,
total_items: 2,
param_name: :page,
params: { search_groups: nil }
}.to_json)
end
end
end
end
@ -193,6 +225,13 @@ RSpec.describe Projects::ProjectMembersHelper do
it 'returns expected hash' do
expect(helper.project_group_links_list_data_attributes(project, project_group_links)).to include({
members: helper.project_group_links_data_json(project_group_links),
pagination: {
current_page: nil,
per_page: nil,
total_items: 1,
param_name: nil,
params: {}
}.to_json,
member_path: '/foo-bar/-/group_links/:id',
source_id: project.id,
can_manage_members: 'true'

View File

@ -61,13 +61,13 @@ RSpec.describe Banzai::Filter::References::AbstractReferenceFilter do
.to eq([project])
end
context "when no project with that path exists" do
it "returns no value" do
context 'when no project with that path exists' do
it 'returns no value' do
expect(filter.find_for_paths(['nonexistent/project']))
.to eq([])
end
it "adds the ref to the project refs cache" do
it 'adds the ref to the project refs cache' do
project_refs_cache = {}
allow(filter).to receive(:refs_cache).and_return(project_refs_cache)
@ -99,4 +99,18 @@ RSpec.describe Banzai::Filter::References::AbstractReferenceFilter do
expect(filter.current_parent_path).to eq(project.full_path)
end
end
context 'abstract methods' do
describe '#find_object' do
it 'raises NotImplementedError' do
expect { filter.find_object(nil, nil) }.to raise_error(NotImplementedError)
end
end
describe '#url_for_object' do
it 'raises NotImplementedError' do
expect { filter.url_for_object(nil, nil) }.to raise_error(NotImplementedError)
end
end
end
end

View File

@ -104,7 +104,7 @@ RSpec.describe Banzai::Filter::References::DesignReferenceFilter do
let(:pattern) { described_class.object_class.link_reference_pattern }
let(:parsed) do
m = pattern.match(url)
described_class.identifier(m) if m
described_class.new('', project: nil).identifier(m) if m
end
it 'can parse the reference' do
@ -119,9 +119,11 @@ RSpec.describe Banzai::Filter::References::DesignReferenceFilter do
describe 'static properties' do
specify do
expect(described_class).to have_attributes(
object_sym: :design,
reference_type: :design,
object_class: ::DesignManagement::Design
)
expect(described_class.new('', project: nil).object_sym).to eq :design
end
end

View File

@ -493,19 +493,19 @@ RSpec.describe Banzai::Filter::References::IssueReferenceFilter do
it 'yields valid references' do
expect do |b|
described_class.references_in(issue.to_reference, &b)
described_class.new('', project: nil).references_in(issue.to_reference, &b)
end.to yield_with_args(issue.to_reference, issue.iid, nil, nil, MatchData)
end
it "doesn't yield invalid references" do
expect do |b|
described_class.references_in('#0', &b)
described_class.new('', project: nil).references_in('#0', &b)
end.not_to yield_control
end
it "doesn't yield unsupported references" do
expect do |b|
described_class.references_in(merge_request.to_reference, &b)
described_class.new('', project: nil).references_in(merge_request.to_reference, &b)
end.not_to yield_control
end
end

View File

@ -85,7 +85,7 @@ RSpec.describe Banzai::Filter::References::ProjectReferenceFilter do
document = Nokogiri::HTML.fragment("<p>#{get_reference(project)}</p>")
filter = described_class.new(document, project: project)
expect(filter.projects_hash).to eq({ project.full_path => project })
expect(filter.send(:projects_hash)).to eq({ project.full_path => project })
end
end
@ -94,7 +94,7 @@ RSpec.describe Banzai::Filter::References::ProjectReferenceFilter do
document = Nokogiri::HTML.fragment("<p>#{get_reference(project)}</p>")
filter = described_class.new(document, project: project)
expect(filter.projects).to eq([project.full_path])
expect(filter.send(:projects)).to eq([project.full_path])
end
end
end

View File

@ -155,7 +155,7 @@ RSpec.describe Banzai::Filter::References::ReferenceFilter do
let(:nodes) { [node] }
it 'skips node' do
expect { |b| filter.replace_text_when_pattern_matches(filter.nodes[0], 0, ref_pattern, &b) }.not_to yield_control
expect { |b| filter.send(:replace_text_when_pattern_matches, filter.nodes[0], 0, ref_pattern, &b) }.not_to yield_control
end
end
@ -183,12 +183,12 @@ RSpec.describe Banzai::Filter::References::ReferenceFilter do
end
end
describe "#call_and_update_nodes" do
describe '#call_and_update_nodes' do
include_context 'new nodes'
let(:document) { Nokogiri::HTML.fragment('<a href="foo">foo</a>') }
let(:filter) { described_class.new(document, project: project) }
it "updates all new nodes", :aggregate_failures do
it 'updates all new nodes', :aggregate_failures do
filter.instance_variable_set('@nodes', nodes)
expect(filter).to receive(:call) { filter.instance_variable_set('@new_nodes', new_nodes) }
@ -201,14 +201,14 @@ RSpec.describe Banzai::Filter::References::ReferenceFilter do
end
end
describe ".call" do
describe '.call' do
include_context 'new nodes'
let(:document) { Nokogiri::HTML.fragment('<a href="foo">foo</a>') }
let(:result) { { reference_filter_nodes: nodes } }
it "updates all nodes", :aggregate_failures do
it 'updates all nodes', :aggregate_failures do
expect_next_instance_of(described_class) do |filter|
expect(filter).to receive(:call_and_update_nodes).and_call_original
expect(filter).to receive(:with_update_nodes).and_call_original
@ -221,4 +221,21 @@ RSpec.describe Banzai::Filter::References::ReferenceFilter do
expect(result[:reference_filter_nodes]).to eq(expected_nodes)
end
end
context 'abstract methods' do
let(:document) { Nokogiri::HTML.fragment('<a href="foo">foo</a>') }
let(:filter) { described_class.new(document, project: project) }
describe '#references_in' do
it 'raises NotImplementedError' do
expect { filter.references_in('foo', %r{(?<!\w)}) }.to raise_error(NotImplementedError)
end
end
describe '#object_link_filter' do
it 'raises NotImplementedError' do
expect { filter.send(:object_link_filter, 'foo', %r{(?<!\w)}) }.to raise_error(NotImplementedError)
end
end
end
end

View File

@ -189,7 +189,7 @@ RSpec.describe Banzai::Filter::References::UserReferenceFilter do
filter = described_class.new(document, project: project)
ns = user.namespace
expect(filter.namespaces).to eq({ ns.path => ns })
expect(filter.send(:namespaces)).to eq({ ns.path => ns })
end
end
@ -198,7 +198,7 @@ RSpec.describe Banzai::Filter::References::UserReferenceFilter do
document = Nokogiri::HTML.fragment("<p>#{get_reference(user)}</p>")
filter = described_class.new(document, project: project)
expect(filter.usernames).to eq([user.username])
expect(filter.send(:usernames)).to eq([user.username])
end
end
end

View File

@ -164,7 +164,7 @@ RSpec.describe ::Gitlab::Graphql::Deprecation do
context 'when the context is :inline' do
it 'renders on one line' do
expectation = '**Deprecated** in 10.10. This was renamed. Use: `X.y`.'
expectation = '**Deprecated** in 10.10. This was renamed. Use: [`X.y`](#xy).'
expect(deprecation.markdown).to eq(expectation)
expect(deprecation.markdown(context: :inline)).to eq(expectation)
@ -177,7 +177,7 @@ RSpec.describe ::Gitlab::Graphql::Deprecation do
WARNING:
**Deprecated** in 10.10.
This was renamed.
Use: `X.y`.
Use: [`X.y`](#xy).
MD
expect(deprecation.markdown(context: :block)).to eq(expectation)

View File

@ -1,10 +1,20 @@
# frozen_string_literal: true
require 'fast_spec_helper'
require 'spec_helper'
RSpec.describe Gitlab::Graphql::Docs::Renderer do
describe '#contents' do
shared_examples 'renders correctly as GraphQL documentation' do
it 'contains the expected section' do
# duplicative - but much better error messages!
section.lines.each { |line| expect(contents).to include(line) }
expect(contents).to include(section)
end
end
let(:template) { Rails.root.join('lib/gitlab/graphql/docs/templates/default.md.haml') }
let(:field_description) { 'List of objects.' }
let(:type) { ::GraphQL::INT_TYPE }
let(:query_type) do
Class.new(Types::BaseObject) { graphql_name 'Query' }.tap do |t|
@ -15,6 +25,13 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
end
end
let(:mutation_root) do
Class.new(::Types::BaseObject) do
include ::Gitlab::Graphql::MountMutation
graphql_name 'Mutation'
end
end
let(:mock_schema) do
Class.new(GraphQL::Schema) do
def resolve_type(obj, ctx)
@ -23,10 +40,9 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
end
end
let(:field_description) { 'List of objects.' }
subject(:contents) do
mock_schema.query(query_type)
mock_schema.mutation(mutation_root) if mutation_root.fields.any?
described_class.new(
mock_schema,
@ -36,17 +52,18 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
end
describe 'headings' do
let(:type) { ::GraphQL::INT_TYPE }
it 'contains the expected sections' do
expect(contents.lines.map(&:chomp)).to include(
'## `Query` type',
'## `Mutation` type',
'## Connections',
'## Object types',
'## Enumeration types',
'## Scalar types',
'## Abstract types',
'### Unions',
'### Interfaces'
'### Interfaces',
'## Input types'
)
end
end
@ -66,9 +83,11 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
expectation = <<~DOC
### `ArrayTest`
| Field | Type | Description |
| ----- | ---- | ----------- |
| `foo` | [`#{type_name}`](##{inner_type}) | A description. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="arraytestfoo"></a>`foo` | [`#{type_name}`](##{inner_type}) | A description. |
DOC
is_expected.to include(expectation)
@ -77,7 +96,7 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
describe 'a top level query field' do
let(:expectation) do
<<~DOC
### `foo`
### `Query.foo`
List of objects.
@ -87,7 +106,7 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
| Name | Type | Description |
| ---- | ---- | ----------- |
| `id` | [`ID`](#id) | ID of the object. |
| <a id="queryfooid"></a>`id` | [`ID`](#id) | ID of the object. |
DOC
end
@ -119,20 +138,60 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
expectation = <<~DOC
### `OrderingTest`
| Field | Type | Description |
| ----- | ---- | ----------- |
| `bar` | [`String!`](#string) | A description of bar field. |
| `foo` | [`String!`](#string) | A description of foo field. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="orderingtestbar"></a>`bar` | [`String!`](#string) | A description of bar field. |
| <a id="orderingtestfoo"></a>`foo` | [`String!`](#string) | A description of foo field. |
DOC
is_expected.to include(expectation)
end
end
context 'when an argument is deprecated' do
let(:type) do
Class.new(Types::BaseObject) do
graphql_name 'DeprecatedTest'
description 'A thing we used to use, but no longer support'
field :foo,
type: GraphQL::STRING_TYPE,
null: false,
description: 'A description.' do
argument :foo_arg, GraphQL::STRING_TYPE,
required: false,
description: 'The argument.',
deprecated: { reason: 'Bad argument', milestone: '101.2' }
end
end
end
let(:section) do
<<~DOC
##### `DeprecatedTest.foo`
A description.
Returns [`String!`](#string).
###### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="deprecatedtestfoofooarg"></a>`fooArg` **{warning-solid}** | [`String`](#string) | **Deprecated** in 101.2. Bad argument. |
DOC
end
it_behaves_like 'renders correctly as GraphQL documentation'
end
context 'when a field is deprecated' do
let(:type) do
Class.new(Types::BaseObject) do
graphql_name 'DeprecatedTest'
description 'A thing we used to use, but no longer support'
field :foo,
type: GraphQL::STRING_TYPE,
@ -142,9 +201,9 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
field :foo_with_args,
type: GraphQL::STRING_TYPE,
null: false,
deprecated: { reason: 'Do not use', milestone: '1.10' },
deprecated: { reason: 'Do not use', milestone: '1.10', replacement: 'X.y' },
description: 'A description.' do
argument :fooity, ::GraphQL::INT_TYPE, required: false, description: 'X'
argument :arg, GraphQL::INT_TYPE, required: false, description: 'Argity'
end
field :bar,
type: GraphQL::STRING_TYPE,
@ -158,24 +217,44 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
end
end
it 'includes the deprecation' do
expectation = <<~DOC
let(:section) do
<<~DOC
### `DeprecatedTest`
| Field | Type | Description |
| ----- | ---- | ----------- |
| `bar` **{warning-solid}** | [`String!`](#string) | **Deprecated** in 1.10. This was renamed. Use: `Query.boom`. |
| `foo` **{warning-solid}** | [`String!`](#string) | **Deprecated** in 1.10. This is deprecated. |
| `fooWithArgs` **{warning-solid}** | [`String!`](#string) | **Deprecated** in 1.10. Do not use. |
DOC
A thing we used to use, but no longer support.
is_expected.to include(expectation)
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="deprecatedtestbar"></a>`bar` **{warning-solid}** | [`String!`](#string) | **Deprecated** in 1.10. This was renamed. Use: [`Query.boom`](#queryboom). |
| <a id="deprecatedtestfoo"></a>`foo` **{warning-solid}** | [`String!`](#string) | **Deprecated** in 1.10. This is deprecated. |
#### Fields with arguments
##### `DeprecatedTest.fooWithArgs`
A description.
WARNING:
**Deprecated** in 1.10.
Do not use.
Use: [`X.y`](#xy).
Returns [`String!`](#string).
###### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="deprecatedtestfoowithargsarg"></a>`arg` | [`Int`](#int) | Argity. |
DOC
end
it_behaves_like 'renders correctly as GraphQL documentation'
end
context 'when a Query.field is deprecated' do
let(:type) { ::GraphQL::INT_TYPE }
before do
query_type.field(
name: :bar,
@ -186,28 +265,30 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
)
end
it 'includes the deprecation' do
expectation = <<~DOC
### `bar`
let(:type) { ::GraphQL::INT_TYPE }
let(:section) do
<<~DOC
### `Query.bar`
A bar.
WARNING:
**Deprecated** in 10.11.
This was renamed.
Use: `Query.foo`.
Use: [`Query.foo`](#queryfoo).
Returns [`Int`](#int).
DOC
is_expected.to include(expectation)
end
it_behaves_like 'renders correctly as GraphQL documentation'
end
context 'when a field has an Enumeration type' do
let(:type) do
enum_type = Class.new(Types::BaseEnum) do
graphql_name 'MyEnum'
description 'A test of an enum.'
value 'BAZ',
description: 'A description of BAZ.'
@ -223,18 +304,20 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
end
end
it 'includes the description of the Enumeration' do
expectation = <<~DOC
let(:section) do
<<~DOC
### `MyEnum`
A test of an enum.
| Value | Description |
| ----- | ----------- |
| `BAR` **{warning-solid}** | **Deprecated:** This is deprecated. Deprecated in 1.10. |
| `BAZ` | A description of BAZ. |
| <a id="myenumbar"></a>`BAR` **{warning-solid}** | **Deprecated:** This is deprecated. Deprecated in 1.10. |
| <a id="myenumbaz"></a>`BAZ` | A description of BAZ. |
DOC
is_expected.to include(expectation)
end
it_behaves_like 'renders correctly as GraphQL documentation'
end
context 'when a field has a global ID type' do
@ -247,27 +330,150 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
end
end
it 'includes the field and the description of the ID, so we can link to it' do
type_section = <<~DOC
### `IDTest`
describe 'section for IDTest' do
let(:section) do
<<~DOC
### `IDTest`
A test for rendering IDs.
A test for rendering IDs.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `foo` | [`UserID`](#userid) | A user foo. |
DOC
#### Fields
id_section = <<~DOC
### `UserID`
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="idtestfoo"></a>`foo` | [`UserID`](#userid) | A user foo. |
DOC
end
A `UserID` is a global ID. It is encoded as a string.
An example `UserID` is: `"gid://gitlab/User/1"`.
DOC
is_expected.to include(type_section, id_section)
it_behaves_like 'renders correctly as GraphQL documentation'
end
describe 'section for UserID' do
let(:section) do
<<~DOC
### `UserID`
A `UserID` is a global ID. It is encoded as a string.
An example `UserID` is: `"gid://gitlab/User/1"`.
DOC
end
it_behaves_like 'renders correctly as GraphQL documentation'
end
end
context 'when there is a mutation' do
let(:mutation) do
mutation = Class.new(::Mutations::BaseMutation)
mutation.graphql_name 'MakeItPretty'
mutation.description 'Make everything very pretty.'
mutation.argument :prettiness_factor,
type: GraphQL::FLOAT_TYPE,
required: true,
description: 'How much prettier?'
mutation.argument :pulchritude,
type: GraphQL::FLOAT_TYPE,
required: false,
description: 'How much prettier?',
deprecated: {
reason: :renamed,
replacement: 'prettinessFactor',
milestone: '72.34'
}
mutation.field :everything,
type: GraphQL::STRING_TYPE,
null: true,
description: 'What we made prettier.'
mutation.field :omnis,
type: GraphQL::STRING_TYPE,
null: true,
description: 'What we made prettier.',
deprecated: {
reason: :renamed,
replacement: 'everything',
milestone: '72.34'
}
mutation
end
before do
mutation_root.mount_mutation mutation
end
it_behaves_like 'renders correctly as GraphQL documentation' do
let(:section) do
<<~DOC
### `Mutation.makeItPretty`
Make everything very pretty.
Input type: `MakeItPrettyInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationmakeitprettyclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationmakeitprettyprettinessfactor"></a>`prettinessFactor` | [`Float!`](#float) | How much prettier?. |
| <a id="mutationmakeitprettypulchritude"></a>`pulchritude` **{warning-solid}** | [`Float`](#float) | **Deprecated:** This was renamed. Please use `prettinessFactor`. Deprecated in 72.34. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationmakeitprettyclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationmakeitprettyerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationmakeitprettyeverything"></a>`everything` | [`String`](#string) | What we made prettier. |
| <a id="mutationmakeitprettyomnis"></a>`omnis` **{warning-solid}** | [`String`](#string) | **Deprecated:** This was renamed. Please use `everything`. Deprecated in 72.34. |
DOC
end
end
it 'does not render the automatically generated payload type' do
expect(contents).not_to include('MakeItPrettyPayload')
end
it 'does not render the automatically generated input type as its own section' do
expect(contents).not_to include('# `MakeItPrettyInput`')
end
end
context 'when there is an input type' do
let(:type) do
Class.new(::Types::BaseObject) do
graphql_name 'Foo'
field :wibble, type: ::GraphQL::INT_TYPE, null: true do
argument :date_range,
type: ::Types::TimeframeInputType,
required: true,
description: 'When the foo happened.'
end
end
end
let(:section) do
<<~DOC
### `Timeframe`
A time-frame defined as a closed inclusive range of two dates.
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="timeframeend"></a>`end` | [`Date!`](#date) | The end of the range. |
| <a id="timeframestart"></a>`start` | [`Date!`](#date) | The start of the range. |
DOC
end
it_behaves_like 'renders correctly as GraphQL documentation'
end
context 'when there is an interface and a union' do
@ -297,7 +503,7 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
interface.orphan_types african_swallow
Class.new(::Types::BaseObject) do
graphql_name 'AbstactTypeTest'
graphql_name 'AbstractTypeTest'
description 'A test for abstract types.'
field :foo, union, null: true, description: 'The foo.'
@ -307,14 +513,16 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
it 'lists the fields correctly, and includes descriptions of all the types' do
type_section = <<~DOC
### `AbstactTypeTest`
### `AbstractTypeTest`
A test for abstract types.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `flying` | [`Flying`](#flying) | A flying thing. |
| `foo` | [`UserOrGroup`](#userorgroup) | The foo. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="abstracttypetestflying"></a>`flying` | [`Flying`](#flying) | A flying thing. |
| <a id="abstracttypetestfoo"></a>`foo` | [`UserOrGroup`](#userorgroup) | The foo. |
DOC
union_section = <<~DOC
@ -337,9 +545,11 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
- [`AfricanSwallow`](#africanswallow)
| Field | Type | Description |
| ----- | ---- | ----------- |
| `flightSpeed` | [`Int`](#int) | Speed in mph. |
##### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="flyingflightspeed"></a>`flightSpeed` | [`Int`](#int) | Speed in mph. |
DOC
implementation_section = <<~DOC
@ -347,9 +557,11 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
A swallow from Africa.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `flightSpeed` | [`Int`](#int) | Speed in mph. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="africanswallowflightspeed"></a>`flightSpeed` | [`Int`](#int) | Speed in mph. |
DOC
is_expected.to include(

Some files were not shown because too many files have changed in this diff Show More